feat: agregado unidad de medida crud

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-24 01:29:28 -06:00
parent e653add755
commit cf80e914fd
26 changed files with 1600 additions and 416 deletions

View File

@ -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>

View 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>

View 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>

View 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>

View File

@ -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',

View File

@ -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')"

View File

@ -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"

View File

@ -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"

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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">

View File

@ -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

View File

@ -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">

View File

@ -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 }}
&nbsp;·&nbsp;
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>

View File

@ -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">

View File

@ -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) {

View File

@ -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"

View File

@ -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>

View File

@ -161,7 +161,6 @@ const closeModal = () => {
v-model="form.notes"
type="text"
placeholder="Notas"
required
/>
<FormError :message="form.errors?.notes" />
</div>

View 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>

View 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>

View 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>

View 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 }

View File

@ -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')
}
]
},

View File

@ -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
View 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' },
];