- 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.
297 lines
13 KiB
Vue
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>
|