feat: Refactorización de la gestión de series en POS

- Se eliminó el modal para eliminar series en Inventory/Serials.vue y se ajustó el diseño de la tabla.
- Se actualizó Movements/Edit.vue para usar el componente SerialInputList en el manejo de entrada de series.
- Se mejoró EntryModal.vue para utilizar SerialInputList en la captura de series.
- Se introdujo BundleSerialSelector.vue para seleccionar series desde paquetes (bundles).
- Se implementaron mejoras en la gestión de series en cart.js para manejar paquetes con series.
- Se agregó un nuevo método de servicio en serialService.js para obtener componentes de paquetes con seguimiento por series.
- Se creó el componente SerialInputList.vue para una mejor gestión de entrada de series.
- Se limpió ReturnDetail.vue eliminando la funcionalidad de cancelación de devoluciones.
This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-18 21:40:31 -06:00
parent 9b8bf57abd
commit fb37a2d62f
12 changed files with 624 additions and 236 deletions

View File

@ -0,0 +1,296 @@
<script setup>
import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import serialService from '@Services/serialService';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
bundle: {
type: Object,
required: true
},
excludeSerials: {
type: Array,
default: () => []
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Estado */
const loading = ref(true);
const components = ref([]);
const serialsByComponent = ref({});
const selectedByComponent = ref({});
const searchQueries = ref({});
/** Computados */
const canConfirm = computed(() => {
return components.value.every(comp => {
const selected = selectedByComponent.value[comp.inventory_id] || [];
return selected.length === comp.quantity;
});
});
const filteredSerials = (inventoryId) => {
const serials = serialsByComponent.value[inventoryId] || [];
const query = (searchQueries.value[inventoryId] || '').toLowerCase();
if (!query) return serials;
return serials.filter(s => s.serial_number.toLowerCase().includes(query));
};
/** Metodos */
const loadComponents = async () => {
loading.value = true;
try {
const items = await serialService.getBundleComponents(props.bundle.id);
const serialComponents = items.filter(item => {
const inv = item.inventory || item.product || {};
return inv.track_serials || item.track_serials;
});
components.value = serialComponents.map(item => {
const inv = item.inventory || item.product || {};
return {
inventory_id: item.inventory_id || inv.id,
name: inv.name || item.product_name || 'Producto',
quantity: item.quantity || 1
};
});
// Cargar seriales para cada componente
await Promise.all(components.value.map(async (comp) => {
try {
const response = await serialService.getAvailableSerials(comp.inventory_id);
const serials = (response.serials?.data || []).filter(
s => !props.excludeSerials.includes(s.serial_number)
);
serialsByComponent.value[comp.inventory_id] = serials;
} catch {
serialsByComponent.value[comp.inventory_id] = [];
}
selectedByComponent.value[comp.inventory_id] = [];
searchQueries.value[comp.inventory_id] = '';
}));
} catch (error) {
console.error('Error loading bundle components:', error);
components.value = [];
} finally {
loading.value = false;
}
};
const toggleSerial = (inventoryId, serial) => {
const selected = selectedByComponent.value[inventoryId] || [];
const index = selected.findIndex(s => s.id === serial.id);
const comp = components.value.find(c => c.inventory_id === inventoryId);
if (index > -1) {
selected.splice(index, 1);
} else if (selected.length < comp.quantity) {
selected.push(serial);
}
selectedByComponent.value[inventoryId] = selected;
};
const isSelected = (inventoryId, serial) => {
return (selectedByComponent.value[inventoryId] || []).some(s => s.id === serial.id);
};
const handleConfirm = () => {
const serialNumbers = {};
components.value.forEach(comp => {
const selected = selectedByComponent.value[comp.inventory_id] || [];
serialNumbers[comp.inventory_id] = selected.map(s => s.serial_number);
});
emit('confirm', { serialNumbers });
};
const handleClose = () => {
emit('close');
};
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown) {
components.value = [];
serialsByComponent.value = {};
selectedByComponent.value = {};
searchQueries.value = {};
loadComponents();
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="2xl" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-900/30">
<GoogleIcon name="inventory_2" class="text-2xl text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Seleccionar Seriales del Paquete
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ bundle.name }}
</p>
</div>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<GoogleIcon name="close" class="text-xl" />
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-12">
<GoogleIcon name="hourglass_empty" class="text-4xl text-gray-400 animate-spin" />
</div>
<!-- Sin componentes con seriales -->
<div v-else-if="components.length === 0" class="text-center py-8">
<GoogleIcon name="info" class="text-5xl text-gray-400 mb-3" />
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Sin seriales requeridos
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">
Ningún componente de este paquete requiere selección de seriales.
</p>
</div>
<!-- Componentes con seriales -->
<div v-else class="space-y-6">
<div
v-for="comp in components"
:key="comp.inventory_id"
class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
>
<!-- Header del componente -->
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<GoogleIcon name="memory" class="text-lg text-gray-500 shrink-0" />
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ comp.name }}
</span>
</div>
<span
class="text-xs font-medium px-2 py-1 rounded-full shrink-0 whitespace-nowrap"
:class="{
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': (selectedByComponent[comp.inventory_id] || []).length === comp.quantity,
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400': (selectedByComponent[comp.inventory_id] || []).length > 0 && (selectedByComponent[comp.inventory_id] || []).length < comp.quantity,
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': (selectedByComponent[comp.inventory_id] || []).length === 0
}"
>
{{ (selectedByComponent[comp.inventory_id] || []).length }} / {{ comp.quantity }}
</span>
</div>
<!-- Buscador -->
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<div class="relative">
<GoogleIcon
name="search"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm"
/>
<input
v-model="searchQueries[comp.inventory_id]"
type="text"
placeholder="Buscar serial..."
class="w-full pl-9 pr-4 py-1.5 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"
/>
</div>
</div>
<!-- Lista de seriales -->
<div class="max-h-48 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-700">
<button
v-for="serial in filteredSerials(comp.inventory_id)"
:key="serial.id"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors"
:class="{
'bg-indigo-50 dark:bg-indigo-900/20': isSelected(comp.inventory_id, serial),
'hover:bg-gray-50 dark:hover:bg-gray-800': !isSelected(comp.inventory_id, serial),
'opacity-40 cursor-not-allowed': !isSelected(comp.inventory_id, serial) && (selectedByComponent[comp.inventory_id] || []).length >= comp.quantity
}"
:disabled="!isSelected(comp.inventory_id, serial) && (selectedByComponent[comp.inventory_id] || []).length >= comp.quantity"
@click="toggleSerial(comp.inventory_id, serial)"
>
<div
class="w-5 h-5 rounded border-2 flex items-center justify-center transition-colors shrink-0"
:class="{
'border-indigo-500 bg-indigo-500': isSelected(comp.inventory_id, serial),
'border-gray-300 dark:border-gray-600': !isSelected(comp.inventory_id, serial)
}"
>
<GoogleIcon
v-if="isSelected(comp.inventory_id, serial)"
name="check"
class="text-xs text-white"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
{{ serial.serial_number }}
</p>
<p v-if="serial.notes" class="text-xs text-gray-500 dark:text-gray-400 truncate">
{{ serial.notes }}
</p>
</div>
</button>
<!-- Sin stock -->
<div
v-if="(serialsByComponent[comp.inventory_id] || []).length === 0"
class="p-4 text-center text-red-500 text-sm"
>
<GoogleIcon name="error" class="text-xl mb-1" />
<p>No hay seriales disponibles para este producto</p>
</div>
<!-- Sin resultados de búsqueda -->
<div
v-else-if="filteredSerials(comp.inventory_id).length === 0"
class="p-4 text-center text-gray-500 text-sm"
>
<GoogleIcon name="search_off" class="text-xl mb-1" />
<p>No se encontraron seriales</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div v-if="!loading && components.length > 0" class="flex items-center justify-end gap-3 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancelar
</button>
<button
type="button"
@click="handleConfirm"
:disabled="!canConfirm"
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-semibold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<GoogleIcon name="check" class="text-lg" />
Confirmar Seriales
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,174 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue']);
const inputRefs = ref([]);
const serials = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const validCount = computed(() => {
return serials.value.filter(s => s.serial_number.trim()).length;
});
const isDuplicate = (index) => {
const val = serials.value[index]?.serial_number?.trim();
if (!val) return false;
return serials.value.some((s, i) => i !== index && s.serial_number.trim() === val);
};
const updateSerial = (index, value) => {
const updated = [...serials.value];
updated[index] = { ...updated[index], serial_number: value };
serials.value = updated;
};
const removeSerial = (index) => {
if (serials.value[index]?.locked) return;
const updated = serials.value.filter((_, i) => i !== index);
serials.value = updated;
};
const addAndFocus = async () => {
const updated = [...serials.value, { serial_number: '', locked: false }];
serials.value = updated;
await nextTick();
const lastIndex = updated.length - 1;
inputRefs.value[lastIndex]?.focus();
};
const onKeydown = async (event, index) => {
if (event.key === 'Enter') {
event.preventDefault();
const current = serials.value[index]?.serial_number?.trim();
if (!current) return;
// Si es el último input, agregar uno nuevo
if (index === serials.value.length - 1) {
await addAndFocus();
} else {
// Si no es el último, mover foco al siguiente
await nextTick();
inputRefs.value[index + 1]?.focus();
}
}
};
const setInputRef = (el, index) => {
if (el) inputRefs.value[index] = el;
};
// Asegurar que siempre haya al menos un input vacío al final para escanear
watch(() => props.modelValue, (val) => {
if (!val || val.length === 0) return;
const lastItem = val[val.length - 1];
// Si el último item tiene valor y no está bloqueado, agregar input vacío
if (lastItem.serial_number.trim() && !lastItem.locked) {
const hasEmptyUnlocked = val.some(s => !s.locked && !s.serial_number.trim());
if (!hasEmptyUnlocked) {
// No emitir aquí para evitar loop, se maneja con el botón/Enter
}
}
}, { deep: true });
</script>
<template>
<div class="space-y-2">
<div
v-for="(item, index) in serials"
:key="index"
class="flex items-center gap-2"
>
<!-- Icono de estado -->
<div class="shrink-0 w-5 flex items-center justify-center">
<GoogleIcon
v-if="item.locked"
name="lock"
class="text-sm text-gray-400"
title="Serial vendido - no editable"
/>
<span
v-else-if="isDuplicate(index)"
class="text-red-500 text-xs font-bold"
title="Serial duplicado"
>!</span>
<GoogleIcon
v-else-if="item.serial_number.trim()"
name="qr_code_2"
class="text-sm text-gray-400"
/>
<span v-else class="text-gray-300 text-xs">{{ index + 1 }}</span>
</div>
<!-- Input -->
<input
:ref="(el) => setInputRef(el, index)"
:value="item.serial_number"
@input="updateSerial(index, $event.target.value)"
@keydown="onKeydown($event, index)"
type="text"
:disabled="props.disabled || item.locked"
:placeholder="item.locked ? '' : 'Escanear o escribir serial...'"
class="flex-1 px-2.5 py-1.5 text-sm font-mono border rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
:class="{
'bg-gray-100 dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-500 cursor-not-allowed': item.locked,
'border-red-400 dark:border-red-600 bg-red-50 dark:bg-red-900/10': isDuplicate(index),
'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100': !item.locked && !isDuplicate(index),
'opacity-50 cursor-not-allowed': props.disabled && !item.locked
}"
/>
<!-- Badge vendido -->
<span
v-if="item.locked"
class="shrink-0 text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
Vendido
</span>
<!-- Botón eliminar -->
<button
v-if="!item.locked && !props.disabled"
type="button"
@click="removeSerial(index)"
class="shrink-0 p-1 text-gray-400 hover:text-red-500 transition-colors"
title="Eliminar serial"
>
<GoogleIcon name="close" class="text-base" />
</button>
<div v-else-if="!item.locked" class="shrink-0 w-7"></div>
</div>
<!-- Botón agregar -->
<button
v-if="!props.disabled"
type="button"
@click="addAndFocus"
class="flex items-center gap-1.5 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors py-1"
>
<GoogleIcon name="add" class="text-base" />
Agregar serial
</button>
<!-- Contador -->
<div v-if="validCount > 0 && !props.disabled" class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<GoogleIcon name="qr_code_2" class="text-sm shrink-0" />
<span>{{ validCount }} serial(es) ingresado(s)</span>
</div>
</div>
</template>

View File

@ -48,10 +48,15 @@ onMounted(() => {
name="Catálogos"
>
<SubLink
v-if="hasPermission('inventario.index')"
icon="inventory_2"
name="pos.inventory"
to="pos.inventory.index"
v-if="hasPermission('warehouses.index')"
icon="warehouse"
name="pos.warehouses"
to="pos.warehouses.index"
/>
<SubLink
icon="accessibility"
name="pos.clients"
to="pos.clients.index"
/>
<SubLink
v-if="hasPermission('inventario.index')"
@ -60,9 +65,10 @@ onMounted(() => {
to="pos.bundles.index"
/>
<SubLink
icon="accessibility"
name="pos.clients"
to="pos.clients.index"
v-if="hasPermission('inventario.index')"
icon="inventory_2"
name="pos.inventory"
to="pos.inventory.index"
/>
<SubLink
icon="support_agent"
@ -70,12 +76,6 @@ onMounted(() => {
to="pos.suppliers.index"
/>
</DropdownMenu>
<Link
v-if="hasPermission('warehouses.index')"
icon="warehouse"
name="pos.warehouses"
to="pos.warehouses.index"
/>
<Link
v-if="hasPermission('movements.index')"
icon="swap_horiz"

View File

@ -252,20 +252,6 @@ watch(() => props.bundle, (bundle) => {
/>
<FormError :message="form.errors?.tax" />
</div>
<!-- Recalcular precio -->
<div class="col-span-2">
<label class="flex items-center cursor-pointer">
<input
v-model="form.recalculate_price"
type="checkbox"
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Recalcular precio desde componentes
</span>
</label>
</div>
</div>
<!-- Botones -->

View File

@ -2,11 +2,8 @@
import { onMounted, ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSearcher, apiURL } from '@Services/Api';
import serialService from '@Services/serialService';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const route = useRoute();
@ -18,10 +15,6 @@ const inventory = ref(null);
const serials = ref({ data: [], total: 0 });
const activeTab = ref('disponible');
// Modales
const showDeleteModal = ref(false);
const deletingSerial = ref(null);
/** Buscador */
const searcher = useSearcher({
url: apiURL(`inventario/${route.params.id}/serials`),
@ -55,28 +48,6 @@ const switchTab = (tab) => {
loadSerials({ q: searcher.query });
};
// Eliminar serial
const openDeleteModal = (serial) => {
deletingSerial.value = serial;
showDeleteModal.value = true;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
deletingSerial.value = null;
};
const confirmDelete = async () => {
try {
await serialService.deleteSerial(inventoryId.value, deletingSerial.value.id);
window.Notify.success('Número de serie eliminado');
closeDeleteModal();
loadSerials();
} catch (error) {
window.Notify.error('Error al eliminar el número de serie');
}
};
// Navegación
const goBack = () => {
router.go(-1);
@ -195,11 +166,11 @@ onMounted(() => {
@send-pagination="(page) => searcher.pagination(page, { status: activeTab })"
>
<template #head>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NÚMERO DE SERIE</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NÚMERO DE SERIE</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-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOTAS</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA</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>
<th v-if="activeTab === 'vendido'" class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">VENTA</th>
</template>
<template #body="{items}">
<tr
@ -207,7 +178,7 @@ onMounted(() => {
:key="serial.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 whitespace-nowrap">
<td class="px-6 py-4 text-center whitespace-nowrap">
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
{{ serial.serial_number }}
</span>
@ -230,29 +201,15 @@ onMounted(() => {
{{ new Date(serial.created_at).toLocaleDateString('es-MX') }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
v-if="serial.status === 'disponible'"
@click="openDeleteModal(serial)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Eliminar serial"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
<span
v-if="serial.status === 'vendido'"
class="text-gray-400 text-xs"
title="No se puede eliminar un serial vendido"
>
Venta {{ serial.sale_detail?.sale?.invoice_number || `#${serial.sale_detail_id}` }}
</span>
</div>
<td v-if="activeTab === 'vendido'" class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-gray-500 dark:text-gray-400 text-xs">
{{ serial.sale_detail?.sale?.invoice_number || `#${serial.sale_detail_id}` }}
</span>
</td>
</tr>
</template>
<template #empty>
<td colspan="5" class="table-cell text-center">
<td :colspan="activeTab === 'vendido' ? 5 : 4" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
:name="activeTab === 'disponible' ? 'qr_code_2' : 'shopping_cart'"
@ -270,48 +227,5 @@ onMounted(() => {
</Table>
</div>
<!-- Modal Eliminar Serial -->
<Modal :show="showDeleteModal" max-width="sm" @close="closeDeleteModal">
<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">
Eliminar Número de Serie
</h3>
<button
@click="closeDeleteModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<GoogleIcon name="close" class="text-xl" />
</button>
</div>
<div class="mb-6">
<p class="text-gray-600 dark:text-gray-400">
¿Estás seguro de eliminar el número de serie
<span class="font-mono font-bold text-gray-900 dark:text-gray-100">
{{ deletingSerial?.serial_number }}
</span>?
</p>
<p class="text-sm text-red-600 mt-2">
Esta acción no se puede deshacer y reducirá el stock del producto.
</p>
</div>
<div class="flex items-center justify-end gap-3">
<button
@click="closeDeleteModal"
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="confirmDelete"
class="px-4 py-2 text-sm font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
Eliminar
</button>
</div>
</div>
</Modal>
</div>
</template>

View File

@ -7,6 +7,7 @@ import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SerialInputList from '@Components/POS/SerialInputList.vue';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
@ -21,6 +22,8 @@ const props = defineProps({
const warehouses = ref([]);
const suppliers = ref([]);
const loading = ref(false);
const serialsText = ref('');
const serialNumbers = ref([]);
const api = useApi();
/** Formulario */
@ -37,7 +40,7 @@ const form = useForm({
});
/** Estado para manejo de seriales */
const serialsText = ref(''); // Texto editable (uno por línea)
const serialsList = ref([]); // Array de { serial_number, locked }
/** Computed */
const movementTypeInfo = computed(() => {
@ -80,9 +83,8 @@ const hasSerials = computed(() => {
});
const serialsArray = computed(() => {
return serialsText.value
.split('\n')
.map(s => s.trim())
return serialsList.value
.map(s => s.serial_number.trim())
.filter(s => s.length > 0);
});
@ -174,19 +176,9 @@ const updateMovement = () => {
data.warehouse_to_id = form.destination_warehouse_id;
}
console.log('📤 Datos a enviar al backend:', {
movement_id: props.movement.id,
data: data,
serial_numbers_count: data.serial_numbers?.length || 0,
hasSerials: hasSerials.value
});
api.put(apiURL(`movimientos/${props.movement.id}`), {
data,
onSuccess: (response) => {
console.log('✅ Respuesta del backend (completa):', JSON.stringify(response, null, 2));
console.log('✅ Tipo de respuesta:', typeof response);
console.log('✅ Keys:', Object.keys(response || {}));
window.Notify.success('Movimiento actualizado correctamente');
emit('updated');
closeModal();
@ -219,18 +211,18 @@ watch(() => props.show, (isShown) => {
form.notes = props.movement.notes || '';
// Cargar números de serie si existen
let serialNumbers = [];
if (props.movement.serial_numbers && props.movement.serial_numbers.length > 0) {
serialNumbers = props.movement.serial_numbers;
} else if (props.movement.serials && props.movement.serials.length > 0) {
serialNumbers = props.movement.serials.map(s => s.serial_number);
}
if (serialNumbers.length > 0) {
serialsText.value = serialNumbers.join('\n');
if (props.movement.serials && props.movement.serials.length > 0) {
serialsList.value = props.movement.serials.map(s => ({
serial_number: s.serial_number,
locked: s.status === 'vendido'
}));
} else if (props.movement.serial_numbers && props.movement.serial_numbers.length > 0) {
serialsList.value = props.movement.serial_numbers.map(sn => ({
serial_number: sn,
locked: false
}));
} else {
serialsText.value = '';
serialsList.value = [{ serial_number: '', locked: false }];
}
// Almacenes según tipo
@ -242,15 +234,6 @@ watch(() => props.show, (isShown) => {
form.origin_warehouse_id = props.movement.origin_warehouse_id || '';
form.destination_warehouse_id = props.movement.destination_warehouse_id || '';
}
console.log('🔍 Debug Movement:', {
movement: props.movement,
has_inventory: !!props.movement.inventory,
track_serials: props.movement?.inventory?.track_serials,
serials_relation: props.movement.serials,
hasSerials_computed: hasSerials.value,
serialNumbers_loaded: serialNumbers,
serialsText_final: serialsText.value
});
}
} else {
// Limpiar seriales al cerrar
@ -338,26 +321,15 @@ watch(() => props.show, (isShown) => {
NÚMEROS DE SERIE
</label>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400 mb-2">
Ingresa un número de serie por línea. Debe coincidir con la cantidad ({{ form.quantity }}).
</p>
<textarea
v-model="serialsText"
rows="5"
placeholder="SN001&#10;SN002&#10;SN003"
class="w-full px-3 py-2 border border-amber-300 dark:border-amber-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm font-mono focus:ring-2 focus:ring-amber-500 focus:border-amber-500 resize-none"
:class="{
'border-red-500 dark:border-red-500 focus:ring-red-500 focus:border-red-500': !serialsValidation.valid && serialsText.length > 0
}"
></textarea>
<SerialInputList v-model="serialsList" />
<!-- Contador y validación -->
<!-- Validación -->
<div class="mt-2 flex items-center justify-between">
<p class="text-xs text-amber-600 dark:text-amber-400">
<span class="font-semibold">{{ serialsArray.length }}</span> de {{ form.quantity }} seriales
</p>
<p
v-if="!serialsValidation.valid && serialsText.length > 0"
v-if="!serialsValidation.valid && serialsArray.length > 0"
class="text-xs text-red-600 dark:text-red-400 font-medium"
>
{{ serialsValidation.message }}

View File

@ -7,6 +7,7 @@ import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SerialInputList from '@Components/POS/SerialInputList.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
@ -101,7 +102,7 @@ const addProduct = () => {
track_serials: false,
unit_of_measure: null,
allows_decimals: false,
serial_numbers_text: '', // Texto con seriales separados por líneas
serial_numbers_list: [{ serial_number: '', locked: false }], // Inputs individuales de seriales
serial_validation_error: ''
});
};
@ -116,7 +117,7 @@ const onProductInput = (index) => {
productNotFound.value = false;
const searchValue = productSearch.value?.trim();
if (!searchValue || searchValue.length < 2) {
if (!searchValue || searchValue.length < 1) {
productSuggestions.value = [];
showProductSuggestions.value = false;
return;
@ -180,7 +181,7 @@ const selectProduct = (product) => {
// Limpiar seriales si la unidad permite decimales
if (product.unit_of_measure?.allows_decimals) {
selectedProducts.value[currentSearchIndex.value].serial_numbers_text = '';
selectedProducts.value[currentSearchIndex.value].serial_numbers_list = [{ serial_number: '', locked: false }];
}
}
@ -195,30 +196,22 @@ const selectProduct = (product) => {
// Contar seriales ingresados (solo para mostrar feedback visual)
const countSerials = (item) => {
if (!item.serial_numbers_text) return 0;
const serials = item.serial_numbers_text
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0)
.filter((s, index, self) => self.indexOf(s) === index); // Eliminar duplicados (igual que createEntry)
return serials.length;
if (!item.serial_numbers_list) return 0;
return item.serial_numbers_list.filter(s => s.serial_number.trim()).length;
};
// Actualizar cantidad según seriales ingresados
// Actualizar cantidad según seriales ingresados (llamado por el watcher del v-model)
const updateQuantityFromSerials = (item) => {
// Solo actualizar cantidad automáticamente si el producto requiere seriales y puede usarlos
if (!item.track_serials || !canUseSerials(item)) {
return;
}
// Contar seriales válidos (sin líneas vacías)
const serialCount = countSerials(item);
// Actualizar cantidad siempre basado en el conteo actual
if (serialCount > 0) {
item.quantity = serialCount;
} else {
// Si no hay seriales, resetear a 1
item.quantity = 1;
}
};
@ -233,14 +226,12 @@ const createEntry = () => {
serial_numbers: [] // Inicializar siempre como array vacío
};
// Agregar seriales solo si la unidad lo permite y hay texto ingresado
if (canUseSerials(item) && item.serial_numbers_text && item.serial_numbers_text.trim()) {
// Limpiar y filtrar seriales - eliminar líneas vacías, espacios, y duplicados
const serials = item.serial_numbers_text
.split('\n')
.map(s => s.trim())
// Agregar seriales solo si la unidad lo permite y hay seriales ingresados
if (canUseSerials(item) && item.serial_numbers_list) {
const serials = item.serial_numbers_list
.map(s => s.serial_number.trim())
.filter(s => s.length > 0)
.filter((s, index, self) => self.indexOf(s) === index); // Eliminar duplicados locales
.filter((s, index, self) => self.indexOf(s) === index);
if (serials.length > 0) {
productData.serial_numbers = serials;
@ -410,7 +401,7 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
:class="{
'border-red-500 focus:ring-red-500 focus:border-red-500': productNotFound && currentSearchIndex === index
}"
:disabled="searchingProduct"
:readonly="searchingProduct"
/>
<div v-if="searchingProduct && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
<GoogleIcon name="hourglass_empty" class="text-sm text-gray-400 animate-spin" />
@ -507,7 +498,6 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
Números de Serie
<span v-if="item.track_serials && canUseSerials(item)" class="text-red-500">*</span>
<span v-else-if="canUseSerials(item)" class="text-gray-500 font-normal">(opcional)</span>
<span v-if="canUseSerials(item)" class="text-gray-500 font-normal">- uno por línea, debe coincidir con la cantidad</span>
</label>
<!-- Advertencia si la unidad permite decimales -->
@ -520,18 +510,11 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
</div>
</div>
<textarea
v-model="item.serial_numbers_text"
@input="updateQuantityFromSerials(item)"
rows="3"
<SerialInputList
v-model="item.serial_numbers_list"
:disabled="!canUseSerials(item)"
placeholder="Ingresa los números de serie, uno por línea&#10;Ejemplo:&#10;IMEI-123456&#10;IMEI-789012"
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 font-mono disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-gray-900"
></textarea>
<div v-if="countSerials(item) > 0 && canUseSerials(item)" class="mt-1 flex items-start gap-1 text-xs text-gray-600 dark:text-gray-400">
<GoogleIcon name="qr_code_2" class="text-sm shrink-0" />
<span>{{ countSerials(item) }} serial(es) ingresado(s)</span>
</div>
@update:model-value="updateQuantityFromSerials(item)"
/>
</div>
<!-- Subtotal del producto -->

View File

@ -17,6 +17,7 @@ import CheckoutModal from '@Components/POS/CheckoutModal.vue';
import ClientModal from '@Components/POS/ClientModal.vue';
import QRscan from '@Components/POS/QRscan.vue';
import SerialSelector from '@Components/POS/SerialSelector.vue';
import BundleSerialSelector from '@Components/POS/BundleSerialSelector.vue';
/** i18n */
const { t } = useI18n();
@ -41,6 +42,10 @@ const lastSaleData = ref(null);
const showSerialSelector = ref(false);
const serialSelectorProduct = ref(null);
// Estado para selector de seriales de bundles
const showBundleSerialSelector = ref(false);
const bundleSerialSelectorBundle = ref(null);
/** Buscador de productos */
const searcher = useSearcher({
url: apiURL('inventario'),
@ -110,7 +115,25 @@ const addBundleToCart = async (bundle) => {
return;
}
// Agregar bundle/kit al carrito
// Verificar si el bundle tiene componentes con seriales
try {
const components = await serialService.getBundleComponents(bundle.id);
const hasSerialComponents = components.some(item => {
const inv = item.inventory || item.product || {};
return inv.track_serials || item.track_serials;
});
if (hasSerialComponents) {
// Abrir selector de seriales para el bundle
bundleSerialSelectorBundle.value = bundle;
showBundleSerialSelector.value = true;
return;
}
} catch {
// Si falla la consulta de componentes, agregar sin seriales
}
// Agregar bundle/kit al carrito sin seriales
cart.addBundle(bundle);
window.Notify.success(`${bundle.name} agregado al carrito`);
} catch (error) {
@ -160,6 +183,19 @@ const handleSerialConfirm = (serialConfig) => {
closeSerialSelector();
};
const closeBundleSerialSelector = () => {
showBundleSerialSelector.value = false;
bundleSerialSelectorBundle.value = null;
};
const handleBundleSerialConfirm = (serialConfig) => {
if (!bundleSerialSelectorBundle.value) return;
cart.addBundle(bundleSerialSelectorBundle.value, serialConfig);
window.Notify.success(`${bundleSerialSelectorBundle.value.name} agregado al carrito`);
closeBundleSerialSelector();
};
const handleClearCart = () => {
if (confirm(t('cart.clearConfirm'))) {
cart.clear();
@ -328,11 +364,15 @@ const handleConfirmSale = async (paymentData) => {
payment_method: paymentData.paymentMethod,
items: cart.items.map(item => {
if (item.is_bundle) {
return {
const bundleItem = {
type: 'bundle',
bundle_id: item.bundle_id,
quantity: item.quantity,
};
if (item.track_serials && item.serial_numbers && Object.keys(item.serial_numbers).length > 0) {
bundleItem.serial_numbers = item.serial_numbers;
}
return bundleItem;
}
return {
type: 'product',
@ -801,5 +841,15 @@ watch(activeTab, (newTab) => {
@close="closeSerialSelector"
@confirm="handleSerialConfirm"
/>
<!-- Modal de Selección de Seriales para Bundles -->
<BundleSerialSelector
v-if="bundleSerialSelectorBundle"
:show="showBundleSerialSelector"
:bundle="bundleSerialSelectorBundle"
:exclude-serials="cart.getSelectedSerials()"
@close="closeBundleSerialSelector"
@confirm="handleBundleSerialConfirm"
/>
</div>
</template>

View File

@ -91,21 +91,6 @@ const handleClose = () => {
emit('close');
};
const handleCancelReturn = async () => {
if (!confirm('¿Cancelar esta devolución? Los productos volverán a estado vendido y el stock se revertirá.')) {
return;
}
try {
const response = await returnsService.cancelReturn(props.returnData.id);
window.Notify.success(response.message || 'Devolución cancelada exitosamente');
emit('cancelled');
emit('close');
} catch (error) {
console.error('Error al cancelar devolución:', error);
window.Notify.error('Error al cancelar la devolución');
}
};
</script>
<template>
@ -295,16 +280,7 @@ const handleCancelReturn = async () => {
<!-- Footer -->
<div class="flex justify-between items-center mt-6">
<button
v-if="!returnData?.deleted_at"
type="button"
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors"
@click="handleCancelReturn"
>
<GoogleIcon name="cancel" class="text-lg" />
Cancelar Devolución
</button>
<div v-else></div>
<div></div>
<button
type="button"
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-semibold rounded-lg transition-colors"

View File

@ -18,6 +18,9 @@ const salesService = {
// También incluye change, cash_received para pagos en efectivo
resolve(response.sale || response.model || response);
},
onFail: (data) => {
reject(data);
},
onError: (error) => {
reject(error);
}

View File

@ -57,6 +57,25 @@ const serialService = {
});
},
/**
* Obtener componentes de un bundle con info de track_serials
* @param {Number} bundleId - ID del bundle
* @returns {Promise}
*/
async getBundleComponents(bundleId) {
return new Promise((resolve, reject) => {
api.get(apiURL(`bundles/${bundleId}`), {
onSuccess: (response) => {
const bundle = response.model || response;
resolve(bundle.items || []);
},
onError: (error) => {
reject(error);
}
});
});
},
/**
* Eliminar un serial
* @param {Number} inventoryId - ID del inventario

View File

@ -85,11 +85,17 @@ const useCart = defineStore('cart', {
},
// Agregar bundle/kit al carrito
addBundle(bundle) {
addBundle(bundle, serialConfig = null) {
const key = 'b:' + bundle.id;
const hasSerials = serialConfig && Object.keys(serialConfig.serialNumbers || {}).length > 0;
const existingItem = this.items.find(item => item.item_key === key);
if (existingItem) {
// Bundles con seriales no se pueden incrementar, se deben agregar de nuevo
if (existingItem.track_serials) {
window.Notify.warning('Este paquete requiere selección de seriales. Elimínalo y agrégalo de nuevo.');
return;
}
if (existingItem.quantity < bundle.available_stock) {
existingItem.quantity++;
} else {
@ -107,8 +113,8 @@ const useCart = defineStore('cart', {
unit_price: parseFloat(bundle.price?.retail_price || 0),
tax_rate: parseFloat(bundle.price?.tax || 16),
max_stock: bundle.available_stock,
track_serials: false,
serial_numbers: [],
track_serials: hasSerials,
serial_numbers: hasSerials ? serialConfig.serialNumbers : {},
serial_selection_mode: null,
unit_of_measure: null,
allows_decimals: false,
@ -161,9 +167,18 @@ const useCart = defineStore('cart', {
// Obtener seriales ya seleccionados (para excluir del selector)
getSelectedSerials() {
return this.items
.filter(item => item.track_serials && item.serial_numbers?.length > 0)
.flatMap(item => item.serial_numbers);
const serials = [];
this.items.forEach(item => {
if (!item.track_serials) return;
if (item.is_bundle && item.serial_numbers) {
// Bundle: serial_numbers es { inventoryId: [serial_number, ...] }
Object.values(item.serial_numbers).forEach(arr => serials.push(...arr));
} else if (Array.isArray(item.serial_numbers)) {
// Producto: serial_numbers es [serial_number, ...]
serials.push(...item.serial_numbers);
}
});
return serials;
},
// Verificar si un item necesita selección de seriales