add: numero de serie

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-16 21:37:13 -06:00
parent 7a28a35f60
commit 46b155c2c8
9 changed files with 1312 additions and 21 deletions

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

View File

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

View File

@ -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)"

View 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&#10;DEF987654321&#10;GHI456123789&#10;..."
></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>

View File

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

View File

@ -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',

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

View File

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

View File

@ -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) {