feat: agregado unidad de medida crud
This commit is contained in:
parent
e653add755
commit
cf80e914fd
@ -129,8 +129,18 @@ const remove = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Unidad de medida seleccionada (equivalencia) -->
|
||||
<div v-if="item.unit_name" class="mb-1">
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 dark:bg-amber-900/20 dark:text-amber-400 px-1.5 py-0.5 rounded">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"/>
|
||||
</svg>
|
||||
{{ item.unit_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje para productos con decimales -->
|
||||
<div v-if="item.allows_decimals && item.unit_of_measure" class="mb-2">
|
||||
<div v-else-if="item.allows_decimals && item.unit_of_measure" class="mb-2">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ item.unit_of_measure.name }} ({{ item.unit_of_measure.abbreviation }}) - Permite decimales
|
||||
</p>
|
||||
|
||||
38
src/components/POS/CfdiSelector.vue
Normal file
38
src/components/POS/CfdiSelector.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { usoCfdiOptions } from '@/utils/fiscalData';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
error: { type: String, default: null },
|
||||
required: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
USO DE CFDI <span v-if="required" class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
@change="emit('update:modelValue', $event.target.value)"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100 disabled:opacity-50"
|
||||
:class="{ 'border-red-500': error }"
|
||||
>
|
||||
<option value="" disabled>Seleccionar uso de CFDI</option>
|
||||
<option
|
||||
v-for="option in usoCfdiOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="error" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
38
src/components/POS/RegimenSelecto.vue
Normal file
38
src/components/POS/RegimenSelecto.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { regimenFiscalOptions } from '@/utils/fiscalData';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
error: { type: String, default: null },
|
||||
required: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
RÉGIMEN FISCAL <span v-if="required" class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
@change="emit('update:modelValue', $event.target.value)"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100 disabled:opacity-50"
|
||||
:class="{ 'border-red-500': error }"
|
||||
>
|
||||
<option value="" disabled>Seleccionar régimen fiscal</option>
|
||||
<option
|
||||
v-for="option in regimenFiscalOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="error" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
167
src/components/POS/UnitEquivalenceSelector.vue
Normal file
167
src/components/POS/UnitEquivalenceSelector.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
/** Propiedades */
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
product: Object,
|
||||
equivalences: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
baseUnit: Object
|
||||
});
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits(['confirm', 'close']);
|
||||
|
||||
/** Estado */
|
||||
const selectedOption = ref(null);
|
||||
|
||||
/** Todas las opciones: unidad base + equivalencias activas */
|
||||
const options = computed(() => {
|
||||
const basePrice = parseFloat(props.product?.price?.retail_price || 0);
|
||||
const baseName = props.baseUnit?.name || props.product?.unit_of_measure?.name || 'Unidad base';
|
||||
const baseAbbr = props.baseUnit?.abbreviation || props.product?.unit_of_measure?.abbreviation || '';
|
||||
|
||||
const base = {
|
||||
unit_of_measure_id: null,
|
||||
unit_name: baseName,
|
||||
unit_abbreviation: baseAbbr,
|
||||
unit_price: basePrice,
|
||||
conversion_factor: 1,
|
||||
label: `${baseName}${baseAbbr ? ` (${baseAbbr})` : ''}`,
|
||||
priceLabel: `$${basePrice.toFixed(2)}`
|
||||
};
|
||||
|
||||
const equivalenceOptions = props.equivalences.map(eq => ({
|
||||
unit_of_measure_id: eq.unit_of_measure_id,
|
||||
unit_name: eq.unit_name,
|
||||
unit_abbreviation: eq.unit_abbreviation,
|
||||
unit_price: parseFloat(eq.retail_price),
|
||||
conversion_factor: parseFloat(eq.conversion_factor),
|
||||
label: `${eq.unit_name}${eq.unit_abbreviation ? ` (${eq.unit_abbreviation})` : ''}`,
|
||||
priceLabel: `$${parseFloat(eq.retail_price).toFixed(2)}`
|
||||
}));
|
||||
|
||||
return [base, ...equivalenceOptions];
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const selectOption = (option) => {
|
||||
selectedOption.value = option;
|
||||
};
|
||||
|
||||
const confirmSelection = () => {
|
||||
if (!selectedOption.value) return;
|
||||
emit('confirm', {
|
||||
unit_of_measure_id: selectedOption.value.unit_of_measure_id,
|
||||
unit_price: selectedOption.value.unit_price,
|
||||
unit_name: selectedOption.value.unit_of_measure_id ? selectedOption.value.unit_name : null,
|
||||
});
|
||||
selectedOption.value = null;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
selectedOption.value = null;
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="show" max-width="sm" @close="handleClose">
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-gray-900 dark:text-gray-100">
|
||||
Seleccionar unidad
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-xs">
|
||||
{{ product?.name }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Opciones de unidad -->
|
||||
<div class="space-y-2 mb-5">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.unit_of_measure_id ?? 'base'"
|
||||
type="button"
|
||||
@click="selectOption(option)"
|
||||
:class="[
|
||||
'w-full flex items-center justify-between p-3 rounded-lg border-2 text-left transition-all',
|
||||
selectedOption?.unit_of_measure_id === option.unit_of_measure_id
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950 dark:border-indigo-400'
|
||||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-500'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Check indicator -->
|
||||
<div
|
||||
:class="[
|
||||
'w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0',
|
||||
selectedOption?.unit_of_measure_id === option.unit_of_measure_id
|
||||
? 'border-indigo-500 bg-indigo-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="selectedOption?.unit_of_measure_id === option.unit_of_measure_id"
|
||||
class="w-1.5 h-1.5 rounded-full bg-white"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ option.label }}
|
||||
</p>
|
||||
<p
|
||||
v-if="option.unit_of_measure_id !== null"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"
|
||||
>
|
||||
1 {{ option.unit_name }} = {{ option.conversion_factor }} {{ baseUnit?.abbreviation || '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Precio -->
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-gray-100 ml-2">
|
||||
{{ option.priceLabel }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleClose"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="confirmSelection"
|
||||
:disabled="!selectedOption"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Agregar al carrito
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@ -462,6 +462,7 @@ export default {
|
||||
returns: 'Devoluciones',
|
||||
clients: 'Clientes',
|
||||
suppliers: 'Proveedores',
|
||||
unitMeasure: 'Unidades de medida',
|
||||
clientTiers: 'Niveles de Clientes',
|
||||
billingRequests: 'Solicitudes de Facturación',
|
||||
warehouses: 'Almacenes',
|
||||
|
||||
@ -75,6 +75,11 @@ onMounted(() => {
|
||||
name="pos.suppliers"
|
||||
to="pos.suppliers.index"
|
||||
/>
|
||||
<SubLink
|
||||
icon="scale"
|
||||
name="pos.unitMeasure"
|
||||
to="pos.unitMeasure.index"
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
v-if="hasPermission('movements.index')"
|
||||
|
||||
@ -44,12 +44,6 @@ const suggestedPrice = computed(() => {
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const calculateTax = () => {
|
||||
if (form.retail_price && !form.tax) {
|
||||
form.tax = (parseFloat(form.retail_price) * 0.16).toFixed(2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProductSelect = (product) => {
|
||||
if (selectedProducts.value.find(item => item.product.id === product.id)) {
|
||||
Notify.warning('Este producto ya está agregado');
|
||||
@ -213,7 +207,6 @@ watch(() => props.show, (val) => {
|
||||
</label>
|
||||
<FormInput
|
||||
v-model.number="form.retail_price"
|
||||
@blur="calculateTax"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
|
||||
@ -46,12 +46,6 @@ const suggestedPrice = computed(() => {
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const calculateTax = () => {
|
||||
if (form.retail_price && !form.tax) {
|
||||
form.tax = (parseFloat(form.retail_price) * 0.16).toFixed(2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProductSelect = (product) => {
|
||||
if (selectedProducts.value.find(item => item.product.id === product.id)) {
|
||||
Notify.warning('Este producto ya está agregado');
|
||||
@ -72,7 +66,6 @@ const updateQuantity = (index, quantity) => {
|
||||
|
||||
const useSuggestedPrice = () => {
|
||||
form.retail_price = suggestedPrice.value.toFixed(2);
|
||||
calculateTax();
|
||||
};
|
||||
|
||||
const updateBundle = () => {
|
||||
@ -229,7 +222,6 @@ watch(() => props.bundle, (bundle) => {
|
||||
</label>
|
||||
<FormInput
|
||||
v-model.number="form.retail_price"
|
||||
@blur="calculateTax"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
|
||||
@ -4,6 +4,8 @@ import { useForm, apiURL } from '@Services/Api';
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import FormInput from '@Holos/Form/Input.vue';
|
||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
|
||||
import SelectRegimenFiscal from '@Components/POS/RegimenSelecto.vue';
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits(['close', 'created']);
|
||||
@ -152,18 +154,10 @@ const closeModal = () => {
|
||||
</div>
|
||||
|
||||
<!-- REGIMEN FISCAL-->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
REGIMEN FISCAL
|
||||
</label>
|
||||
<FormInput
|
||||
<SelectRegimenFiscal
|
||||
v-model="form.regimen_fiscal"
|
||||
type="text"
|
||||
placeholder="Regimen Fiscal"
|
||||
required
|
||||
:error="form.errors?.regimen_fiscal"
|
||||
/>
|
||||
<FormError :message="form.errors?.regimen_fiscal" />
|
||||
</div>
|
||||
|
||||
<!-- CP FISCAL-->
|
||||
<div>
|
||||
@ -180,18 +174,10 @@ const closeModal = () => {
|
||||
</div>
|
||||
|
||||
<!-- USO CFDI -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
USO DE CFDI
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.uso_cdfi"
|
||||
type="text"
|
||||
placeholder="03 - Gastos en general"
|
||||
required
|
||||
<SelectUsoCfdi
|
||||
v-model="form.uso_cfdi"
|
||||
:error="form.errors?.uso_cfdi"
|
||||
/>
|
||||
<FormError :message="form.errors?.uso_cdfi" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
|
||||
@ -5,6 +5,8 @@ import { useForm, apiURL } from '@Services/Api';
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import FormInput from '@Holos/Form/Input.vue';
|
||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
|
||||
import SelectRegimenFiscal from '@Components/POS/RegimenSelecto.vue';
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits(['close', 'updated']);
|
||||
@ -169,20 +171,6 @@ watch(() => props.client, (newClient) => {
|
||||
<FormError :message="form.errors?.razon_social" />
|
||||
</div>
|
||||
|
||||
<!-- REGIMEN FISCAL-->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
REGIMEN FISCAL
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.regimen_fiscal"
|
||||
type="text"
|
||||
placeholder="Régimen fiscal del cliente"
|
||||
required
|
||||
/>
|
||||
<FormError :message="form.errors?.regimen_fiscal" />
|
||||
</div>
|
||||
|
||||
<!-- CP FISCAL-->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
@ -197,19 +185,18 @@ watch(() => props.client, (newClient) => {
|
||||
<FormError :message="form.errors?.cp_fiscal" />
|
||||
</div>
|
||||
|
||||
<!-- USO CFDI -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
USO CFDI
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.uso_cfdi"
|
||||
type="text"
|
||||
placeholder="03 - GASTOS EN GENERAL"
|
||||
required
|
||||
<!-- REGIMEN FISCAL-->
|
||||
<SelectRegimenFiscal
|
||||
v-model="form.regimen_fiscal"
|
||||
:error="form.errors?.regimen_fiscal"
|
||||
/>
|
||||
|
||||
|
||||
<!-- USO CFDI -->
|
||||
<SelectUsoCfdi
|
||||
v-model="form.uso_cfdi"
|
||||
:error="form.errors?.uso_cfdi"
|
||||
/>
|
||||
<FormError :message="form.errors?.uso_cfdi" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useSearcher, apiURL } from '@Services/Api';
|
||||
import { can } from './Module.js';
|
||||
import { regimenFiscalOptions, usoCfdiOptions } from '@/utils/fiscalData';
|
||||
|
||||
const regimenFiscalLabel = (value) => regimenFiscalOptions.find(o => o.value === value)?.label ?? value;
|
||||
const usoCfdiLabel = (value) => usoCfdiOptions.find(o => o.value === value)?.label ?? value;
|
||||
|
||||
import SearcherHead from '@Holos/Searcher.vue';
|
||||
import ExcelModal from '@Components/POS/ExcelClient.vue';
|
||||
@ -198,13 +202,13 @@ onMounted(() => {
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.razon_social }}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.regimen_fiscal }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ regimenFiscalLabel(client.regimen_fiscal) }}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.cp_fiscal }}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.uso_cfdi }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ usoCfdiLabel(client.uso_cfdi) }}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
|
||||
@ -8,6 +8,8 @@ import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import Loader from '@Shared/Loader.vue';
|
||||
import Input from '@Holos/Form/Input.vue';
|
||||
import PrimaryButton from '@Holos/Button/Primary.vue';
|
||||
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
|
||||
import SelectRegimenFiscal from '@Components/POS/RegimenSelector.vue';
|
||||
|
||||
/** Definidores */
|
||||
const route = useRoute();
|
||||
@ -43,32 +45,6 @@ const paymentMethods = [
|
||||
{ value: 'debit_card', label: 'Tarjeta de Débito' }
|
||||
];
|
||||
|
||||
const usoCfdiOptions = [
|
||||
{ value: 'G01', label: 'G01 - Adquisición de mercancías' },
|
||||
{ value: 'G02', label: 'G02 - Devoluciones, descuentos o bonificaciones' },
|
||||
{ value: 'G03', label: 'G03 - Gastos en general' },
|
||||
{ value: 'I01', label: 'I01 - Construcciones' },
|
||||
{ value: 'I02', label: 'I02 - Mobiliario y equipo de oficina por inversiones' },
|
||||
{ value: 'I03', label: 'I03 - Equipo de transporte' },
|
||||
{ value: 'I04', label: 'I04 - Equipo de computo y accesorios' },
|
||||
{ value: 'I05', label: 'I05 - Dados, troqueles, moldes, matrices y herramental' },
|
||||
{ value: 'I06', label: 'I06 - Comunicaciones telefónicas' },
|
||||
{ value: 'I07', label: 'I07 - Comunicaciones satelitales' },
|
||||
{ value: 'I08', label: 'I08 - Otra maquinaria y equipo' },
|
||||
{ value: 'S01', label: 'S01 - Sin efectos fiscales' }
|
||||
];
|
||||
|
||||
const regimenFiscalOptions = [
|
||||
{ value: '601', label: '601 - General de Ley Personas Morales' },
|
||||
{ value: '603', label: '603 - Personas Morales con Fines no Lucrativos' },
|
||||
{ value: '610', label: '610 - Residentes en el Extranjero sin Establecimiento Permanente en México' },
|
||||
{ value: '620', label: '620 - Sociedades Cooperativas de Producción que optan por diferir sus ingresos' },
|
||||
{ value: '622', label: '622 - Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras' },
|
||||
{ value: '623', label: '623 - Opcional para Grupos de Sociedades' },
|
||||
{ value: '624', label: '624 - Coordinados' },
|
||||
{ value: '626', label: '626 - Régimen Simplificado de Confianza' }
|
||||
];
|
||||
|
||||
const paymentMethodLabel = computed(() => {
|
||||
const method = paymentMethods.find(m => saleData.value?.payment_method?.includes(m.value));
|
||||
return method?.label || saleData.value?.payment_method || 'N/A';
|
||||
@ -93,16 +69,9 @@ const canRequestInvoice = computed(() => {
|
||||
return latestRequest.value.status === 'rejected';
|
||||
});
|
||||
|
||||
/** Helpers para mostrar labels legibles */
|
||||
const getRegimenFiscalLabel = (value) => {
|
||||
const option = regimenFiscalOptions.find(o => o.value === value);
|
||||
return option ? option.label : value || 'No registrado';
|
||||
};
|
||||
|
||||
const getUsoCfdiLabel = (value) => {
|
||||
const option = usoCfdiOptions.find(o => o.value === value);
|
||||
return option ? option.label : value || 'No registrado';
|
||||
};
|
||||
/** Helpers */
|
||||
const getRegimenFiscalLabel = (value) => value || 'No registrado';
|
||||
const getUsoCfdiLabel = (value) => value || 'No registrado';
|
||||
|
||||
/** Métodos */
|
||||
const fetchSaleData = () => {
|
||||
@ -113,8 +82,6 @@ const fetchSaleData = () => {
|
||||
.then(({ data }) => {
|
||||
if (data.status === 'success') {
|
||||
saleData.value = data.data.sale;
|
||||
|
||||
// Si la venta ya tiene un cliente asociado, cargar sus datos
|
||||
if (data.data.client) {
|
||||
clientData.value = data.data.client;
|
||||
fillFormWithClient(data.data.client);
|
||||
@ -136,9 +103,6 @@ const fetchSaleData = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Llenar el formulario con los datos del cliente
|
||||
*/
|
||||
const fillFormWithClient = (client) => {
|
||||
form.value = {
|
||||
name: client.name || '',
|
||||
@ -153,9 +117,6 @@ const fillFormWithClient = (client) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Buscar cliente por RFC
|
||||
*/
|
||||
const searchClientByRfc = () => {
|
||||
const rfc = rfcSearch.value?.trim().toUpperCase();
|
||||
|
||||
@ -174,7 +135,6 @@ const searchClientByRfc = () => {
|
||||
|
||||
window.axios.get(apiURL(`facturacion/check-rfc?rfc=${rfc}`))
|
||||
.then(({ data }) => {
|
||||
// La respuesta viene: { status: 'success', data: { exists: true, client: {...} } }
|
||||
if (data.status === 'success' && data.data?.exists && data.data?.client) {
|
||||
clientData.value = data.data.client;
|
||||
fillFormWithClient(data.data.client);
|
||||
@ -195,9 +155,6 @@ const searchClientByRfc = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Manejar Enter en el input de búsqueda
|
||||
*/
|
||||
const handleSearchKeypress = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@ -205,9 +162,6 @@ const handleSearchKeypress = (event) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Limpiar datos del cliente y volver al formulario limpio
|
||||
*/
|
||||
const clearFoundClient = () => {
|
||||
clientData.value = null;
|
||||
rfcSearchError.value = '';
|
||||
@ -230,7 +184,7 @@ const submitForm = () => {
|
||||
formErrors.value = {};
|
||||
|
||||
window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value)
|
||||
.then(({ data }) => {
|
||||
.then(() => {
|
||||
submitted.value = true;
|
||||
})
|
||||
.catch(({ response }) => {
|
||||
@ -247,7 +201,6 @@ const submitForm = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/** Ciclos */
|
||||
onMounted(() => {
|
||||
fetchSaleData();
|
||||
});
|
||||
@ -649,30 +602,11 @@ onMounted(() => {
|
||||
:onError="formErrors.razon_social"
|
||||
/>
|
||||
|
||||
<!-- Régimen Fiscal Select -->
|
||||
<div>
|
||||
<label for="regimen_fiscal" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
Régimen Fiscal *
|
||||
</label>
|
||||
<select
|
||||
<SelectRegimenFiscal
|
||||
v-model="form.regimen_fiscal"
|
||||
id="regimen_fiscal"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
:class="{ 'border-red-500': formErrors.regimen_fiscal }"
|
||||
>
|
||||
<option value="" disabled>Seleccionar régimen fiscal</option>
|
||||
<option
|
||||
v-for="option in regimenFiscalOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="formErrors.regimen_fiscal" class="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{{ formErrors.regimen_fiscal[0] }}
|
||||
</p>
|
||||
</div>
|
||||
:error="formErrors.regimen_fiscal?.[0]"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="form.cp_fiscal"
|
||||
@ -683,30 +617,11 @@ onMounted(() => {
|
||||
:onError="formErrors.cp_fiscal"
|
||||
/>
|
||||
|
||||
<!-- Uso CFDI Select -->
|
||||
<div>
|
||||
<label for="uso_cfdi" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
Uso de CFDI *
|
||||
</label>
|
||||
<select
|
||||
<SelectUsoCfdi
|
||||
v-model="form.uso_cfdi"
|
||||
id="uso_cfdi"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
|
||||
:class="{ 'border-red-500': formErrors.uso_cfdi }"
|
||||
>
|
||||
<option value="" disabled>Seleccionar uso de CFDI</option>
|
||||
<option
|
||||
v-for="option in usoCfdiOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="formErrors.uso_cfdi" class="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{{ formErrors.uso_cfdi[0] }}
|
||||
</p>
|
||||
</div>
|
||||
:error="formErrors.uso_cfdi?.[0]"
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<Input
|
||||
|
||||
@ -91,7 +91,10 @@ const validateSerialsAndUnit = () => {
|
||||
};
|
||||
|
||||
const createProduct = () => {
|
||||
form.post(apiURL('inventario'), {
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
track_serials: selectedUnit.value ? !selectedUnit.value.allows_decimals && !!data.track_serials : false
|
||||
})).post(apiURL('inventario'), {
|
||||
onSuccess: () => {
|
||||
Notify.success('Producto creado exitosamente');
|
||||
emit('created');
|
||||
@ -167,10 +170,9 @@ watch(() => form.track_serials, () => {
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.key_sat"
|
||||
type="text"
|
||||
type="string"
|
||||
placeholder="Clave SAT del producto"
|
||||
required
|
||||
maxlength="9"
|
||||
maxlength="8"
|
||||
/>
|
||||
<FormError :message="form.errors?.key_sat" />
|
||||
</div>
|
||||
@ -251,25 +253,6 @@ watch(() => form.track_serials, () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Track Serials -->
|
||||
<div class="col-span-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.track_serials"
|
||||
type="checkbox"
|
||||
:disabled="!canUseSerials"
|
||||
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Rastrear números de serie
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="selectedUnit && selectedUnit.allows_decimals" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
No se pueden usar números de serie con esta unidad de medida.
|
||||
</p>
|
||||
<FormError :message="form.errors?.track_serials" />
|
||||
</div>
|
||||
|
||||
<!-- Precio de Venta -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useForm, apiURL } from '@Services/Api';
|
||||
import { useForm, useApi, apiURL } from '@Services/Api';
|
||||
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import FormInput from '@Holos/Form/Input.vue';
|
||||
@ -22,8 +22,18 @@ const props = defineProps({
|
||||
/** Estado */
|
||||
const categories = ref([]);
|
||||
const units = ref([]);
|
||||
const activeTab = ref('general');
|
||||
|
||||
/** Formulario */
|
||||
// Estado de equivalencias
|
||||
const equivalences = ref([]);
|
||||
const baseUnit = ref(null);
|
||||
const loadingEquivalences = ref(false);
|
||||
const showEquivalenceForm = ref(false);
|
||||
const editingEquivalence = ref(null);
|
||||
|
||||
const api = useApi();
|
||||
|
||||
/** Formulario principal */
|
||||
const form = useForm({
|
||||
name: '',
|
||||
key_sat: '',
|
||||
@ -36,6 +46,13 @@ const form = useForm({
|
||||
track_serials: false
|
||||
});
|
||||
|
||||
/** Formulario de equivalencia */
|
||||
const eqForm = useForm({
|
||||
unit_of_measure_id: null,
|
||||
conversion_factor: '',
|
||||
retail_price: ''
|
||||
});
|
||||
|
||||
/** Computed */
|
||||
const selectedUnit = computed(() => {
|
||||
if (!form.unit_of_measure_id) return null;
|
||||
@ -44,7 +61,23 @@ const selectedUnit = computed(() => {
|
||||
|
||||
const canUseSerials = computed(() => {
|
||||
if (!selectedUnit.value) return true;
|
||||
return !selectedUnit.value.allows_decimals;
|
||||
if (selectedUnit.value.allows_decimals) return false;
|
||||
return equivalences.value.length === 0; // No puede tener seriales si ya tiene equivalencias
|
||||
});
|
||||
|
||||
const canHaveEquivalences = computed(() => {
|
||||
return props.product && !props.product.track_serials;
|
||||
});
|
||||
|
||||
// Unidades disponibles para agregar equivalencia (excluir la base y las ya usadas)
|
||||
const availableUnitsForEquivalence = computed(() => {
|
||||
const usedIds = equivalences.value.map(e => e.unit_of_measure_id);
|
||||
const editingId = editingEquivalence.value?.unit_of_measure_id;
|
||||
return units.value.filter(u => {
|
||||
if (u.id === form.unit_of_measure_id) return false; // excluir unidad base
|
||||
if (usedIds.includes(u.id) && u.id !== editingId) return false; // excluir ya usadas (excepto la que se edita)
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
@ -82,6 +115,24 @@ const loadUnits = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadEquivalences = () => {
|
||||
if (!props.product?.id) return;
|
||||
loadingEquivalences.value = true;
|
||||
|
||||
api.get(apiURL(`inventario/${props.product.id}/equivalencias`), {
|
||||
onSuccess: (data) => {
|
||||
equivalences.value = data.equivalences || [];
|
||||
baseUnit.value = data.base_unit || null;
|
||||
},
|
||||
onFail: () => {
|
||||
equivalences.value = [];
|
||||
},
|
||||
onFinish: () => {
|
||||
loadingEquivalences.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const validateSerialsAndUnit = () => {
|
||||
if (form.track_serials && selectedUnit.value && selectedUnit.value.allows_decimals) {
|
||||
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
|
||||
@ -90,7 +141,14 @@ const validateSerialsAndUnit = () => {
|
||||
};
|
||||
|
||||
const updateProduct = () => {
|
||||
form.put(apiURL(`inventario/${props.product.id}`), {
|
||||
const hasSerials = Number(props.product?.serials_count || 0) > 0;
|
||||
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
track_serials: selectedUnit.value && !selectedUnit.value.allows_decimals
|
||||
? (hasSerials || !!data.track_serials)
|
||||
: false
|
||||
})).put(apiURL(`inventario/${props.product.id}`), {
|
||||
onSuccess: () => {
|
||||
Notify.success('Producto actualizado exitosamente');
|
||||
emit('updated');
|
||||
@ -104,14 +162,100 @@ const updateProduct = () => {
|
||||
|
||||
const closeModal = () => {
|
||||
form.reset();
|
||||
activeTab.value = 'general';
|
||||
showEquivalenceForm.value = false;
|
||||
editingEquivalence.value = null;
|
||||
equivalences.value = [];
|
||||
baseUnit.value = null;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const openSerials = () => {
|
||||
if (selectedUnit.value && selectedUnit.value.allows_decimals) {
|
||||
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
|
||||
return;
|
||||
}
|
||||
form.track_serials = true;
|
||||
closeModal();
|
||||
router.push({ name: 'pos.inventory.serials', params: { id: props.product.id } });
|
||||
};
|
||||
|
||||
// Equivalencias
|
||||
const openAddEquivalence = () => {
|
||||
editingEquivalence.value = null;
|
||||
eqForm.unit_of_measure_id = null;
|
||||
eqForm.conversion_factor = '';
|
||||
eqForm.retail_price = '';
|
||||
showEquivalenceForm.value = true;
|
||||
};
|
||||
|
||||
const openEditEquivalence = (eq) => {
|
||||
editingEquivalence.value = eq;
|
||||
eqForm.unit_of_measure_id = eq.unit_of_measure_id;
|
||||
eqForm.conversion_factor = parseFloat(eq.conversion_factor);
|
||||
eqForm.retail_price = eq.retail_price ? parseFloat(eq.retail_price) : '';
|
||||
showEquivalenceForm.value = true;
|
||||
};
|
||||
|
||||
const cancelEquivalenceForm = () => {
|
||||
showEquivalenceForm.value = false;
|
||||
editingEquivalence.value = null;
|
||||
eqForm.reset();
|
||||
};
|
||||
|
||||
const saveEquivalence = () => {
|
||||
const productId = props.product.id;
|
||||
|
||||
if (editingEquivalence.value) {
|
||||
const eqId = editingEquivalence.value.id;
|
||||
eqForm.transform((data) => ({
|
||||
conversion_factor: data.conversion_factor,
|
||||
retail_price: data.retail_price || undefined,
|
||||
})).put(apiURL(`inventario/${productId}/equivalencias/${eqId}`), {
|
||||
onSuccess: () => {
|
||||
Notify.success('Equivalencia actualizada');
|
||||
cancelEquivalenceForm();
|
||||
loadEquivalences();
|
||||
},
|
||||
onError: () => {
|
||||
Notify.error('Error al actualizar la equivalencia');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
eqForm.transform((data) => ({
|
||||
unit_of_measure_id: data.unit_of_measure_id,
|
||||
conversion_factor: data.conversion_factor,
|
||||
retail_price: data.retail_price || undefined,
|
||||
})).post(apiURL(`inventario/${productId}/equivalencias`), {
|
||||
onSuccess: () => {
|
||||
Notify.success('Equivalencia creada');
|
||||
cancelEquivalenceForm();
|
||||
loadEquivalences();
|
||||
},
|
||||
onError: () => {
|
||||
Notify.error('Error al crear la equivalencia');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEquivalence = (eq) => {
|
||||
if (!confirm(`¿Eliminar la equivalencia con "${eq.unit_name}"?`)) return;
|
||||
|
||||
api.delete(apiURL(`inventario/${props.product.id}/equivalencias/${eq.id}`), {
|
||||
onSuccess: () => {
|
||||
Notify.success('Equivalencia eliminada');
|
||||
loadEquivalences();
|
||||
},
|
||||
onFail: (data) => {
|
||||
Notify.error(data.message || 'Error al eliminar la equivalencia');
|
||||
},
|
||||
onError: () => {
|
||||
Notify.error('Error de conexión');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Observadores */
|
||||
watch(() => props.product, (newProduct) => {
|
||||
if (newProduct) {
|
||||
@ -124,7 +268,9 @@ watch(() => props.product, (newProduct) => {
|
||||
form.cost = parseFloat(newProduct.price?.cost || 0);
|
||||
form.retail_price = parseFloat(newProduct.price?.retail_price || 0);
|
||||
form.tax = parseFloat(newProduct.price?.tax || 16);
|
||||
form.track_serials = newProduct.track_serials || false;
|
||||
|
||||
const serialCount = Number(newProduct.serials_count || 0);
|
||||
form.track_serials = !!newProduct.track_serials || serialCount > 0;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
@ -132,6 +278,7 @@ watch(() => props.show, (newValue) => {
|
||||
if (newValue) {
|
||||
loadCategories();
|
||||
loadUnits();
|
||||
activeTab.value = 'general';
|
||||
}
|
||||
});
|
||||
|
||||
@ -139,8 +286,10 @@ watch(() => form.unit_of_measure_id, () => {
|
||||
validateSerialsAndUnit();
|
||||
});
|
||||
|
||||
watch(() => form.track_serials, () => {
|
||||
validateSerialsAndUnit();
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'equivalences' && props.product?.id) {
|
||||
loadEquivalences();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -148,7 +297,7 @@ watch(() => form.track_serials, () => {
|
||||
<Modal :show="show" max-width="md" @close="closeModal">
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Editar Producto
|
||||
</h3>
|
||||
@ -162,7 +311,48 @@ watch(() => form.track_serials, () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulario -->
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-5">
|
||||
<button
|
||||
@click="activeTab = 'general'"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium border-b-2 transition-colors',
|
||||
activeTab === 'general'
|
||||
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
Información General
|
||||
</button>
|
||||
<button
|
||||
v-if="canHaveEquivalences"
|
||||
@click="activeTab = 'equivalences'"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5',
|
||||
activeTab === 'equivalences'
|
||||
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
Equivalencias
|
||||
<span
|
||||
v-if="equivalences.length > 0"
|
||||
class="inline-flex items-center justify-center w-4 h-4 text-xs font-bold rounded-full bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
|
||||
>
|
||||
{{ equivalences.length }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-300 dark:text-gray-600 cursor-not-allowed"
|
||||
title="No disponible para productos con rastreo de seriales"
|
||||
>
|
||||
Equivalencias
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Información General -->
|
||||
<div v-if="activeTab === 'general'">
|
||||
<form @submit.prevent="updateProduct" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Nombre -->
|
||||
@ -188,7 +378,6 @@ watch(() => form.track_serials, () => {
|
||||
v-model="form.key_sat"
|
||||
type="number"
|
||||
placeholder="Clave SAT del producto"
|
||||
maxlength="9"
|
||||
/>
|
||||
<FormError :message="form.errors?.key_sat" />
|
||||
</div>
|
||||
@ -269,25 +458,6 @@ watch(() => form.track_serials, () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Track Serials -->
|
||||
<div class="col-span-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
v-model="form.track_serials"
|
||||
type="checkbox"
|
||||
:disabled="!canUseSerials"
|
||||
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Rastrear números de serie
|
||||
</span>
|
||||
</label>
|
||||
<p v-if="selectedUnit && selectedUnit.allows_decimals" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
No se pueden usar números de serie con esta unidad de medida.
|
||||
</p>
|
||||
<FormError :message="form.errors?.track_serials" />
|
||||
</div>
|
||||
|
||||
<!-- Precio de Venta -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
@ -333,7 +503,11 @@ watch(() => form.track_serials, () => {
|
||||
<GoogleIcon name="qr_code_2" class="text-lg" />
|
||||
Gestionar Seriales
|
||||
</button>
|
||||
<div v-else class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-400 bg-gray-50 border border-gray-200 rounded-lg cursor-not-allowed" :title="selectedUnit?.allows_decimals ? `No disponible: ${selectedUnit.name} permite decimales` : 'Selecciona una unidad de medida'">
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-400 bg-gray-50 border border-gray-200 rounded-lg cursor-not-allowed"
|
||||
:title="equivalences.length > 0 ? 'No disponible: el producto tiene equivalencias de unidad' : selectedUnit?.allows_decimals ? `No disponible: ${selectedUnit.name} permite decimales` : 'Selecciona una unidad de medida'"
|
||||
>
|
||||
<GoogleIcon name="qr_code_2" class="text-lg opacity-50" />
|
||||
Gestionar Seriales
|
||||
</div>
|
||||
@ -357,5 +531,200 @@ watch(() => form.track_serials, () => {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Equivalencias -->
|
||||
<div v-else-if="activeTab === 'equivalences'">
|
||||
<!-- Unidad base -->
|
||||
<div v-if="baseUnit" class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg flex items-center gap-2">
|
||||
<GoogleIcon name="info" class="text-gray-400 text-sm" />
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Unidad base:
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ baseUnit.name }} ({{ baseUnit.abbreviation }})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loadingEquivalences" class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Lista de equivalencias -->
|
||||
<div v-if="equivalences.length > 0" class="space-y-2 mb-4">
|
||||
<div
|
||||
v-for="eq in equivalences"
|
||||
:key="eq.id"
|
||||
class="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg"
|
||||
:class="{ 'opacity-50': !eq.is_active }"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ eq.unit_name }}
|
||||
<span class="font-mono text-gray-500 dark:text-gray-400 text-xs">({{ eq.unit_abbreviation }})</span>
|
||||
</p>
|
||||
<span
|
||||
v-if="!eq.is_active"
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
Inactivo
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
1 {{ eq.unit_name }} = {{ parseFloat(eq.conversion_factor) }} {{ baseUnit?.abbreviation }}
|
||||
·
|
||||
Precio: ${{ parseFloat(eq.retail_price).toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
@click="openEditEquivalence(eq)"
|
||||
class="p-1.5 text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-900 rounded-lg transition-colors"
|
||||
title="Editar equivalencia"
|
||||
>
|
||||
<GoogleIcon name="edit" class="text-base" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteEquivalence(eq)"
|
||||
class="p-1.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900 rounded-lg transition-colors"
|
||||
title="Eliminar equivalencia"
|
||||
>
|
||||
<GoogleIcon name="delete" class="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!showEquivalenceForm"
|
||||
class="flex flex-col items-center justify-center py-6 text-gray-400"
|
||||
>
|
||||
<GoogleIcon name="straighten" class="text-4xl mb-2 opacity-50" />
|
||||
<p class="text-sm">Este producto no tiene equivalencias</p>
|
||||
</div>
|
||||
|
||||
<!-- Formulario inline de equivalencia -->
|
||||
<div v-if="showEquivalenceForm" class="border border-indigo-200 dark:border-indigo-800 rounded-lg p-4 bg-indigo-50 dark:bg-indigo-950 space-y-3">
|
||||
<h4 class="text-sm font-semibold text-indigo-800 dark:text-indigo-200">
|
||||
{{ editingEquivalence ? 'Editar equivalencia' : 'Nueva equivalencia' }}
|
||||
</h4>
|
||||
|
||||
<!-- Unidad (solo en creación) -->
|
||||
<div v-if="!editingEquivalence">
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
UNIDAD
|
||||
</label>
|
||||
<select
|
||||
v-model="eqForm.unit_of_measure_id"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option :value="null">Seleccionar unidad</option>
|
||||
<option
|
||||
v-for="unit in availableUnitsForEquivalence"
|
||||
:key="unit.id"
|
||||
:value="unit.id"
|
||||
>
|
||||
{{ unit.name }} ({{ unit.abbreviation }})
|
||||
</option>
|
||||
</select>
|
||||
<FormError :message="eqForm.errors?.unit_of_measure_id" />
|
||||
</div>
|
||||
|
||||
<!-- Si estamos editando, mostrar la unidad como texto -->
|
||||
<div v-else>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
UNIDAD
|
||||
</label>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{{ editingEquivalence.unit_name }} ({{ editingEquivalence.unit_abbreviation }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- Factor de conversión -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
FACTOR DE CONVERSIÓN
|
||||
</label>
|
||||
<FormInput
|
||||
v-model.number="eqForm.conversion_factor"
|
||||
type="number"
|
||||
min="0.001"
|
||||
step="0.001"
|
||||
placeholder="Ej: 24"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
1 unidad = X {{ baseUnit?.abbreviation || 'base' }}
|
||||
</p>
|
||||
<FormError :message="eqForm.errors?.conversion_factor" />
|
||||
</div>
|
||||
|
||||
<!-- Precio sugerido -->
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
PRECIO SUGERIDO
|
||||
</label>
|
||||
<FormInput
|
||||
v-model.number="eqForm.retail_price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Automático"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Dejar vacío para calcular automáticamente
|
||||
</p>
|
||||
<FormError :message="eqForm.errors?.retail_price" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelEquivalenceForm"
|
||||
class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="saveEquivalence"
|
||||
:disabled="eqForm.processing"
|
||||
class="px-3 py-1.5 text-xs font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<span v-if="eqForm.processing">Guardando...</span>
|
||||
<span v-else>{{ editingEquivalence ? 'Actualizar' : 'Agregar' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botón agregar equivalencia -->
|
||||
<button
|
||||
v-if="!showEquivalenceForm"
|
||||
type="button"
|
||||
@click="openAddEquivalence"
|
||||
class="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-indigo-700 bg-indigo-50 border border-dashed border-indigo-300 rounded-lg hover:bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-300 dark:border-indigo-700 dark:hover:bg-indigo-800 transition-colors"
|
||||
>
|
||||
<GoogleIcon name="add" class="text-lg" />
|
||||
Agregar equivalencia
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Botón cerrar en tab equivalencias -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@ -372,6 +372,9 @@ onMounted(() => {
|
||||
>
|
||||
{{ model.stock }}
|
||||
</span>
|
||||
<p v-if="model.unit_of_measure" class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{{ model.unit_of_measure.abbreviation }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="text-sm">
|
||||
|
||||
@ -218,9 +218,9 @@ watch(() => props.show, (isShown) => {
|
||||
// Cargar datos del movimiento
|
||||
form.quantity = props.movement.quantity || 0;
|
||||
form.unit_cost = props.movement.unit_cost || 0;
|
||||
form.supplier_id = props.movement.supplier_id || null;
|
||||
form.invoice_reference = props.movement.invoice_reference || '';
|
||||
form.notes = props.movement.notes || '';
|
||||
form.supplier_id = props.movement.supplier_id || null;
|
||||
|
||||
// Cargar números de serie si existen
|
||||
if (props.movement.serials && props.movement.serials.length > 0) {
|
||||
|
||||
@ -33,6 +33,9 @@ const productSuggestions = ref([]);
|
||||
const showProductSuggestions = ref(false);
|
||||
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
|
||||
|
||||
// Cache de equivalencias por producto
|
||||
const equivalencesCache = ref({});
|
||||
|
||||
const api = useApi();
|
||||
|
||||
/** Formulario */
|
||||
@ -92,6 +95,34 @@ const loadData = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const loadEquivalencesForProduct = async (productId) => {
|
||||
if (equivalencesCache.value[productId]) {
|
||||
return equivalencesCache.value[productId];
|
||||
}
|
||||
try {
|
||||
const response = await fetch(apiURL(`inventario/${productId}/equivalencias`), {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const json = await response.json();
|
||||
const result = {
|
||||
equivalences: (json.data?.equivalences || []).filter(e => e.is_active),
|
||||
base_unit: json.data?.base_unit || null
|
||||
};
|
||||
equivalencesCache.value[productId] = result;
|
||||
return result;
|
||||
} catch {
|
||||
return { equivalences: [], base_unit: null };
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedEquivalence = (item) => {
|
||||
if (!item.selected_unit_id || !item.equivalences?.length) return null;
|
||||
return item.equivalences.find(e => e.unit_of_measure_id === item.selected_unit_id) || null;
|
||||
};
|
||||
|
||||
const addProduct = () => {
|
||||
selectedProducts.value.push({
|
||||
inventory_id: '',
|
||||
@ -102,8 +133,12 @@ const addProduct = () => {
|
||||
track_serials: false,
|
||||
unit_of_measure: null,
|
||||
allows_decimals: false,
|
||||
serial_numbers_list: [{ serial_number: '', locked: false }], // Inputs individuales de seriales
|
||||
serial_validation_error: ''
|
||||
serial_numbers_list: [{ serial_number: '', locked: false }],
|
||||
serial_validation_error: '',
|
||||
// Equivalencias
|
||||
equivalences: [],
|
||||
base_unit: null,
|
||||
selected_unit_id: null,
|
||||
});
|
||||
};
|
||||
|
||||
@ -170,18 +205,27 @@ const searchProduct = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const selectProduct = (product) => {
|
||||
const selectProduct = async (product) => {
|
||||
if (currentSearchIndex.value !== null) {
|
||||
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
|
||||
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
|
||||
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
|
||||
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
|
||||
selectedProducts.value[currentSearchIndex.value].unit_of_measure = product.unit_of_measure || null;
|
||||
selectedProducts.value[currentSearchIndex.value].allows_decimals = product.unit_of_measure?.allows_decimals || false;
|
||||
const item = selectedProducts.value[currentSearchIndex.value];
|
||||
item.inventory_id = product.id;
|
||||
item.product_name = product.name;
|
||||
item.product_sku = product.sku;
|
||||
item.track_serials = product.track_serials || false;
|
||||
item.unit_of_measure = product.unit_of_measure || null;
|
||||
item.allows_decimals = product.unit_of_measure?.allows_decimals || false;
|
||||
item.selected_unit_id = null;
|
||||
|
||||
// Limpiar seriales si la unidad permite decimales
|
||||
if (product.unit_of_measure?.allows_decimals) {
|
||||
selectedProducts.value[currentSearchIndex.value].serial_numbers_list = [{ serial_number: '', locked: false }];
|
||||
item.serial_numbers_list = [{ serial_number: '', locked: false }];
|
||||
}
|
||||
|
||||
// Cargar equivalencias (solo si no tiene seriales)
|
||||
if (!product.track_serials) {
|
||||
const { equivalences, base_unit } = await loadEquivalencesForProduct(product.id);
|
||||
item.equivalences = equivalences;
|
||||
item.base_unit = base_unit;
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,9 +267,14 @@ const createEntry = () => {
|
||||
inventory_id: item.inventory_id,
|
||||
quantity: Number(item.quantity),
|
||||
unit_cost: Number(item.unit_cost),
|
||||
serial_numbers: [] // Inicializar siempre como array vacío
|
||||
serial_numbers: []
|
||||
};
|
||||
|
||||
// Incluir unidad de equivalencia si se seleccionó una distinta a la base
|
||||
if (item.selected_unit_id) {
|
||||
productData.unit_of_measure_id = item.selected_unit_id;
|
||||
}
|
||||
|
||||
// Agregar seriales solo si la unidad lo permite y hay seriales ingresados
|
||||
if (canUseSerials(item) && item.serial_numbers_list) {
|
||||
const serials = item.serial_numbers_list
|
||||
@ -367,6 +416,24 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
:key="index"
|
||||
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
<!-- Selector de unidad (si el producto tiene equivalencias activas) -->
|
||||
<div v-if="item.equivalences?.length > 0" class="mb-3 flex items-center gap-3">
|
||||
<label class="shrink-0 text-xs font-medium text-gray-600 dark:text-gray-400">Unidad:</label>
|
||||
<select
|
||||
v-model="item.selected_unit_id"
|
||||
class="flex-1 px-2 py-1.5 text-sm border border-indigo-300 dark:border-indigo-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option :value="null">{{ item.base_unit?.name }} ({{ item.base_unit?.abbreviation }}) — unidad base</option>
|
||||
<option
|
||||
v-for="eq in item.equivalences"
|
||||
:key="eq.unit_of_measure_id"
|
||||
:value="eq.unit_of_measure_id"
|
||||
>
|
||||
{{ eq.unit_name }} — 1 {{ eq.unit_abbreviation }} = {{ parseFloat(eq.conversion_factor) }} {{ item.base_unit?.abbreviation }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3 items-start">
|
||||
<!-- Producto -->
|
||||
<div class="col-span-12 sm:col-span-5">
|
||||
@ -447,17 +514,24 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
<div class="col-span-5 sm:col-span-3">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Cantidad
|
||||
<span v-if="getSelectedEquivalence(item)" class="text-indigo-600 dark:text-indigo-400">
|
||||
({{ getSelectedEquivalence(item).unit_abbreviation }})
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="item.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:step="item.allows_decimals && !item.selected_unit_id ? '0.001' : '1'"
|
||||
placeholder="0"
|
||||
:disabled="item.track_serials && canUseSerials(item)"
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-gray-900"
|
||||
/>
|
||||
<p v-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<!-- Conversión a unidad base -->
|
||||
<p v-if="getSelectedEquivalence(item) && item.quantity > 0" class="mt-1 text-xs text-indigo-600 dark:text-indigo-400">
|
||||
= {{ (item.quantity * parseFloat(getSelectedEquivalence(item).conversion_factor)).toLocaleString('es-MX') }} {{ item.base_unit?.abbreviation }}
|
||||
</p>
|
||||
<p v-else-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Controlado por seriales
|
||||
</p>
|
||||
</div>
|
||||
@ -465,7 +539,9 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
||||
<!-- Costo unitario -->
|
||||
<div class="col-span-5 sm:col-span-3">
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
||||
Costo unit.
|
||||
Costo
|
||||
<span v-if="getSelectedEquivalence(item)">/ {{ getSelectedEquivalence(item).unit_abbreviation }}</span>
|
||||
<span v-else>unit.</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="item.unit_cost"
|
||||
|
||||
@ -18,6 +18,7 @@ import ClientModal from '@Components/POS/ClientModal.vue';
|
||||
import QRscan from '@Components/POS/QRscan.vue';
|
||||
import SerialSelector from '@Components/POS/SerialSelector.vue';
|
||||
import BundleSerialSelector from '@Components/POS/BundleSerialSelector.vue';
|
||||
import UnitEquivalenceSelector from '@Components/POS/UnitEquivalenceSelector.vue';
|
||||
|
||||
/** i18n */
|
||||
const { t } = useI18n();
|
||||
@ -46,6 +47,12 @@ const serialSelectorProduct = ref(null);
|
||||
const showBundleSerialSelector = ref(false);
|
||||
const bundleSerialSelectorBundle = ref(null);
|
||||
|
||||
// Estado para selector de equivalencias de unidad
|
||||
const showUnitEquivalenceSelector = ref(false);
|
||||
const unitEquivalenceSelectorProduct = ref(null);
|
||||
const unitEquivalenceSelectorData = ref({ equivalences: [], baseUnit: null });
|
||||
const equivalencesCache = ref({});
|
||||
|
||||
/** Buscador de productos */
|
||||
const searcher = useSearcher({
|
||||
url: apiURL('inventario'),
|
||||
@ -142,6 +149,29 @@ const addBundleToCart = async (bundle) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadEquivalencesForProduct = async (productId) => {
|
||||
if (equivalencesCache.value[productId]) {
|
||||
return equivalencesCache.value[productId];
|
||||
}
|
||||
try {
|
||||
const response = await fetch(apiURL(`inventario/${productId}/equivalencias`), {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionStorage.token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
const data = {
|
||||
equivalences: (result.data?.equivalences || []).filter(e => e.is_active),
|
||||
baseUnit: result.data?.base_unit || null
|
||||
};
|
||||
equivalencesCache.value[productId] = data;
|
||||
return data;
|
||||
} catch {
|
||||
return { equivalences: [], baseUnit: null };
|
||||
}
|
||||
};
|
||||
|
||||
const addToCart = async (product) => {
|
||||
try {
|
||||
const response = await serialService.getAvailableSerials(product.id);
|
||||
@ -176,6 +206,27 @@ const addToCart = async (product) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si el producto ya está en el carrito (sin seriales), solo incrementar sin abrir selectores
|
||||
const existingItem = cart.items.find(i => i.item_key === 'p:' + product.id);
|
||||
if (existingItem && !existingItem.track_serials) {
|
||||
if (existingItem.quantity < product.stock) {
|
||||
existingItem.quantity++;
|
||||
window.Notify.success(`${product.name} agregado al carrito`);
|
||||
} else {
|
||||
window.Notify.warning('No hay suficiente stock disponible');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Producto nuevo: verificar si tiene equivalencias activas
|
||||
const eqData = await loadEquivalencesForProduct(product.id);
|
||||
if (eqData.equivalences.length > 0) {
|
||||
unitEquivalenceSelectorProduct.value = product;
|
||||
unitEquivalenceSelectorData.value = eqData;
|
||||
showUnitEquivalenceSelector.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
cart.addProduct(product);
|
||||
window.Notify.success(`${product.name} agregado al carrito`);
|
||||
} catch (error) {
|
||||
@ -185,6 +236,18 @@ const addToCart = async (product) => {
|
||||
}
|
||||
};
|
||||
|
||||
const closeUnitEquivalenceSelector = () => {
|
||||
showUnitEquivalenceSelector.value = false;
|
||||
unitEquivalenceSelectorProduct.value = null;
|
||||
};
|
||||
|
||||
const handleUnitEquivalenceConfirm = (unitConfig) => {
|
||||
if (!unitEquivalenceSelectorProduct.value) return;
|
||||
cart.addProduct(unitEquivalenceSelectorProduct.value, unitConfig);
|
||||
window.Notify.success(`${unitEquivalenceSelectorProduct.value.name} agregado al carrito`);
|
||||
closeUnitEquivalenceSelector();
|
||||
};
|
||||
|
||||
const closeSerialSelector = () => {
|
||||
showSerialSelector.value = false;
|
||||
serialSelectorProduct.value = null;
|
||||
@ -394,7 +457,7 @@ const handleConfirmSale = async (paymentData) => {
|
||||
}
|
||||
return bundleItem;
|
||||
}
|
||||
return {
|
||||
const productItem = {
|
||||
type: 'product',
|
||||
inventory_id: item.inventory_id,
|
||||
product_name: item.product_name,
|
||||
@ -403,6 +466,10 @@ const handleConfirmSale = async (paymentData) => {
|
||||
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
|
||||
serial_numbers: item.track_serials ? (item.serial_numbers || []) : undefined
|
||||
};
|
||||
if (item.unit_of_measure_id) {
|
||||
productItem.unit_of_measure_id = item.unit_of_measure_id;
|
||||
}
|
||||
return productItem;
|
||||
})
|
||||
};
|
||||
|
||||
@ -871,5 +938,16 @@ watch(activeTab, (newTab) => {
|
||||
@close="closeBundleSerialSelector"
|
||||
@confirm="handleBundleSerialConfirm"
|
||||
/>
|
||||
|
||||
<!-- Modal de Selección de Unidad de Medida -->
|
||||
<UnitEquivalenceSelector
|
||||
v-if="unitEquivalenceSelectorProduct"
|
||||
:show="showUnitEquivalenceSelector"
|
||||
:product="unitEquivalenceSelectorProduct"
|
||||
:equivalences="unitEquivalenceSelectorData.equivalences"
|
||||
:base-unit="unitEquivalenceSelectorData.baseUnit"
|
||||
@close="closeUnitEquivalenceSelector"
|
||||
@confirm="handleUnitEquivalenceConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -161,7 +161,6 @@ const closeModal = () => {
|
||||
v-model="form.notes"
|
||||
type="text"
|
||||
placeholder="Notas"
|
||||
required
|
||||
/>
|
||||
<FormError :message="form.errors?.notes" />
|
||||
</div>
|
||||
|
||||
148
src/pages/POS/UnitMeasure/Create.vue
Normal file
148
src/pages/POS/UnitMeasure/Create.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<script setup>
|
||||
import { useForm, apiURL } from '@Services/Api';
|
||||
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import FormInput from '@Holos/Form/Input.vue';
|
||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits(['close', 'created']);
|
||||
|
||||
/** Propiedades */
|
||||
defineProps({
|
||||
show: Boolean
|
||||
});
|
||||
|
||||
/** Formulario */
|
||||
const form = useForm({
|
||||
name: '',
|
||||
abbreviation: '',
|
||||
allows_decimals: false,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const createUnit = () => {
|
||||
form.post(apiURL('unidades-medida'), {
|
||||
onSuccess: () => {
|
||||
window.Notify.success('Unidad de medida creada exitosamente');
|
||||
emit('created');
|
||||
closeModal();
|
||||
},
|
||||
onError: () => {
|
||||
window.Notify.error('Error al crear la unidad de medida');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
form.reset();
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="show" max-width="md" @close="closeModal">
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Nueva Unidad de Medida
|
||||
</h3>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulario -->
|
||||
<form @submit.prevent="createUnit" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Nombre -->
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NOMBRE
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="Ej: Kilogramo"
|
||||
required
|
||||
/>
|
||||
<FormError :message="form.errors?.name" />
|
||||
</div>
|
||||
|
||||
<!-- Abreviación -->
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
ABREVIACIÓN
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.abbreviation"
|
||||
type="text"
|
||||
placeholder="Ej: kg"
|
||||
required
|
||||
/>
|
||||
<FormError :message="form.errors?.abbreviation" />
|
||||
</div>
|
||||
|
||||
<!-- Permite decimales -->
|
||||
<div class="col-span-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="form.allows_decimals"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Permite cantidades decimales
|
||||
</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-6">
|
||||
Actívalo para unidades como kg, litros o metros. Desactívalo para piezas, cajas o unidades enteras.
|
||||
</p>
|
||||
<FormError :message="form.errors?.allows_decimals" />
|
||||
</div>
|
||||
|
||||
<!-- Activo -->
|
||||
<div class="col-span-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Unidad activa
|
||||
</span>
|
||||
</label>
|
||||
<FormError :message="form.errors?.is_active" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
<div class="flex items-center justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="form.processing">Guardando...</span>
|
||||
<span v-else>Guardar</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
160
src/pages/POS/UnitMeasure/Edit.vue
Normal file
160
src/pages/POS/UnitMeasure/Edit.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { useForm, apiURL } from '@Services/Api';
|
||||
|
||||
import Modal from '@Holos/Modal.vue';
|
||||
import FormInput from '@Holos/Form/Input.vue';
|
||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits(['close', 'updated']);
|
||||
|
||||
/** Propiedades */
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
unit: Object
|
||||
});
|
||||
|
||||
/** Formulario */
|
||||
const form = useForm({
|
||||
name: '',
|
||||
abbreviation: '',
|
||||
allows_decimals: false,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
/** Observadores */
|
||||
watch(() => props.unit, (newUnit) => {
|
||||
if (newUnit) {
|
||||
form.name = newUnit.name || '';
|
||||
form.abbreviation = newUnit.abbreviation || '';
|
||||
form.allows_decimals = !!newUnit.allows_decimals;
|
||||
form.is_active = !!newUnit.is_active;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
/** Métodos */
|
||||
const updateUnit = () => {
|
||||
form.put(apiURL(`unidades-medida/${props.unit.id}`), {
|
||||
onSuccess: () => {
|
||||
window.Notify.success('Unidad de medida actualizada exitosamente');
|
||||
emit('updated');
|
||||
closeModal();
|
||||
},
|
||||
onError: () => {
|
||||
window.Notify.error('Error al actualizar la unidad de medida');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
form.reset();
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="show" max-width="md" @close="closeModal">
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Editar Unidad de Medida
|
||||
</h3>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulario -->
|
||||
<form @submit.prevent="updateUnit" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Nombre -->
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NOMBRE
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="Ej: Kilogramo"
|
||||
required
|
||||
/>
|
||||
<FormError :message="form.errors?.name" />
|
||||
</div>
|
||||
|
||||
<!-- Abreviación -->
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
ABREVIACIÓN
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.abbreviation"
|
||||
type="text"
|
||||
placeholder="Ej: kg"
|
||||
required
|
||||
/>
|
||||
<FormError :message="form.errors?.abbreviation" />
|
||||
</div>
|
||||
|
||||
<!-- Permite decimales -->
|
||||
<div class="col-span-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="form.allows_decimals"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Permite cantidades decimales
|
||||
</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-6">
|
||||
Actívalo para unidades como kg, litros o metros. Desactívalo para piezas, cajas o unidades enteras.
|
||||
</p>
|
||||
<FormError :message="form.errors?.allows_decimals" />
|
||||
</div>
|
||||
|
||||
<!-- Activo -->
|
||||
<div class="col-span-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="form.is_active"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500 dark:focus:ring-indigo-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Unidad activa
|
||||
</span>
|
||||
</label>
|
||||
<FormError :message="form.errors?.is_active" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
<div class="flex items-center justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="form.processing">Actualizando...</span>
|
||||
<span v-else>Actualizar</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
183
src/pages/POS/UnitMeasure/Index.vue
Normal file
183
src/pages/POS/UnitMeasure/Index.vue
Normal file
@ -0,0 +1,183 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { api, useSearcher, apiURL } from '@Services/Api';
|
||||
import { can } from './Module.js';
|
||||
|
||||
import SearcherHead from '@Holos/Searcher.vue';
|
||||
import Table from '@Holos/Table.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
import CreateModal from './Create.vue';
|
||||
import EditModal from './Edit.vue';
|
||||
|
||||
/** Estado */
|
||||
const units = ref([]);
|
||||
const showCreateModal = ref(false);
|
||||
const showEditModal = ref(false);
|
||||
const editingUnit = ref(null);
|
||||
|
||||
/** Búsqueda / paginación */
|
||||
const searcher = useSearcher({
|
||||
url: apiURL('unidades-medida'),
|
||||
onSuccess: (r) => {
|
||||
units.value = r.units;
|
||||
},
|
||||
onError: () => { units.value = []; }
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const openCreateModal = () => { showCreateModal.value = true; };
|
||||
const closeCreateModal = () => { showCreateModal.value = false; };
|
||||
|
||||
const openEditModal = (unit) => {
|
||||
editingUnit.value = unit;
|
||||
showEditModal.value = true;
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false;
|
||||
editingUnit.value = null;
|
||||
};
|
||||
|
||||
const onUnitSaved = () => { searcher.search(); };
|
||||
|
||||
const deleteUnit = (unit) => {
|
||||
if (!confirm(`¿Eliminar la unidad "${unit.name}"? Esta acción no se puede deshacer.`)) return;
|
||||
|
||||
api.delete(apiURL(`unidades-medida/${unit.id}`), {
|
||||
onSuccess: () => {
|
||||
window.Notify.success('Unidad de medida eliminada');
|
||||
searcher.search();
|
||||
},
|
||||
onFail: (data) => {
|
||||
window.Notify.error(data.message || 'No se puede eliminar esta unidad porque está en uso');
|
||||
},
|
||||
onError: () => {
|
||||
window.Notify.error('Error de conexión al eliminar la unidad');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Ciclo de vida */
|
||||
onMounted(() => { searcher.search(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SearcherHead
|
||||
title="Unidades de Medida"
|
||||
placeholder="Buscar por nombre o abreviación..."
|
||||
@search="(x) => searcher.search(x)"
|
||||
>
|
||||
<button
|
||||
v-if="can('create')"
|
||||
class="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||
@click="openCreateModal"
|
||||
>
|
||||
<GoogleIcon name="add" class="text-xl" />
|
||||
Nueva Unidad
|
||||
</button>
|
||||
</SearcherHead>
|
||||
|
||||
<div class="pt-2 w-full">
|
||||
<Table
|
||||
:items="units"
|
||||
@send-pagination="(page) => searcher.pagination(page)"
|
||||
>
|
||||
<template #head>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOMBRE</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ABREVIACIÓN</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DECIMALES</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ACCIONES</th>
|
||||
</template>
|
||||
|
||||
<template #body="{ items }">
|
||||
<tr
|
||||
v-for="unit in items"
|
||||
:key="unit.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ unit.name }}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-mono font-semibold bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
{{ unit.abbreviation }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
unit.allows_decimals
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||
]"
|
||||
>
|
||||
{{ unit.allows_decimals ? 'Sí' : 'No' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
unit.is_active
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
]"
|
||||
>
|
||||
{{ unit.is_active ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
v-if="can('edit')"
|
||||
@click="openEditModal(unit)"
|
||||
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||
title="Editar unidad"
|
||||
>
|
||||
<GoogleIcon name="edit" class="text-xl" />
|
||||
</button>
|
||||
<button
|
||||
v-if="can('destroy')"
|
||||
@click="deleteUnit(unit)"
|
||||
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||
title="Eliminar unidad"
|
||||
>
|
||||
<GoogleIcon name="delete" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<td colspan="5" class="table-cell text-center">
|
||||
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<GoogleIcon name="straighten" class="text-6xl mb-2 opacity-50" />
|
||||
<p class="font-semibold">No hay unidades de medida registradas</p>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Modal Crear -->
|
||||
<CreateModal
|
||||
v-if="can('create')"
|
||||
:show="showCreateModal"
|
||||
@close="closeCreateModal"
|
||||
@created="onUnitSaved"
|
||||
/>
|
||||
|
||||
<!-- Modal Editar -->
|
||||
<EditModal
|
||||
v-if="can('edit')"
|
||||
:show="showEditModal"
|
||||
:unit="editingUnit"
|
||||
@close="closeEditModal"
|
||||
@updated="onUnitSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
8
src/pages/POS/UnitMeasure/Module.js
Normal file
8
src/pages/POS/UnitMeasure/Module.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||
|
||||
const can = (permission) => hasPermission(`units.${permission}`)
|
||||
|
||||
const viewTo = ({ name = '', params = {}, query = {} }) =>
|
||||
({ name: `pos.unitMeasure.${name}`, params, query })
|
||||
|
||||
export { can, viewTo }
|
||||
@ -129,6 +129,12 @@ const router = createRouter({
|
||||
name: 'pos.suppliers.index',
|
||||
beforeEnter: (to, from, next) => can(next, 'suppliers.index'),
|
||||
component: () => import('@Pages/POS/Suppliers/Index.vue')
|
||||
},
|
||||
{
|
||||
path: 'unit-measure',
|
||||
name: 'pos.unitMeasure.index',
|
||||
beforeEnter: (to, from, next) => can(next, 'units.index'),
|
||||
component: () => import('@Pages/POS/UnitMeasure/Index.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -39,7 +39,9 @@ const useCart = defineStore('cart', {
|
||||
|
||||
actions: {
|
||||
// Agregar producto al carrito
|
||||
addProduct(product, serialConfig = null) {
|
||||
// config puede incluir: serialNumbers, selectionMode (para seriales)
|
||||
// unit_of_measure_id, unit_price, unit_name (para equivalencias)
|
||||
addProduct(product, config = null) {
|
||||
const key = 'p:' + product.id;
|
||||
const existingItem = this.items.find(item => item.item_key === key);
|
||||
|
||||
@ -61,6 +63,11 @@ const useCart = defineStore('cart', {
|
||||
window.Notify.warning('No hay suficiente stock disponible');
|
||||
}
|
||||
} else {
|
||||
// Determinar precio: usar el de la equivalencia si se proporcionó, si no el base
|
||||
const unitPrice = config?.unit_price !== undefined
|
||||
? parseFloat(config.unit_price)
|
||||
: parseFloat(product.price?.retail_price || 0);
|
||||
|
||||
// Agregar nuevo item
|
||||
this.items.push({
|
||||
item_key: key,
|
||||
@ -70,16 +77,19 @@ const useCart = defineStore('cart', {
|
||||
product_name: product.name,
|
||||
sku: product.sku,
|
||||
quantity: 1,
|
||||
unit_price: parseFloat(product.price?.retail_price || 0),
|
||||
unit_price: unitPrice,
|
||||
tax_rate: parseFloat(product.price?.tax || 16),
|
||||
max_stock: product.stock,
|
||||
// Campos para seriales
|
||||
track_serials: product.track_serials || false,
|
||||
serial_numbers: serialConfig?.serialNumbers || [],
|
||||
serial_selection_mode: serialConfig?.selectionMode || null,
|
||||
// Campos para unidad de medida
|
||||
serial_numbers: config?.serialNumbers || [],
|
||||
serial_selection_mode: config?.selectionMode || null,
|
||||
// Campos para unidad de medida base
|
||||
unit_of_measure: product.unit_of_measure || null,
|
||||
allows_decimals: product.unit_of_measure?.allows_decimals || false
|
||||
allows_decimals: product.unit_of_measure?.allows_decimals || false,
|
||||
// Campos para equivalencia de unidad seleccionada
|
||||
unit_of_measure_id: config?.unit_of_measure_id || null,
|
||||
unit_name: config?.unit_name || null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
25
src/utils/fiscalData.js
Normal file
25
src/utils/fiscalData.js
Normal file
@ -0,0 +1,25 @@
|
||||
export const regimenFiscalOptions = [
|
||||
{ value: '601', label: '601 - General de Ley Personas Morales' },
|
||||
{ value: '603', label: '603 - Personas Morales con Fines no Lucrativos' },
|
||||
{ value: '610', label: '610 - Residentes en el Extranjero sin Establecimiento Permanente en México' },
|
||||
{ value: '620', label: '620 - Sociedades Cooperativas de Producción que optan por diferir sus ingresos' },
|
||||
{ value: '622', label: '622 - Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras' },
|
||||
{ value: '623', label: '623 - Opcional para Grupos de Sociedades' },
|
||||
{ value: '624', label: '624 - Coordinados' },
|
||||
{ value: '626', label: '626 - Régimen Simplificado de Confianza' },
|
||||
];
|
||||
|
||||
export const usoCfdiOptions = [
|
||||
{ value: 'G01', label: 'G01 - Adquisición de mercancías' },
|
||||
{ value: 'G02', label: 'G02 - Devoluciones, descuentos o bonificaciones' },
|
||||
{ value: 'G03', label: 'G03 - Gastos en general' },
|
||||
{ value: 'I01', label: 'I01 - Construcciones' },
|
||||
{ value: 'I02', label: 'I02 - Mobiliario y equipo de oficina por inversiones' },
|
||||
{ value: 'I03', label: 'I03 - Equipo de transporte' },
|
||||
{ value: 'I04', label: 'I04 - Equipo de computo y accesorios' },
|
||||
{ value: 'I05', label: 'I05 - Dados, troqueles, moldes, matrices y herramental' },
|
||||
{ value: 'I06', label: 'I06 - Comunicaciones telefónicas' },
|
||||
{ value: 'I07', label: 'I07 - Comunicaciones satelitales' },
|
||||
{ value: 'I08', label: 'I08 - Otra maquinaria y equipo' },
|
||||
{ value: 'S01', label: 'S01 - Sin efectos fiscales' },
|
||||
];
|
||||
Loading…
x
Reference in New Issue
Block a user