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:
parent
9b8bf57abd
commit
fb37a2d62f
296
src/components/POS/BundleSerialSelector.vue
Normal file
296
src/components/POS/BundleSerialSelector.vue
Normal 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>
|
||||||
174
src/components/POS/SerialInputList.vue
Normal file
174
src/components/POS/SerialInputList.vue
Normal 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>
|
||||||
@ -48,10 +48,15 @@ onMounted(() => {
|
|||||||
name="Catálogos"
|
name="Catálogos"
|
||||||
>
|
>
|
||||||
<SubLink
|
<SubLink
|
||||||
v-if="hasPermission('inventario.index')"
|
v-if="hasPermission('warehouses.index')"
|
||||||
icon="inventory_2"
|
icon="warehouse"
|
||||||
name="pos.inventory"
|
name="pos.warehouses"
|
||||||
to="pos.inventory.index"
|
to="pos.warehouses.index"
|
||||||
|
/>
|
||||||
|
<SubLink
|
||||||
|
icon="accessibility"
|
||||||
|
name="pos.clients"
|
||||||
|
to="pos.clients.index"
|
||||||
/>
|
/>
|
||||||
<SubLink
|
<SubLink
|
||||||
v-if="hasPermission('inventario.index')"
|
v-if="hasPermission('inventario.index')"
|
||||||
@ -60,9 +65,10 @@ onMounted(() => {
|
|||||||
to="pos.bundles.index"
|
to="pos.bundles.index"
|
||||||
/>
|
/>
|
||||||
<SubLink
|
<SubLink
|
||||||
icon="accessibility"
|
v-if="hasPermission('inventario.index')"
|
||||||
name="pos.clients"
|
icon="inventory_2"
|
||||||
to="pos.clients.index"
|
name="pos.inventory"
|
||||||
|
to="pos.inventory.index"
|
||||||
/>
|
/>
|
||||||
<SubLink
|
<SubLink
|
||||||
icon="support_agent"
|
icon="support_agent"
|
||||||
@ -70,12 +76,6 @@ onMounted(() => {
|
|||||||
to="pos.suppliers.index"
|
to="pos.suppliers.index"
|
||||||
/>
|
/>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Link
|
|
||||||
v-if="hasPermission('warehouses.index')"
|
|
||||||
icon="warehouse"
|
|
||||||
name="pos.warehouses"
|
|
||||||
to="pos.warehouses.index"
|
|
||||||
/>
|
|
||||||
<Link
|
<Link
|
||||||
v-if="hasPermission('movements.index')"
|
v-if="hasPermission('movements.index')"
|
||||||
icon="swap_horiz"
|
icon="swap_horiz"
|
||||||
|
|||||||
@ -252,20 +252,6 @@ watch(() => props.bundle, (bundle) => {
|
|||||||
/>
|
/>
|
||||||
<FormError :message="form.errors?.tax" />
|
<FormError :message="form.errors?.tax" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Botones -->
|
<!-- Botones -->
|
||||||
|
|||||||
@ -2,11 +2,8 @@
|
|||||||
import { onMounted, ref, computed } from 'vue';
|
import { onMounted, ref, computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useSearcher, apiURL } from '@Services/Api';
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
import serialService from '@Services/serialService';
|
|
||||||
|
|
||||||
import SearcherHead from '@Holos/Searcher.vue';
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
import Table from '@Holos/Table.vue';
|
import Table from '@Holos/Table.vue';
|
||||||
import Modal from '@Holos/Modal.vue';
|
|
||||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -18,10 +15,6 @@ const inventory = ref(null);
|
|||||||
const serials = ref({ data: [], total: 0 });
|
const serials = ref({ data: [], total: 0 });
|
||||||
const activeTab = ref('disponible');
|
const activeTab = ref('disponible');
|
||||||
|
|
||||||
// Modales
|
|
||||||
const showDeleteModal = ref(false);
|
|
||||||
const deletingSerial = ref(null);
|
|
||||||
|
|
||||||
/** Buscador */
|
/** Buscador */
|
||||||
const searcher = useSearcher({
|
const searcher = useSearcher({
|
||||||
url: apiURL(`inventario/${route.params.id}/serials`),
|
url: apiURL(`inventario/${route.params.id}/serials`),
|
||||||
@ -55,28 +48,6 @@ const switchTab = (tab) => {
|
|||||||
loadSerials({ q: searcher.query });
|
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
|
// Navegación
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
router.go(-1);
|
router.go(-1);
|
||||||
@ -195,11 +166,11 @@ onMounted(() => {
|
|||||||
@send-pagination="(page) => searcher.pagination(page, { status: activeTab })"
|
@send-pagination="(page) => searcher.pagination(page, { status: activeTab })"
|
||||||
>
|
>
|
||||||
<template #head>
|
<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-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">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-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>
|
||||||
<template #body="{items}">
|
<template #body="{items}">
|
||||||
<tr
|
<tr
|
||||||
@ -207,7 +178,7 @@ onMounted(() => {
|
|||||||
:key="serial.id"
|
:key="serial.id"
|
||||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
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">
|
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ serial.serial_number }}
|
{{ serial.serial_number }}
|
||||||
</span>
|
</span>
|
||||||
@ -230,29 +201,15 @@ onMounted(() => {
|
|||||||
{{ new Date(serial.created_at).toLocaleDateString('es-MX') }}
|
{{ new Date(serial.created_at).toLocaleDateString('es-MX') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
<td v-if="activeTab === 'vendido'" class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<span class="text-gray-500 dark:text-gray-400 text-xs">
|
||||||
<button
|
{{ serial.sale_detail?.sale?.invoice_number || `#${serial.sale_detail_id}` }}
|
||||||
v-if="serial.status === 'disponible'"
|
</span>
|
||||||
@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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<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">
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
<GoogleIcon
|
<GoogleIcon
|
||||||
:name="activeTab === 'disponible' ? 'qr_code_2' : 'shopping_cart'"
|
:name="activeTab === 'disponible' ? 'qr_code_2' : 'shopping_cart'"
|
||||||
@ -270,48 +227,5 @@ onMounted(() => {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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 GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import SerialInputList from '@Components/POS/SerialInputList.vue';
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
const emit = defineEmits(['close', 'updated']);
|
const emit = defineEmits(['close', 'updated']);
|
||||||
@ -21,6 +22,8 @@ const props = defineProps({
|
|||||||
const warehouses = ref([]);
|
const warehouses = ref([]);
|
||||||
const suppliers = ref([]);
|
const suppliers = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const serialsText = ref('');
|
||||||
|
const serialNumbers = ref([]);
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
/** Formulario */
|
/** Formulario */
|
||||||
@ -37,7 +40,7 @@ const form = useForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Estado para manejo de seriales */
|
/** Estado para manejo de seriales */
|
||||||
const serialsText = ref(''); // Texto editable (uno por línea)
|
const serialsList = ref([]); // Array de { serial_number, locked }
|
||||||
|
|
||||||
/** Computed */
|
/** Computed */
|
||||||
const movementTypeInfo = computed(() => {
|
const movementTypeInfo = computed(() => {
|
||||||
@ -80,9 +83,8 @@ const hasSerials = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const serialsArray = computed(() => {
|
const serialsArray = computed(() => {
|
||||||
return serialsText.value
|
return serialsList.value
|
||||||
.split('\n')
|
.map(s => s.serial_number.trim())
|
||||||
.map(s => s.trim())
|
|
||||||
.filter(s => s.length > 0);
|
.filter(s => s.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -174,19 +176,9 @@ const updateMovement = () => {
|
|||||||
data.warehouse_to_id = form.destination_warehouse_id;
|
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}`), {
|
api.put(apiURL(`movimientos/${props.movement.id}`), {
|
||||||
data,
|
data,
|
||||||
onSuccess: (response) => {
|
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');
|
window.Notify.success('Movimiento actualizado correctamente');
|
||||||
emit('updated');
|
emit('updated');
|
||||||
closeModal();
|
closeModal();
|
||||||
@ -219,18 +211,18 @@ watch(() => props.show, (isShown) => {
|
|||||||
form.notes = props.movement.notes || '';
|
form.notes = props.movement.notes || '';
|
||||||
|
|
||||||
// Cargar números de serie si existen
|
// Cargar números de serie si existen
|
||||||
let serialNumbers = [];
|
if (props.movement.serials && props.movement.serials.length > 0) {
|
||||||
|
serialsList.value = props.movement.serials.map(s => ({
|
||||||
if (props.movement.serial_numbers && props.movement.serial_numbers.length > 0) {
|
serial_number: s.serial_number,
|
||||||
serialNumbers = props.movement.serial_numbers;
|
locked: s.status === 'vendido'
|
||||||
} else if (props.movement.serials && props.movement.serials.length > 0) {
|
}));
|
||||||
serialNumbers = props.movement.serials.map(s => s.serial_number);
|
} else if (props.movement.serial_numbers && props.movement.serial_numbers.length > 0) {
|
||||||
}
|
serialsList.value = props.movement.serial_numbers.map(sn => ({
|
||||||
|
serial_number: sn,
|
||||||
if (serialNumbers.length > 0) {
|
locked: false
|
||||||
serialsText.value = serialNumbers.join('\n');
|
}));
|
||||||
} else {
|
} else {
|
||||||
serialsText.value = '';
|
serialsList.value = [{ serial_number: '', locked: false }];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Almacenes según tipo
|
// Almacenes según tipo
|
||||||
@ -242,15 +234,6 @@ watch(() => props.show, (isShown) => {
|
|||||||
form.origin_warehouse_id = props.movement.origin_warehouse_id || '';
|
form.origin_warehouse_id = props.movement.origin_warehouse_id || '';
|
||||||
form.destination_warehouse_id = props.movement.destination_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 {
|
} else {
|
||||||
// Limpiar seriales al cerrar
|
// Limpiar seriales al cerrar
|
||||||
@ -338,26 +321,15 @@ watch(() => props.show, (isShown) => {
|
|||||||
NÚMEROS DE SERIE
|
NÚMEROS DE SERIE
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-amber-600 dark:text-amber-400 mb-2">
|
<SerialInputList v-model="serialsList" />
|
||||||
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 SN002 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>
|
|
||||||
|
|
||||||
<!-- Contador y validación -->
|
<!-- Validación -->
|
||||||
<div class="mt-2 flex items-center justify-between">
|
<div class="mt-2 flex items-center justify-between">
|
||||||
<p class="text-xs text-amber-600 dark:text-amber-400">
|
<p class="text-xs text-amber-600 dark:text-amber-400">
|
||||||
<span class="font-semibold">{{ serialsArray.length }}</span> de {{ form.quantity }} seriales
|
<span class="font-semibold">{{ serialsArray.length }}</span> de {{ form.quantity }} seriales
|
||||||
</p>
|
</p>
|
||||||
<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"
|
class="text-xs text-red-600 dark:text-red-400 font-medium"
|
||||||
>
|
>
|
||||||
{{ serialsValidation.message }}
|
{{ serialsValidation.message }}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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 GoogleIcon from '@Shared/GoogleIcon.vue';
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import SerialInputList from '@Components/POS/SerialInputList.vue';
|
||||||
|
|
||||||
/** Eventos */
|
/** Eventos */
|
||||||
const emit = defineEmits(['close', 'created']);
|
const emit = defineEmits(['close', 'created']);
|
||||||
@ -101,7 +102,7 @@ const addProduct = () => {
|
|||||||
track_serials: false,
|
track_serials: false,
|
||||||
unit_of_measure: null,
|
unit_of_measure: null,
|
||||||
allows_decimals: false,
|
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: ''
|
serial_validation_error: ''
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -116,7 +117,7 @@ const onProductInput = (index) => {
|
|||||||
productNotFound.value = false;
|
productNotFound.value = false;
|
||||||
|
|
||||||
const searchValue = productSearch.value?.trim();
|
const searchValue = productSearch.value?.trim();
|
||||||
if (!searchValue || searchValue.length < 2) {
|
if (!searchValue || searchValue.length < 1) {
|
||||||
productSuggestions.value = [];
|
productSuggestions.value = [];
|
||||||
showProductSuggestions.value = false;
|
showProductSuggestions.value = false;
|
||||||
return;
|
return;
|
||||||
@ -180,7 +181,7 @@ const selectProduct = (product) => {
|
|||||||
|
|
||||||
// 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_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)
|
// Contar seriales ingresados (solo para mostrar feedback visual)
|
||||||
const countSerials = (item) => {
|
const countSerials = (item) => {
|
||||||
if (!item.serial_numbers_text) return 0;
|
if (!item.serial_numbers_list) return 0;
|
||||||
const serials = item.serial_numbers_text
|
return item.serial_numbers_list.filter(s => s.serial_number.trim()).length;
|
||||||
.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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actualizar cantidad según seriales ingresados
|
// Actualizar cantidad según seriales ingresados (llamado por el watcher del v-model)
|
||||||
const updateQuantityFromSerials = (item) => {
|
const updateQuantityFromSerials = (item) => {
|
||||||
// Solo actualizar cantidad automáticamente si el producto requiere seriales y puede usarlos
|
// Solo actualizar cantidad automáticamente si el producto requiere seriales y puede usarlos
|
||||||
if (!item.track_serials || !canUseSerials(item)) {
|
if (!item.track_serials || !canUseSerials(item)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contar seriales válidos (sin líneas vacías)
|
|
||||||
const serialCount = countSerials(item);
|
const serialCount = countSerials(item);
|
||||||
|
|
||||||
// Actualizar cantidad siempre basado en el conteo actual
|
|
||||||
if (serialCount > 0) {
|
if (serialCount > 0) {
|
||||||
item.quantity = serialCount;
|
item.quantity = serialCount;
|
||||||
} else {
|
} else {
|
||||||
// Si no hay seriales, resetear a 1
|
|
||||||
item.quantity = 1;
|
item.quantity = 1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -233,14 +226,12 @@ const createEntry = () => {
|
|||||||
serial_numbers: [] // Inicializar siempre como array vacío
|
serial_numbers: [] // Inicializar siempre como array vacío
|
||||||
};
|
};
|
||||||
|
|
||||||
// Agregar seriales solo si la unidad lo permite y hay texto ingresado
|
// Agregar seriales solo si la unidad lo permite y hay seriales ingresados
|
||||||
if (canUseSerials(item) && item.serial_numbers_text && item.serial_numbers_text.trim()) {
|
if (canUseSerials(item) && item.serial_numbers_list) {
|
||||||
// Limpiar y filtrar seriales - eliminar líneas vacías, espacios, y duplicados
|
const serials = item.serial_numbers_list
|
||||||
const serials = item.serial_numbers_text
|
.map(s => s.serial_number.trim())
|
||||||
.split('\n')
|
|
||||||
.map(s => s.trim())
|
|
||||||
.filter(s => s.length > 0)
|
.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) {
|
if (serials.length > 0) {
|
||||||
productData.serial_numbers = serials;
|
productData.serial_numbers = serials;
|
||||||
@ -410,7 +401,7 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
:class="{
|
:class="{
|
||||||
'border-red-500 focus:ring-red-500 focus:border-red-500': productNotFound && currentSearchIndex === index
|
'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">
|
<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" />
|
<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
|
Números de Serie
|
||||||
<span v-if="item.track_serials && canUseSerials(item)" class="text-red-500">*</span>
|
<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-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>
|
</label>
|
||||||
|
|
||||||
<!-- Advertencia si la unidad permite decimales -->
|
<!-- Advertencia si la unidad permite decimales -->
|
||||||
@ -520,18 +510,11 @@ watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<SerialInputList
|
||||||
v-model="item.serial_numbers_text"
|
v-model="item.serial_numbers_list"
|
||||||
@input="updateQuantityFromSerials(item)"
|
|
||||||
rows="3"
|
|
||||||
:disabled="!canUseSerials(item)"
|
:disabled="!canUseSerials(item)"
|
||||||
placeholder="Ingresa los números de serie, uno por línea Ejemplo: IMEI-123456 IMEI-789012"
|
@update:model-value="updateQuantityFromSerials(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 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subtotal del producto -->
|
<!-- Subtotal del producto -->
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import CheckoutModal from '@Components/POS/CheckoutModal.vue';
|
|||||||
import ClientModal from '@Components/POS/ClientModal.vue';
|
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';
|
||||||
|
|
||||||
/** i18n */
|
/** i18n */
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -41,6 +42,10 @@ const lastSaleData = ref(null);
|
|||||||
const showSerialSelector = ref(false);
|
const showSerialSelector = ref(false);
|
||||||
const serialSelectorProduct = ref(null);
|
const serialSelectorProduct = ref(null);
|
||||||
|
|
||||||
|
// Estado para selector de seriales de bundles
|
||||||
|
const showBundleSerialSelector = ref(false);
|
||||||
|
const bundleSerialSelectorBundle = ref(null);
|
||||||
|
|
||||||
/** Buscador de productos */
|
/** Buscador de productos */
|
||||||
const searcher = useSearcher({
|
const searcher = useSearcher({
|
||||||
url: apiURL('inventario'),
|
url: apiURL('inventario'),
|
||||||
@ -110,7 +115,25 @@ const addBundleToCart = async (bundle) => {
|
|||||||
return;
|
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);
|
cart.addBundle(bundle);
|
||||||
window.Notify.success(`${bundle.name} agregado al carrito`);
|
window.Notify.success(`${bundle.name} agregado al carrito`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -160,6 +183,19 @@ const handleSerialConfirm = (serialConfig) => {
|
|||||||
closeSerialSelector();
|
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 = () => {
|
const handleClearCart = () => {
|
||||||
if (confirm(t('cart.clearConfirm'))) {
|
if (confirm(t('cart.clearConfirm'))) {
|
||||||
cart.clear();
|
cart.clear();
|
||||||
@ -328,11 +364,15 @@ const handleConfirmSale = async (paymentData) => {
|
|||||||
payment_method: paymentData.paymentMethod,
|
payment_method: paymentData.paymentMethod,
|
||||||
items: cart.items.map(item => {
|
items: cart.items.map(item => {
|
||||||
if (item.is_bundle) {
|
if (item.is_bundle) {
|
||||||
return {
|
const bundleItem = {
|
||||||
type: 'bundle',
|
type: 'bundle',
|
||||||
bundle_id: item.bundle_id,
|
bundle_id: item.bundle_id,
|
||||||
quantity: item.quantity,
|
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 {
|
return {
|
||||||
type: 'product',
|
type: 'product',
|
||||||
@ -801,5 +841,15 @@ watch(activeTab, (newTab) => {
|
|||||||
@close="closeSerialSelector"
|
@close="closeSerialSelector"
|
||||||
@confirm="handleSerialConfirm"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -91,21 +91,6 @@ const handleClose = () => {
|
|||||||
emit('close');
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -295,16 +280,7 @@ const handleCancelReturn = async () => {
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="flex justify-between items-center mt-6">
|
<div class="flex justify-between items-center mt-6">
|
||||||
<button
|
<div></div>
|
||||||
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>
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
||||||
|
|||||||
@ -18,6 +18,9 @@ const salesService = {
|
|||||||
// También incluye change, cash_received para pagos en efectivo
|
// También incluye change, cash_received para pagos en efectivo
|
||||||
resolve(response.sale || response.model || response);
|
resolve(response.sale || response.model || response);
|
||||||
},
|
},
|
||||||
|
onFail: (data) => {
|
||||||
|
reject(data);
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
* Eliminar un serial
|
||||||
* @param {Number} inventoryId - ID del inventario
|
* @param {Number} inventoryId - ID del inventario
|
||||||
|
|||||||
@ -85,11 +85,17 @@ const useCart = defineStore('cart', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Agregar bundle/kit al carrito
|
// Agregar bundle/kit al carrito
|
||||||
addBundle(bundle) {
|
addBundle(bundle, serialConfig = null) {
|
||||||
const key = 'b:' + bundle.id;
|
const key = 'b:' + bundle.id;
|
||||||
|
const hasSerials = serialConfig && Object.keys(serialConfig.serialNumbers || {}).length > 0;
|
||||||
const existingItem = this.items.find(item => item.item_key === key);
|
const existingItem = this.items.find(item => item.item_key === key);
|
||||||
|
|
||||||
if (existingItem) {
|
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) {
|
if (existingItem.quantity < bundle.available_stock) {
|
||||||
existingItem.quantity++;
|
existingItem.quantity++;
|
||||||
} else {
|
} else {
|
||||||
@ -107,8 +113,8 @@ const useCart = defineStore('cart', {
|
|||||||
unit_price: parseFloat(bundle.price?.retail_price || 0),
|
unit_price: parseFloat(bundle.price?.retail_price || 0),
|
||||||
tax_rate: parseFloat(bundle.price?.tax || 16),
|
tax_rate: parseFloat(bundle.price?.tax || 16),
|
||||||
max_stock: bundle.available_stock,
|
max_stock: bundle.available_stock,
|
||||||
track_serials: false,
|
track_serials: hasSerials,
|
||||||
serial_numbers: [],
|
serial_numbers: hasSerials ? serialConfig.serialNumbers : {},
|
||||||
serial_selection_mode: null,
|
serial_selection_mode: null,
|
||||||
unit_of_measure: null,
|
unit_of_measure: null,
|
||||||
allows_decimals: false,
|
allows_decimals: false,
|
||||||
@ -161,9 +167,18 @@ const useCart = defineStore('cart', {
|
|||||||
|
|
||||||
// Obtener seriales ya seleccionados (para excluir del selector)
|
// Obtener seriales ya seleccionados (para excluir del selector)
|
||||||
getSelectedSerials() {
|
getSelectedSerials() {
|
||||||
return this.items
|
const serials = [];
|
||||||
.filter(item => item.track_serials && item.serial_numbers?.length > 0)
|
this.items.forEach(item => {
|
||||||
.flatMap(item => item.serial_numbers);
|
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
|
// Verificar si un item necesita selección de seriales
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user