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> </span>
</div> </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 --> <!-- 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"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ item.unit_of_measure.name }} ({{ item.unit_of_measure.abbreviation }}) - Permite decimales {{ item.unit_of_measure.name }} ({{ item.unit_of_measure.abbreviation }}) - Permite decimales
</p> </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', returns: 'Devoluciones',
clients: 'Clientes', clients: 'Clientes',
suppliers: 'Proveedores', suppliers: 'Proveedores',
unitMeasure: 'Unidades de medida',
clientTiers: 'Niveles de Clientes', clientTiers: 'Niveles de Clientes',
billingRequests: 'Solicitudes de Facturación', billingRequests: 'Solicitudes de Facturación',
warehouses: 'Almacenes', warehouses: 'Almacenes',

View File

@ -75,6 +75,11 @@ onMounted(() => {
name="pos.suppliers" name="pos.suppliers"
to="pos.suppliers.index" to="pos.suppliers.index"
/> />
<SubLink
icon="scale"
name="pos.unitMeasure"
to="pos.unitMeasure.index"
/>
</DropdownMenu> </DropdownMenu>
<Link <Link
v-if="hasPermission('movements.index')" v-if="hasPermission('movements.index')"

View File

@ -44,12 +44,6 @@ const suggestedPrice = computed(() => {
}); });
/** Métodos */ /** Métodos */
const calculateTax = () => {
if (form.retail_price && !form.tax) {
form.tax = (parseFloat(form.retail_price) * 0.16).toFixed(2);
}
};
const handleProductSelect = (product) => { const handleProductSelect = (product) => {
if (selectedProducts.value.find(item => item.product.id === product.id)) { if (selectedProducts.value.find(item => item.product.id === product.id)) {
Notify.warning('Este producto ya está agregado'); Notify.warning('Este producto ya está agregado');
@ -213,7 +207,6 @@ watch(() => props.show, (val) => {
</label> </label>
<FormInput <FormInput
v-model.number="form.retail_price" v-model.number="form.retail_price"
@blur="calculateTax"
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"

View File

@ -46,12 +46,6 @@ const suggestedPrice = computed(() => {
}); });
/** Métodos */ /** Métodos */
const calculateTax = () => {
if (form.retail_price && !form.tax) {
form.tax = (parseFloat(form.retail_price) * 0.16).toFixed(2);
}
};
const handleProductSelect = (product) => { const handleProductSelect = (product) => {
if (selectedProducts.value.find(item => item.product.id === product.id)) { if (selectedProducts.value.find(item => item.product.id === product.id)) {
Notify.warning('Este producto ya está agregado'); Notify.warning('Este producto ya está agregado');
@ -72,7 +66,6 @@ const updateQuantity = (index, quantity) => {
const useSuggestedPrice = () => { const useSuggestedPrice = () => {
form.retail_price = suggestedPrice.value.toFixed(2); form.retail_price = suggestedPrice.value.toFixed(2);
calculateTax();
}; };
const updateBundle = () => { const updateBundle = () => {
@ -229,7 +222,6 @@ watch(() => props.bundle, (bundle) => {
</label> </label>
<FormInput <FormInput
v-model.number="form.retail_price" v-model.number="form.retail_price"
@blur="calculateTax"
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"

View File

@ -4,6 +4,8 @@ import { useForm, apiURL } from '@Services/Api';
import Modal from '@Holos/Modal.vue'; import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue'; import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue'; import FormError from '@Holos/Form/Elements/Error.vue';
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
import SelectRegimenFiscal from '@Components/POS/RegimenSelecto.vue';
/** Eventos */ /** Eventos */
const emit = defineEmits(['close', 'created']); const emit = defineEmits(['close', 'created']);
@ -152,18 +154,10 @@ const closeModal = () => {
</div> </div>
<!-- REGIMEN FISCAL--> <!-- REGIMEN FISCAL-->
<div> <SelectRegimenFiscal
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5"> v-model="form.regimen_fiscal"
REGIMEN FISCAL :error="form.errors?.regimen_fiscal"
</label> />
<FormInput
v-model="form.regimen_fiscal"
type="text"
placeholder="Regimen Fiscal"
required
/>
<FormError :message="form.errors?.regimen_fiscal" />
</div>
<!-- CP FISCAL--> <!-- CP FISCAL-->
<div> <div>
@ -180,18 +174,10 @@ const closeModal = () => {
</div> </div>
<!-- USO CFDI --> <!-- USO CFDI -->
<div> <SelectUsoCfdi
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5"> v-model="form.uso_cfdi"
USO DE CFDI :error="form.errors?.uso_cfdi"
</label> />
<FormInput
v-model="form.uso_cdfi"
type="text"
placeholder="03 - Gastos en general"
required
/>
<FormError :message="form.errors?.uso_cdfi" />
</div>
</div> </div>
<!-- Botones --> <!-- Botones -->

View File

@ -5,6 +5,8 @@ import { useForm, apiURL } from '@Services/Api';
import Modal from '@Holos/Modal.vue'; import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue'; import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue'; import FormError from '@Holos/Form/Elements/Error.vue';
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
import SelectRegimenFiscal from '@Components/POS/RegimenSelecto.vue';
/** Eventos */ /** Eventos */
const emit = defineEmits(['close', 'updated']); const emit = defineEmits(['close', 'updated']);
@ -168,21 +170,7 @@ watch(() => props.client, (newClient) => {
/> />
<FormError :message="form.errors?.razon_social" /> <FormError :message="form.errors?.razon_social" />
</div> </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--> <!-- CP FISCAL-->
<div> <div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5"> <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" /> <FormError :message="form.errors?.cp_fiscal" />
</div> </div>
<!-- REGIMEN FISCAL-->
<SelectRegimenFiscal
v-model="form.regimen_fiscal"
:error="form.errors?.regimen_fiscal"
/>
<!-- USO CFDI --> <!-- USO CFDI -->
<div> <SelectUsoCfdi
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5"> v-model="form.uso_cfdi"
USO CFDI :error="form.errors?.uso_cfdi"
</label> />
<FormInput
v-model="form.uso_cfdi"
type="text"
placeholder="03 - GASTOS EN GENERAL"
required
/>
<FormError :message="form.errors?.uso_cfdi" />
</div>
</div> </div>
<!-- Botones --> <!-- Botones -->

View File

@ -2,6 +2,10 @@
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useSearcher, apiURL } from '@Services/Api'; import { useSearcher, apiURL } from '@Services/Api';
import { can } from './Module.js'; 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 SearcherHead from '@Holos/Searcher.vue';
import ExcelModal from '@Components/POS/ExcelClient.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> <p class="text-sm text-gray-700 dark:text-gray-300">{{ client.razon_social }}</p>
</td> </td>
<td class="px-6 py-4 text-center"> <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>
<td class="px-6 py-4 text-center"> <td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.cp_fiscal }}</p> <p class="text-sm text-gray-700 dark:text-gray-300">{{ client.cp_fiscal }}</p>
</td> </td>
<td class="px-6 py-4 text-center"> <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>
<td class="px-6 py-4 whitespace-nowrap text-center"> <td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2"> <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 Loader from '@Shared/Loader.vue';
import Input from '@Holos/Form/Input.vue'; import Input from '@Holos/Form/Input.vue';
import PrimaryButton from '@Holos/Button/Primary.vue'; import PrimaryButton from '@Holos/Button/Primary.vue';
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
import SelectRegimenFiscal from '@Components/POS/RegimenSelector.vue';
/** Definidores */ /** Definidores */
const route = useRoute(); const route = useRoute();
@ -43,32 +45,6 @@ const paymentMethods = [
{ value: 'debit_card', label: 'Tarjeta de Débito' } { 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 paymentMethodLabel = computed(() => {
const method = paymentMethods.find(m => saleData.value?.payment_method?.includes(m.value)); const method = paymentMethods.find(m => saleData.value?.payment_method?.includes(m.value));
return method?.label || saleData.value?.payment_method || 'N/A'; return method?.label || saleData.value?.payment_method || 'N/A';
@ -93,16 +69,9 @@ const canRequestInvoice = computed(() => {
return latestRequest.value.status === 'rejected'; return latestRequest.value.status === 'rejected';
}); });
/** Helpers para mostrar labels legibles */ /** Helpers */
const getRegimenFiscalLabel = (value) => { const getRegimenFiscalLabel = (value) => value || 'No registrado';
const option = regimenFiscalOptions.find(o => o.value === value); const getUsoCfdiLabel = (value) => value || 'No registrado';
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';
};
/** Métodos */ /** Métodos */
const fetchSaleData = () => { const fetchSaleData = () => {
@ -113,8 +82,6 @@ const fetchSaleData = () => {
.then(({ data }) => { .then(({ data }) => {
if (data.status === 'success') { if (data.status === 'success') {
saleData.value = data.data.sale; saleData.value = data.data.sale;
// Si la venta ya tiene un cliente asociado, cargar sus datos
if (data.data.client) { if (data.data.client) {
clientData.value = data.data.client; clientData.value = data.data.client;
fillFormWithClient(data.data.client); fillFormWithClient(data.data.client);
@ -136,9 +103,6 @@ const fetchSaleData = () => {
}); });
}; };
/**
* Llenar el formulario con los datos del cliente
*/
const fillFormWithClient = (client) => { const fillFormWithClient = (client) => {
form.value = { form.value = {
name: client.name || '', name: client.name || '',
@ -153,9 +117,6 @@ const fillFormWithClient = (client) => {
}; };
}; };
/**
* Buscar cliente por RFC
*/
const searchClientByRfc = () => { const searchClientByRfc = () => {
const rfc = rfcSearch.value?.trim().toUpperCase(); const rfc = rfcSearch.value?.trim().toUpperCase();
@ -174,7 +135,6 @@ const searchClientByRfc = () => {
window.axios.get(apiURL(`facturacion/check-rfc?rfc=${rfc}`)) window.axios.get(apiURL(`facturacion/check-rfc?rfc=${rfc}`))
.then(({ data }) => { .then(({ data }) => {
// La respuesta viene: { status: 'success', data: { exists: true, client: {...} } }
if (data.status === 'success' && data.data?.exists && data.data?.client) { if (data.status === 'success' && data.data?.exists && data.data?.client) {
clientData.value = data.data.client; clientData.value = data.data.client;
fillFormWithClient(data.data.client); fillFormWithClient(data.data.client);
@ -195,9 +155,6 @@ const searchClientByRfc = () => {
}); });
}; };
/**
* Manejar Enter en el input de búsqueda
*/
const handleSearchKeypress = (event) => { const handleSearchKeypress = (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
@ -205,9 +162,6 @@ const handleSearchKeypress = (event) => {
} }
}; };
/**
* Limpiar datos del cliente y volver al formulario limpio
*/
const clearFoundClient = () => { const clearFoundClient = () => {
clientData.value = null; clientData.value = null;
rfcSearchError.value = ''; rfcSearchError.value = '';
@ -230,7 +184,7 @@ const submitForm = () => {
formErrors.value = {}; formErrors.value = {};
window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value) window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value)
.then(({ data }) => { .then(() => {
submitted.value = true; submitted.value = true;
}) })
.catch(({ response }) => { .catch(({ response }) => {
@ -247,7 +201,6 @@ const submitForm = () => {
}); });
}; };
/** Ciclos */
onMounted(() => { onMounted(() => {
fetchSaleData(); fetchSaleData();
}); });
@ -649,30 +602,11 @@ onMounted(() => {
:onError="formErrors.razon_social" :onError="formErrors.razon_social"
/> />
<!-- Régimen Fiscal Select --> <SelectRegimenFiscal
<div> v-model="form.regimen_fiscal"
<label for="regimen_fiscal" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5"> :error="formErrors.regimen_fiscal?.[0]"
Régimen Fiscal * required
</label> />
<select
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>
<Input <Input
v-model="form.cp_fiscal" v-model="form.cp_fiscal"
@ -683,30 +617,11 @@ onMounted(() => {
:onError="formErrors.cp_fiscal" :onError="formErrors.cp_fiscal"
/> />
<!-- Uso CFDI Select --> <SelectUsoCfdi
<div> v-model="form.uso_cfdi"
<label for="uso_cfdi" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5"> :error="formErrors.uso_cfdi?.[0]"
Uso de CFDI * required
</label> />
<select
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>
<div class="md:col-span-2"> <div class="md:col-span-2">
<Input <Input

View File

@ -91,7 +91,10 @@ const validateSerialsAndUnit = () => {
}; };
const createProduct = () => { 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: () => { onSuccess: () => {
Notify.success('Producto creado exitosamente'); Notify.success('Producto creado exitosamente');
emit('created'); emit('created');
@ -167,10 +170,9 @@ watch(() => form.track_serials, () => {
</label> </label>
<FormInput <FormInput
v-model="form.key_sat" v-model="form.key_sat"
type="text" type="string"
placeholder="Clave SAT del producto" placeholder="Clave SAT del producto"
required maxlength="8"
maxlength="9"
/> />
<FormError :message="form.errors?.key_sat" /> <FormError :message="form.errors?.key_sat" />
</div> </div>
@ -251,25 +253,6 @@ watch(() => form.track_serials, () => {
</p> </p>
</div> </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 --> <!-- Precio de Venta -->
<div> <div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5"> <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> <script setup>
import { ref, watch, computed } from 'vue'; import { ref, watch, computed } from 'vue';
import { useRouter } from 'vue-router'; 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 Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue'; import FormInput from '@Holos/Form/Input.vue';
@ -22,8 +22,18 @@ const props = defineProps({
/** Estado */ /** Estado */
const categories = ref([]); const categories = ref([]);
const units = 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({ const form = useForm({
name: '', name: '',
key_sat: '', key_sat: '',
@ -36,6 +46,13 @@ const form = useForm({
track_serials: false track_serials: false
}); });
/** Formulario de equivalencia */
const eqForm = useForm({
unit_of_measure_id: null,
conversion_factor: '',
retail_price: ''
});
/** Computed */ /** Computed */
const selectedUnit = computed(() => { const selectedUnit = computed(() => {
if (!form.unit_of_measure_id) return null; if (!form.unit_of_measure_id) return null;
@ -44,7 +61,23 @@ const selectedUnit = computed(() => {
const canUseSerials = computed(() => { const canUseSerials = computed(() => {
if (!selectedUnit.value) return true; 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 */ /** 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 = () => { const validateSerialsAndUnit = () => {
if (form.track_serials && selectedUnit.value && selectedUnit.value.allows_decimals) { 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.'); Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
@ -90,7 +141,14 @@ const validateSerialsAndUnit = () => {
}; };
const updateProduct = () => { 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: () => { onSuccess: () => {
Notify.success('Producto actualizado exitosamente'); Notify.success('Producto actualizado exitosamente');
emit('updated'); emit('updated');
@ -104,14 +162,100 @@ const updateProduct = () => {
const closeModal = () => { const closeModal = () => {
form.reset(); form.reset();
activeTab.value = 'general';
showEquivalenceForm.value = false;
editingEquivalence.value = null;
equivalences.value = [];
baseUnit.value = null;
emit('close'); emit('close');
}; };
const openSerials = () => { 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(); closeModal();
router.push({ name: 'pos.inventory.serials', params: { id: props.product.id } }); 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 */ /** Observadores */
watch(() => props.product, (newProduct) => { watch(() => props.product, (newProduct) => {
if (newProduct) { if (newProduct) {
@ -124,7 +268,9 @@ watch(() => props.product, (newProduct) => {
form.cost = parseFloat(newProduct.price?.cost || 0); form.cost = parseFloat(newProduct.price?.cost || 0);
form.retail_price = parseFloat(newProduct.price?.retail_price || 0); form.retail_price = parseFloat(newProduct.price?.retail_price || 0);
form.tax = parseFloat(newProduct.price?.tax || 16); 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 }); }, { immediate: true });
@ -132,6 +278,7 @@ watch(() => props.show, (newValue) => {
if (newValue) { if (newValue) {
loadCategories(); loadCategories();
loadUnits(); loadUnits();
activeTab.value = 'general';
} }
}); });
@ -139,8 +286,10 @@ watch(() => form.unit_of_measure_id, () => {
validateSerialsAndUnit(); validateSerialsAndUnit();
}); });
watch(() => form.track_serials, () => { watch(activeTab, (tab) => {
validateSerialsAndUnit(); if (tab === 'equivalences' && props.product?.id) {
loadEquivalences();
}
}); });
</script> </script>
@ -148,7 +297,7 @@ watch(() => form.track_serials, () => {
<Modal :show="show" max-width="md" @close="closeModal"> <Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6"> <div class="p-6">
<!-- Header --> <!-- 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"> <h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Editar Producto Editar Producto
</h3> </h3>
@ -162,200 +311,420 @@ watch(() => form.track_serials, () => {
</button> </button>
</div> </div>
<!-- Formulario --> <!-- Tabs -->
<form @submit.prevent="updateProduct" class="space-y-4"> <div class="flex border-b border-gray-200 dark:border-gray-700 mb-5">
<div class="grid grid-cols-2 gap-4"> <button
<!-- Nombre --> @click="activeTab = 'general'"
<div class="col-span-2"> :class="[
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5"> 'px-4 py-2 text-sm font-medium border-b-2 transition-colors',
NOMBRE activeTab === 'general'
</label> ? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
<FormInput : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
v-model="form.name" ]"
type="text" >
placeholder="Nombre del producto" Información General
required </button>
/> <button
<FormError :message="form.errors?.name" /> v-if="canHaveEquivalences"
</div> @click="activeTab = 'equivalences'"
:class="[
<!-- Clave SAT --> 'px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5',
<div class="col-span-2"> activeTab === 'equivalences'
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5"> ? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
CLAVE SAT : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
</label> ]"
<FormInput >
v-model="form.key_sat" Equivalencias
type="number" <span
placeholder="Clave SAT del producto" v-if="equivalences.length > 0"
maxlength="9" 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"
/>
<FormError :message="form.errors?.key_sat" />
</div>
<!-- SKU -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SKU
</label>
<FormInput
v-model="form.sku"
type="text"
placeholder="SKU"
required
/>
<FormError :message="form.errors?.sku" />
</div>
<!-- Código de Barras -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CÓDIGO DE BARRAS
</label>
<FormInput
v-model="form.barcode"
type="text"
placeholder="1234567890123"
maxlength="100"
/>
<FormError :message="form.errors?.barcode" />
</div>
<!-- Categoría -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CATEGORÍA
</label>
<select
v-model="form.category_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="">Seleccionar categoría</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
<FormError :message="form.errors?.category_id" />
</div>
<!-- Unidad de Medida -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
UNIDAD DE MEDIDA *
</label>
<select
v-model="form.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 units"
:key="unit.id"
:value="unit.id"
>
{{ unit.name }} ({{ unit.abbreviation }})
</option>
</select>
<FormError :message="form.errors?.unit_of_measure_id" />
<p v-if="selectedUnit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span v-if="selectedUnit.allows_decimals">Esta unidad permite cantidades decimales (ej: 25.750)</span>
<span v-else>Esta unidad solo permite cantidades enteras</span>
</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">
PRECIO VENTA
</label>
<FormInput
v-model.number="form.retail_price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
required
/>
<FormError :message="form.errors?.retail_price" />
</div>
<!-- Impuesto/Tax -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
IMPUESTO (%)
</label>
<FormInput
v-model.number="form.tax"
type="number"
min="0"
max="100"
step="0.01"
placeholder="16.00"
required
/>
<FormError :message="form.errors?.tax" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-between mt-6">
<button
v-if="canUseSerials"
type="button"
@click="openSerials"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-lg hover:bg-emerald-100 transition-colors"
> >
<GoogleIcon name="qr_code_2" class="text-lg" /> {{ equivalences.length }}
Gestionar Seriales </span>
</button> </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
<GoogleIcon name="qr_code_2" class="text-lg opacity-50" /> v-else
Gestionar Seriales class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-300 dark:text-gray-600 cursor-not-allowed"
</div> title="No disponible para productos con rastreo de seriales"
<div class="flex items-center gap-3"> >
<button Equivalencias
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>
</div> </div>
</form> </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 -->
<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="Nombre del producto"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Clave SAT -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CLAVE SAT
</label>
<FormInput
v-model="form.key_sat"
type="number"
placeholder="Clave SAT del producto"
/>
<FormError :message="form.errors?.key_sat" />
</div>
<!-- SKU -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SKU
</label>
<FormInput
v-model="form.sku"
type="text"
placeholder="SKU"
required
/>
<FormError :message="form.errors?.sku" />
</div>
<!-- Código de Barras -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CÓDIGO DE BARRAS
</label>
<FormInput
v-model="form.barcode"
type="text"
placeholder="1234567890123"
maxlength="100"
/>
<FormError :message="form.errors?.barcode" />
</div>
<!-- Categoría -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CATEGORÍA
</label>
<select
v-model="form.category_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="">Seleccionar categoría</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
<FormError :message="form.errors?.category_id" />
</div>
<!-- Unidad de Medida -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
UNIDAD DE MEDIDA *
</label>
<select
v-model="form.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 units"
:key="unit.id"
:value="unit.id"
>
{{ unit.name }} ({{ unit.abbreviation }})
</option>
</select>
<FormError :message="form.errors?.unit_of_measure_id" />
<p v-if="selectedUnit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span v-if="selectedUnit.allows_decimals">Esta unidad permite cantidades decimales (ej: 25.750)</span>
<span v-else>Esta unidad solo permite cantidades enteras</span>
</p>
</div>
<!-- Precio de Venta -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PRECIO VENTA
</label>
<FormInput
v-model.number="form.retail_price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
required
/>
<FormError :message="form.errors?.retail_price" />
</div>
<!-- Impuesto/Tax -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
IMPUESTO (%)
</label>
<FormInput
v-model.number="form.tax"
type="number"
min="0"
max="100"
step="0.01"
placeholder="16.00"
required
/>
<FormError :message="form.errors?.tax" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-between mt-6">
<button
v-if="canUseSerials"
type="button"
@click="openSerials"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-lg hover:bg-emerald-100 transition-colors"
>
<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="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>
<div class="flex items-center gap-3">
<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>
</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> </div>
</Modal> </Modal>
</template> </template>

View File

@ -372,6 +372,9 @@ onMounted(() => {
> >
{{ model.stock }} {{ model.stock }}
</span> </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>
<td class="px-6 py-4 whitespace-nowrap text-center"> <td class="px-6 py-4 whitespace-nowrap text-center">
<div class="text-sm"> <div class="text-sm">

View File

@ -218,9 +218,9 @@ watch(() => props.show, (isShown) => {
// Cargar datos del movimiento // Cargar datos del movimiento
form.quantity = props.movement.quantity || 0; form.quantity = props.movement.quantity || 0;
form.unit_cost = props.movement.unit_cost || 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.invoice_reference = props.movement.invoice_reference || '';
form.notes = props.movement.notes || ''; form.notes = props.movement.notes || '';
form.supplier_id = props.movement.supplier_id || null;
// Cargar números de serie si existen // Cargar números de serie si existen
if (props.movement.serials && props.movement.serials.length > 0) { if (props.movement.serials && props.movement.serials.length > 0) {

View File

@ -33,6 +33,9 @@ const productSuggestions = ref([]);
const showProductSuggestions = ref(false); const showProductSuggestions = ref(false);
const currentSearchIndex = ref(null); // Índice del producto que se está buscando const currentSearchIndex = ref(null); // Índice del producto que se está buscando
// Cache de equivalencias por producto
const equivalencesCache = ref({});
const api = useApi(); const api = useApi();
/** Formulario */ /** 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 = () => { const addProduct = () => {
selectedProducts.value.push({ selectedProducts.value.push({
inventory_id: '', inventory_id: '',
@ -102,8 +133,12 @@ const addProduct = () => {
track_serials: false, track_serials: false,
unit_of_measure: null, unit_of_measure: null,
allows_decimals: false, allows_decimals: false,
serial_numbers_list: [{ serial_number: '', locked: false }], // Inputs individuales de seriales serial_numbers_list: [{ serial_number: '', locked: false }],
serial_validation_error: '' 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) { if (currentSearchIndex.value !== null) {
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id; const item = selectedProducts.value[currentSearchIndex.value];
selectedProducts.value[currentSearchIndex.value].product_name = product.name; item.inventory_id = product.id;
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku; item.product_name = product.name;
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false; item.product_sku = product.sku;
selectedProducts.value[currentSearchIndex.value].unit_of_measure = product.unit_of_measure || null; item.track_serials = product.track_serials || false;
selectedProducts.value[currentSearchIndex.value].allows_decimals = product.unit_of_measure?.allows_decimals || 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 // Limpiar seriales si la unidad permite decimales
if (product.unit_of_measure?.allows_decimals) { 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, inventory_id: item.inventory_id,
quantity: Number(item.quantity), quantity: Number(item.quantity),
unit_cost: Number(item.unit_cost), 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 // Agregar seriales solo si la unidad lo permite y hay seriales ingresados
if (canUseSerials(item) && item.serial_numbers_list) { if (canUseSerials(item) && item.serial_numbers_list) {
const serials = item.serial_numbers_list const serials = item.serial_numbers_list
@ -367,6 +416,24 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
:key="index" :key="index"
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50" 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"> <div class="grid grid-cols-12 gap-3 items-start">
<!-- Producto --> <!-- Producto -->
<div class="col-span-12 sm:col-span-5"> <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"> <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"> <label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Cantidad Cantidad
<span v-if="getSelectedEquivalence(item)" class="text-indigo-600 dark:text-indigo-400">
({{ getSelectedEquivalence(item).unit_abbreviation }})
</span>
</label> </label>
<input <input
v-model="item.quantity" v-model="item.quantity"
type="number" type="number"
min="1" min="1"
step="1" :step="item.allows_decimals && !item.selected_unit_id ? '0.001' : '1'"
placeholder="0" placeholder="0"
:disabled="item.track_serials && canUseSerials(item)" :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" 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 Controlado por seriales
</p> </p>
</div> </div>
@ -465,7 +539,9 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
<!-- Costo unitario --> <!-- Costo unitario -->
<div class="col-span-5 sm:col-span-3"> <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"> <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> </label>
<input <input
v-model="item.unit_cost" 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 QRscan from '@Components/POS/QRscan.vue';
import SerialSelector from '@Components/POS/SerialSelector.vue'; import SerialSelector from '@Components/POS/SerialSelector.vue';
import BundleSerialSelector from '@Components/POS/BundleSerialSelector.vue'; import BundleSerialSelector from '@Components/POS/BundleSerialSelector.vue';
import UnitEquivalenceSelector from '@Components/POS/UnitEquivalenceSelector.vue';
/** i18n */ /** i18n */
const { t } = useI18n(); const { t } = useI18n();
@ -46,6 +47,12 @@ const serialSelectorProduct = ref(null);
const showBundleSerialSelector = ref(false); const showBundleSerialSelector = ref(false);
const bundleSerialSelectorBundle = ref(null); 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 */ /** Buscador de productos */
const searcher = useSearcher({ const searcher = useSearcher({
url: apiURL('inventario'), 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) => { const addToCart = async (product) => {
try { try {
const response = await serialService.getAvailableSerials(product.id); const response = await serialService.getAvailableSerials(product.id);
@ -176,6 +206,27 @@ const addToCart = async (product) => {
return; 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); cart.addProduct(product);
window.Notify.success(`${product.name} agregado al carrito`); window.Notify.success(`${product.name} agregado al carrito`);
} catch (error) { } 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 = () => { const closeSerialSelector = () => {
showSerialSelector.value = false; showSerialSelector.value = false;
serialSelectorProduct.value = null; serialSelectorProduct.value = null;
@ -394,7 +457,7 @@ const handleConfirmSale = async (paymentData) => {
} }
return bundleItem; return bundleItem;
} }
return { const productItem = {
type: 'product', type: 'product',
inventory_id: item.inventory_id, inventory_id: item.inventory_id,
product_name: item.product_name, product_name: item.product_name,
@ -403,6 +466,10 @@ const handleConfirmSale = async (paymentData) => {
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)), subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
serial_numbers: item.track_serials ? (item.serial_numbers || []) : undefined 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" @close="closeBundleSerialSelector"
@confirm="handleBundleSerialConfirm" @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> </div>
</template> </template>

View File

@ -161,7 +161,6 @@ const closeModal = () => {
v-model="form.notes" v-model="form.notes"
type="text" type="text"
placeholder="Notas" placeholder="Notas"
required
/> />
<FormError :message="form.errors?.notes" /> <FormError :message="form.errors?.notes" />
</div> </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', name: 'pos.suppliers.index',
beforeEnter: (to, from, next) => can(next, 'suppliers.index'), beforeEnter: (to, from, next) => can(next, 'suppliers.index'),
component: () => import('@Pages/POS/Suppliers/Index.vue') 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: { actions: {
// Agregar producto al carrito // 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 key = 'p:' + product.id;
const existingItem = this.items.find(item => item.item_key === key); 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'); window.Notify.warning('No hay suficiente stock disponible');
} }
} else { } 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 // Agregar nuevo item
this.items.push({ this.items.push({
item_key: key, item_key: key,
@ -70,16 +77,19 @@ const useCart = defineStore('cart', {
product_name: product.name, product_name: product.name,
sku: product.sku, sku: product.sku,
quantity: 1, quantity: 1,
unit_price: parseFloat(product.price?.retail_price || 0), unit_price: unitPrice,
tax_rate: parseFloat(product.price?.tax || 16), tax_rate: parseFloat(product.price?.tax || 16),
max_stock: product.stock, max_stock: product.stock,
// Campos para seriales // Campos para seriales
track_serials: product.track_serials || false, track_serials: product.track_serials || false,
serial_numbers: serialConfig?.serialNumbers || [], serial_numbers: config?.serialNumbers || [],
serial_selection_mode: serialConfig?.selectionMode || null, serial_selection_mode: config?.selectionMode || null,
// Campos para unidad de medida // Campos para unidad de medida base
unit_of_measure: product.unit_of_measure || null, 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' },
];