diff --git a/src/components/POS/SerialSelector.vue b/src/components/POS/SerialSelector.vue new file mode 100644 index 0000000..5a13cb4 --- /dev/null +++ b/src/components/POS/SerialSelector.vue @@ -0,0 +1,390 @@ + + + diff --git a/src/pages/POS/Inventory/EditModal.vue b/src/pages/POS/Inventory/EditModal.vue index f43ec1e..3a0433c 100644 --- a/src/pages/POS/Inventory/EditModal.vue +++ b/src/pages/POS/Inventory/EditModal.vue @@ -1,10 +1,14 @@ + + diff --git a/src/pages/POS/Point.vue b/src/pages/POS/Point.vue index 4df8c66..5d113e2 100644 --- a/src/pages/POS/Point.vue +++ b/src/pages/POS/Point.vue @@ -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" /> + + + diff --git a/src/router/Index.js b/src/router/Index.js index e7b08d2..0596338 100644 --- a/src/router/Index.js +++ b/src/router/Index.js @@ -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', diff --git a/src/services/serialService.js b/src/services/serialService.js new file mode 100644 index 0000000..dbff0a4 --- /dev/null +++ b/src/services/serialService.js @@ -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; diff --git a/src/services/ticketService.js b/src/services/ticketService.js index 80f2c56..9370718 100644 --- a/src/services/ticketService.js +++ b/src/services/ticketService.js @@ -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; diff --git a/src/stores/cart.js b/src/stores/cart.js index 3ad83c1..4d9c46e 100644 --- a/src/stores/cart.js +++ b/src/stores/cart.js @@ -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) {