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"
|
||||
>
|
||||
<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"
|
||||
|
||||
@ -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 -->
|
||||
|
||||
@ -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}` }}
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@ -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 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>
|
||||
<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 }}
|
||||
|
||||
@ -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 Ejemplo: IMEI-123456 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 -->
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user