add: numero de serie
This commit is contained in:
parent
7a28a35f60
commit
46b155c2c8
390
src/components/POS/SerialSelector.vue
Normal file
390
src/components/POS/SerialSelector.vue
Normal file
@ -0,0 +1,390 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } 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
|
||||
},
|
||||
product: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
quantity: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
excludeSerials: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
/** Emits */
|
||||
const emit = defineEmits(['close', 'confirm']);
|
||||
|
||||
/** Estado */
|
||||
const loading = ref(false);
|
||||
const availableSerials = ref([]);
|
||||
const selectedSerials = ref([]);
|
||||
const selectionMode = ref('auto'); // 'auto' | 'manual'
|
||||
const searchQuery = ref('');
|
||||
|
||||
/** Computados */
|
||||
const filteredSerials = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return availableSerials.value;
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return availableSerials.value.filter(serial =>
|
||||
serial.serial_number.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => selectedSerials.value.length);
|
||||
|
||||
const isComplete = computed(() => {
|
||||
if (selectionMode.value === 'auto') {
|
||||
return availableSerials.value.length >= props.quantity;
|
||||
}
|
||||
return selectedSerials.value.length === props.quantity;
|
||||
});
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
if (selectionMode.value === 'auto') {
|
||||
return availableSerials.value.length >= props.quantity;
|
||||
}
|
||||
return selectedSerials.value.length === props.quantity;
|
||||
});
|
||||
|
||||
const hasEnoughStock = computed(() => {
|
||||
return availableSerials.value.length >= props.quantity;
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const loadSerials = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await serialService.getAvailableSerials(props.product.id);
|
||||
// Filtrar seriales que ya están en el carrito
|
||||
availableSerials.value = (response.serials?.data || []).filter(
|
||||
serial => !props.excludeSerials.includes(serial.serial_number)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error loading serials:', error);
|
||||
availableSerials.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSerial = (serial) => {
|
||||
const index = selectedSerials.value.findIndex(s => s.id === serial.id);
|
||||
if (index > -1) {
|
||||
selectedSerials.value.splice(index, 1);
|
||||
} else {
|
||||
if (selectedSerials.value.length < props.quantity) {
|
||||
selectedSerials.value.push(serial);
|
||||
} else {
|
||||
Notify.warning(`Solo puedes seleccionar ${props.quantity} serial(es)`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (serial) => {
|
||||
return selectedSerials.value.some(s => s.id === serial.id);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
selectedSerials.value = availableSerials.value.slice(0, props.quantity);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedSerials.value = [];
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
let serialNumbers = [];
|
||||
|
||||
if (selectionMode.value === 'auto') {
|
||||
// En modo automático, el backend asignará los seriales
|
||||
serialNumbers = null;
|
||||
} else {
|
||||
// En modo manual, enviamos los seriales seleccionados
|
||||
serialNumbers = selectedSerials.value.map(s => s.serial_number);
|
||||
}
|
||||
|
||||
emit('confirm', {
|
||||
selectionMode: selectionMode.value,
|
||||
serialNumbers: serialNumbers
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
selectedSerials.value = [];
|
||||
searchQuery.value = '';
|
||||
selectionMode.value = 'auto';
|
||||
};
|
||||
|
||||
/** Watchers */
|
||||
watch(() => props.show, (isShown) => {
|
||||
if (isShown) {
|
||||
resetState();
|
||||
loadSerials();
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectionMode, (newMode) => {
|
||||
if (newMode === 'auto') {
|
||||
selectedSerials.value = [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :show="show" max-width="lg" @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-emerald-100 dark:bg-emerald-900/30">
|
||||
<GoogleIcon name="qr_code_2" class="text-2xl text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
Seleccionar Números de Serie
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ product.name }} - Cantidad: {{ quantity }}
|
||||
</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 seriales disponibles -->
|
||||
<div v-else-if="!hasEnoughStock" class="text-center py-8">
|
||||
<GoogleIcon name="error" class="text-5xl text-red-400 mb-3" />
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Stock insuficiente
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Solo hay {{ availableSerials.length }} serial(es) disponible(s), pero necesitas {{ quantity }}.
|
||||
</p>
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="mt-4 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"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Modo de selección -->
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
|
||||
Modo de asignación
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex items-center gap-3 p-4 rounded-xl border-2 transition-all"
|
||||
:class="{
|
||||
'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20 ring-2 ring-emerald-500': selectionMode === 'auto',
|
||||
'border-gray-200 dark:border-gray-700 hover:border-gray-300': selectionMode !== 'auto'
|
||||
}"
|
||||
@click="selectionMode = 'auto'"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-lg bg-emerald-500 flex items-center justify-center">
|
||||
<GoogleIcon name="auto_awesome" class="text-xl text-white" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<p class="text-sm font-bold text-gray-800 dark:text-gray-200">
|
||||
Automático
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Asignar primeros disponibles
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectionMode === 'auto'" class="absolute top-2 right-2">
|
||||
<GoogleIcon name="check_circle" class="text-emerald-500" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex items-center gap-3 p-4 rounded-xl border-2 transition-all"
|
||||
:class="{
|
||||
'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 ring-2 ring-indigo-500': selectionMode === 'manual',
|
||||
'border-gray-200 dark:border-gray-700 hover:border-gray-300': selectionMode !== 'manual'
|
||||
}"
|
||||
@click="selectionMode = 'manual'"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-lg bg-indigo-500 flex items-center justify-center">
|
||||
<GoogleIcon name="touch_app" class="text-xl text-white" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<p class="text-sm font-bold text-gray-800 dark:text-gray-200">
|
||||
Manual
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Elegir seriales específicos
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectionMode === 'manual'" class="absolute top-2 right-2">
|
||||
<GoogleIcon name="check_circle" class="text-indigo-500" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selección manual -->
|
||||
<div v-if="selectionMode === 'manual'" class="space-y-4">
|
||||
<!-- Buscador y acciones -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 relative">
|
||||
<GoogleIcon
|
||||
name="search"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Buscar número de serie..."
|
||||
class="w-full pl-10 pr-4 py-2 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>
|
||||
<button
|
||||
v-if="selectedCount < quantity"
|
||||
@click="selectAll"
|
||||
class="px-3 py-2 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors"
|
||||
>
|
||||
Seleccionar primeros {{ quantity }}
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedCount > 0"
|
||||
@click="clearSelection"
|
||||
class="px-3 py-2 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contador -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ availableSerials.length }} serial(es) disponible(s)
|
||||
</span>
|
||||
<span
|
||||
class="font-semibold"
|
||||
:class="{
|
||||
'text-green-600': selectedCount === quantity,
|
||||
'text-amber-600': selectedCount > 0 && selectedCount < quantity,
|
||||
'text-gray-500': selectedCount === 0
|
||||
}"
|
||||
>
|
||||
{{ selectedCount }} / {{ quantity }} seleccionado(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Lista de seriales -->
|
||||
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<button
|
||||
v-for="serial in filteredSerials"
|
||||
:key="serial.id"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 p-3 text-left transition-colors"
|
||||
:class="{
|
||||
'bg-indigo-50 dark:bg-indigo-900/20': isSelected(serial),
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-800': !isSelected(serial)
|
||||
}"
|
||||
@click="toggleSerial(serial)"
|
||||
>
|
||||
<div
|
||||
class="w-5 h-5 rounded border-2 flex items-center justify-center transition-colors"
|
||||
:class="{
|
||||
'border-indigo-500 bg-indigo-500': isSelected(serial),
|
||||
'border-gray-300 dark:border-gray-600': !isSelected(serial)
|
||||
}"
|
||||
>
|
||||
<GoogleIcon
|
||||
v-if="isSelected(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 resultados -->
|
||||
<div v-if="filteredSerials.length === 0" class="p-8 text-center text-gray-500">
|
||||
<GoogleIcon name="search_off" class="text-3xl mb-2" />
|
||||
<p class="text-sm">No se encontraron seriales</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info modo automático -->
|
||||
<div v-else class="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-lg p-4">
|
||||
<div class="flex gap-3">
|
||||
<GoogleIcon name="info" class="text-emerald-600 dark:text-emerald-400 text-xl shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-emerald-800 dark:text-emerald-300">
|
||||
Asignación automática
|
||||
</p>
|
||||
<p class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">
|
||||
Se asignarán automáticamente los primeros {{ quantity }} número(s) de serie disponible(s) al confirmar la venta.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="hasEnoughStock && !loading" 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-emerald-600 hover:bg-emerald-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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@ -1,10 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useForm, apiURL } from '@Services/Api';
|
||||
|
||||
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';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
/** Eventos */
|
||||
const emit = defineEmits(['close', 'updated']);
|
||||
@ -66,6 +70,11 @@ const closeModal = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const openSerials = () => {
|
||||
closeModal();
|
||||
router.push({ name: 'pos.inventory.serials', params: { id: props.product.id } });
|
||||
};
|
||||
|
||||
/** Observadores */
|
||||
watch(() => props.product, (newProduct) => {
|
||||
if (newProduct) {
|
||||
@ -238,22 +247,32 @@ watch(() => props.show, (newValue) => {
|
||||
</div>
|
||||
|
||||
<!-- Botones -->
|
||||
<div class="flex items-center justify-end gap-3 mt-6">
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||
@click="openSerials"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-lg hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="form.processing">Actualizando...</span>
|
||||
<span v-else>Actualizar</span>
|
||||
<GoogleIcon name="qr_code_2" class="text-lg" />
|
||||
Gestionar Seriales
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="form.processing">Actualizando...</span>
|
||||
<span v-else>Actualizar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSearcher, apiURL } from '@Services/Api';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import { can } from './Module.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
import SearcherHead from '@Holos/Searcher.vue';
|
||||
import Table from '@Holos/Table.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
@ -76,6 +79,10 @@ const onProductsImported = () => {
|
||||
searcher.search();
|
||||
};
|
||||
|
||||
const openSerials = (product) => {
|
||||
router.push({ name: 'pos.inventory.serials', params: { id: product.id } });
|
||||
};
|
||||
|
||||
const confirmDelete = async (id) => {
|
||||
try {
|
||||
const response = await fetch(apiURL(`inventario/${id}`), {
|
||||
@ -188,6 +195,13 @@ onMounted(() => {
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
@click="openSerials(model)"
|
||||
class="text-emerald-600 hover:text-emerald-900 dark:text-emerald-400 dark:hover:text-emerald-300 transition-colors"
|
||||
title="Gestionar números de serie"
|
||||
>
|
||||
<GoogleIcon name="qr_code_2" class="text-xl" />
|
||||
</button>
|
||||
<button
|
||||
v-if="can('edit')"
|
||||
@click="openEditModal(model)"
|
||||
|
||||
609
src/pages/POS/Inventory/Serials.vue
Normal file
609
src/pages/POS/Inventory/Serials.vue
Normal file
@ -0,0 +1,609 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useSearcher, useForm, 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 FormInput from '@Holos/Form/Input.vue';
|
||||
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/** Estado */
|
||||
const inventoryId = computed(() => route.params.id);
|
||||
const inventory = ref(null);
|
||||
const serials = ref({ data: [], total: 0 });
|
||||
const statusFilter = ref('');
|
||||
|
||||
// Modales
|
||||
const showCreateModal = ref(false);
|
||||
const showEditModal = ref(false);
|
||||
const showDeleteModal = ref(false);
|
||||
const showBulkModal = ref(false);
|
||||
const editingSerial = ref(null);
|
||||
const deletingSerial = ref(null);
|
||||
|
||||
// Bulk import
|
||||
const bulkSerials = ref('');
|
||||
const bulkProcessing = ref(false);
|
||||
|
||||
/** Formularios */
|
||||
const form = useForm({
|
||||
serial_number: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const editForm = useForm({
|
||||
serial_number: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
/** Buscador */
|
||||
const searcher = useSearcher({
|
||||
url: apiURL(`inventario/${route.params.id}/serials`),
|
||||
filters: {},
|
||||
onSuccess: (r) => {
|
||||
serials.value = r.serials || { data: [], total: 0 };
|
||||
inventory.value = r.inventory || null;
|
||||
},
|
||||
onError: () => {
|
||||
serials.value = { data: [], total: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
/** Métodos */
|
||||
const loadSerials = (filters = {}) => {
|
||||
searcher.load({
|
||||
url: apiURL(`inventario/${inventoryId.value}/serials`),
|
||||
filters: {
|
||||
...filters,
|
||||
status: statusFilter.value || undefined
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSearch = (query) => {
|
||||
searcher.search(query, { status: statusFilter.value || undefined });
|
||||
};
|
||||
|
||||
const onStatusChange = () => {
|
||||
loadSerials({ q: searcher.query });
|
||||
};
|
||||
|
||||
// Crear serial
|
||||
const openCreateModal = () => {
|
||||
form.reset();
|
||||
showCreateModal.value = true;
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false;
|
||||
form.reset();
|
||||
};
|
||||
|
||||
const createSerial = () => {
|
||||
const url = apiURL(`inventario/${inventoryId.value}/serials`);
|
||||
console.log('Creating serial at URL:', url, 'inventoryId:', inventoryId.value);
|
||||
|
||||
form.post(url, {
|
||||
onSuccess: (response) => {
|
||||
Notify.success('Número de serie creado exitosamente');
|
||||
if (response.inventory) {
|
||||
inventory.value = response.inventory;
|
||||
}
|
||||
closeCreateModal();
|
||||
loadSerials();
|
||||
},
|
||||
onError: () => {
|
||||
Notify.error('Error al crear el número de serie');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Editar serial
|
||||
const openEditModal = (serial) => {
|
||||
editingSerial.value = serial;
|
||||
editForm.serial_number = serial.serial_number;
|
||||
editForm.notes = serial.notes || '';
|
||||
showEditModal.value = true;
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false;
|
||||
editingSerial.value = null;
|
||||
editForm.reset();
|
||||
};
|
||||
|
||||
const updateSerial = () => {
|
||||
editForm.put(apiURL(`inventario/${inventoryId.value}/serials/${editingSerial.value.id}`), {
|
||||
onSuccess: () => {
|
||||
Notify.success('Número de serie actualizado');
|
||||
closeEditModal();
|
||||
loadSerials();
|
||||
},
|
||||
onError: () => {
|
||||
Notify.error('Error al actualizar el número de serie');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 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);
|
||||
Notify.success('Número de serie eliminado');
|
||||
closeDeleteModal();
|
||||
loadSerials();
|
||||
} catch (error) {
|
||||
Notify.error('Error al eliminar el número de serie');
|
||||
}
|
||||
};
|
||||
|
||||
// Importación masiva
|
||||
const openBulkModal = () => {
|
||||
bulkSerials.value = '';
|
||||
showBulkModal.value = true;
|
||||
};
|
||||
|
||||
const closeBulkModal = () => {
|
||||
showBulkModal.value = false;
|
||||
bulkSerials.value = '';
|
||||
};
|
||||
|
||||
const bulkImport = async () => {
|
||||
const lines = bulkSerials.value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
if (lines.length === 0) {
|
||||
Notify.warning('Ingresa al menos un número de serie');
|
||||
return;
|
||||
}
|
||||
|
||||
bulkProcessing.value = true;
|
||||
try {
|
||||
const response = await serialService.bulkImport(inventoryId.value, lines);
|
||||
Notify.success(`${response.count || lines.length} números de serie importados`);
|
||||
if (response.inventory) {
|
||||
inventory.value = response.inventory;
|
||||
}
|
||||
closeBulkModal();
|
||||
loadSerials();
|
||||
} catch (error) {
|
||||
Notify.error('Error al importar números de serie');
|
||||
} finally {
|
||||
bulkProcessing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const bulkCount = computed(() => {
|
||||
return bulkSerials.value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0).length;
|
||||
});
|
||||
|
||||
// Navegación
|
||||
const goBack = () => {
|
||||
router.push({ name: 'pos.inventory.index' });
|
||||
};
|
||||
|
||||
// Badge de estado
|
||||
const getStatusBadge = (status) => {
|
||||
if (status === 'disponible') {
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
|
||||
}
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
return status === 'disponible' ? 'Disponible' : 'Vendido';
|
||||
};
|
||||
|
||||
/** Ciclos */
|
||||
onMounted(() => {
|
||||
loadSerials();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header con navegación -->
|
||||
<div class="mb-4">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<GoogleIcon name="arrow_back" class="text-xl" />
|
||||
Volver a Inventario
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info del producto -->
|
||||
<div v-if="inventory" class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ inventory.name }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
SKU: {{ inventory.sku }}
|
||||
<span v-if="inventory.category">
|
||||
| Categoría: {{ inventory.category.name }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||
{{ inventory.stock }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">
|
||||
Disponibles
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buscador y filtros -->
|
||||
<SearcherHead
|
||||
title="Números de Serie"
|
||||
placeholder="Buscar por número de serie..."
|
||||
@search="onSearch"
|
||||
>
|
||||
<!-- Filtro de estado -->
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
@change="onStatusChange"
|
||||
class="px-3 py-2 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"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="disponible">Disponible</option>
|
||||
<option value="vendido">Vendido</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||
@click="openBulkModal"
|
||||
title="Importar múltiples números de serie"
|
||||
>
|
||||
<GoogleIcon name="upload" class="text-xl" />
|
||||
Importar
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
|
||||
@click="openCreateModal"
|
||||
>
|
||||
<GoogleIcon name="add" class="text-xl" />
|
||||
Agregar Serial
|
||||
</button>
|
||||
</SearcherHead>
|
||||
|
||||
<!-- Tabla de seriales -->
|
||||
<div class="pt-2 w-full">
|
||||
<Table
|
||||
:items="serials"
|
||||
:processing="searcher.processing"
|
||||
@send-pagination="(page) => searcher.pagination(page, { status: statusFilter || undefined })"
|
||||
>
|
||||
<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">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>
|
||||
</template>
|
||||
<template #body="{items}">
|
||||
<tr
|
||||
v-for="serial in items"
|
||||
:key="serial.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ serial.serial_number }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="getStatusBadge(serial.status)"
|
||||
>
|
||||
{{ getStatusLabel(serial.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ serial.notes || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ 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="openEditModal(serial)"
|
||||
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||
title="Editar serial"
|
||||
>
|
||||
<GoogleIcon name="edit" class="text-xl" />
|
||||
</button>
|
||||
<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 editar/eliminar un serial vendido"
|
||||
>
|
||||
Venta #{{ serial.sale_detail_id }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template #empty>
|
||||
<td colspan="5" class="table-cell text-center">
|
||||
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<GoogleIcon
|
||||
name="qr_code_2"
|
||||
class="text-6xl mb-2 opacity-50"
|
||||
/>
|
||||
<p class="font-semibold">
|
||||
No hay números de serie registrados
|
||||
</p>
|
||||
<p class="text-sm mt-1">
|
||||
Agrega seriales individuales o importa múltiples
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Modal Crear Serial -->
|
||||
<Modal :show="showCreateModal" max-width="sm" @close="closeCreateModal">
|
||||
<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">
|
||||
Agregar Número de Serie
|
||||
</h3>
|
||||
<button
|
||||
@click="closeCreateModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<GoogleIcon name="close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createSerial" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NÚMERO DE SERIE *
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="form.serial_number"
|
||||
type="text"
|
||||
placeholder="Ej: ABC123456789"
|
||||
required
|
||||
/>
|
||||
<FormError :message="form.errors?.serial_number" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NOTAS (OPCIONAL)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="form.notes"
|
||||
class="w-full px-3 py-2 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"
|
||||
rows="2"
|
||||
placeholder="Notas adicionales..."
|
||||
></textarea>
|
||||
<FormError :message="form.errors?.notes" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeCreateModal"
|
||||
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
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="form.processing">Guardando...</span>
|
||||
<span v-else>Guardar</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal Editar Serial -->
|
||||
<Modal :show="showEditModal" max-width="sm" @close="closeEditModal">
|
||||
<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">
|
||||
Editar Número de Serie
|
||||
</h3>
|
||||
<button
|
||||
@click="closeEditModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<GoogleIcon name="close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateSerial" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NÚMERO DE SERIE *
|
||||
</label>
|
||||
<FormInput
|
||||
v-model="editForm.serial_number"
|
||||
type="text"
|
||||
placeholder="Ej: ABC123456789"
|
||||
required
|
||||
/>
|
||||
<FormError :message="editForm.errors?.serial_number" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NOTAS (OPCIONAL)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="editForm.notes"
|
||||
class="w-full px-3 py-2 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"
|
||||
rows="2"
|
||||
placeholder="Notas adicionales..."
|
||||
></textarea>
|
||||
<FormError :message="editForm.errors?.notes" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeEditModal"
|
||||
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
|
||||
type="submit"
|
||||
:disabled="editForm.processing"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="editForm.processing">Actualizando...</span>
|
||||
<span v-else>Actualizar</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Modal Importar Múltiples -->
|
||||
<Modal :show="showBulkModal" max-width="md" @close="closeBulkModal">
|
||||
<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">
|
||||
Importar Números de Serie
|
||||
</h3>
|
||||
<button
|
||||
@click="closeBulkModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<GoogleIcon name="close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||
NÚMEROS DE SERIE (UNO POR LÍNEA)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="bulkSerials"
|
||||
class="w-full px-3 py-2 text-sm font-mono 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"
|
||||
rows="10"
|
||||
placeholder="ABC123456789 DEF987654321 GHI456123789 ..."
|
||||
></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ bulkCount }} número(s) de serie detectado(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeBulkModal"
|
||||
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="bulkImport"
|
||||
:disabled="bulkProcessing || bulkCount === 0"
|
||||
class="px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<span v-if="bulkProcessing">Importando...</span>
|
||||
<span v-else>Importar {{ bulkCount }} serial(es)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@ -14,6 +14,7 @@ import CartItem from '@Components/POS/CartItem.vue';
|
||||
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';
|
||||
|
||||
/** i18n */
|
||||
const { t } = useI18n();
|
||||
@ -30,6 +31,11 @@ const scanMode = ref(false);
|
||||
const showClientModal = ref(false);
|
||||
const lastSaleData = ref(null);
|
||||
|
||||
// Estado para selector de seriales
|
||||
const showSerialSelector = ref(false);
|
||||
const serialSelectorProduct = ref(null);
|
||||
const serialSelectorQuantity = ref(1);
|
||||
|
||||
/** Buscador de productos */
|
||||
const searcher = useSearcher({
|
||||
url: apiURL('inventario'),
|
||||
@ -60,6 +66,14 @@ const filteredProducts = computed(() => {
|
||||
/** Métodos */
|
||||
const addToCart = (product) => {
|
||||
try {
|
||||
// Si el producto tiene seriales, mostrar selector
|
||||
if (product.has_serials) {
|
||||
serialSelectorProduct.value = product;
|
||||
serialSelectorQuantity.value = 1;
|
||||
showSerialSelector.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
cart.addProduct(product);
|
||||
window.Notify.success(`${product.name} agregado al carrito`);
|
||||
} catch (error) {
|
||||
@ -67,6 +81,25 @@ const addToCart = (product) => {
|
||||
}
|
||||
};
|
||||
|
||||
const closeSerialSelector = () => {
|
||||
showSerialSelector.value = false;
|
||||
serialSelectorProduct.value = null;
|
||||
serialSelectorQuantity.value = 1;
|
||||
};
|
||||
|
||||
const handleSerialConfirm = (serialConfig) => {
|
||||
if (!serialSelectorProduct.value) return;
|
||||
|
||||
cart.addProductWithSerials(
|
||||
serialSelectorProduct.value,
|
||||
serialSelectorQuantity.value,
|
||||
serialConfig
|
||||
);
|
||||
|
||||
window.Notify.success(`${serialSelectorProduct.value.name} agregado al carrito`);
|
||||
closeSerialSelector();
|
||||
};
|
||||
|
||||
const handleClearCart = () => {
|
||||
if (confirm(t('cart.clearConfirm'))) {
|
||||
cart.clear();
|
||||
@ -154,7 +187,8 @@ const handleConfirmSale = async (paymentData) => {
|
||||
product_name: item.product_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2))
|
||||
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2)),
|
||||
serial_numbers: item.has_serials ? (item.serial_numbers || []) : undefined
|
||||
}))
|
||||
};
|
||||
|
||||
@ -436,5 +470,16 @@ onMounted(() => {
|
||||
@close="closeClientModal"
|
||||
@save="handleClientSave"
|
||||
/>
|
||||
|
||||
<!-- Modal de Selección de Seriales -->
|
||||
<SerialSelector
|
||||
v-if="serialSelectorProduct"
|
||||
:show="showSerialSelector"
|
||||
:product="serialSelectorProduct"
|
||||
:quantity="serialSelectorQuantity"
|
||||
:exclude-serials="cart.getSelectedSerials()"
|
||||
@close="closeSerialSelector"
|
||||
@confirm="handleSerialConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -42,6 +42,12 @@ const router = createRouter({
|
||||
beforeEnter: (to, from, next) => can(next, 'inventario.index'),
|
||||
component: () => import('@Pages/POS/Inventory/Index.vue')
|
||||
},
|
||||
{
|
||||
path: 'inventory/:id/serials',
|
||||
name: 'pos.inventory.serials',
|
||||
beforeEnter: (to, from, next) => can(next, 'inventario.index'),
|
||||
component: () => import('@Pages/POS/Inventory/Serials.vue')
|
||||
},
|
||||
{
|
||||
path: 'point',
|
||||
name: 'pos.point',
|
||||
|
||||
136
src/services/serialService.js
Normal file
136
src/services/serialService.js
Normal file
@ -0,0 +1,136 @@
|
||||
import { api, apiURL } from '@Services/Api';
|
||||
|
||||
/**
|
||||
* Servicio para gestionar números de serie de inventario
|
||||
*/
|
||||
const serialService = {
|
||||
/**
|
||||
* Obtener lista de seriales de un producto
|
||||
* @param {Number} inventoryId - ID del inventario
|
||||
* @param {Object} filters - Filtros opcionales (status, q, page)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async getSerials(inventoryId, filters = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.get(apiURL(`inventario/${inventoryId}/serials`), {
|
||||
params: filters,
|
||||
onSuccess: (response) => {
|
||||
resolve(response);
|
||||
},
|
||||
onError: (error) => {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener solo seriales disponibles de un producto
|
||||
* @param {Number} inventoryId - ID del inventario
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async getAvailableSerials(inventoryId) {
|
||||
return this.getSerials(inventoryId, { status: 'disponible' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener un serial específico
|
||||
* @param {Number} inventoryId - ID del inventario
|
||||
* @param {Number} serialId - ID del serial
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async getSerial(inventoryId, serialId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.get(apiURL(`inventario/${inventoryId}/serials/${serialId}`), {
|
||||
onSuccess: (response) => {
|
||||
resolve(response.serial || response);
|
||||
},
|
||||
onError: (error) => {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Crear un nuevo serial
|
||||
* @param {Number} inventoryId - ID del inventario
|
||||
* @param {Object} data - Datos del serial (serial_number, notes)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async createSerial(inventoryId, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.post(apiURL(`inventario/${inventoryId}/serials`), {
|
||||
data: data,
|
||||
onSuccess: (response) => {
|
||||
resolve(response);
|
||||
},
|
||||
onError: (error) => {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Importar múltiples seriales
|
||||
* @param {Number} inventoryId - ID del inventario
|
||||
* @param {Array} serialNumbers - Array de números de serie
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async bulkImport(inventoryId, serialNumbers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.post(apiURL(`inventario/${inventoryId}/serials/bulk`), {
|
||||
data: { serial_numbers: serialNumbers },
|
||||
onSuccess: (response) => {
|
||||
resolve(response);
|
||||
},
|
||||
onError: (error) => {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualizar un serial
|
||||
* @param {Number} inventoryId - ID del inventario
|
||||
* @param {Number} serialId - ID del serial
|
||||
* @param {Object} data - Datos a actualizar (serial_number, status, notes)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async updateSerial(inventoryId, serialId, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.put(apiURL(`inventario/${inventoryId}/serials/${serialId}`), {
|
||||
data: data,
|
||||
onSuccess: (response) => {
|
||||
resolve(response);
|
||||
},
|
||||
onError: (error) => {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar un serial
|
||||
* @param {Number} inventoryId - ID del inventario
|
||||
* @param {Number} serialId - ID del serial
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async deleteSerial(inventoryId, serialId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
api.delete(apiURL(`inventario/${inventoryId}/serials/${serialId}`), {
|
||||
onSuccess: (response) => {
|
||||
resolve(response);
|
||||
},
|
||||
onError: (error) => {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default serialService;
|
||||
@ -149,12 +149,12 @@ const ticketService = {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...blackColor);
|
||||
const items = saleData.details || saleData.items || [];
|
||||
|
||||
|
||||
items.forEach((item) => {
|
||||
// Nombre del producto
|
||||
const productName = item.product_name || item.name || 'Producto';
|
||||
const nameLines = doc.splitTextToSize(productName, 45);
|
||||
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(nameLines, leftMargin, yPosition);
|
||||
@ -176,11 +176,27 @@ const ticketService = {
|
||||
doc.setFontSize(8);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...blackColor);
|
||||
|
||||
|
||||
doc.text(String(quantity), 52, yPosition, { align: 'center' });
|
||||
doc.text(`$${unitPrice}`, rightMargin, yPosition, { align: 'right' });
|
||||
|
||||
yPosition += 5;
|
||||
|
||||
yPosition += 4;
|
||||
|
||||
// Números de serie (si existen)
|
||||
const serials = item.serial_numbers || item.serials || [];
|
||||
if (serials.length > 0) {
|
||||
doc.setFontSize(6);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(...darkGrayColor);
|
||||
|
||||
serials.forEach((serial) => {
|
||||
const serialNumber = typeof serial === 'string' ? serial : serial.serial_number;
|
||||
doc.text(` S/N: ${serialNumber}`, leftMargin, yPosition);
|
||||
yPosition += 3;
|
||||
});
|
||||
}
|
||||
|
||||
yPosition += 2;
|
||||
});
|
||||
|
||||
yPosition += 2;
|
||||
|
||||
@ -39,13 +39,18 @@ const useCart = defineStore('cart', {
|
||||
|
||||
actions: {
|
||||
// Agregar producto al carrito
|
||||
addProduct(product) {
|
||||
addProduct(product, serialConfig = null) {
|
||||
const existingItem = this.items.find(item => item.inventory_id === product.id);
|
||||
|
||||
|
||||
if (existingItem) {
|
||||
// Si ya existe, incrementar cantidad
|
||||
if (existingItem.quantity < product.stock) {
|
||||
existingItem.quantity++;
|
||||
// Si tiene seriales, limpiar para que el usuario vuelva a seleccionar
|
||||
if (existingItem.has_serials) {
|
||||
existingItem.serial_numbers = [];
|
||||
existingItem.serial_selection_mode = null;
|
||||
}
|
||||
} else {
|
||||
window.Notify.warning('No hay suficiente stock disponible');
|
||||
}
|
||||
@ -58,10 +63,61 @@ const useCart = defineStore('cart', {
|
||||
quantity: 1,
|
||||
unit_price: parseFloat(product.price?.retail_price || 0),
|
||||
tax_rate: parseFloat(product.price?.tax || 16),
|
||||
max_stock: product.stock
|
||||
max_stock: product.stock,
|
||||
// Campos para seriales
|
||||
has_serials: product.has_serials || false,
|
||||
serial_numbers: serialConfig?.serialNumbers || [],
|
||||
serial_selection_mode: serialConfig?.selectionMode || null
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Agregar producto con seriales ya configurados
|
||||
addProductWithSerials(product, quantity, serialConfig) {
|
||||
// Eliminar item existente si hay
|
||||
this.removeProduct(product.id);
|
||||
|
||||
// Agregar nuevo item con seriales
|
||||
this.items.push({
|
||||
inventory_id: product.id,
|
||||
product_name: product.name,
|
||||
sku: product.sku,
|
||||
quantity: quantity,
|
||||
unit_price: parseFloat(product.price?.retail_price || 0),
|
||||
tax_rate: parseFloat(product.price?.tax || 16),
|
||||
max_stock: product.stock,
|
||||
has_serials: true,
|
||||
serial_numbers: serialConfig.serialNumbers || [],
|
||||
serial_selection_mode: serialConfig.selectionMode
|
||||
});
|
||||
},
|
||||
|
||||
// Actualizar seriales de un item
|
||||
updateSerials(inventoryId, serialConfig) {
|
||||
const item = this.items.find(i => i.inventory_id === inventoryId);
|
||||
if (item) {
|
||||
item.serial_numbers = serialConfig.serialNumbers || [];
|
||||
item.serial_selection_mode = serialConfig.selectionMode;
|
||||
}
|
||||
},
|
||||
|
||||
// Obtener seriales ya seleccionados (para excluir del selector)
|
||||
getSelectedSerials() {
|
||||
return this.items
|
||||
.filter(item => item.has_serials && item.serial_numbers?.length > 0)
|
||||
.flatMap(item => item.serial_numbers);
|
||||
},
|
||||
|
||||
// Verificar si un item necesita selección de seriales
|
||||
needsSerialSelection(inventoryId) {
|
||||
const item = this.items.find(i => i.inventory_id === inventoryId);
|
||||
if (!item || !item.has_serials) return false;
|
||||
// Necesita selección si es manual y no tiene suficientes seriales
|
||||
if (item.serial_selection_mode === 'manual') {
|
||||
return (item.serial_numbers?.length || 0) < item.quantity;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// Actualizar cantidad de un item
|
||||
updateQuantity(inventoryId, quantity) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user