pdv.frontend/src/components/POS/BundleSerialSelector.vue
Juan Felipe Zapata Moreno fb37a2d62f 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.
2026-02-18 21:40:31 -06:00

297 lines
13 KiB
Vue

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