This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-19 11:55:05 -06:00
parent 46b155c2c8
commit 83dd71f80f
6 changed files with 44 additions and 253 deletions

View File

@ -14,10 +14,6 @@ const props = defineProps({
type: Object, type: Object,
required: true required: true
}, },
quantity: {
type: Number,
required: true
},
excludeSerials: { excludeSerials: {
type: Array, type: Array,
default: () => [] default: () => []
@ -31,7 +27,6 @@ const emit = defineEmits(['close', 'confirm']);
const loading = ref(false); const loading = ref(false);
const availableSerials = ref([]); const availableSerials = ref([]);
const selectedSerials = ref([]); const selectedSerials = ref([]);
const selectionMode = ref('auto'); // 'auto' | 'manual'
const searchQuery = ref(''); const searchQuery = ref('');
/** Computados */ /** Computados */
@ -47,22 +42,12 @@ const filteredSerials = computed(() => {
const selectedCount = computed(() => selectedSerials.value.length); const selectedCount = computed(() => selectedSerials.value.length);
const isComplete = computed(() => { const hasEnoughStock = computed(() => {
if (selectionMode.value === 'auto') { return availableSerials.value.length > 0;
return availableSerials.value.length >= props.quantity;
}
return selectedSerials.value.length === props.quantity;
}); });
const canConfirm = computed(() => { const canConfirm = computed(() => {
if (selectionMode.value === 'auto') { return selectedSerials.value.length > 0;
return availableSerials.value.length >= props.quantity;
}
return selectedSerials.value.length === props.quantity;
});
const hasEnoughStock = computed(() => {
return availableSerials.value.length >= props.quantity;
}); });
/** Métodos */ /** Métodos */
@ -87,11 +72,7 @@ const toggleSerial = (serial) => {
if (index > -1) { if (index > -1) {
selectedSerials.value.splice(index, 1); selectedSerials.value.splice(index, 1);
} else { } else {
if (selectedSerials.value.length < props.quantity) { selectedSerials.value.push(serial);
selectedSerials.value.push(serial);
} else {
Notify.warning(`Solo puedes seleccionar ${props.quantity} serial(es)`);
}
} }
}; };
@ -100,7 +81,7 @@ const isSelected = (serial) => {
}; };
const selectAll = () => { const selectAll = () => {
selectedSerials.value = availableSerials.value.slice(0, props.quantity); selectedSerials.value = [...availableSerials.value];
}; };
const clearSelection = () => { const clearSelection = () => {
@ -108,19 +89,9 @@ const clearSelection = () => {
}; };
const handleConfirm = () => { const handleConfirm = () => {
let serialNumbers = [];
if (selectionMode.value === 'auto') {
// En modo automático, el backend asignará los seriales
serialNumbers = null;
} else {
// En modo manual, enviamos los seriales seleccionados
serialNumbers = selectedSerials.value.map(s => s.serial_number);
}
emit('confirm', { emit('confirm', {
selectionMode: selectionMode.value, serialNumbers: selectedSerials.value.map(s => s.serial_number),
serialNumbers: serialNumbers quantity: selectedSerials.value.length
}); });
}; };
@ -131,7 +102,6 @@ const handleClose = () => {
const resetState = () => { const resetState = () => {
selectedSerials.value = []; selectedSerials.value = [];
searchQuery.value = ''; searchQuery.value = '';
selectionMode.value = 'auto';
}; };
/** Watchers */ /** Watchers */
@ -140,13 +110,7 @@ watch(() => props.show, (isShown) => {
resetState(); resetState();
loadSerials(); loadSerials();
} }
}); },{ immediate: true });
watch(selectionMode, (newMode) => {
if (newMode === 'auto') {
selectedSerials.value = [];
}
});
</script> </script>
<template> <template>
@ -163,7 +127,7 @@ watch(selectionMode, (newMode) => {
Seleccionar Números de Serie Seleccionar Números de Serie
</h3> </h3>
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">
{{ product.name }} - Cantidad: {{ quantity }} {{ product.name }}
</p> </p>
</div> </div>
</div> </div>
@ -199,66 +163,9 @@ watch(selectionMode, (newMode) => {
<!-- Content --> <!-- Content -->
<div v-else class="space-y-6"> <div v-else class="space-y-6">
<!-- Modo de selección -->
<div>
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
Modo de asignación
</h4>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="relative flex items-center gap-3 p-4 rounded-xl border-2 transition-all"
:class="{
'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20 ring-2 ring-emerald-500': selectionMode === 'auto',
'border-gray-200 dark:border-gray-700 hover:border-gray-300': selectionMode !== 'auto'
}"
@click="selectionMode = 'auto'"
>
<div class="w-10 h-10 rounded-lg bg-emerald-500 flex items-center justify-center">
<GoogleIcon name="auto_awesome" class="text-xl text-white" />
</div>
<div class="text-left">
<p class="text-sm font-bold text-gray-800 dark:text-gray-200">
Automático
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Asignar primeros disponibles
</p>
</div>
<div v-if="selectionMode === 'auto'" class="absolute top-2 right-2">
<GoogleIcon name="check_circle" class="text-emerald-500" />
</div>
</button>
<button
type="button"
class="relative flex items-center gap-3 p-4 rounded-xl border-2 transition-all"
:class="{
'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 ring-2 ring-indigo-500': selectionMode === 'manual',
'border-gray-200 dark:border-gray-700 hover:border-gray-300': selectionMode !== 'manual'
}"
@click="selectionMode = 'manual'"
>
<div class="w-10 h-10 rounded-lg bg-indigo-500 flex items-center justify-center">
<GoogleIcon name="touch_app" class="text-xl text-white" />
</div>
<div class="text-left">
<p class="text-sm font-bold text-gray-800 dark:text-gray-200">
Manual
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Elegir seriales específicos
</p>
</div>
<div v-if="selectionMode === 'manual'" class="absolute top-2 right-2">
<GoogleIcon name="check_circle" class="text-indigo-500" />
</div>
</button>
</div>
</div>
<!-- Selección manual --> <!-- Selección manual -->
<div v-if="selectionMode === 'manual'" class="space-y-4"> <div class="space-y-4">
<!-- Buscador y acciones --> <!-- Buscador y acciones -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex-1 relative"> <div class="flex-1 relative">
@ -274,11 +181,11 @@ watch(selectionMode, (newMode) => {
/> />
</div> </div>
<button <button
v-if="selectedCount < quantity" v-if="selectedCount < availableSerials.length"
@click="selectAll" @click="selectAll"
class="px-3 py-2 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors" class="px-3 py-2 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors"
> >
Seleccionar primeros {{ quantity }} Seleccionar todos
</button> </button>
<button <button
v-if="selectedCount > 0" v-if="selectedCount > 0"
@ -297,12 +204,11 @@ watch(selectionMode, (newMode) => {
<span <span
class="font-semibold" class="font-semibold"
:class="{ :class="{
'text-green-600': selectedCount === quantity, 'text-green-600': selectedCount > 0,
'text-amber-600': selectedCount > 0 && selectedCount < quantity,
'text-gray-500': selectedCount === 0 'text-gray-500': selectedCount === 0
}" }"
> >
{{ selectedCount }} / {{ quantity }} seleccionado(s) {{ selectedCount }} seleccionado(s)
</span> </span>
</div> </div>
@ -349,21 +255,6 @@ watch(selectionMode, (newMode) => {
</div> </div>
</div> </div>
</div> </div>
<!-- Info modo automático -->
<div v-else class="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-lg p-4">
<div class="flex gap-3">
<GoogleIcon name="info" class="text-emerald-600 dark:text-emerald-400 text-xl shrink-0" />
<div>
<p class="text-sm font-semibold text-emerald-800 dark:text-emerald-300">
Asignación automática
</p>
<p class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">
Se asignarán automáticamente los primeros {{ quantity }} número(s) de serie disponible(s) al confirmar la venta.
</p>
</div>
</div>
</div>
</div> </div>
<!-- Footer --> <!-- Footer -->

View File

@ -154,51 +154,6 @@ const confirmDelete = async () => {
} }
}; };
// Importación masiva
const openBulkModal = () => {
bulkSerials.value = '';
showBulkModal.value = true;
};
const closeBulkModal = () => {
showBulkModal.value = false;
bulkSerials.value = '';
};
const bulkImport = async () => {
const lines = bulkSerials.value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (lines.length === 0) {
Notify.warning('Ingresa al menos un número de serie');
return;
}
bulkProcessing.value = true;
try {
const response = await serialService.bulkImport(inventoryId.value, lines);
Notify.success(`${response.count || lines.length} números de serie importados`);
if (response.inventory) {
inventory.value = response.inventory;
}
closeBulkModal();
loadSerials();
} catch (error) {
Notify.error('Error al importar números de serie');
} finally {
bulkProcessing.value = false;
}
};
const bulkCount = computed(() => {
return bulkSerials.value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0).length;
});
// Navegación // Navegación
const goBack = () => { const goBack = () => {
router.push({ name: 'pos.inventory.index' }); router.push({ name: 'pos.inventory.index' });
@ -276,15 +231,6 @@ onMounted(() => {
<option value="disponible">Disponible</option> <option value="disponible">Disponible</option>
<option value="vendido">Vendido</option> <option value="vendido">Vendido</option>
</select> </select>
<button
class="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="openBulkModal"
title="Importar múltiples números de serie"
>
<GoogleIcon name="upload" class="text-xl" />
Importar
</button>
<button <button
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" 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" @click="openCreateModal"
@ -360,7 +306,7 @@ onMounted(() => {
class="text-gray-400 text-xs" class="text-gray-400 text-xs"
title="No se puede editar/eliminar un serial vendido" title="No se puede editar/eliminar un serial vendido"
> >
Venta #{{ serial.sale_detail_id }} Venta {{ serial.sale_detail?.sale?.invoice_number || `#${serial.sale_detail_id}` }}
</span> </span>
</div> </div>
</td> </td>
@ -554,56 +500,5 @@ onMounted(() => {
</div> </div>
</div> </div>
</Modal> </Modal>
<!-- Modal Importar Múltiples -->
<Modal :show="showBulkModal" max-width="md" @close="closeBulkModal">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Importar Números de Serie
</h3>
<button
@click="closeBulkModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<GoogleIcon name="close" class="text-xl" />
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NÚMEROS DE SERIE (UNO POR LÍNEA)
</label>
<textarea
v-model="bulkSerials"
class="w-full px-3 py-2 text-sm font-mono 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"
rows="10"
placeholder="ABC123456789&#10;DEF987654321&#10;GHI456123789&#10;..."
></textarea>
<p class="text-xs text-gray-500 mt-1">
{{ bulkCount }} número(s) de serie detectado(s)
</p>
</div>
<div class="flex items-center justify-end gap-3 pt-4">
<button
@click="closeBulkModal"
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"
>
Cancelar
</button>
<button
@click="bulkImport"
:disabled="bulkProcessing || bulkCount === 0"
class="px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span v-if="bulkProcessing">Importando...</span>
<span v-else>Importar {{ bulkCount }} serial(es)</span>
</button>
</div>
</div>
</div>
</Modal>
</div> </div>
</template> </template>

View File

@ -34,7 +34,6 @@ const lastSaleData = ref(null);
// Estado para selector de seriales // Estado para selector de seriales
const showSerialSelector = ref(false); const showSerialSelector = ref(false);
const serialSelectorProduct = ref(null); const serialSelectorProduct = ref(null);
const serialSelectorQuantity = ref(1);
/** Buscador de productos */ /** Buscador de productos */
const searcher = useSearcher({ const searcher = useSearcher({
@ -69,7 +68,6 @@ const addToCart = (product) => {
// Si el producto tiene seriales, mostrar selector // Si el producto tiene seriales, mostrar selector
if (product.has_serials) { if (product.has_serials) {
serialSelectorProduct.value = product; serialSelectorProduct.value = product;
serialSelectorQuantity.value = 1;
showSerialSelector.value = true; showSerialSelector.value = true;
return; return;
} }
@ -84,7 +82,6 @@ const addToCart = (product) => {
const closeSerialSelector = () => { const closeSerialSelector = () => {
showSerialSelector.value = false; showSerialSelector.value = false;
serialSelectorProduct.value = null; serialSelectorProduct.value = null;
serialSelectorQuantity.value = 1;
}; };
const handleSerialConfirm = (serialConfig) => { const handleSerialConfirm = (serialConfig) => {
@ -92,7 +89,7 @@ const handleSerialConfirm = (serialConfig) => {
cart.addProductWithSerials( cart.addProductWithSerials(
serialSelectorProduct.value, serialSelectorProduct.value,
serialSelectorQuantity.value, serialConfig.quantity,
serialConfig serialConfig
); );
@ -468,7 +465,6 @@ onMounted(() => {
:show="showClientModal" :show="showClientModal"
:sale-data="lastSaleData" :sale-data="lastSaleData"
@close="closeClientModal" @close="closeClientModal"
@save="handleClientSave"
/> />
<!-- Modal de Selección de Seriales --> <!-- Modal de Selección de Seriales -->
@ -476,7 +472,6 @@ onMounted(() => {
v-if="serialSelectorProduct" v-if="serialSelectorProduct"
:show="showSerialSelector" :show="showSerialSelector"
:product="serialSelectorProduct" :product="serialSelectorProduct"
:quantity="serialSelectorQuantity"
:exclude-serials="cart.getSelectedSerials()" :exclude-serials="cart.getSelectedSerials()"
@close="closeSerialSelector" @close="closeSerialSelector"
@confirm="handleSerialConfirm" @confirm="handleSerialConfirm"

View File

@ -229,8 +229,9 @@ watch(() => props.show, () => {
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"> <p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ item.product_name }} {{ item.product_name }}
</p> </p>
<p v-if="item.inventory?.sku" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <!-- Muestra los seriales si existen -->
SKU: {{ item.inventory.sku }} <p v-if="item.serials && item.serials.length > 0" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
{{ item.serials.map(s => s.serial_number).join(', ') }}
</p> </p>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">

View File

@ -74,22 +74,28 @@ const useCart = defineStore('cart', {
// Agregar producto con seriales ya configurados // Agregar producto con seriales ya configurados
addProductWithSerials(product, quantity, serialConfig) { addProductWithSerials(product, quantity, serialConfig) {
// Eliminar item existente si hay const existingItem = this.items.find(item => item.inventory_id === product.id);
this.removeProduct(product.id); const newSerials = serialConfig.serialNumbers || [];
// Agregar nuevo item con seriales if (existingItem) {
this.items.push({ // Combinar seriales existentes con los nuevos
inventory_id: product.id, const combinedSerials = [...existingItem.serial_numbers, ...newSerials];
product_name: product.name, existingItem.serial_numbers = combinedSerials;
sku: product.sku, existingItem.quantity = combinedSerials.length;
quantity: quantity, } else {
unit_price: parseFloat(product.price?.retail_price || 0), // Agregar nuevo item con seriales
tax_rate: parseFloat(product.price?.tax || 16), this.items.push({
max_stock: product.stock, inventory_id: product.id,
has_serials: true, product_name: product.name,
serial_numbers: serialConfig.serialNumbers || [], sku: product.sku,
serial_selection_mode: serialConfig.selectionMode quantity: quantity,
}); unit_price: parseFloat(product.price?.retail_price || 0),
tax_rate: parseFloat(product.price?.tax || 16),
max_stock: product.stock,
has_serials: true,
serial_numbers: newSerials
});
}
}, },
// Actualizar seriales de un item // Actualizar seriales de un item

View File

@ -29,7 +29,10 @@ export const formatCurrency = (amount) => {
* formatMoney(null) // "0.00" * formatMoney(null) // "0.00"
*/ */
export const formatMoney = (amount) => { export const formatMoney = (amount) => {
return parseFloat(amount || 0).toFixed(2); return new Intl.NumberFormat('es-MX', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount || 0);
}; };
/** /**