Compare commits

...

71 Commits

Author SHA1 Message Date
Juan Felipe Zapata Moreno
53a5208cdf feat: agregar gestión de proveedores en la creación y edición de facturas 2026-03-21 12:38:57 -06:00
Juan Felipe Zapata Moreno
5dbb52a9e9 feat: agregar gestión de facturas con creación, edición y eliminación 2026-03-21 11:49:00 -06:00
Juan Felipe Zapata Moreno
847d2af7ef fix: agregar número de teléfono a la información del negocio en los servicios de cotización y ticket 2026-03-14 13:16:52 -06:00
Juan Felipe Zapata Moreno
a5411a6318 fix: actualizar nombre del negocio y eliminar referencia a días de vigencia en cotizaciones y tickets 2026-03-14 09:15:27 -06:00
Juan Felipe Zapata Moreno
71ac03309d fix: mejorar la asignación de productos y metadatos en la búsqueda de inventario 2026-03-12 18:28:46 -06:00
Juan Felipe Zapata Moreno
f8e4421ffc Merge branch 'main' of git.golsystems.mx:juan.zapata/pdv.frontend 2026-03-12 18:15:22 -06:00
Juan Felipe Zapata Moreno
393aa04150 fix: corregir la URL de la API para la carga de inventario en función del almacén 2026-03-12 18:14:16 -06:00
6fac8d0797 feat: ajustar validaciones de entrada en el formulario de edición de inventario 2026-03-11 21:50:17 -06:00
Juan Felipe Zapata Moreno
18755271d3 feat: agregar control de permisos para la sección de devoluciones en la navegación y en el módulo de devoluciones 2026-03-10 11:20:34 -06:00
Juan Felipe Zapata Moreno
a743ea73e7 feat: agregar control de permisos en enlaces de navegación y búsqueda de productos 2026-03-10 11:08:40 -06:00
Juan Felipe Zapata Moreno
581ce37449 feat: agregar servicio de cotización y componente de formulario de Google para feedback 2026-03-09 17:11:40 -06:00
Juan Felipe Zapata Moreno
68a3da8e3f feat: mejorar etiquetas de régimen fiscal y uso de CFDI utilizando opciones dinámicas 2026-03-06 10:54:17 -06:00
Juan Felipe Zapata Moreno
0f622f5620 hotfix: corregir la importación de SelectRegimenFiscal en los componentes de cliente 2026-03-06 10:11:35 -06:00
Juan Felipe Zapata Moreno
b7154af381 feat: actualizar terminología de categoría a clasificación en toda la aplicación 2026-03-06 10:08:09 -06:00
Juan Felipe Zapata Moreno
fe79f843f6 feat: agregar soporte para gestión de almacén principal en selección de seriales 2026-03-04 17:17:08 -06:00
Juan Felipe Zapata Moreno
54f15ab7b4 fix: cambiar tipo de entrada de número a cadena para la clave SAT del producto 2026-03-04 10:13:42 -06:00
Juan Felipe Zapata Moreno
cfd990ae0a feat: mejorar gestión de stock y crear modal para subcategorías en bloque 2026-03-02 13:12:29 -06:00
fccb425781 feat: agregar selección de seriales en movimientos y mejorar gestión de categorías y subcategorías 2026-02-26 22:05:17 -06:00
Juan Felipe Zapata Moreno
1909ebec68 feat: agregar subcategorias y modificar vistas 2026-02-25 13:37:02 -06:00
b2dea0785e feat: agregar opción para permitir eliminación de seriales y mejorar gestión de estado en componentes 2026-02-24 23:30:11 -06:00
Juan Felipe Zapata Moreno
44d86af459 feat: agregar factor de conversión y ajustar gestión de stock en el carrito 2026-02-24 18:43:21 -06:00
cf80e914fd feat: agregado unidad de medida crud 2026-02-24 01:29:28 -06:00
Juan Felipe Zapata Moreno
e653add755 feat: agregar campo Clave SAT en formularios de creación y edición de productos 2026-02-23 16:30:47 -06:00
e51f3fad0f feat: actualizar nombres de columnas y mejorar gestión de seriales en componentes 2026-02-19 21:45:23 -06:00
Juan Felipe Zapata Moreno
2bb50c48c9 feat: agregar traducción de descripciones y actualizar términos de kit a paq en varios componentes 2026-02-19 16:53:56 -06:00
fb37a2d62f feat: Refactorización de la gestión de series en POS
- Se eliminó el modal para eliminar series en Inventory/Serials.vue y se ajustó el diseño de la tabla.
- Se actualizó Movements/Edit.vue para usar el componente SerialInputList en el manejo de entrada de series.
- Se mejoró EntryModal.vue para utilizar SerialInputList en la captura de series.
- Se introdujo BundleSerialSelector.vue para seleccionar series desde paquetes (bundles).
- Se implementaron mejoras en la gestión de series en cart.js para manejar paquetes con series.
- Se agregó un nuevo método de servicio en serialService.js para obtener componentes de paquetes con seguimiento por series.
- Se creó el componente SerialInputList.vue para una mejor gestión de entrada de series.
- Se limpió ReturnDetail.vue eliminando la funcionalidad de cancelación de devoluciones.
2026-02-18 21:40:31 -06:00
Juan Felipe Zapata Moreno
9b8bf57abd wip 2026-02-17 16:37:19 -06:00
32949fe13a feat: búsqueda, tickets y gestión UI de paquetes- Integra búsqueda y visualización de paquetes en el POS.
- Desglosa detalles de paquetes en la generación de tickets.
- Mejora modales de edición y eliminación.
2026-02-16 23:34:22 -06:00
Juan Felipe Zapata Moreno
1466cd2166 feat: agregar documentación y componentes para gestión de bundles 2026-02-16 17:16:13 -06:00
Juan Felipe Zapata Moreno
156c915403 feat: agregar manejo de números de serie en movimientos y actualización de PDF 2026-02-12 15:47:48 -06:00
Juan Felipe Zapata Moreno
898643cdab feat: mejorar el envío de facturas por WhatsApp con autenticación y manejo de errores 2026-02-11 16:46:50 -06:00
Juan Felipe Zapata Moreno
d521f0b2c2 feat: gestión de proveedores e integración en inventario
- Implementa CRUD completo, rutas y permisos para proveedores.
- Integra datos de proveedor en movimientos (creación, edición y detalles).
- Actualiza navegación principal y agrega traducciones.
2026-02-10 16:40:30 -06:00
99f190f61b feat: unidades de medida, validación de series y WhatsApp
- Integra selección de unidad y restringe series en cantidades decimales.
- Implementa servicio de mensajería y facturación por WhatsApp.
- Agrega componentes de sidebar y actualiza vistas de inventario.
2026-02-10 00:06:42 -06:00
Juan Felipe Zapata Moreno
cbf8ccb64c fix: ticketDetailMovement 2026-02-09 16:32:46 -06:00
093cea3c4c feat: reportes de movimientos, inventario por almacén y series
- Habilita vista de stock con filtros y selección de series en traspasos.
- Implementa servicio de tickets PDF y corrige datos (ubicación/negocio).
- Renombra botón a Reporte y elimina opción de almacén principal.
2026-02-08 20:26:59 -06:00
Juan Felipe Zapata Moreno
6c70d1ba4f feat: agregar modal de edición para movimientos y mejorar la gestión de permisos 2026-02-07 12:10:02 -06:00
4307d97639 feat: exportación de kardex y mejoras en búsqueda
- Implementa KardexModal para generar reportes de inventario.
- Optimiza búsqueda en modales con debounce y visualización de SKU.
- Agrega cálculo de costos totales y stock disponible por almacén.
2026-02-06 23:22:59 -06:00
Juan Felipe Zapata Moreno
04e84f6241 feat: gestión multiproducto en salidas y traspasos
- Habilita selección múltiple con cantidades en ExitModal y TransferModal.
- Implementa lógica para agregar/quitar productos y calcular totales.
- Agrega validación de selección mínima antes de enviar el formulario.
2026-02-06 16:01:39 -06:00
2c7d2f2001 feat: agregar componentes de gestión de almacenes y movimientos
- Implementar vistas CRUD para administración de almacenes (Index, Create, Edit, Delete).
- Añadir  para realizar traspasos de productos entre almacenes.
- Configurar lógica de rutas y API (Module.js) para almacenes y movimientos.
2026-02-06 00:01:45 -06:00
Juan Felipe Zapata Moreno
7c27200290 feat: mejorar la gestión de productos estancados y agregar paginación en el servicio de reportes 2026-02-05 12:00:54 -06:00
Juan Felipe Zapata Moreno
69c015d51b feat: Implementacion para generar un excel para inventario y modificacion a permisos 2026-02-04 16:45:39 -06:00
Juan Felipe Zapata Moreno
c9251e0c8f feat: Se implementó el componente para subir archivos xml y pdf para facturas 2026-02-04 14:28:28 -06:00
4dfeeeea20 feat: agregar funcionalidad para imprimir imágenes en base64 y mejorar la impresión de tickets 2026-02-03 21:04:08 -06:00
Juan Felipe Zapata Moreno
21b28b5bff WIP: agregar configuración de impresora y funcionalidad de impresión automática de tickets 2026-02-03 17:02:28 -06:00
Juan Felipe Zapata Moreno
a45cc247c1 feat: mejorar gestión de facturación y búsqueda por scanner
- Agrega estadísticas detalladas y manejo de carga en BillingRequests.vue.
- Actualiza tablas para mostrar historial, montos y estados con badges.
- Previene solicitudes duplicadas hasta que la anterior sea procesada.
- Implementa hook useBarcodeScanner en Point.vue para búsqueda por código/serie
2026-02-03 15:25:19 -06:00
Juan Felipe Zapata Moreno
b895836849 feat: mejorar la gestión de errores y optimizar la carga de datos en los componentes de devolución 2026-01-30 16:48:25 -06:00
Juan Felipe Zapata Moreno
8210d7dd2f feat: agregar campos fiscales y funcionalidad de descarga de reportes en Excel 2026-01-30 14:17:55 -06:00
Juan Felipe Zapata Moreno
992ecb07b7 feat: añadir modal para generación de reportes en Excel de descuentos por cliente 2026-01-29 17:39:00 -06:00
27825a7dd4 feat: Implementar funcionalidad de descuento de cliente en CheckoutModal
Se agregó la funcionalidad de búsqueda de clientes para recuperar sus detalles y descuentos.

Se calcula el total estimado aplicando el descuento del cliente.

Se actualizó la visualización del total para mostrar los montos con descuento cuando corresponda.

Se mejoró la validación del pago para considerar los descuentos en los pagos en efectivo.

Se emiten los datos del cliente durante la confirmación del pago para su procesamiento en el backend.

feat: Crear componente ToggleButton para la activación de niveles (tiers)

Se implementó el componente ToggleButton para activar/desactivar los niveles de los clientes.

Se integró la funcionalidad de alternancia (toggle) con la API para actualizar el estado del nivel.

refactor: Actualizar páginas de Clientes y Niveles para la nueva estructura de niveles

Se ajustaron las páginas de Clientes y Niveles para reflejar las nuevas propiedades y estructura de los niveles.

Se actualizaron los elementos de la interfaz (UI) para mostrar información del nivel, incluyendo descuentos y límites de compra.

Se mejoró el modal de confirmación de eliminación para reflejar el contexto de la eliminación del nivel.

fix: Formatear y mostrar correctamente la información del nivel en las vistas Stats e Index

Se corrigió la visualización de los nombres de los niveles y los porcentajes de descuento en varios componentes.

Se aseguró el manejo adecuado de los datos de los niveles en formularios y modales.

chore: Actualizar ticketService para incluir información de descuentos en los recibos

Se agregó lógica para mostrar los detalles del descuento en los tickets impresos si corresponde.
2026-01-28 22:47:06 -06:00
Juan Felipe Zapata Moreno
fb2f7cb068 ADD: vista para tiers de clientes 2026-01-28 16:48:08 -06:00
Juan Felipe Zapata Moreno
eb7fc0de14 feat: implementar lógica de autenticación en rutas y redirección al cerrar sesión 2026-01-28 15:54:13 -06:00
5b7b6f2343 feat: implementar gestión de pestañas para números de serie y mejorar lógica de estado 2026-01-27 21:33:26 -06:00
d469b18bf5 feat: añadir funcionalidad de gestión de clientes con creación, edición y eliminación 2026-01-27 21:14:05 -06:00
Juan Felipe Zapata Moreno
5c3df890e4 feat: añadir módulo de devoluciones, vistas y lógica relacionada 2026-01-27 16:36:59 -06:00
Juan Felipe Zapata Moreno
83dd71f80f refactor 2026-01-19 11:55:05 -06:00
46b155c2c8 add: numero de serie 2026-01-16 21:37:13 -06:00
Juan Felipe Zapata Moreno
7a28a35f60 fix: corregir título del campo de nombre en el formulario 2026-01-16 17:39:39 -06:00
Juan Felipe Zapata Moreno
31e4cd9214 add: QR al ticket, datos para facturación 2026-01-16 17:11:24 -06:00
Juan Felipe Zapata Moreno
24b59f0b16 fix: imagen 2026-01-16 10:16:48 -06:00
Juan Felipe Zapata Moreno
cd58a97f57 fix: logo 2026-01-15 17:07:22 -06:00
Juan Felipe Zapata Moreno
b1d0f73e94 fix: login diseño 2026-01-14 17:07:17 -06:00
Juan Felipe Zapata Moreno
0ffa93019c add: agregar gestión de clientes con interfaz y funcionalidad de registro 2026-01-12 14:49:08 -06:00
Juan Felipe Zapata Moreno
c6dadb22e8 add: agregar soporte para múltiples formatos de código de barras en el escáner QR 2026-01-06 09:07:13 -06:00
0fd2d06db4 add: agregar campo de código de barras en formularios de creación y edición de productos 2026-01-05 20:31:01 -06:00
Juan Felipe Zapata Moreno
58a0e5b89e add: implementar escáner de códigos QR y agregar soporte en la interfaz de usuario 2026-01-05 16:31:12 -06:00
Juan Felipe Zapata Moreno
62cf6afc42 add: implementar filtros de fecha para reportes de productos más vendidos y sin movimiento 2026-01-05 15:52:51 -06:00
0c98dc0bb1 add: implementar servicio de reportes para productos más vendidos y sin movimiento 2026-01-04 17:37:05 -06:00
708cc31496 add: cambio al pagar con efectivo 2026-01-04 17:12:12 -06:00
Juan Felipe Zapata Moreno
cabba3621e fix: importación de excel 2026-01-02 15:52:39 -06:00
Juan Felipe Zapata Moreno
1daed06f35 WIP: importar excel 2026-01-02 15:35:29 -06:00
173f5417b3 WIP 2026-01-01 21:59:45 -06:00
135 changed files with 27228 additions and 137 deletions

View File

@ -10,6 +10,12 @@ VITE_APP_API_SECURE=false
VITE_MICROSERVICE_STOCK=http://localhost:3000/api
VITE_APP_NOTIFICATIONS=false
# Información del Negocio (para tickets)
VITE_BUSINESS_CITY=Ciudad
VITE_BUSINESS_STATE=Estado
VITE_BUSINESS_COUNTRY=México
VITE_BUSINESS_PHONE=Tel: (52) 0000-0000
VITE_REVERB_APP_ID=
VITE_REVERB_APP_KEY=
VITE_REVERB_APP_SECRET=

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ notes.md
*.njsproj
*.sln
*.sw?
CLAUDE.md

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Holos</title>
<title>PDV</title>
</head>
<body>
<div id="app"></div>

570
package-lock.json generated
View File

@ -1,21 +1,23 @@
{
"name": "notsoweb.frontend",
"name": "pdv.frontend",
"version": "0.9.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "notsoweb.frontend",
"name": "pdv.frontend",
"version": "0.9.10",
"dependencies": {
"@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/vite": "^4.0.9",
"@vitejs/plugin-vue": "^5.2.1",
"axios": "^1.8.1",
"jspdf": "^3.0.4",
"laravel-echo": "^2.0.2",
"luxon": "^3.5.0",
"pinia": "^3.0.1",
"pusher-js": "^8.4.0",
"qrcode": "^1.5.4",
"tailwindcss": "^4.0",
"toastr": "^2.1.4",
"uuid": "^11.1.0",
@ -23,6 +25,7 @@
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-multiselect": "^3.2.0",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.0",
"ziggy-js": "^2.5.2"
},
@ -89,6 +92,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
@ -1224,18 +1236,50 @@
"vite": "^5.2.0 || ^6"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.41.5",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"license": "MIT"
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@ -1407,11 +1451,19 @@
"node": ">=0.4.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@ -1492,6 +1544,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/barcode-detector": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz",
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.11",
"zxing-wasm": "1.1.3"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/birpc": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz",
@ -1596,6 +1668,15 @@
"tslib": "^2.0.3"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001718",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz",
@ -1617,6 +1698,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -1656,11 +1757,21 @@
"node": ">= 10.0"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -1673,7 +1784,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
@ -1744,6 +1854,28 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-js": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-select": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
@ -1798,6 +1930,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1816,6 +1957,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@ -1860,6 +2007,16 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@ -1946,6 +2103,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
@ -2111,6 +2274,17 @@
"node": ">=8.6.0"
}
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -2135,6 +2309,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@ -2181,6 +2361,19 @@
"node": ">=8"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
@ -2268,6 +2461,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -2423,6 +2625,26 @@
"node": ">=12"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -2433,6 +2655,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -2515,6 +2746,23 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jspdf": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/laravel-echo": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.1.4.tgz",
@ -2756,6 +3004,18 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lower-case": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@ -2983,6 +3243,48 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@ -3005,6 +3307,15 @@
"tslib": "^2.0.3"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-0.2.0.tgz",
@ -3018,6 +3329,13 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3057,6 +3375,15 @@
}
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@ -3107,6 +3434,23 @@
"tweetnacl": "^1.0.3"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz",
@ -3140,6 +3484,23 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/relateurl": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
@ -3150,6 +3511,21 @@
"node": ">= 0.10"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@ -3167,6 +3543,16 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz",
@ -3230,6 +3616,18 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@ -3299,6 +3697,42 @@
"node": ">=0.10.0"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/superjson": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
@ -3324,6 +3758,16 @@
"node": ">=8"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
@ -3382,6 +3826,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
@ -3473,6 +3927,16 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
@ -3641,6 +4105,19 @@
"npm": ">= 6.14.15"
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.7.3.tgz",
"integrity": "sha512-iSGko42FsEvdHyizBMBs/X+HMO9Z5ONDxjW+mQdoraOR5emRNedmjC5SEJdYzGz8ZP5ME3lwB4iHy3S7MOt5Qw==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
@ -3662,6 +4139,39 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/webrtc-adapter": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.3.tgz",
"integrity": "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
@ -3693,6 +4203,12 @@
"node": ">=0.4.0"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@ -3702,6 +4218,41 @@
"node": ">=18"
}
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/ziggy-js": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/ziggy-js/-/ziggy-js-2.5.3.tgz",
@ -3711,6 +4262,15 @@
"@types/qs": "^6.9.17",
"qs": "~6.9.7"
}
},
"node_modules/zxing-wasm": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.39.10"
}
}
}
}

View File

@ -1,6 +1,6 @@
{
"name": "notsoweb.frontend",
"copyright": "Notsoweb Software Inc.",
"name": "pdv.frontend",
"copyright": "Golsystems",
"private": true,
"version": "0.9.10",
"type": "module",
@ -14,10 +14,12 @@
"@tailwindcss/vite": "^4.0.9",
"@vitejs/plugin-vue": "^5.2.1",
"axios": "^1.8.1",
"jspdf": "^3.0.4",
"laravel-echo": "^2.0.2",
"luxon": "^3.5.0",
"pinia": "^3.0.1",
"pusher-js": "^8.4.0",
"qrcode": "^1.5.4",
"tailwindcss": "^4.0",
"toastr": "^2.1.4",
"uuid": "^11.1.0",
@ -25,6 +27,7 @@
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-multiselect": "^3.2.0",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.0",
"ziggy-js": "^2.5.2"
},

BIN
public/Logo-hk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -1,19 +1,12 @@
<script setup>
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import useLoader from '@Stores/Loader';
import { hasToken } from '@Services/Api';
/** Definidores */
const router = useRouter();
const loader = useLoader();
/** Ciclos */
onMounted(() => {
if(!hasToken()) {
return router.push({ name: 'auth.index' })
}
loader.boot()
})
</script>

View File

@ -4,7 +4,6 @@ import { APP_VERSION, APP_COPYRIGHT } from '@/config.js'
import useDarkMode from '@Stores/DarkMode'
import Logo from '@Holos/Logo.vue'
import IconButton from '@Holos/Button/Icon.vue'
/** Definidores */
const darkMode = useDarkMode()
@ -21,49 +20,56 @@ onMounted(() => {
</script>
<template>
<div class="h-screen flex bg-primary dark:bg-primary-d">
<div
class="relative flex w-full lg:w-full justify-around items-center with-transition"
:class="{'app-bg-light':darkMode.isLight,'app-bg-dark':darkMode.isDark}"
>
<header class="absolute top-0 flex w-full h-8 px-1 items-center justify-end text-white">
<div>
<IconButton v-if="darkMode.isLight"
icon="light_mode"
:title="$t('app.theme.light')"
@click="darkMode.applyDark()"
/>
<IconButton v-else
icon="dark_mode"
:title="$t('app.theme.dark')"
@click="darkMode.applyLight()"
/>
</div>
</header>
<div class="flex w-full flex-col items-center justify-center space-y-2">
<div class="flex space-x-2 items-center justify-start text-white">
<Logo
class="text-lg inline-flex"
/>
<div class="min-h-screen flex">
<!-- Panel Izquierdo - Branding -->
<div class="hidden lg:flex lg:w-1/2 bg-primary dark:bg-primary-d relative overflow-hidden">
<!-- Fondo con patron sutil -->
<div class="absolute inset-0 opacity-5">
<div class="absolute inset-0" style="background-image: radial-gradient(circle at 25px 25px, white 2px, transparent 0); background-size: 50px 50px;"></div>
</div>
<!-- Contenido centrado -->
<div class="relative z-10 flex flex-col items-center justify-center w-full px-12 text-white">
<!-- Logo/Icono -->
<div class="mb-8">
<Logo size="xl" />
</div>
<!-- Titulo -->
<h1 class="text-3xl font-bold text-center mb-4">
Punto de Venta
</h1>
<!-- Descripcion -->
<p class="text-white/70 text-center max-w-sm mb-8">
Sistema de Gestion de Inventario y Ventas.<br>
</p>
</div>
</div>
<!-- Panel Derecho - Formulario -->
<div class="w-full lg:w-1/2 bg-page dark:bg-page-d flex flex-col">
<!-- Contenido principal -->
<div class="flex-1 flex items-center justify-center px-6 py-12">
<div class="w-full max-w-md">
<!-- Logo mobile -->
<div class="lg:hidden flex justify-center mb-8">
<div class="w-16 h-16 bg-primary dark:bg-primary-d rounded-xl flex items-center justify-center">
<Logo size="sm" />
</div>
</div>
<main class="bg-white/10 w-full backdrop-blur-xs text-white px-4 py-4 rounded-sm max-w-80">
<RouterView />
</main>
<footer class="absolute bottom-0 flex w-full h-8 px-4 items-center justify-between bg-primary dark:bg-primary-d backdrop-blur-md text-white transition-colors duration-global">
<div>
<span>
&copy;2024 {{ APP_COPYRIGHT }}
</span>
</div>
<div>
<span>
APP {{ APP_VERSION }} API {{ $page.app.version }}
</span>
</div>
<!-- Footer del panel derecho -->
<footer class="py-4 px-6 border-t border-primary/10 dark:border-primary-dt/10">
<div class="flex items-center justify-between text-xs text-page-t/50 dark:text-page-dt/50">
<span>&copy; {{ new Date().getFullYear() }} {{ APP_COPYRIGHT }}</span>
<span>v{{ APP_VERSION }}</span>
</div>
</footer>
</div>
</div>
</div>
</template>

View File

@ -1,10 +1,30 @@
<script setup>
import { computed } from 'vue';
import { hasToken } from '@Services/Api';
import { useRouter } from 'vue-router';
/** Definidores */
const router = useRouter();
/** Props */
const props = defineProps({
size: {
type: String,
default: 'md' // sm, md, lg, xl
}
});
/** Computed */
const sizeClass = computed(() => {
const sizes = {
'sm': 'h-12',
'md': 'h-16',
'lg': 'h-20',
'xl': 'h-24'
};
return sizes[props.size] || sizes.md;
});
/** Métodos */
const home = () => {
if(hasToken()) {
@ -14,11 +34,12 @@ const home = () => {
}
}
</script>
<template>
<div
class="flex w-full justify-center items-center space-x-2 cursor-pointer"
@click="home"
>
<img :src="$page.app.logo" class="h-20" />
<img :src="'/Logo-hk.png'" :class="sizeClass" />
</div>
</template>

View File

@ -42,6 +42,9 @@ const maxWidthClass = computed(() => {
'lg': 'sm:max-w-lg',
'xl': 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
}[props.maxWidth];
});

View File

@ -38,45 +38,36 @@ const clear = () => {
v-text="title"
/>
</div>
<div class="flex w-full justify-between items-center border-y-2 border-page-t dark:border-page-dt">
<div class="flex w-full justify-between items-center py-4 mb-4">
<div>
<div class="relative py-1 z-0">
<div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
<div class="relative z-0">
<div @click="search" class="absolute inset-y-0 right-3 flex items-center gap-1 cursor-pointer">
<GoogleIcon
:title="$t('search')"
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
class="text-lg text-gray-500 hover:text-gray-700"
name="search"
/>
<GoogleIcon
v-show="query"
:title="$t('clear')"
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
class="text-lg text-gray-500 hover:text-gray-700"
name="close"
@click="clear"
/>
</div>
<input
id="search"
class="bg-gray-100 border border-gray-300 text-gray-700 text-sm rounded-sm outline-0 focus:ring-primary focus:border-primary block sm:w-56 md:w-72 lg:w-80 pr-10 px-2.5 py-1"
class="bg-white border border-gray-300 text-gray-700 text-sm rounded-lg outline-0 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 block sm:w-56 md:w-72 lg:w-96 pr-16 px-4 py-2.5 shadow-sm"
autocomplete="off"
:placeholder="placeholder"
required
type="text"
v-model="query"
@keyup.enter="search"
/>
</div>
</div>
<div class="flex items-center space-x-1 text-sm" id="buttons">
<div class="flex items-center gap-2" id="buttons">
<slot />
<RouterLink :to="$view({name:'index'})">
<IconButton
:title="$t('home')"
class="text-white"
icon="home"
filled
/>
</RouterLink>
</div>
</div>
</template>

View File

@ -32,7 +32,7 @@ const loader = useLoader()
class="fixed px-2 w-[calc(100vw)] bg-transparent transition-all duration-300 z-50"
:class="{'md:w-[calc(100vw-16rem)]':leftSidebar.isOpened,'md:w-[calc(100vw)]':!leftSidebar.isClosed}"
>
<div class="my-2 flex px-2 items-center justify-between h-[2.75rem] rounded-sm bg-primary dark:bg-primary-d text-white z-20 ">
<div class="my-2 flex px-2 items-center justify-between h-[2.75rem] rounded-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-700 shadow-sm z-20 ">
<GoogleIcon
class="text-2xl mt-1 z-50"
name="list"

View File

@ -0,0 +1,70 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
icon: String,
name: String
});
/** Estado */
const isOpen = ref(false);
const vroute = useRoute();
/** Métodos */
const toggle = () => {
isOpen.value = !isOpen.value;
};
/** Computed */
const buttonClasses = computed(() => {
const baseClasses = 'flex items-center justify-between h-11 w-full focus:outline-hidden hover:bg-gray-100 dark:hover:bg-gray-800 border-l-4 pr-6 transition text-left';
const colorClasses = isOpen.value
? 'bg-indigo-50 dark:bg-indigo-900/20 border-indigo-600 dark:border-indigo-400 text-indigo-700 dark:text-indigo-300'
: 'border-transparent text-gray-700 dark:text-gray-300';
return `${baseClasses} ${colorClasses}`;
});
</script>
<template>
<li>
<button
@click="toggle"
:class="buttonClasses"
type="button"
>
<div class="flex items-center">
<span
v-if="icon"
class="inline-flex justify-center items-center ml-4 mr-2"
>
<GoogleIcon
class="text-xl"
:name="icon"
outline
/>
</span>
<span
v-if="name"
v-text="$t(name)"
class="text-sm tracking-wide truncate"
/>
</div>
<GoogleIcon
:name="isOpen ? 'expand_less' : 'expand_more'"
class="text-lg mr-2"
/>
</button>
<!-- Submenu -->
<ul
v-show="isOpen"
class="bg-gray-50 dark:bg-gray-900/50"
>
<slot />
</ul>
</li>
</template>

View File

@ -29,11 +29,10 @@ const year = (new Date).getFullYear();
:class="{'w-64': leftSidebar.isClosed, 'w-screen': leftSidebar.isOpened}"
>
<div class="flex flex-col h-full p-2 md:w-64">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-primary dark:bg-primary-d text-white">
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 border-r border-gray-200 dark:border-gray-700">
<div>
<div class="flex w-full px-2 mt-2">
<div class="flex w-full px-2 pt-3 pb-1">
<Logo
class="text-lg inline-flex"
/>
</div>
<ul class="flex h-full flex-col md:pb-4 space-y-1">
@ -41,11 +40,10 @@ const year = (new Date).getFullYear();
</ul>
</div>
<div class="mb-4 px-5 space-y-1">
<p class="block text-center text-xs">
<p class="block text-center text-xs text-gray-500 dark:text-gray-400">
&copy {{year}} {{ APP_COPYRIGHT }}
</p>
<p class="text-center text-xs text-yellow-500 cursor-pointer">
<RouterLink :to="{name:'changelogs.app'}"> APP {{ APP_VERSION }} </RouterLink> <RouterLink :to="{name:'changelogs.core'}"> API {{ $page.app.version }} </RouterLink>
<p class="text-center text-xs text-indigo-600 dark:text-indigo-400 cursor-pointer">
</p>
</div>
</div>

View File

@ -18,10 +18,10 @@ const props = defineProps({
const classes = computed(() => {
let status = props.to === vroute.name
? 'bg-secondary/30 dark:bg-secondary-d/30 border-secondary dark:border-secondary-d'
: 'border-transparent';
? 'bg-indigo-50 dark:bg-indigo-900/20 border-indigo-600 dark:border-indigo-400 text-indigo-700 dark:text-indigo-300'
: 'border-transparent text-gray-700 dark:text-gray-300';
return `flex items-center h-11 focus:outline-hidden hover:bg-secondary/30 dark:hover:bg-secondary-d/30 border-l-4 hover:border-secondary dark:hover:border-secondary-d pr-6 ${status} transition`
return `flex items-center h-11 focus:outline-hidden hover:bg-gray-100 dark:hover:bg-gray-800 border-l-4 hover:border-indigo-400 dark:hover:border-indigo-500 pr-6 ${status} transition`
});
const closeSidebar = () => {

View File

@ -8,8 +8,8 @@ const props = defineProps({
<template>
<ul v-if="$slots['default']">
<li class="px-5 hidden md:block">
<div class="flex flex-row items-center h-8">
<div class="text-sm font-light tracking-wide text-gray-400 uppercase">
<div class="flex flex-row items-center h-8 mt-2">
<div class="text-xs font-semibold tracking-wide text-gray-500 dark:text-gray-400 uppercase">
{{name}}
</div>
</div>

View File

@ -0,0 +1,62 @@
<script setup>
import { computed } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import useLeftSidebar from '@Stores/LeftSidebar';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Definidores */
const leftSidebar = useLeftSidebar();
const vroute = useRoute();
/** Propiedades */
const props = defineProps({
icon: String,
name: String,
to: String
});
const classes = computed(() => {
let status = props.to === vroute.name
? 'bg-indigo-100/50 dark:bg-indigo-900/30 border-indigo-500 dark:border-indigo-400 text-indigo-700 dark:text-indigo-300'
: 'border-transparent text-gray-600 dark:text-gray-400';
return `flex items-center h-10 focus:outline-hidden hover:bg-gray-100 dark:hover:bg-gray-800 border-l-4 hover:border-indigo-400 dark:hover:border-indigo-500 pr-6 ${status} transition`;
});
const closeSidebar = () => {
if(TwScreen.isDevice('phone') || TwScreen.isDevice('tablet')) {
leftSidebar.close();
}
};
</script>
<template>
<li @click="closeSidebar()">
<RouterLink
:class="classes"
:to="$view({name:to})"
>
<span
v-if="icon"
class="inline-flex justify-center items-center ml-8 mr-2"
>
<GoogleIcon
class="text-lg"
:name="icon"
outline
/>
</span>
<span
v-else
class="ml-8"
/>
<span
v-if="name"
v-text="$t(name)"
class="text-sm tracking-wide truncate"
/>
<slot />
</RouterLink>
</li>
</template>

View File

@ -16,15 +16,15 @@ const props = defineProps({
<template>
<section class="pb-2">
<div class="w-full overflow-hidden rounded-sm shadow-lg dark:shadow-xs dark:shadow-white">
<div class="w-full overflow-hidden rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700">
<div class="w-full overflow-x-auto">
<table v-if="!processing" class="w-full">
<thead class="bg-primary text-primary-t dark:bg-primary-d dark:text-primary-dt">
<thead class="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<tr>
<slot name="head" />
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template v-if="items?.total > 0">
<slot
name="body"

View File

@ -12,6 +12,7 @@ const emit = defineEmits([
/** Propiedades */
const props = defineProps({
event: Object,
nameMap: { type: Object, default: () => ({}) },
});
const icons = {
@ -42,6 +43,14 @@ const borderColor = computed(() => {
return `border-${colors[eventType.value]} dark:border-${colors[eventType.value]}-d`;
});
const translatedDescription = computed(() => {
let desc = props.event.description ?? '';
for (const [key, value] of Object.entries(props.nameMap)) {
desc = desc.replaceAll(`"${key}"`, `"${value}"`);
}
return desc;
});
</script>
<template>
@ -79,7 +88,7 @@ const borderColor = computed(() => {
<div class="flex w-full flex-col justify-start space-y-2">
<div>
<h4 class="font-semibold">{{ $t('description') }}:</h4>
<p>{{ event.description }}.</p>
<p>{{ translatedDescription }}</p>
</div>
<div>
<h4 class="font-semibold">{{ $t('author') }}:</h4>

View File

@ -0,0 +1,300 @@
<script setup>
import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import serialService from '@Services/serialService';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
bundle: {
type: Object,
required: true
},
excludeSerials: {
type: Array,
default: () => []
},
mainWarehouse: {
type: Boolean,
default: false
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Estado */
const loading = ref(true);
const components = ref([]);
const serialsByComponent = ref({});
const selectedByComponent = ref({});
const searchQueries = ref({});
/** Computados */
const canConfirm = computed(() => {
return components.value.every(comp => {
const selected = selectedByComponent.value[comp.inventory_id] || [];
return selected.length === comp.quantity;
});
});
const filteredSerials = (inventoryId) => {
const serials = serialsByComponent.value[inventoryId] || [];
const query = (searchQueries.value[inventoryId] || '').toLowerCase();
if (!query) return serials;
return serials.filter(s => s.serial_number.toLowerCase().includes(query));
};
/** Metodos */
const loadComponents = async () => {
loading.value = true;
try {
const items = await serialService.getBundleComponents(props.bundle.id);
const serialComponents = items.filter(item => {
const inv = item.inventory || item.product || {};
return inv.track_serials || item.track_serials;
});
components.value = serialComponents.map(item => {
const inv = item.inventory || item.product || {};
return {
inventory_id: item.inventory_id || inv.id,
name: inv.name || item.product_name || 'Producto',
quantity: item.quantity || 1
};
});
// Cargar seriales para cada componente
await Promise.all(components.value.map(async (comp) => {
try {
const response = await serialService.getAvailableSerials(comp.inventory_id, null, { mainWarehouse: props.mainWarehouse });
const serials = (response.serials?.data || []).filter(
s => !props.excludeSerials.includes(s.serial_number)
);
serialsByComponent.value[comp.inventory_id] = serials;
} catch {
serialsByComponent.value[comp.inventory_id] = [];
}
selectedByComponent.value[comp.inventory_id] = [];
searchQueries.value[comp.inventory_id] = '';
}));
} catch (error) {
console.error('Error loading bundle components:', error);
components.value = [];
} finally {
loading.value = false;
}
};
const toggleSerial = (inventoryId, serial) => {
const selected = selectedByComponent.value[inventoryId] || [];
const index = selected.findIndex(s => s.id === serial.id);
const comp = components.value.find(c => c.inventory_id === inventoryId);
if (index > -1) {
selected.splice(index, 1);
} else if (selected.length < comp.quantity) {
selected.push(serial);
}
selectedByComponent.value[inventoryId] = selected;
};
const isSelected = (inventoryId, serial) => {
return (selectedByComponent.value[inventoryId] || []).some(s => s.id === serial.id);
};
const handleConfirm = () => {
const serialNumbers = {};
components.value.forEach(comp => {
const selected = selectedByComponent.value[comp.inventory_id] || [];
serialNumbers[comp.inventory_id] = selected.map(s => s.serial_number);
});
emit('confirm', { serialNumbers });
};
const handleClose = () => {
emit('close');
};
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown) {
components.value = [];
serialsByComponent.value = {};
selectedByComponent.value = {};
searchQueries.value = {};
loadComponents();
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="2xl" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-900/30">
<GoogleIcon name="inventory_2" class="text-2xl text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Seleccionar Seriales del Paquete
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ bundle.name }}
</p>
</div>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<GoogleIcon name="close" class="text-xl" />
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-12">
<GoogleIcon name="hourglass_empty" class="text-4xl text-gray-400 animate-spin" />
</div>
<!-- Sin componentes con seriales -->
<div v-else-if="components.length === 0" class="text-center py-8">
<GoogleIcon name="info" class="text-5xl text-gray-400 mb-3" />
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Sin seriales requeridos
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">
Ningún componente de este paquete requiere selección de seriales.
</p>
</div>
<!-- Componentes con seriales -->
<div v-else class="space-y-6">
<div
v-for="comp in components"
:key="comp.inventory_id"
class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
>
<!-- Header del componente -->
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<GoogleIcon name="memory" class="text-lg text-gray-500 shrink-0" />
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ comp.name }}
</span>
</div>
<span
class="text-xs font-medium px-2 py-1 rounded-full shrink-0 whitespace-nowrap"
:class="{
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': (selectedByComponent[comp.inventory_id] || []).length === comp.quantity,
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400': (selectedByComponent[comp.inventory_id] || []).length > 0 && (selectedByComponent[comp.inventory_id] || []).length < comp.quantity,
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': (selectedByComponent[comp.inventory_id] || []).length === 0
}"
>
{{ (selectedByComponent[comp.inventory_id] || []).length }} / {{ comp.quantity }}
</span>
</div>
<!-- Buscador -->
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<div class="relative">
<GoogleIcon
name="search"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm"
/>
<input
v-model="searchQueries[comp.inventory_id]"
type="text"
placeholder="Buscar serial..."
class="w-full pl-9 pr-4 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
/>
</div>
</div>
<!-- Lista de seriales -->
<div class="max-h-48 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-700">
<button
v-for="serial in filteredSerials(comp.inventory_id)"
:key="serial.id"
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors"
:class="{
'bg-indigo-50 dark:bg-indigo-900/20': isSelected(comp.inventory_id, serial),
'hover:bg-gray-50 dark:hover:bg-gray-800': !isSelected(comp.inventory_id, serial),
'opacity-40 cursor-not-allowed': !isSelected(comp.inventory_id, serial) && (selectedByComponent[comp.inventory_id] || []).length >= comp.quantity
}"
:disabled="!isSelected(comp.inventory_id, serial) && (selectedByComponent[comp.inventory_id] || []).length >= comp.quantity"
@click="toggleSerial(comp.inventory_id, serial)"
>
<div
class="w-5 h-5 rounded border-2 flex items-center justify-center transition-colors shrink-0"
:class="{
'border-indigo-500 bg-indigo-500': isSelected(comp.inventory_id, serial),
'border-gray-300 dark:border-gray-600': !isSelected(comp.inventory_id, serial)
}"
>
<GoogleIcon
v-if="isSelected(comp.inventory_id, serial)"
name="check"
class="text-xs text-white"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
{{ serial.serial_number }}
</p>
<p v-if="serial.notes" class="text-xs text-gray-500 dark:text-gray-400 truncate">
{{ serial.notes }}
</p>
</div>
</button>
<!-- Sin stock -->
<div
v-if="(serialsByComponent[comp.inventory_id] || []).length === 0"
class="p-4 text-center text-red-500 text-sm"
>
<GoogleIcon name="error" class="text-xl mb-1" />
<p>No hay seriales disponibles para este producto</p>
</div>
<!-- Sin resultados de búsqueda -->
<div
v-else-if="filteredSerials(comp.inventory_id).length === 0"
class="p-4 text-center text-gray-500 text-sm"
>
<GoogleIcon name="search_off" class="text-xl mb-1" />
<p>No se encontraron seriales</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div v-if="!loading && components.length > 0" class="flex items-center justify-end gap-3 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancelar
</button>
<button
type="button"
@click="handleConfirm"
:disabled="!canConfirm"
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-semibold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<GoogleIcon name="check" class="text-lg" />
Confirmar Seriales
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,203 @@
<script setup>
import { computed } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
item: {
type: Object,
required: true
}
});
/** Emits */
const emit = defineEmits(['update-quantity', 'remove', 'select-serials']);
/** Computados */
const formattedUnitPrice = computed(() => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(props.item.unit_price);
});
const formattedSubtotal = computed(() => {
const subtotal = props.item.unit_price * props.item.quantity;
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(subtotal);
});
const canIncrement = computed(() => {
// Si tiene seriales, no permitir incremento directo
if (props.item.track_serials) return false;
// Si permite decimales, incrementar en 1
const incrementValue = props.item.allows_decimals ? 1 : 1;
return (props.item.quantity + incrementValue) <= props.item.max_stock;
});
const canEditQuantity = computed(() => {
// No permitir edición directa si tiene seriales
return !props.item.track_serials;
});
const quantityStep = computed(() => {
return props.item.allows_decimals ? '0.001' : '1';
});
const quantityMin = computed(() => {
return props.item.allows_decimals ? '0.001' : '1';
});
const hasSerials = computed(() => props.item.track_serials);
const serialsSelected = computed(() => {
return props.item.serial_numbers && props.item.serial_numbers.length > 0;
});
const needsSerialSelection = computed(() => {
return hasSerials.value && (!serialsSelected.value || props.item.serial_numbers.length !== props.item.quantity);
});
/** Métodos */
const increment = () => {
if (canIncrement.value) {
const incrementValue = props.item.allows_decimals ? 1 : 1;
const newQuantity = props.item.quantity + incrementValue;
emit('update-quantity', props.item.item_key, newQuantity);
}
};
const decrement = () => {
const decrementValue = props.item.allows_decimals ? 1 : 1;
const minValue = props.item.allows_decimals ? 0.001 : 1;
if (props.item.quantity > minValue) {
const newQuantity = Math.max(minValue, props.item.quantity - decrementValue);
emit('update-quantity', props.item.item_key, newQuantity);
} else {
emit('remove', props.item.item_key);
}
};
const handleQuantityInput = (event) => {
const value = event.target.value;
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0) {
emit('update-quantity', props.item.item_key, numValue);
}
};
const remove = () => {
emit('remove', props.item.item_key);
};
</script>
<template>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-indigo-300 dark:hover:border-indigo-600 transition-colors p-3">
<!-- Fila principal: Nombre y botón eliminar -->
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-1.5 pr-2 min-w-0">
<span
v-if="item.is_bundle"
class="shrink-0 px-1.5 py-0.5 text-xs font-bold rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
>
PAQ
</span>
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 line-clamp-2">
{{ item.product_name }}
</h4>
</div>
<button
type="button"
class="shrink-0 w-6 h-6 flex items-center justify-center rounded-full bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400 transition-colors"
@click="remove"
>
<GoogleIcon name="close" class="text-base" />
</button>
</div>
<!-- SKU y precio unitario -->
<div class="flex items-center gap-2 mb-1">
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono">
{{ item.sku }}
</span>
<span class="text-xs text-gray-400 dark:text-gray-500"></span>
<span class="text-xs font-medium text-indigo-600 dark:text-indigo-400">
{{ formattedUnitPrice }}
</span>
</div>
<!-- Unidad de medida seleccionada (equivalencia) -->
<div v-if="item.unit_name" class="mb-1">
<span class="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 dark:bg-amber-900/20 dark:text-amber-400 px-1.5 py-0.5 rounded">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3"/>
</svg>
{{ item.unit_name }}
</span>
</div>
<!-- Mensaje para productos con decimales -->
<div v-else-if="item.allows_decimals && item.unit_of_measure" class="mb-2">
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ item.unit_of_measure.name }} ({{ item.unit_of_measure.abbreviation }})
</p>
</div>
<!-- Fila inferior: Controles de cantidad y subtotal -->
<div class="flex items-center justify-between">
<!-- Controles de cantidad -->
<div class="flex items-center gap-2">
<button
type="button"
class="w-7 h-7 flex items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 transition-colors"
@click="decrement"
>
<GoogleIcon
:name="item.quantity <= (item.allows_decimals ? 0.001 : 1) ? 'delete' : 'remove'"
class="text-base"
/>
</button>
<input
v-if="canEditQuantity"
type="number"
:value="item.quantity"
:min="quantityMin"
:step="quantityStep"
:max="item.max_stock"
@change="handleQuantityInput"
class="w-16 text-center text-base font-bold text-gray-800 dark:text-gray-200 bg-transparent border border-gray-300 dark:border-gray-600 rounded px-1 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
<span v-else class="w-16 text-center text-base font-bold text-gray-800 dark:text-gray-200">
{{ item.quantity }}
</span>
<button
type="button"
class="w-7 h-7 flex items-center justify-center rounded-full transition-colors"
:class="{
'bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:hover:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400': canIncrement,
'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed': !canIncrement
}"
:disabled="!canIncrement"
@click="increment"
>
<GoogleIcon name="add" class="text-base" />
</button>
</div>
<!-- Subtotal -->
<div class="text-right">
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ formattedSubtotal }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ item.quantity }} × {{ formattedUnitPrice }}
</p>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script setup>
import { usoCfdiOptions } from '@/utils/fiscalData';
const props = defineProps({
modelValue: { type: String, default: '' },
error: { type: String, default: null },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
USO DE CFDI <span v-if="required" class="text-red-500">*</span>
</label>
<select
:value="modelValue"
:disabled="disabled"
:required="required"
@change="emit('update:modelValue', $event.target.value)"
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 disabled:opacity-50"
:class="{ 'border-red-500': error }"
>
<option value="" disabled>Seleccionar uso de CFDI</option>
<option
v-for="option in usoCfdiOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<p v-if="error" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
</div>
</template>

View File

@ -0,0 +1,651 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useApi, apiURL } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
cart: {
type: Object,
required: true
},
processing: {
type: Boolean,
default: false
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Estado */
let debounceTimer = null;
const selectedMethod = ref('cash');
const cashReceived = ref(0);
const clientNumber = ref('');
const selectedClient = ref(null);
const searchingClient = ref(false);
const clientNotFound = ref(false);
const clientSuggestions = ref([]);
const showClientSuggestions = ref(false);
/** Computados */
const formattedSubtotal = computed(() => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(props.cart.subtotal);
});
const formattedTax = computed(() => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(props.cart.tax);
});
// Descuento del cliente
const clientDiscount = computed(() => {
if (selectedClient.value?.tier?.discount_percentage) {
return parseFloat(selectedClient.value.tier.discount_percentage);
}
return 0;
});
const estimatedDiscountAmount = computed(() => {
return (props.cart.subtotal * clientDiscount.value) / 100;
});
// Total sin descuento
const formattedTotal = computed(() => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(props.cart.total);
});
// Cálculo de cambio (usando total sin descuento)
const change = computed(() => {
if (selectedMethod.value === 'cash' && cashReceived.value > 0) {
return cashReceived.value - estimatedTotalWithDiscount.value;
}
return 0;
});
// Validar que el efectivo sea suficiente
const isValidCash = computed(() => {
if (selectedMethod.value === 'cash') {
return cashReceived.value >= estimatedTotalWithDiscount.value;
}
return true; // Tarjetas siempre válidas
});
// Puede confirmar si no es efectivo o si el efectivo es válido
const canConfirm = computed(() => {
if (selectedMethod.value === 'cash') {
return cashReceived.value > 0 && isValidCash.value;
}
return true;
});
const paymentMethods = [
{
value: 'cash',
label: 'Efectivo',
subtitle: 'Pago en efectivo',
icon: 'payments',
color: 'bg-green-500'
},
{
value: 'credit_card',
label: 'Tarjeta de Crédito',
subtitle: 'Pago con tarjeta de crédito',
icon: 'credit_card',
color: 'bg-blue-500'
},
{
value: 'debit_card',
label: 'Tarjeta de Débito',
subtitle: 'Pago con tarjeta de débito',
icon: 'credit_card',
color: 'bg-purple-500'
}
];
/** Métodos de búsqueda de cliente */
const onClientInput = () =>{
clientNotFound.value = false;
if(!clientNumber.value || clientNumber.value.trim().length < 2) {
clientSuggestions.value = [];
showClientSuggestions.value = false;
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
showClientSuggestions.value = true;
searchClient();
}, 300);
}
const searchClient = () => {
if (!clientNumber.value || clientNumber.value.trim() === '') {
clientSuggestions.value = [];
showClientSuggestions.value = false;
return;
}
searchingClient.value = true;
clientNotFound.value = false;
const api = useApi();
let urlParams = `client_number=${clientNumber.value.trim()}&with=tier`;
api.get(apiURL(`clients?${urlParams}`), {
onSuccess: (data) => {
if (data.clients && data.clients.data.length > 0) {
clientSuggestions.value = data.clients.data;
showClientSuggestions.value = true;
} else {
clientSuggestions.value = [];
showClientSuggestions.value = false;
clientNotFound.value = true;
}
},
onFail: (data) => {
clientSuggestions.value = [];
showClientSuggestions.value = false;
clientNotFound.value = true;
window.Notify.error(data.message || 'Error al buscar cliente');
},
onError: () => {
clientSuggestions.value = [];
showClientSuggestions.value = false;
clientNotFound.value = true;
},
onFinish: () => {
searchingClient.value = false;
}
});
};
const selectClient = (client) => {
selectedClient.value = client;
clientNumber.value = '';
clientSuggestions.value = [];
showClientSuggestions.value = false;
clientNotFound.value = false;
window.Notify.success(`Cliente ${client.name} seleccionado`);
};
const clearClient = () => {
clientNumber.value = '';
selectedClient.value = null;
clientNotFound.value = false;
clientSuggestions.value = [];
showClientSuggestions.value = false;
};
/** Watchers */
// Limpiar cashReceived cuando cambia el método de pago
watch(selectedMethod, (newMethod) => {
if (newMethod !== 'cash') {
cashReceived.value = 0;
}
});
// Resetear cuando se abre el modal
watch(() => props.show, (isShown) => {
if (isShown) {
cashReceived.value = 0;
clientNumber.value = '';
selectedClient.value = null;
clientNotFound.value = false;
clientSuggestions.value = [];
showClientSuggestions.value = false;
}
});
/** Métodos */
const handleConfirm = () => {
// Validar método de pago seleccionado
if (!selectedMethod.value) {
window.Notify.error('Selecciona un método de pago');
return;
}
// Validaciones específicas para efectivo (usando total sin descuento)
if (selectedMethod.value === 'cash') {
if (!cashReceived.value || cashReceived.value <= 0) {
window.Notify.error('Ingresa el efectivo recibido');
return;
}
if (cashReceived.value < props.cart.total) {
window.Notify.error(`El efectivo recibido es insuficiente. Faltan $${(props.cart.total - cashReceived.value).toFixed(2)}`);
return;
}
}
// Emitir evento con client_number para que el backend aplique el descuento automáticamente
emit('confirm', {
paymentMethod: selectedMethod.value,
cashReceived: selectedMethod.value === 'cash' ? parseFloat(cashReceived.value) : null,
clientNumber: selectedClient.value ? selectedClient.value.client_number : null,
clientData: selectedClient.value // Enviar datos completos del cliente para referencia
});
};
const handleClose = () => {
if (!props.processing) {
selectedMethod.value = 'cash';
cashReceived.value = 0;
clearClient();
emit('close');
}
};
// Total estimado con descuento (para mostrar al operador)
const estimatedTotalWithDiscount = computed(() => {
if (clientDiscount.value > 0) {
return props.cart.total - estimatedDiscountAmount.value;
}
return props.cart.total;
});
const formattedEstimatedTotal = computed(() => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(estimatedTotalWithDiscount.value);
});
</script>
<template>
<Modal :show="show" max-width="xl" @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-14 h-14 rounded-full bg-indigo-100 dark:bg-indigo-900/30">
<GoogleIcon name="point_of_sale" class="text-3xl text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
Cobrar
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Resumen de compra y método de pago</p>
</div>
</div>
<button
@click="handleClose"
:disabled="processing"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:opacity-50"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-6">
<!-- Resumen de compra -->
<div>
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
Resumen de compra
</h4>
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 space-y-3">
<!-- Items -->
<div class="space-y-2 max-h-56 overflow-y-auto">
<div
v-for="item in cart.items"
:key="item.inventory_id"
class="flex items-start justify-between py-2"
>
<div class="flex-1 min-w-0 pr-4">
<p class="text-sm font-semibold text-gray-800 dark:text-gray-200 truncate">
{{ item.product_name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ item.quantity }} × ${{ item.unit_price.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</p>
</div>
<span class="text-base font-bold text-gray-900 dark:text-gray-100 shrink-0">
${{ (item.quantity * item.unit_price).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</span>
</div>
</div>
<!-- Totales -->
<div class="pt-3 border-t border-gray-300 dark:border-gray-600 space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
<span class="font-semibold text-gray-800 dark:text-gray-200">{{ formattedSubtotal }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">IVA (16%)</span>
<span class="font-semibold text-gray-800 dark:text-gray-200">{{ formattedTax }}</span>
</div>
</div>
<!-- Descuento del cliente (informativo) -->
<div v-if="selectedClient && clientDiscount > 0" class="pt-3 border-t border-gray-300 dark:border-gray-600">
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg p-3">
<div class="flex items-center gap-2 mb-1">
<GoogleIcon name="info" class="text-green-600 dark:text-green-400 text-sm" />
<span class="text-xs text-green-700 dark:text-green-300 font-semibold">Descuento a aplicar</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-green-600 dark:text-green-400 font-semibold">{{ clientDiscount.toFixed(2) }}% ({{ selectedClient.tier?.tier_name }})</span>
<span class="font-semibold text-green-600 dark:text-green-400"> -${{ estimatedDiscountAmount.toFixed(2) }}</span>
</div>
<p class="text-xs text-green-700 dark:text-green-400 mt-1">
El descuento se aplicará automáticamente al procesar la venta
</p>
</div>
</div>
<!-- Total a pagar -->
<div class="pt-3 border-t-2 border-gray-300 dark:border-gray-600 space-y-2">
<!-- Total original (tachado si hay descuento) -->
<div v-if="selectedClient && clientDiscount > 0" class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Total sin descuento</span>
<span class="text-lg text-gray-400 line-through">{{ formattedTotal }}</span>
</div>
<!-- Total final (con o sin descuento) -->
<div class="flex items-center justify-between">
<span class="text-base font-bold text-gray-800 dark:text-gray-200">
{{ selectedClient && clientDiscount > 0 ? 'Total con descuento' : 'Total a pagar' }}
</span>
<span
class="text-3xl font-bold"
:class="selectedClient && clientDiscount > 0
? 'text-green-600 dark:text-green-400'
: 'text-indigo-600 dark:text-indigo-400'"
>
{{ selectedClient && clientDiscount > 0 ? formattedEstimatedTotal : formattedTotal }}
</span>
</div>
</div>
</div>
</div>
<!-- Sección de Cliente -->
<div>
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
Cliente (Opcional)
</h4>
<!-- Input de búsqueda de cliente -->
<div v-if="!selectedClient" class="space-y-2">
<div class="relative">
<input
v-model="clientNumber"
@keyup.enter="onClientInput"
@focus="clientSuggestions.length > 0 && (showClientSuggestions = true)"
type="text"
placeholder="Ingresa el código de cliente (ej: MOGF780404S36)"
class="w-full px-4 py-3 pr-24 border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:class="{
'border-red-500 focus:ring-red-500 focus:border-red-500': clientNotFound
}"
:disabled="searchingClient"
/>
<div v-if="searchingClient" class="absolute right-3 top-1/2 -translate-y-1/2">
<GoogleIcon name="hourglass_empty" class="text-xl text-gray-400 animate-spin" />
</div>
<div v-else-if="clientNumber" class="absolute right-3 top-1/2 -translate-y-1/2">
<GoogleIcon name="search" class="text-xl text-gray-400" />
</div>
<!-- Dropdown de sugerencias -->
<div
v-if="showClientSuggestions && clientSuggestions.length > 0"
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<button
v-for="client in clientSuggestions"
:key="client.id"
type="button"
@click="selectClient(client)"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0"
>
<div class="w-8 h-8 rounded-full bg-indigo-600 flex items-center justify-center text-white font-bold text-sm shrink-0">
{{ client.name.charAt(0).toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ client.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ client.client_number }}
<span v-if="client.tier" class="ml-1 text-indigo-500">· {{ client.tier.tier_name }}</span>
</p>
</div>
<span v-if="client.tier?.discount_percentage" class="text-xs font-semibold text-green-600 dark:text-green-400 shrink-0">
{{ parseFloat(client.tier.discount_percentage).toFixed(0) }}% dto.
</span>
</button>
</div>
</div>
<!-- Error de cliente no encontrado -->
<div v-if="clientNotFound" class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<GoogleIcon name="error" class="text-red-600 dark:text-red-400 text-lg shrink-0 mt-0.5" />
<p class="text-sm text-red-800 dark:text-red-300">
Cliente no encontrado. Verifica el código e intenta nuevamente.
</p>
</div>
</div>
<!-- Cliente seleccionado -->
<div v-else class="bg-linear-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 border border-indigo-200 dark:border-indigo-800 rounded-xl p-4 space-y-3">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full bg-indigo-600 flex items-center justify-center text-white font-bold text-lg">
{{ selectedClient.name.charAt(0).toUpperCase() }}
</div>
<div>
<p class="text-base font-bold text-gray-900 dark:text-gray-100">
{{ selectedClient.name }}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ selectedClient.client_number }}
</p>
</div>
</div>
<button
@click="clearClient"
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
title="Quitar cliente"
>
<GoogleIcon name="close" class="text-xl" />
</button>
</div>
<!-- Tier y estadísticas -->
<div class="grid grid-cols-2 gap-2">
<div class="bg-white/50 dark:bg-gray-900/20 rounded-lg p-2">
<p class="text-xs text-gray-600 dark:text-gray-400">Tier actual</p>
<p class="text-sm font-bold text-gray-900 dark:text-gray-100">
{{ selectedClient.tier?.tier_name || 'Sin tier' }}
</p>
</div>
<div class="bg-white/50 dark:bg-gray-900/20 rounded-lg p-2">
<p class="text-xs text-gray-600 dark:text-gray-400">Descuento</p>
<p class="text-sm font-bold text-green-600 dark:text-green-400">
{{ clientDiscount.toFixed(2) }}%
</p>
</div>
<div v-if="selectedClient.total_purchases !== undefined" class="bg-white/50 dark:bg-gray-900/20 rounded-lg p-2">
<p class="text-xs text-gray-600 dark:text-gray-400">Compras acumuladas</p>
<p class="text-sm font-bold text-gray-900 dark:text-gray-100">
{{ formatCurrency(selectedClient.total_purchases) }}
</p>
</div>
<div v-if="selectedClient.total_transactions !== undefined" class="bg-white/50 dark:bg-gray-900/20 rounded-lg p-2">
<p class="text-xs text-gray-600 dark:text-gray-400">Transacciones</p>
<p class="text-sm font-bold text-gray-900 dark:text-gray-100">
{{ selectedClient.total_transactions || 0 }}
</p>
</div>
</div>
</div>
</div>
<!-- Método de pago -->
<div>
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3">
Selecciona método de pago
</h4>
<div class="grid grid-cols-1 gap-3">
<button
v-for="method in paymentMethods"
:key="method.value"
type="button"
:disabled="processing"
class="relative flex items-center gap-4 p-4 rounded-xl border-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
:class="{
'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20 ring-2 ring-indigo-500 ring-offset-2 dark:ring-offset-gray-900': selectedMethod === method.value,
'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800': selectedMethod !== method.value
}"
@click="selectedMethod = method.value"
>
<div
class="w-14 h-14 rounded-xl flex items-center justify-center text-white shadow-lg transition-transform"
:class="[
method.color,
selectedMethod === method.value ? 'scale-110' : ''
]"
>
<GoogleIcon :name="method.icon" class="text-2xl" />
</div>
<div class="flex-1 text-left">
<p class="text-base font-bold text-gray-800 dark:text-gray-200">
{{ method.label }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ method.subtitle }}
</p>
</div>
<div v-if="selectedMethod === method.value" class="absolute top-3 right-3">
<div class="w-6 h-6 rounded-full bg-indigo-600 flex items-center justify-center">
<GoogleIcon name="check" class="text-sm text-white" />
</div>
</div>
</button>
</div>
</div>
<!-- Sección de efectivo -->
<div v-if="selectedMethod === 'cash'" class="space-y-4">
<!-- Input de efectivo recibido -->
<div>
<label class="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2">
Efectivo Recibido *
</label>
<div class="relative">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-xl font-semibold">$</span>
<input
v-model.number="cashReceived"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full pl-10 pr-4 py-3 text-xl font-bold border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:class="{
'border-red-500 focus:ring-red-500 focus:border-red-500': cashReceived > 0 && !isValidCash,
'border-green-500 focus:ring-green-500 focus:border-green-500': cashReceived > 0 && isValidCash
}"
autofocus
/>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
Ingresa el monto en efectivo que recibiste del cliente
</p>
</div>
<!-- Mostrar cambio -->
<div v-if="cashReceived > 0" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
{{ change >= 0 ? 'Cambio a devolver' : 'Falta' }}
</p>
<p
class="text-4xl font-bold"
:class="{
'text-green-600 dark:text-green-400': change >= 0,
'text-red-600 dark:text-red-400': change < 0
}"
>
${{ Math.abs(change).toFixed(2) }}
</p>
</div>
<div>
<GoogleIcon
:name="change >= 0 ? 'check_circle' : 'error'"
class="text-5xl"
:class="{
'text-green-500': change >= 0,
'text-red-500': change < 0
}"
/>
</div>
</div>
</div>
<!-- Advertencia de dinero insuficiente -->
<div v-if="cashReceived > 0 && !isValidCash" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex gap-3">
<GoogleIcon name="warning" class="text-red-600 dark:text-red-400 text-xl shrink-0" />
<div>
<p class="text-sm font-semibold text-red-800 dark:text-red-300">
Efectivo insuficiente
</p>
<p class="text-xs text-red-700 dark:text-red-400 mt-1">
El dinero recibido debe ser mayor o igual al total de {{ formattedEstimatedTotal }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
:disabled="processing"
class="px-6 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancelar
</button>
<button
type="button"
@click="handleConfirm"
:disabled="processing || !canConfirm"
class="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600 shadow-lg shadow-indigo-600/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
>
<GoogleIcon
:name="processing ? 'hourglass_empty' : 'check'"
class="text-xl"
:class="{ 'animate-spin': processing }"
/>
<span v-if="processing">Procesando...</span>
<span v-else>Confirmar Cobro</span>
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,350 @@
<script setup>
import { ref, watch } from 'vue';
import { api, apiURL } from '@Services/Api';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
saleData: {
type: Object,
default: null
}
});
const usoCfdiOptions = [
{ value: 'G01', label: 'G01 - Adquisición de mercancías' },
{ value: 'G02', label: 'G02 - Devoluciones, descuentos o bonificaciones' },
{ value: 'G03', label: 'G03 - Gastos en general' },
{ value: 'I01', label: 'I01 - Construcciones' },
{ value: 'I02', label: 'I02 - Mobiliario y equipo de oficina por inversiones' },
{ value: 'I03', label: 'I03 - Equipo de transporte' },
{ value: 'I04', label: 'I04 - Equipo de computo y accesorios' },
{ value: 'I05', label: 'I05 - Dados, troqueles, moldes, matrices y herramental' },
{ value: 'I06', label: 'I06 - Comunicaciones telefónicas' },
{ value: 'I07', label: 'I07 - Comunicaciones satelitales' },
{ value: 'I08', label: 'I08 - Otra maquinaria y equipo' },
{ value: 'S01', label: 'S01 - Sin efectos fiscales' }
];
const regimenFiscalOptions = [
{ value: '601', label: '601 - General de Ley Personas Morales' },
{ value: '603', label: '603 - Personas Morales con Fines no Lucrativos' },
{ value: '610', label: '610 - Residentes en el Extranjero sin Establecimiento Permanentes en México' },
{ value: '620', label: '620 - Sociedades Cooperativas de Producción que optan por diferir sus ingresos' },
{ value: '622', label: '622 - Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras' },
{ value: '623', label: '623 - Opcional para Grupos de Sociedades' },
{ value: '624', label: '624 - Coordinados' },
{ value: '626', label: '626 - Régimen Simplificado de Confianza' }
];
/** Emits */
const emit = defineEmits(['close', 'save']);
/** Estado */
const form = ref({
name: '',
email: '',
phone: '',
address: '',
rfc: '',
razon_social: '',
regimen_fiscal: '',
cp_fiscal: '',
uso_cfdi: ''
});
const saving = ref(false);
/** Watchers */
// Resetear formulario cuando se abre el modal
watch(() => props.show, (isShown) => {
if (isShown) {
form.value = {
name: '',
email: '',
phone: '',
address: '',
rfc: '',
razon_social: '',
regimen_fiscal: '',
cp_fiscal: '',
uso_cfdi: ''
};
}
});
/** Métodos */
const handleSave = async () => {
// Validar que al menos el nombre esté presente
if (!form.value.name || form.value.name.trim() === '') {
window.Notify.error('El nombre del cliente es requerido');
return;
}
saving.value = true;
api.post(apiURL('clients'), {
data: form.value,
onSuccess: (response) => {
saving.value = false;
window.Notify.success('Cliente guardado correctamente');
emit('save', response.client);
handleClose();
},
onFail: (failData) => {
saving.value = false;
window.Notify.error(failData?.message || 'Error al guardar el cliente');
},
onError: (error) => {
saving.value = false;
window.Notify.error(error?.message || 'Error en la solicitud al guardar el cliente');
}
});
};
const handleClose = () => {
if (!saving.value) {
form.value = {
name: '',
email: '',
phone: '',
address: '',
rfc: '',
razon_social: '',
regimen_fiscal: '',
cp_fiscal: '',
uso_cfdi: ''
};
emit('close');
}
};
</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-14 h-14 rounded-full bg-blue-100 dark:bg-blue-900/30">
<GoogleIcon name="person_add" class="text-3xl text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
Registrar Cliente
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
¿Deseas guardar los datos del cliente? (Opcional)
</p>
</div>
</div>
<button
@click="handleClose"
:disabled="saving"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:opacity-50"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Información de venta (opcional) -->
<div v-if="saleData" class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="receipt_long" class="text-lg text-gray-600 dark:text-gray-400" />
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Venta realizada</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Total: <span class="font-bold">${{ saleData.total?.toFixed(2) || '0.00' }}</span>
</p>
</div>
<!-- Formulario -->
<form @submit.prevent="handleSave" class="space-y-4">
<!-- Nombre (Requerido) -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Nombre Completo *
</label>
<div class="relative">
<input
v-model="form.name"
type="text"
placeholder="Nombre del cliente"
maxlength="255"
required
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:disabled="saving"
/>
<GoogleIcon name="person" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
</div>
</div>
<!-- Email -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Correo Electrónico
</label>
<div class="relative">
<input
v-model="form.email"
type="email"
placeholder="correo@ejemplo.com"
maxlength="255"
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:disabled="saving"
/>
<GoogleIcon name="email" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
</div>
</div>
<!-- Teléfono -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Teléfono
</label>
<div class="relative">
<input
v-model="form.phone"
type="tel"
placeholder="(123) 456-7890"
maxlength="20"
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:disabled="saving"
/>
<GoogleIcon name="phone" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
</div>
</div>
<!-- RFC -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
RFC
</label>
<div class="relative">
<input
v-model="form.rfc"
type="text"
placeholder="XAXX010101000"
maxlength="13"
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 uppercase"
:disabled="saving"
/>
<GoogleIcon name="badge" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
</div>
</div>
<!-- Razón Social-->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Razón Social
</label>
<div class="relative">
<input
v-model="form.razon_social"
type="text"
placeholder="Razón social"
maxlength="13"
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
:disabled="saving"
/>
<GoogleIcon name="badge" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xl" />
</div>
</div>
<!-- Uso CFDI Select -->
<div>
<label for="uso_cfdi" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
Uso de CFDI
</label>
<select
v-model="form.uso_cfdi"
id="uso_cfdi"
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-700 dark:border-gray-600 dark:text-gray-100"
:disabled="saving"
>
<option value="">Seleccionar uso de CFDI</option>
<option
v-for="option in usoCfdiOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<!-- Régimen Fiscal Select -->
<div>
<label for="regimen_fiscal" class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
Régimen Fiscal
</label>
<select
v-model="form.regimen_fiscal"
id="regimen_fiscal"
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-700 dark:border-gray-600 dark:text-gray-100"
:disabled="saving"
>
<option value="">Seleccionar régimen fiscal</option>
<option
v-for="option in regimenFiscalOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<!-- Dirección -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Dirección
</label>
<div class="relative">
<textarea
v-model="form.address"
placeholder="Calle, número, colonia, ciudad"
maxlength="500"
rows="3"
class="w-full px-4 py-3 pl-11 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 resize-none"
:disabled="saving"
></textarea>
<GoogleIcon name="location_on" class="absolute left-3 top-4 text-gray-400 text-xl" />
</div>
</div>
</form>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
:disabled="saving"
class="px-6 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancelar
</button>
<button
type="button"
@click="handleSave"
:disabled="saving || !form.name"
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-600 shadow-lg shadow-blue-600/30 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
>
<GoogleIcon
:name="saving ? 'hourglass_empty' : 'save'"
class="text-xl"
:class="{ 'animate-spin': saving }"
/>
<span v-if="saving">Guardando...</span>
<span v-else>Guardar Cliente</span>
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,193 @@
<script setup>
import { ref } from 'vue';
import { apiURL } from '@Services/Api';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
required: true
}
});
/** Emits */
const emit = defineEmits(['close']);
/** Estado */
const startDate = ref('');
const endDate = ref('');
const downloading = ref(false);
/** Métodos */
const downloadReport = () => {
if (!startDate.value || !endDate.value) {
window.Notify.error('Por favor selecciona ambas fechas');
return;
}
if (new Date(startDate.value) > new Date(endDate.value)) {
window.Notify.error('La fecha inicial debe ser menor a la fecha final');
return;
}
downloading.value = true;
// Construir URL con parámetros
const url = apiURL(`reports/client-discounts/excel?fecha_inicio=${startDate.value}&fecha_fin=${endDate.value}`);
// Hacer petición con axios para descargar archivo
window.axios.get(url, {
responseType: 'blob',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
}
})
.then(response => {
// Crear URL del blob
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `reporte_descuentos_${startDate.value}_${endDate.value}.xlsx`;
document.body.appendChild(link);
link.click();
// Limpiar
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
window.Notify.success('Reporte descargado exitosamente');
})
.catch(async error => {
if (error.response && error.response.data instanceof Blob) {
const text = await error.response.data.text();
try {
const json = JSON.parse(text);
window.Notify.warning(json.message || 'Error al descargar el reporte');
} catch {
window.Notify.error('Error al descargar el reporte');
}
} else {
window.Notify.error('Error al descargar el reporte');
}
})
.finally(() => {
downloading.value = false;
});
};
const clearDates = () => {
startDate.value = '';
endDate.value = '';
};
const close = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="3xl" @close="close">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 relative">
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30">
<GoogleIcon name="download" class="text-xl text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Reporte de Descuentos por Cliente
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Genera un archivo Excel con los descuentos aplicados
</p>
</div>
<div class="absolute top-4 right-4">
<button
@click="close"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="space-y-4">
<!-- Rango de Fechas -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Fecha Inicial
</label>
<input
v-model="startDate"
type="date"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:disabled="downloading"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Fecha Final
</label>
<input
v-model="endDate"
type="date"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:disabled="downloading"
/>
</div>
</div>
<!-- Botones -->
<div class="flex items-center gap-3">
<PrimaryButton
@click="downloadReport"
:disabled="downloading || !startDate || !endDate"
class="flex items-center gap-2"
>
<svg v-if="downloading" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<GoogleIcon v-else name="download" />
{{ downloading ? 'Generando...' : 'Descargar Excel' }}
</PrimaryButton>
<button
@click="clearDates"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
:disabled="downloading"
>
Limpiar
</button>
</div>
<!-- Nota informativa -->
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex gap-2">
<GoogleIcon name="info" class="text-blue-600 dark:text-blue-400 text-xl flex-shrink-0" />
<div class="text-sm text-blue-700 dark:text-blue-300">
<p class="font-medium mb-1">Información del reporte:</p>
<ul class="list-disc list-inside space-y-1">
<li>Incluye descuentos aplicados en el rango seleccionado</li>
<li>Muestra nivel y porcentaje de descuento por cliente</li>
<li>Se descarga automáticamente en formato Excel (.xlsx)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,229 @@
<script setup>
import { onMounted, ref, vModelSelect } from 'vue';
import { useApi, apiURL } from '@Services/Api';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
required: true
}
});
/** Emits */
const emit = defineEmits(['close']);
/** Estado */
const startDate = ref('');
const endDate = ref('');
const downloading = ref(false);
const users = ref([]);
const user_id = ref('');
const fetchUsers = () => {
const api = useApi();
api.get(apiURL('admin/users'), {
onSuccess: (data) => {
users.value = data.models?.data
}
});
};
/** Métodos */
const downloadReport = () => {
if (!startDate.value || !endDate.value) {
window.Notify.error('Por favor selecciona ambas fechas');
return;
}
if (new Date(startDate.value) > new Date(endDate.value)) {
window.Notify.error('La fecha inicial debe ser menor a la fecha final');
return;
}
downloading.value = true;
// Construir URL con parámetros
let url = apiURL(`reports/sales/excel?fecha_inicio=${startDate.value}&fecha_fin=${endDate.value}`);
if (user_id.value) {
url += `&user_id=${user_id.value}`;
}
// Hacer petición con axios para descargar archivo
window.axios.get(url, {
responseType: 'blob',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
}
})
.then(response => {
// Crear URL del blob
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `reporte_ventas_${startDate.value}_${endDate.value}.xlsx`;
document.body.appendChild(link);
link.click();
// Limpiar
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
window.Notify.success('Reporte descargado exitosamente');
})
.catch(async error => {
if (error.response && error.response.data instanceof Blob) {
const text = await error.response.data.text();
try {
const json = JSON.parse(text);
window.Notify.warning(json.message || 'Error al descargar el reporte');
} catch {
window.Notify.error('Error al descargar el reporte');
}
} else {
window.Notify.error('Error al descargar el reporte');
}
})
.finally(() => {
downloading.value = false;
});
};
const clearDates = () => {
startDate.value = '';
endDate.value = '';
user_id.value = '';
};
const close = () => {
emit('close');
};
onMounted(() => {
fetchUsers();
});
</script>
<template>
<Modal :show="show" max-width="3xl" @close="close">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 relative">
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30">
<GoogleIcon name="download" class="text-xl text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Reporte de Ventas
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Genera un archivo Excel con las ventas realizadas
</p>
</div>
<div class="absolute top-4 right-4">
<button
@click="close"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="space-y-4">
<!-- Rango de Fechas -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Fecha Inicial
</label>
<input
v-model="startDate"
type="date"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:disabled="downloading"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Fecha Final
</label>
<input
v-model="endDate"
type="date"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:disabled="downloading"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Vendedor
</label>
<select
v-model="user_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
:disabled="downloading"
>
<option value="">Todos</option>
<option v-for="user in users" :key="user.id" :value="user.id">
{{ user.name }}
</option>
</select>
</div>
</div>
<!-- Botones -->
<div class="flex items-center gap-3">
<PrimaryButton
@click="downloadReport"
:disabled="downloading || !startDate || !endDate"
class="flex items-center gap-2"
>
<svg v-if="downloading" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<GoogleIcon v-else name="download" />
{{ downloading ? 'Generando...' : 'Descargar Excel' }}
</PrimaryButton>
<button
@click="clearDates"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
:disabled="downloading"
>
Limpiar
</button>
</div>
<!-- Nota informativa -->
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex gap-2">
<GoogleIcon name="info" class="text-blue-600 dark:text-blue-400 text-xl flex-shrink-0" />
<div class="text-sm text-blue-700 dark:text-blue-300">
<p class="font-medium mb-1">Información del reporte:</p>
<ul class="list-disc list-inside space-y-1">
<li>Incluye ventas realizadas en el rango seleccionado</li>
<li>Muestra los detalles de venta y cliente si tiene asociado uno</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,125 @@
<script setup>
import Modal from '@Holos/Modal.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
required: true
},
formUrl: {
type: String,
required: true
},
title: {
type: String,
default: '¡Tu opinión nos importa!'
},
message: {
type: String,
default: 'Cuéntanos cómo fue tu experiencia. Tu feedback nos ayuda a mejorar y solo te tomará <strong class="underline">menos de un minuto</strong>.'
},
buttonText: {
type: String,
default: 'Ir a la encuesta'
},
laterText: {
type: String,
default: 'Tal vez más tarde'
},
footerText: {
type: String,
default: 'MEJORA CONTINUA · EXPERIENCIA DEL CLIENTE'
},
openInNewTab: {
type: Boolean,
default: true
}
});
/** Emits */
const emit = defineEmits(['close']);
/** Methods */
const close = () => {
emit('close');
};
const goToForm = () => {
if (!props.formUrl) {
window.Notify?.warning('No se encontro la URL del formulario.');
return;
}
if (props.openInNewTab) {
window.open(props.formUrl, '_blank', 'noopener,noreferrer');
} else {
window.location.href = props.formUrl;
}
close();
};
</script>
<template>
<Modal :show="show" max-width="sm" @close="close">
<div class="relative bg-white dark:bg-gray-900 rounded-2xl p-8 flex flex-col items-center text-center">
<!-- Close button -->
<button
type="button"
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
@click="close"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Star icon -->
<div class="mb-5 flex items-center justify-center w-16 h-16 rounded-2xl bg-indigo-50 dark:bg-indigo-900/30">
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
</div>
<!-- Title -->
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
{{ title }}
</h2>
<!-- Message -->
<p
class="text-sm text-indigo-500 dark:text-indigo-400 mb-7 leading-relaxed max-w-xs"
v-html="message"
/>
<!-- Primary button -->
<button
type="button"
class="w-full flex items-center justify-center gap-2 bg-indigo-500 hover:bg-indigo-600 active:bg-indigo-700 text-white font-semibold text-sm py-3 px-6 rounded-full transition-colors mb-4"
@click="goToForm"
>
{{ buttonText }}
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</button>
<!-- Later link -->
<button
type="button"
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors mb-6"
@click="close"
>
{{ laterText }}
</button>
<!-- Footer -->
<p class="text-[10px] tracking-widest uppercase text-gray-400 dark:text-gray-500 font-medium">
{{ footerText }}
</p>
</div>
</Modal>
</template>

View File

@ -0,0 +1,104 @@
<script setup>
import { computed } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
product: {
type: Object,
required: true
}
});
/** Emits */
const emit = defineEmits(['add-to-cart']);
/** Computados */
const availableStock = computed(() => props.product?.main_warehouse_stock ?? props.product?.stock ?? 0);
const isLowStock = computed(() => availableStock.value < 10);
const isOutOfStock = computed(() => availableStock.value <= 0);
const formattedPrice = computed(() => {
const price = props.product?.price?.retail_price || 0;
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(price);
});
/** Métodos */
const handleAddToCart = () => {
if (!isOutOfStock.value) {
emit('add-to-cart', props.product);
}
};
</script>
<template>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-all duration-200 overflow-hidden border border-gray-200 dark:border-gray-700"
:class="{
'opacity-60 cursor-not-allowed': isOutOfStock,
'cursor-pointer hover:border-indigo-500': !isOutOfStock
}"
@click="handleAddToCart"
>
<!-- Header con SKU y Stock -->
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">
{{ product.sku }}
</span>
<span
class="px-2 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400': isOutOfStock,
'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400': isLowStock && !isOutOfStock,
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': !isLowStock && !isOutOfStock
}"
>
{{ isOutOfStock ? 'Sin stock' : `Stock: ${availableStock}` }}
</span>
</div>
<!-- Contenido -->
<div class="p-4">
<!-- Nombre del producto -->
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2 line-clamp-2 min-h-10">
{{ product.name }}
</h3>
<!-- Categoría -->
<div class="flex items-center gap-1 mb-3">
<GoogleIcon name="category" class="text-sm text-gray-400" />
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ product.category?.name || 'Sin clasificación' }}
</span>
</div>
<!-- Precio y botón -->
<div class="flex items-center justify-between">
<div>
<p class="text-xl font-bold text-indigo-600 dark:text-indigo-400">
{{ formattedPrice }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
+ IVA {{ product.price?.tax || 16 }}%
</p>
</div>
<button
type="button"
class="flex items-center justify-center w-10 h-10 rounded-full transition-colors"
:class="{
'bg-indigo-600 hover:bg-indigo-700 text-white': !isOutOfStock,
'bg-gray-300 dark:bg-gray-700 text-gray-500 cursor-not-allowed': isOutOfStock
}"
:disabled="isOutOfStock"
@click.stop="handleAddToCart"
>
<GoogleIcon name="add_shopping_cart" class="text-xl" />
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,162 @@
<script setup>
import { ref, computed } from 'vue';
import { QrcodeStream } from 'vue-qrcode-reader';
/** Props y Emits */
const props = defineProps({
modelValue: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue', 'qr-detected']);
/** Refs */
const error = ref('');
const isScanning = ref(true);
/** Formatos de código de barras soportados */
const barcodeFormats = ref({
qr_code: true, // QR codes
ean_13: true, // Código de barras estándar (13 dígitos)
ean_8: true, // Código de barras corto (8 dígitos)
code_128: true, // Común en retail y logística
code_39: true, // Común en inventario industrial
upc_a: true, // Común en productos USA
upc_e: true, // Común en productos USA (versión corta)
aztec: false,
code_93: false,
codabar: false,
databar: false,
databar_expanded: false,
data_matrix: false,
dx_film_edge: false,
itf: false,
maxi_code: false,
micro_qr_code: false,
pdf417: false,
rm_qr_code: false,
linear_codes: false,
matrix_codes: false
});
// Computed para obtener solo los formatos activados
const selectedBarcodeFormats = computed(() => {
return Object.keys(barcodeFormats.value).filter((format) => barcodeFormats.value[format]);
});
/** Métodos */
function paintBoundingBox(detectedCodes, ctx) {
for (const detectedCode of detectedCodes) {
const {
boundingBox: { x, y, width, height }
} = detectedCode;
ctx.lineWidth = 4;
ctx.strokeStyle = '#10b981';
ctx.strokeRect(x, y, width, height);
}
}
function onDetect(detectedCodes) {
if (detectedCodes.length > 0) {
const code = detectedCodes[0].rawValue;
// Emitir el código escaneado al componente padre
emit('update:modelValue', code);
// Pausar el escaneo después de detectar
isScanning.value = false;
// Emitir evento con el código detectado
emit('qr-detected', code);
// Reactivar el escaneo después de 2 segundos
setTimeout(() => {
isScanning.value = true;
error.value = '';
}, 2000);
}
}
function onError(err) {
error.value = `[${err.name}]: `;
if (err.name === 'NotAllowedError') {
error.value += 'Necesitas otorgar permiso de acceso a la cámara';
} else if (err.name === 'NotFoundError') {
error.value += 'No se encontró cámara en este dispositivo';
} else if (err.name === 'NotSupportedError') {
error.value += 'Se requiere contexto seguro (HTTPS o localhost)';
} else if (err.name === 'NotReadableError') {
error.value += '¿La cámara ya está en uso?';
} else if (err.name === 'OverconstrainedError') {
error.value += 'Las cámaras instaladas no son adecuadas';
} else if (err.name === 'StreamApiNotSupportedError') {
error.value += 'No es compatible con este navegador';
} else if (err.name === 'InsecureContextError') {
error.value += 'El acceso a la cámara solo se permite en contexto seguro (HTTPS).';
} else {
error.value += err.message;
}
console.error('Error de cámara:', err);
}
</script>
<template>
<div class="relative w-full h-full">
<!-- Componente de escaneo QR y códigos de barras -->
<QrcodeStream
v-if="isScanning"
@detect="onDetect"
@error="onError"
:track="paintBoundingBox"
:formats="selectedBarcodeFormats"
:constraints="{ facingMode: 'environment' }"
class="w-full h-full rounded-lg overflow-hidden"
>
</QrcodeStream>
<!-- Mensaje de éxito -->
<div
v-else
class="w-full h-full rounded-lg bg-green-600 flex items-center justify-center"
>
<div class="text-center text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mx-auto mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<p class="text-lg font-semibold">¡Código detectado!</p>
</div>
</div>
<!-- Mensaje de error -->
<div
v-if="error"
class="absolute bottom-0 left-0 right-0 bg-red-500 text-white p-3 text-sm"
>
{{ error }}
</div>
</div>
</template>
<style scoped>
:deep(video) {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@ -0,0 +1,38 @@
<script setup>
import { regimenFiscalOptions } from '@/utils/fiscalData';
const props = defineProps({
modelValue: { type: String, default: '' },
error: { type: String, default: null },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RÉGIMEN FISCAL <span v-if="required" class="text-red-500">*</span>
</label>
<select
:value="modelValue"
:disabled="disabled"
:required="required"
@change="emit('update:modelValue', $event.target.value)"
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 disabled:opacity-50"
:class="{ 'border-red-500': error }"
>
<option value="" disabled>Seleccionar régimen fiscal</option>
<option
v-for="option in regimenFiscalOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<p v-if="error" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ error }}</p>
</div>
</template>

View File

@ -0,0 +1,187 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
},
allowRemove: {
type: Boolean,
default: true
}
});
const emit = defineEmits(['update:modelValue']);
const inputRefs = ref([]);
const serials = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const validCount = computed(() => {
return serials.value.filter(s => s.serial_number.trim()).length;
});
const isDuplicate = (index) => {
const val = serials.value[index]?.serial_number?.trim();
if (!val) return false;
return serials.value.some((s, i) => i !== index && s.serial_number.trim() === val);
};
const updateSerial = (index, value) => {
const updated = [...serials.value];
updated[index] = { ...updated[index], serial_number: value };
serials.value = updated;
};
const removeSerial = (index) => {
if (serials.value[index]?.locked) return;
const updated = serials.value.filter((_, i) => i !== index);
serials.value = updated;
};
const addAndFocus = async () => {
const updated = [...serials.value, { serial_number: '', locked: false }];
serials.value = updated;
await nextTick();
const lastIndex = updated.length - 1;
inputRefs.value[lastIndex]?.focus();
};
const onKeydown = async (event, index) => {
if (event.key === 'Enter') {
event.preventDefault();
const current = serials.value[index]?.serial_number?.trim();
if (!current) return;
// Si es el último input, agregar uno nuevo
if (index === serials.value.length - 1) {
await addAndFocus();
} else {
// Si no es el último, mover foco al siguiente
await nextTick();
inputRefs.value[index + 1]?.focus();
}
}
};
const setInputRef = (el, index) => {
if (el) inputRefs.value[index] = el;
};
// Asegurar que siempre haya al menos un input vacío al final para escanear
watch(() => props.modelValue, (val) => {
if (!val || val.length === 0) return;
const lastItem = val[val.length - 1];
// Si el último item tiene valor y no está bloqueado, agregar input vacío
if (lastItem.serial_number.trim() && !lastItem.locked) {
const hasEmptyUnlocked = val.some(s => !s.locked && !s.serial_number.trim());
if (!hasEmptyUnlocked) {
// No emitir aquí para evitar loop, se maneja con el botón/Enter
}
}
}, { deep: true });
</script>
<template>
<div class="space-y-2">
<div
v-for="(item, index) in serials"
:key="index"
class="flex items-center gap-2"
>
<!-- Icono de estado -->
<div class="shrink-0 w-5 flex items-center justify-center">
<GoogleIcon
v-if="item.locked"
name="lock"
class="text-sm text-gray-400"
title="Serial vendido - no editable"
/>
<span
v-else-if="isDuplicate(index)"
class="text-red-500 text-xs font-bold"
title="Serial duplicado"
>!</span>
<GoogleIcon
v-else-if="item.serial_number.trim()"
name="qr_code_2"
class="text-sm text-gray-400"
/>
<span v-else class="text-gray-300 text-xs">{{ index + 1 }}</span>
</div>
<!-- Input -->
<input
:ref="(el) => setInputRef(el, index)"
:value="item.serial_number"
@input="updateSerial(index, $event.target.value)"
@keydown="onKeydown($event, index)"
type="text"
:disabled="props.disabled || item.locked"
:placeholder="item.locked ? '' : 'Escanear o escribir serial...'"
class="flex-1 px-2.5 py-1.5 text-sm font-mono border rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
:class="{
'bg-gray-100 dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-500 cursor-not-allowed': item.locked,
'border-red-400 dark:border-red-600 bg-red-50 dark:bg-red-900/10': isDuplicate(index),
'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100': !item.locked && !isDuplicate(index),
'opacity-50 cursor-not-allowed': props.disabled && !item.locked
}"
/>
<!-- Badge estado -->
<span
v-if="item.locked"
class="shrink-0 text-xs px-1.5 py-0.5 rounded"
:class="item.lock_reason === 'traspasado'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: item.lock_reason === 'salida'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
>
{{
item.lock_reason === 'traspasado' ? 'Traspasado' :
item.lock_reason === 'salida' ? 'Salida' :
'Vendido'
}}
</span>
<!-- Botón eliminar -->
<button
v-if="!item.locked && !props.disabled && props.allowRemove"
type="button"
@click="removeSerial(index)"
class="shrink-0 p-1 text-gray-400 hover:text-red-500 transition-colors"
title="Eliminar serial"
>
<GoogleIcon name="close" class="text-base" />
</button>
<div v-else-if="!item.locked" class="shrink-0 w-7"></div>
</div>
<!-- Botón agregar -->
<button
v-if="!props.disabled && props.allowRemove"
type="button"
@click="addAndFocus"
class="flex items-center gap-1.5 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 transition-colors py-1"
>
<GoogleIcon name="add" class="text-base" />
Agregar serial
</button>
<!-- Contador -->
<div v-if="validCount > 0 && !props.disabled" class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<GoogleIcon name="qr_code_2" class="text-sm shrink-0" />
<span>{{ validCount }} serial(es) ingresado(s)</span>
</div>
</div>
</template>

View File

@ -0,0 +1,300 @@
<script setup>
import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import serialService from '@Services/serialService';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
product: {
type: Object,
required: true
},
excludeSerials: {
type: Array,
default: () => []
},
warehouseId: {
type: Number,
default: null
},
mainWarehouse: {
type: Boolean,
default: false
},
preSelectedSerials: {
type: Array,
default: () => []
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Estado */
const loading = ref(false);
const availableSerials = ref([]);
const selectedSerials = ref([]);
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 hasEnoughStock = computed(() => {
return availableSerials.value.length > 0;
});
const canConfirm = computed(() => {
return selectedSerials.value.length > 0;
});
/** Métodos */
const loadSerials = async () => {
loading.value = true;
try {
const response = await serialService.getAvailableSerials(props.product.id, props.warehouseId, { mainWarehouse: props.mainWarehouse });
const fetched = (response.serials?.data || []).filter(
serial => !props.excludeSerials.includes(serial.serial_number)
);
const fetchedIds = new Set(fetched.map(s => s.id));
const extras = props.preSelectedSerials.filter(s => !fetchedIds.has(s.id));
availableSerials.value = [...extras, ...fetched];
selectedSerials.value = props.preSelectedSerials.filter(s =>
availableSerials.value.some(a => a.id === s.id)
);
} 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 {
selectedSerials.value.push(serial);
}
};
const isSelected = (serial) => {
return selectedSerials.value.some(s => s.id === serial.id);
};
const selectAll = () => {
selectedSerials.value = [...availableSerials.value];
};
const clearSelection = () => {
selectedSerials.value = [];
};
const handleConfirm = () => {
emit('confirm', {
serials: selectedSerials.value,
serialNumbers: selectedSerials.value.map(s => s.serial_number),
quantity: selectedSerials.value.length
});
};
const handleClose = () => {
emit('close');
};
const resetState = () => {
selectedSerials.value = [];
searchQuery.value = '';
};
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown) {
resetState();
loadSerials();
}
},{ immediate: true });
</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 }}
</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">
<!-- Selección manual -->
<div 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 < availableSerials.length"
@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 todos
</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 > 0,
'text-gray-500': selectedCount === 0
}"
>
{{ selectedCount }} 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>
</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

@ -0,0 +1,84 @@
<script setup>
import { ref } from 'vue';
import axios from 'axios';
import { apiURL } from '@Services/Api';
/** Props */
const props = defineProps({
tier: {
type: Object,
required: true
}
});
/** Emits */
const emit = defineEmits(['toggled']);
/** Estado */
const isProcessing = ref(false);
/** Métodos */
const toggleActive = async () => {
if (isProcessing.value) return;
isProcessing.value = true;
try {
const { data } = await axios({
method: 'patch',
url: apiURL(`client-tiers/${props.tier.id}/toggle-active`),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${sessionStorage.token}`
}
});
if (data.status === 'success') {
window.Notify.success(`Nivel ${props.tier.is_active ? 'desactivado' : 'activado'} exitosamente`);
emit('toggled');
} else {
window.Notify.error(data.message || 'Error al cambiar el estado del nivel');
}
} catch (error) {
console.error('Error toggling tier:', error);
const message = error.response?.data?.data?.message || error.response?.data?.message || 'Error de conexión al cambiar el estado';
window.Notify.error(message);
} finally {
isProcessing.value = false;
}
};
</script>
<template>
<div class="flex justify-center items-center w-full">
<button
@click="toggleActive"
:disabled="isProcessing"
:class="[
'relative inline-flex items-center h-7 rounded-full w-20 transition-colors duration-200 focus:outline-none',
props.tier.is_active ? 'bg-green-500' : 'bg-gray-400',
isProcessing ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
]"
:title="props.tier.is_active ? 'Click para desactivar' : 'Click para activar'"
>
<!-- Texto -->
<span
:class="[
'absolute text-white font-semibold text-[8px] tracking-wide transition-all duration-200',
props.tier.is_active ? 'left-2' : 'right-2'
]"
>
{{ props.tier.is_active ? 'ACTIVO' : 'INACTIVO' }}
</span>
<!-- Círculo deslizable -->
<span
:class="[
'inline-block h-5 w-5 rounded-full bg-white shadow transition-transform duration-200',
props.tier.is_active ? 'translate-x-[54px]' : 'translate-x-1'
]"
></span>
</button>
</div>
</template>

View File

@ -0,0 +1,168 @@
<script setup>
import { ref, computed } from 'vue';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
show: Boolean,
product: Object,
equivalences: {
type: Array,
default: () => []
},
baseUnit: Object
});
/** Eventos */
const emit = defineEmits(['confirm', 'close']);
/** Estado */
const selectedOption = ref(null);
/** Todas las opciones: unidad base + equivalencias activas */
const options = computed(() => {
const basePrice = parseFloat(props.product?.price?.retail_price || 0);
const baseName = props.baseUnit?.name || props.product?.unit_of_measure?.name || 'Unidad base';
const baseAbbr = props.baseUnit?.abbreviation || props.product?.unit_of_measure?.abbreviation || '';
const base = {
unit_of_measure_id: null,
unit_name: baseName,
unit_abbreviation: baseAbbr,
unit_price: basePrice,
conversion_factor: 1,
label: `${baseName}${baseAbbr ? ` (${baseAbbr})` : ''}`,
priceLabel: `$${basePrice.toFixed(2)}`
};
const equivalenceOptions = props.equivalences.map(eq => ({
unit_of_measure_id: eq.unit_of_measure_id,
unit_name: eq.unit_name,
unit_abbreviation: eq.unit_abbreviation,
unit_price: parseFloat(eq.retail_price),
conversion_factor: parseFloat(eq.conversion_factor),
label: `${eq.unit_name}${eq.unit_abbreviation ? ` (${eq.unit_abbreviation})` : ''}`,
priceLabel: `$${parseFloat(eq.retail_price).toFixed(2)}`
}));
return [base, ...equivalenceOptions];
});
/** Métodos */
const selectOption = (option) => {
selectedOption.value = option;
};
const confirmSelection = () => {
if (!selectedOption.value) return;
emit('confirm', {
unit_of_measure_id: selectedOption.value.unit_of_measure_id,
unit_price: selectedOption.value.unit_price,
unit_name: selectedOption.value.unit_of_measure_id ? selectedOption.value.unit_name : null,
conversion_factor: selectedOption.value.conversion_factor
});
selectedOption.value = null;
};
const handleClose = () => {
selectedOption.value = null;
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="sm" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-5">
<div>
<h3 class="text-base font-bold text-gray-900 dark:text-gray-100">
Seleccionar unidad
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5 truncate max-w-xs">
{{ product?.name }}
</p>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Opciones de unidad -->
<div class="space-y-2 mb-5">
<button
v-for="option in options"
:key="option.unit_of_measure_id ?? 'base'"
type="button"
@click="selectOption(option)"
:class="[
'w-full flex items-center justify-between p-3 rounded-lg border-2 text-left transition-all',
selectedOption?.unit_of_measure_id === option.unit_of_measure_id
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950 dark:border-indigo-400'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-500'
]"
>
<div class="flex items-center gap-3">
<!-- Check indicator -->
<div
:class="[
'w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0',
selectedOption?.unit_of_measure_id === option.unit_of_measure_id
? 'border-indigo-500 bg-indigo-500'
: 'border-gray-300 dark:border-gray-600'
]"
>
<div
v-if="selectedOption?.unit_of_measure_id === option.unit_of_measure_id"
class="w-1.5 h-1.5 rounded-full bg-white"
></div>
</div>
<!-- Info -->
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ option.label }}
</p>
<p
v-if="option.unit_of_measure_id !== null"
class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"
>
1 {{ option.unit_name }} = {{ option.conversion_factor }} {{ baseUnit?.abbreviation || '' }}
</p>
</div>
</div>
<!-- Precio -->
<span class="text-sm font-bold text-gray-900 dark:text-gray-100 ml-2">
{{ option.priceLabel }}
</span>
</button>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3">
<button
type="button"
@click="handleClose"
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 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
>
Cancelar
</button>
<button
type="button"
@click="confirmSelection"
:disabled="!selectedOption"
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"
>
Agregar al carrito
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,346 @@
<script setup>
import { ref } from 'vue';
import { useForm, apiURL } from '@Services/Api';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
required: true
},
request: {
type: Object,
required: true
}
});
/** Emits */
const emit = defineEmits(['close', 'refresh']);
/** Estado */
const processing = ref(false);
const uploadForm = useForm({
invoice_xml: null,
invoice_pdf: null,
cfdi_uuid: ''
});
/** Métodos */
const closeModal = () => {
uploadForm.reset();
emit('close');
};
const handleXmlChange = (event) => {
const file = event.target.files[0];
if (file) {
if (file.name.endsWith('.xml') || file.type === 'text/xml' || file.type === 'application/xml') {
uploadForm.invoice_xml = file;
} else {
window.Notify.warning('Por favor selecciona un archivo XML válido');
event.target.value = '';
}
}
};
const handlePdfChange = (event) => {
const file = event.target.files[0];
if (file) {
if (file.name.endsWith('.pdf') || file.type === 'application/pdf') {
uploadForm.invoice_pdf = file;
} else {
window.Notify.warning('Por favor selecciona un archivo PDF válido');
event.target.value = '';
}
}
};
const submitUpload = () => {
// Debug: ver qué datos se van a enviar
console.log('Datos del formulario antes de enviar:', {
invoice_xml: uploadForm.invoice_xml,
invoice_pdf: uploadForm.invoice_pdf,
cfdi_uuid: uploadForm.cfdi_uuid
});
// Validación: al menos debe haber PDF
if (!uploadForm.invoice_pdf) {
window.Notify.warning('Por favor selecciona el archivo PDF');
return;
}
processing.value = true;
// Crear FormData manualmente para mayor control
const formData = new FormData();
// Agregar archivos si existen
if (uploadForm.invoice_xml) {
formData.append('xml_file', uploadForm.invoice_xml, uploadForm.invoice_xml.name);
console.log('XML agregado:', uploadForm.invoice_xml.name);
}
if (uploadForm.invoice_pdf) {
formData.append('invoice_pdf', uploadForm.invoice_pdf, uploadForm.invoice_pdf.name);
console.log('PDF agregado:', uploadForm.invoice_pdf.name, 'Tamaño:', uploadForm.invoice_pdf.size, 'bytes');
}
// Agregar UUID si existe
if (uploadForm.cfdi_uuid && uploadForm.cfdi_uuid.trim()) {
formData.append('cfdi_uuid', uploadForm.cfdi_uuid.trim());
console.log('UUID agregado:', uploadForm.cfdi_uuid);
}
// Debug: mostrar todo lo que hay en FormData
console.log('=== FormData a enviar ===');
for (let pair of formData.entries()) {
console.log(pair[0] + ':', pair[1]);
}
// Enviar con axios directamente
window.axios({
method: 'POST',
url: apiURL(`invoice-requests/${props.request.id}/upload`),
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
'Accept': 'application/json',
'Authorization': `Bearer ${sessionStorage.token}`,
'X-CSRF-TOKEN': localStorage.csrfToken
}
})
.then(response => {
console.log('Respuesta exitosa:', response.data);
if (response.data.status === 'success') {
window.Notify.success('Archivos de factura subidos correctamente');
uploadForm.reset();
emit('refresh');
emit('close');
} else if (response.data.status === 'fail') {
console.error('Error del backend:', response.data);
window.Notify.error(response.data.data?.message || 'Error al subir los archivos');
}
})
.catch(error => {
console.error('Error completo:', error);
console.error('Respuesta del servidor:', error.response?.data);
if (error.response?.status === 422) {
// Errores de validación
const errors = error.response.data.errors || {};
console.error('Errores de validación:', errors);
window.Notify.error(Object.values(errors).flat().join(', '));
} else {
window.Notify.error(error.response?.data?.message || 'Error al subir los archivos');
}
})
.finally(() => {
processing.value = false;
});
};
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30">
<GoogleIcon name="upload_file" class="text-2xl text-blue-600 dark:text-blue-400" />
</div>
<div class="flex-1">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Subir Archivos de Factura
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Solicitud #{{ request.id }} - {{ request.client?.name }}
</p>
</div>
<button
@click="closeModal"
:disabled="processing"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors disabled:opacity-50"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Información de la venta -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="receipt_long" class="text-lg text-gray-600 dark:text-gray-400" />
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
Folio: {{ request.sale?.invoice_number || 'N/A' }}
</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
RFC: <span class="font-mono font-semibold">{{ request.client?.rfc || 'N/A' }}</span>
</div>
</div>
<!-- Formulario -->
<form @submit.prevent="submitUpload" class="space-y-5">
<!-- UUID del CFDI -->
<div>
<label class="flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
<GoogleIcon name="key" class="text-lg text-purple-600 dark:text-purple-400" />
UUID del CFDI
</label>
<input
v-model="uploadForm.cfdi_uuid"
type="text"
placeholder="Ej: 123e4567-e89b-12d3-a456-426614174000"
:disabled="processing"
class="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-mono text-sm"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Identificador único del CFDI timbrado
</p>
</div>
<!-- Archivo XML -->
<div>
<label class="flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
<GoogleIcon name="description" class="text-lg text-blue-600 dark:text-blue-400" />
Archivo XML
</label>
<div class="relative">
<input
type="file"
accept=".xml,application/xml,text/xml"
:disabled="processing"
@change="handleXmlChange"
class="w-full text-sm text-gray-500 dark:text-gray-400
file:mr-4 file:py-2.5 file:px-4
file:rounded-lg file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100
file:cursor-pointer
dark:file:bg-blue-900/30 dark:file:text-blue-400
dark:hover:file:bg-blue-900/50
file:transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
/>
<GoogleIcon
v-if="uploadForm.invoice_xml"
name="check_circle"
class="absolute right-3 top-1/2 -translate-y-1/2 text-2xl text-green-500"
/>
</div>
<p v-if="uploadForm.invoice_xml" class="mt-2 flex items-center gap-2 text-xs">
<GoogleIcon name="insert_drive_file" class="text-base text-blue-600 dark:text-blue-400" />
<span class="font-medium text-gray-700 dark:text-gray-300">{{ uploadForm.invoice_xml.name }}</span>
<span class="text-gray-500 dark:text-gray-400">
({{ (uploadForm.xml_file.size / 1024).toFixed(2) }} KB)
</span>
</p>
<p v-else class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Archivo XML del CFDI timbrado por el PAC
</p>
</div>
<!-- Archivo PDF -->
<div>
<label class="flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
<GoogleIcon name="picture_as_pdf" class="text-lg text-red-600 dark:text-red-400" />
Archivo PDF
</label>
<div class="relative">
<input
type="file"
accept=".pdf,application/pdf"
:disabled="processing"
@change="handlePdfChange"
class="w-full text-sm text-gray-500 dark:text-gray-400
file:mr-4 file:py-2.5 file:px-4
file:rounded-lg file:border-0
file:text-sm file:font-semibold
file:bg-red-50 file:text-red-700
hover:file:bg-red-100
file:cursor-pointer
dark:file:bg-red-900/30 dark:file:text-red-400
dark:hover:file:bg-red-900/50
file:transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
/>
<GoogleIcon
v-if="uploadForm.invoice_pdf"
name="check_circle"
class="absolute right-3 top-1/2 -translate-y-1/2 text-2xl text-green-500"
/>
</div>
<p v-if="uploadForm.invoice_pdf" class="mt-2 flex items-center gap-2 text-xs">
<GoogleIcon name="picture_as_pdf" class="text-base text-red-600 dark:text-red-400" />
<span class="font-medium text-gray-700 dark:text-gray-300">{{ uploadForm.invoice_pdf.name }}</span>
<span class="text-gray-500 dark:text-gray-400">
({{ (uploadForm.invoice_pdf.size / 1024).toFixed(2) }} KB)
</span>
</p>
<p v-else class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Representación impresa del CFDI en formato PDF
</p>
</div>
<!-- Nota informativa -->
<div class="flex gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<GoogleIcon name="info" class="text-xl text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div class="text-xs text-blue-800 dark:text-blue-300">
<p class="font-semibold mb-1">Información importante:</p>
<ul class="list-disc list-inside space-y-1">
<li>Los archivos deben corresponder a la factura timbrada</li>
<li>El UUID debe coincidir con el del XML timbrado</li>
<li>Tamaño máximo por archivo: 5MB</li>
</ul>
</div>
</div>
<!-- Acciones -->
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeModal"
:disabled="processing"
class="px-5 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancelar
</button>
<button
type="submit"
:disabled="processing"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-blue-500/30"
>
<GoogleIcon name="upload" class="text-lg" />
{{ processing ? 'Subiendo...' : 'Subir Archivos' }}
</button>
</div>
</form>
</div>
</Modal>
</template>
<style scoped>
/* Animación para el check de archivo seleccionado */
@keyframes checkIn {
0% {
transform: translate(50%, -50%) scale(0);
opacity: 0;
}
50% {
transform: translate(50%, -50%) scale(1.2);
}
100% {
transform: translate(50%, -50%) scale(1);
opacity: 1;
}
}
.absolute.right-3 {
animation: checkIn 0.3s ease-out;
}
</style>

View File

@ -0,0 +1,45 @@
import { onMounted, onUnmounted } from 'vue';
export function useBarcodeScanner(options = {}) {
const {
onScan,
minLength = 3,
scanTimeout = 100,
enterKey = true
} = options;
let barcode = '';
let timeout;
const handleKeyPress = (e) => {
// Ignorar si está escribiendo en inputs
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) {
return;
}
if (e.key === 'Enter' && enterKey) {
if (barcode.length >= minLength) {
onScan?.(barcode);
barcode = '';
}
} else if (e.key.length === 1) {
clearTimeout(timeout);
barcode += e.key;
timeout = setTimeout(() => {
barcode = '';
}, scanTimeout);
}
};
onMounted(() => {
document.addEventListener('keypress', handleKeyPress);
});
onUnmounted(() => {
document.removeEventListener('keypress', handleKeyPress);
clearTimeout(timeout);
});
return {};
}

View File

@ -1,7 +1,35 @@
import { success } from "toastr";
export default {
'&':'y',
bills: {
title: 'Facturas / Gastos',
name: 'Nombre',
cost: 'Costo',
file: 'Archivo (PDF/Imagen)',
deadline: 'Fecha límite de pago',
file_replace: 'Reemplazar archivo',
view_file: 'Ver archivo',
current_file: 'Archivo actual',
search: 'Buscar por nombre...',
create: {
title: 'Nueva Factura',
description: 'Registra una nueva factura o gasto. Sube el archivo PDF o imagen correspondiente.',
},
edit: {
title: 'Editar Factura',
description: 'Actualiza los datos de la factura. Si subes un nuevo archivo, el anterior será reemplazado.',
},
supplier: 'Proveedor',
paid: 'Pagada',
pending: 'Pendiente',
export: 'Exportar pendientes',
toggle_paid: 'Marcar como pagada',
toggle_unpaid: 'Marcar como pendiente',
delete: {
title: 'Eliminar Factura',
confirm: '¿Estás seguro de que deseas eliminar esta factura?',
warning: 'Esta acción es permanente e incluye el archivo adjunto. No se puede deshacer.',
},
},
account: {
delete: {
confirm:'¿Está seguro de que quiere eliminar su cuenta? Una vez eliminada su cuenta, todos sus recursos y datos se borrarán permanentemente. Por favor, introduzca su contraseña para confirmar que desea eliminar permanentemente su cuenta.',
@ -449,4 +477,145 @@ export default {
},
title: 'Puestos de trabajos'
},
pos: {
title: 'Punto de Venta',
subtitle: 'Gestión de ventas y caja',
category: 'Clasificaciones',
bundles: 'Paquetes',
inventory: 'Productos',
prices: 'Precios',
cashRegister: 'Caja',
point: 'Punto de Venta',
sales: 'Ventas',
returns: 'Devoluciones',
clients: 'Clientes',
suppliers: 'Proveedores',
unitMeasure: 'Unidades de medida',
clientTiers: 'Niveles de Clientes',
billingRequests: 'Solicitudes de Facturación',
warehouses: 'Almacenes',
movements: 'Movimientos',
bills: 'Facturas / Gastos'
},
cashRegister: {
title: 'Caja Registradora',
description: 'Gestión de apertura y cierre de caja',
open: 'Abrir Caja',
close: 'Cerrar Caja',
history: 'Historial de Cajas',
status: {
open: 'Abierta',
closed: 'Cerrada'
},
initialCash: 'Efectivo Inicial',
finalCash: 'Efectivo Final',
expectedCash: 'Efectivo Esperado',
cashSales: 'Ventas en Efectivo',
cardSales: 'Ventas con Tarjeta',
totalSales: 'Total de Ventas',
difference: 'Diferencia',
transactions: 'Transacciones',
openedBy: 'Abierta por',
openedAt: 'Fecha de Apertura',
closedAt: 'Fecha de Cierre',
notes: 'Notas / Observaciones'
},
inventory: {
title: 'Gestión De Inventario',
description: 'Administra los productos del inventario.',
create: {
title: 'Nuevo Producto',
},
edit: {
title: 'Editar Producto',
},
sku: 'SKU / Código',
product: 'Producto',
category: 'Clasificación',
stock: 'Stock',
state: 'Estado',
cost: 'Costo',
retailPrice: 'Precio Venta',
tax: 'Impuesto',
active: 'Activo',
inactive: 'Inactivo',
},
category: {
title: 'Gestión De Clasificaciones',
description: 'Administra las clasificaciones de productos.',
create: {
title: 'Nueva Clasificación',
},
edit: {
title: 'Editar Clasificación',
},
},
prices: {
title: 'Precios',
},
sales: {
title: 'Historial de Ventas',
invoice: 'Factura',
invoiceNumber: 'Número de Factura',
paymentMethod: 'Método de pago',
status: 'Estado',
total: 'Total',
subtotal: 'Subtotal',
tax: 'Impuesto (IVA)',
cancel: 'Cancelar venta',
cancelConfirm: '¿Estás seguro de cancelar esta venta? Se restaurará el stock.',
cancelled: 'Venta cancelada exitosamente',
detail: 'Detalle de venta',
date: 'Fecha de venta',
cashier: 'Cajero',
empty: 'No hay ventas registradas',
methods: {
cash: 'Efectivo',
credit_card: 'Tarjeta de Crédito',
debit_card: 'Tarjeta de Débito'
},
statuses: {
completed: 'Completada',
cancelled: 'Cancelada'
}
},
cart: {
title: 'Carrito de Compras',
empty: 'El carrito está vacío',
emptyMessage: 'Agrega productos para comenzar una venta',
addProduct: 'Agregar producto',
removeProduct: 'Eliminar producto',
quantity: 'Cantidad',
unitPrice: 'Precio unitario',
subtotal: 'Subtotal',
clear: 'Vaciar carrito',
clearConfirm: '¿Estás seguro de vaciar el carrito?',
checkout: 'Cobrar',
selectPayment: 'Selecciona método de pago',
processing: 'Procesando venta...',
success: 'Venta realizada exitosamente',
error: 'Error al procesar la venta',
noStock: 'No hay suficiente stock disponible',
total: 'Total a pagar'
},
clients: {
title: 'Clientes',
description: 'Gestión de clientes',
},
clientTiers: {
title: 'Niveles de Clientes',
description: 'Gestión de niveles de clientes',
},
warehouses: {
title: 'Almacenes',
description: 'Gestión de almacenes',
},
movements: {
title: 'Movimientos de Inventario',
description: 'Historial de entradas, salidas y traspasos de productos',
},
suppliers: {
title: 'Proveedores',
description: 'Gestión de proveedores',
},
}

View File

@ -6,6 +6,8 @@ import { hasPermission } from '@Plugins/RolePermission';
import Layout from '@Holos/Layout/App.vue';
import Link from '@Holos/Skeleton/Sidebar/Link.vue';
import Section from '@Holos/Skeleton/Sidebar/Section.vue';
import DropdownMenu from '@Holos/Skeleton/Sidebar/DropdownMenu.vue';
import SubLink from '@Holos/Skeleton/Sidebar/SubLink.vue';
/** Definidores */
const loader = useLoader()
@ -34,10 +36,93 @@ onMounted(() => {
name="dashboard"
to="dashboard.index"
/>
</Section>
<Section :name="$t('pos.title')">
<Link
icon="person"
name="profile"
to="profile.show"
icon="category"
name="pos.category"
to="pos.category.index"
/>
<DropdownMenu
icon="folder"
name="Catálogos"
>
<SubLink
v-if="hasPermission('warehouses.index')"
icon="warehouse"
name="pos.warehouses"
to="pos.warehouses.index"
/>
<SubLink
v-if="hasPermission('clients.index')"
icon="accessibility"
name="pos.clients"
to="pos.clients.index"
/>
<SubLink
v-if="hasPermission('inventario.index')"
icon="stack"
name="pos.bundles"
to="pos.bundles.index"
/>
<SubLink
v-if="hasPermission('inventario.index')"
icon="inventory_2"
name="pos.inventory"
to="pos.inventory.index"
/>
<SubLink
v-if="hasPermission('suppliers.index')"
icon="support_agent"
name="pos.suppliers"
to="pos.suppliers.index"
/>
<SubLink
v-if="hasPermission('units.index')"
icon="scale"
name="pos.unitMeasure"
to="pos.unitMeasure.index"
/>
</DropdownMenu>
<Link
v-if="hasPermission('movements.index')"
icon="swap_horiz"
name="pos.movements"
to="pos.movements.index"
/>
<Link
icon="point_of_sale"
name="pos.cashRegister"
to="pos.cashRegister.index"
/>
<Link
icon="receipt_long"
name="pos.sales"
to="pos.sales.index"
/>
<Link
v-if="hasPermission('returns.index')"
icon="Sync"
name="pos.returns"
to="pos.returns.index"
/>
<Link
v-if="hasPermission('client-tiers.index')"
icon="leaderboard"
name="pos.clientTiers"
to="pos.client-tiers.index"
/>
<Link
v-if="hasPermission('invoice-requests.index')"
icon="request_quote"
name="pos.billingRequests"
to="pos.billingRequests.index"
/>
<Link
v-if="hasPermission('bills.index')"
icon="finance"
name="Facturas / Gastos"
to="admin.bills.index"
/>
</Section>
<Section

View File

@ -35,6 +35,12 @@ const filters = reactive({
user: ''
});
const movements = {
'entry': 'Entrada',
'exit': 'Salida',
'transfer': 'Transferencia'
};
/** Métodos */
const searcher = useSearcher({
url: apiTo('index'),
@ -133,6 +139,7 @@ onMounted(() => {
<template v-for="event in items">
<Item
:event="event"
:name-map="movements"
@show="Modal.switchShowModal(event)"
/>
</template>

View File

@ -0,0 +1,206 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useForm, useApi, apiURL } from '@Services/Api';
import { transl } from './Module';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import SingleFile from '@Holos/Form/SingleFile.vue';
import Selectable from '@Holos/Form/Selectable.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SupplierCreate from '@Pages/POS/Suppliers/Create.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
defineProps({
show: Boolean
});
/** Estado */
const api = useApi();
const suppliers = ref([]);
const selectedSupplier = ref(null);
const showSupplierModal = ref(false);
/** Formulario */
const form = useForm({
name: '',
cost: '',
deadline: '',
supplier_id: null,
paid: false,
file: null,
});
/** Métodos */
const create = () => {
form.supplier_id = selectedSupplier.value?.id ?? null;
form.post(apiURL('bills'), {
onSuccess: (data) => {
window.Notify.success(Lang('register.create.onSuccess'));
emit('created', data.bill);
closeModal();
},
});
};
const closeModal = () => {
form.reset();
selectedSupplier.value = null;
emit('close');
};
const loadSuppliers = () => {
api.get(apiURL('proveedores'), {
onSuccess: (data) => suppliers.value = data.suppliers?.data ?? [],
});
};
/** Ciclo de vida */
onMounted(loadSuppliers);
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ transl('create.title') }}
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="create" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Proveedor -->
<div class="col-span-2">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
{{ transl('supplier') }}
</label>
<button
type="button"
class="flex items-center justify-center w-6 h-6 rounded-full bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
:title="$t('crud.create')"
@click="showSupplierModal = true"
>
<GoogleIcon name="add" class="text-sm" />
</button>
</div>
<Selectable
v-model="selectedSupplier"
:options="suppliers"
label="business_name"
track-by="id"
:placeholder="transl('supplier')"
/>
<FormError :message="form.errors?.supplier_id" />
</div>
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('name') }}
</label>
<FormInput
v-model="form.name"
type="text"
:placeholder="transl('name')"
autofocus
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Costo -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('cost') }}
</label>
<FormInput
v-model="form.cost"
type="number"
step="0.01"
min="0"
:placeholder="transl('cost')"
required
/>
<FormError :message="form.errors?.cost" />
</div>
<!-- Fecha límite -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('deadline') }}
</label>
<FormInput
v-model="form.deadline"
type="date"
/>
<FormError :message="form.errors?.deadline" />
</div>
<!-- Pagada -->
<div class="col-span-2 flex items-center gap-3">
<input
id="create-paid"
v-model="form.paid"
type="checkbox"
class="w-4 h-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"
/>
<label for="create-paid" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
{{ transl('paid') }}
</label>
</div>
<!-- Archivo -->
<div class="col-span-2">
<SingleFile
v-model="form.file"
accept="application/pdf,image/jpeg,image/png,image/jpg"
title="bills.file"
/>
<FormError :message="form.errors?.file" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
{{ $t('cancel') }}
</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">{{ $t('saving') }}...</span>
<span v-else>{{ $t('save') }}</span>
</button>
</div>
</form>
</div>
</Modal>
<SupplierCreate
:show="showSupplierModal"
@close="showSupplierModal = false"
@created="() => { showSupplierModal = false; loadSuppliers(); }"
/>
</template>

View File

@ -0,0 +1,96 @@
<script setup>
import { transl } from './Module';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Propiedades */
const props = defineProps({
show: {
type: Boolean,
default: false
},
bill: {
type: Object,
default: null
}
});
/** Eventos */
const emit = defineEmits(['close', 'confirm']);
/** Métodos */
const handleConfirm = () => emit('confirm', props.bill.id);
const handleClose = () => emit('close');
</script>
<template>
<Modal :show="show" max-width="md" @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-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ transl('delete.title') }}
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Contenido -->
<div class="space-y-5">
<p class="text-gray-700 dark:text-gray-300 text-base">
{{ transl('delete.confirm') }}
</p>
<div
v-if="bill"
class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-1"
>
<p class="text-base font-bold text-gray-900 dark:text-gray-100">
{{ bill.name }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
${{ Number(bill.cost).toFixed(2) }}
</p>
</div>
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-800 dark:text-red-300 font-medium">
{{ transl('delete.warning') }}
</p>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-5 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
>
{{ $t('cancel') }}
</button>
<button
type="button"
@click="handleConfirm"
class="flex items-center gap-2 px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 shadow-lg shadow-red-600/30 transition-all"
>
<GoogleIcon name="delete" class="text-xl" />
{{ transl('delete.title') }}
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,233 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useForm, useApi, apiURL } from '@Services/Api';
import { transl } from './Module';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import SingleFile from '@Holos/Form/SingleFile.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Selectable from '@Holos/Form/Selectable.vue';
import SupplierCreate from '@Pages/POS/Suppliers/Create.vue';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
bill: Object
});
/** Estado */
const api = useApi();
const suppliers = ref([]);
const selectedSupplier = ref(null);
const showSupplierModal = ref(false);
const loadSuppliers = () => {
api.get(apiURL('proveedores'), {
onSuccess: (data) => suppliers.value = data.suppliers?.data ?? [],
});
};
/** Formulario */
const form = useForm({
name: '',
cost: '',
deadline: '',
supplier_id: null,
paid: false,
file: null,
});
/** Métodos */
const update = () => {
form.supplier_id = selectedSupplier.value?.id ?? null;
form.post(apiURL(`bills/${props.bill.id}`), {
onSuccess: () => {
window.Notify.success(Lang('register.edit.onSuccess'));
emit('updated');
closeModal();
},
});
};
const closeModal = () => {
form.reset();
selectedSupplier.value = null;
emit('close');
};
/** Ciclo de vida */
onMounted(loadSuppliers);
/** Observadores */
watch(() => props.bill, (bill) => {
if (bill) {
form.name = bill.name || '';
form.cost = bill.cost || '';
form.deadline = bill.deadline || '';
form.paid = !!bill.paid;
form.file = null;
selectedSupplier.value = bill.supplier ?? null;
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ transl('edit.title') }}
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="update" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Proveedor -->
<div class="col-span-2">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
{{ transl('supplier') }}
</label>
<button
type="button"
class="flex items-center justify-center w-6 h-6 rounded-full bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
:title="$t('crud.create')"
@click="showSupplierModal = true"
>
<GoogleIcon name="add" class="text-sm" />
</button>
</div>
<Selectable
v-model="selectedSupplier"
:options="suppliers"
label="business_name"
track-by="id"
:placeholder="transl('supplier')"
/>
<FormError :message="form.errors?.supplier_id" />
</div>
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('name') }}
</label>
<FormInput
v-model="form.name"
type="text"
:placeholder="transl('name')"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Costo -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('cost') }}
</label>
<FormInput
v-model="form.cost"
type="number"
step="0.01"
min="0"
:placeholder="transl('cost')"
required
/>
<FormError :message="form.errors?.cost" />
</div>
<!-- Fecha límite -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('deadline') }}
</label>
<FormInput
v-model="form.deadline"
type="date"
/>
<FormError :message="form.errors?.deadline" />
</div>
<!-- Archivo actual -->
<div v-if="bill?.file_url" class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
{{ transl('current_file') }}
</label>
<a
:href="bill.file_url"
target="_blank"
class="inline-flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-100 dark:hover:bg-indigo-900/40 transition-colors"
>
<GoogleIcon name="description" class="text-base" />
{{ transl('view_file') }}
</a>
</div>
<!-- Pagada -->
<div class="col-span-2 flex items-center gap-3">
<input
id="edit-paid"
v-model="form.paid"
type="checkbox"
class="w-4 h-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500"
/>
<label for="edit-paid" class="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
{{ transl('paid') }}
</label>
</div>
<!-- Nuevo archivo -->
<div class="col-span-2">
<SingleFile
v-model="form.file"
accept="application/pdf,image/jpeg,image/png,image/jpg"
title="bills.file_replace"
/>
<FormError :message="form.errors?.file" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
{{ $t('cancel') }}
</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">{{ $t('saving') }}...</span>
<span v-else>{{ $t('update') }}</span>
</button>
</div>
</form>
</div>
</Modal>
<SupplierCreate
:show="showSupplierModal"
@close="showSupplierModal = false"
@created="() => { showSupplierModal = false; loadSuppliers(); }"
/>
</template>

View File

@ -0,0 +1,257 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useSearcher, useApi, apiURL } from '@Services/Api';
import { can, transl } from './Module';
import { formatCurrency } from '@/utils/formatters';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import CreateModal from './Create.vue';
import EditModal from './Edit.vue';
import DeleteModal from './Delete.vue';
/** Estado */
const bills = ref([]);
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const editingBill = ref(null);
const deletingBill = ref(null);
/** Búsqueda */
const searcher = useSearcher({
url: apiURL('bills'),
onSuccess: (r) => bills.value = r.bills,
onError: () => bills.value = []
});
/** Métodos */
const openCreateModal = () => showCreateModal.value = true;
const closeCreateModal = () => showCreateModal.value = false;
const openEditModal = (bill) => {
editingBill.value = bill;
showEditModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
editingBill.value = null;
};
const openDeleteModal = (bill) => {
deletingBill.value = bill;
showDeleteModal.value = true;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
deletingBill.value = null;
};
const confirmDelete = async (id) => {
try {
const response = await fetch(apiURL(`bills/${id}`), {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
if (response.ok) {
window.Notify.success('Factura eliminada exitosamente');
closeDeleteModal();
searcher.search();
} else {
window.Notify.error('Error al eliminar la factura');
}
} catch (error) {
window.Notify.error('Error al eliminar la factura');
}
};
const onSaved = () => searcher.search();
const api = useApi();
const togglePaid = (bill) => {
api.patch(apiURL(`bills/${bill.id}/toggle-paid`), {
onSuccess: () => searcher.refresh(),
});
};
const exportPending = () => {
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
api.download(apiURL('bills/pending/excel'), `Facturas_Pendientes_${date}_${time}.xlsx`);
};
/** Ciclo de vida */
onMounted(() => searcher.search());
</script>
<template>
<div>
<SearcherHead
:title="transl('title')"
:placeholder="transl('search')"
@search="(x) => searcher.search(x)"
>
<button
class="flex items-center gap-2 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="exportPending"
>
<GoogleIcon name="download" class="text-xl" />
{{ transl('export') }}
</button>
<button
v-if="can('create')"
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" />
{{ transl('create.title') }}
</button>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="bills"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('name') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('supplier') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('cost') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('deadline') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('file') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ transl('paid') }}
</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{{ $t('actions') }}
</th>
</template>
<template #body="{ items }">
<tr
v-for="bill in items"
:key="bill.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ bill.name }}</p>
</td>
<td class="px-6 py-4 text-center">
<p v-if="bill.supplier" class="text-sm text-gray-700 dark:text-gray-300">
{{ bill.supplier.business_name }}
</p>
<span v-else class="text-sm text-gray-400"></span>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(bill.cost) }}
</p>
</td>
<td class="px-6 py-4 text-center">
<p v-if="bill.deadline" class="text-sm text-gray-700 dark:text-gray-300">
{{ bill.deadline }}
</p>
<span v-else class="text-sm text-gray-400"></span>
</td>
<td class="px-6 py-4 text-center">
<a
v-if="bill.file_url"
:href="bill.file_url"
target="_blank"
class="inline-flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
>
<GoogleIcon name="description" class="text-base" />
{{ transl('view_file') }}
</a>
<span v-else class="text-sm text-gray-400"></span>
</td>
<td class="px-6 py-4 text-center">
<button
class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold transition-colors"
:class="bill.paid
? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'"
:title="bill.paid ? transl('toggle_unpaid') : transl('toggle_paid')"
@click.stop="togglePaid(bill)"
>
<GoogleIcon :name="bill.paid ? 'check_circle' : 'schedule'" class="text-base" />
{{ bill.paid ? transl('paid') : transl('pending') }}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
v-if="can('edit')"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
:title="$t('crud.edit')"
@click.stop="openEditModal(bill)"
>
<GoogleIcon name="edit" class="text-xl" />
</button>
<button
v-if="can('destroy')"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
:title="$t('crud.destroy')"
@click.stop="openDeleteModal(bill)"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="7" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon name="receipt_long" class="text-6xl mb-2 opacity-50" />
<p class="font-semibold">{{ $t('registers.empty') }}</p>
</div>
</td>
</template>
</Table>
</div>
<CreateModal
v-if="can('create')"
:show="showCreateModal"
@close="closeCreateModal"
@created="onSaved"
/>
<EditModal
v-if="can('edit')"
:show="showEditModal"
:bill="editingBill"
@close="closeEditModal"
@updated="onSaved"
/>
<DeleteModal
v-if="can('destroy')"
:show="showDeleteModal"
:bill="deletingBill"
@close="closeDeleteModal"
@confirm="confirmDelete"
/>
</div>
</template>

View File

@ -0,0 +1,21 @@
import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`admin.bills.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `admin.bills.${name}`, params, query })
// Obtener traducción del componente
const transl = (str) => lang(`bills.${str}`)
// Determina si un usuario puede hacer algo en base a los permisos
const can = (permission) => hasPermission(`bills.${permission}`)
export {
can,
viewTo,
apiTo,
transl
}

View File

@ -1,12 +1,13 @@
<script setup>
import { onMounted } from 'vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { defineApiToken, defineCsrfToken, hasToken, useForm } from '@Services/Api.js'
import { defineUser } from '@Services/Page';
import { viewTo } from './Module.js';
import PrimaryButton from '@Holos/Button/Primary.vue'
import Input from '@Holos/Form/InputWithIcon.vue'
import GoogleIcon from '@Shared/GoogleIcon.vue'
import Error from '@Holos/Form/Elements/Error.vue'
/** Definidores */
const router = useRouter();
@ -22,19 +23,32 @@ const form = useForm({
password: ''
});
const showPassword = ref(false);
const isLoading = ref(false);
/** Métodos */
const login = () => {
isLoading.value = true;
form.post(route('auth.login'), {
onSuccess: (res) => {
defineApiToken(res.token)
defineUser(res.user)
defineCsrfToken(res.csrf)
location.replace('/')
},
onError: () => {
isLoading.value = false;
},
onFinish: () => {
isLoading.value = false;
}
});
};
const togglePassword = () => {
showPassword.value = !showPassword.value;
};
/** Ciclos */
onMounted(() => {
if (hasToken()) {
@ -44,33 +58,100 @@ onMounted(() => {
</script>
<template>
<form @submit.prevent="login">
<Input
icon="mail"
<div class="w-full">
<!-- Header del formulario -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-page-t dark:text-page-dt mb-2">
Iniciar Sesion
</h1>
<p class="text-page-t/60 dark:text-page-dt/60 text-sm">
Ingrese sus credenciales.
</p>
</div>
<form @submit.prevent="login" class="space-y-6">
<!-- Campo Email/Usuario -->
<div class="space-y-2">
<label for="email" class="block text-xs font-semibold text-page-t/70 dark:text-page-dt/70 uppercase tracking-wider">
Email / Usuario
</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<GoogleIcon
name="person"
class="text-page-t/40 dark:text-page-dt/40 group-focus-within:text-primary dark:group-focus-within:text-primary-dt transition-colors"
/>
</div>
<input
id="email"
type="email"
v-model="form.email"
:onError="form.errors.email"
:placeholder="$t('email.title')"
placeholder="ID de empleado"
class="w-full pl-12 pr-4 py-3.5 bg-page dark:bg-page-d border border-primary/20 dark:border-primary-dt/20 rounded-lg
text-page-t dark:text-page-dt placeholder-page-t/40 dark:placeholder-page-dt/40
focus:outline-none focus:border-primary/50 dark:focus:border-primary-dt/50 focus:ring-2 focus:ring-primary/10 dark:focus:ring-primary-dt/10
hover:border-primary/30 dark:hover:border-primary-dt/30 transition-all duration-200"
:class="{ 'border-danger! focus:border-danger! focus:ring-danger/20!': form.errors.email }"
autocomplete="email"
/>
<Input
v-model="form.password"
icon="password"
id="password"
type="password"
:onError="form.errors.password"
:placeholder="$t('password')"
/>
<PrimaryButton class="!w-full">
{{ $t('auth.login') }}
</PrimaryButton>
<div class="flex justify-end mt-4">
<RouterLink
class="text-sm ml-2 hover:text-blue-200 cursor-pointer hover:-translate-y-1 duration-500 transition-all"
:to="viewTo({ name: 'forgot-password' })"
>
{{ $t('auth.forgotPassword.ask') }}
</RouterLink>
</div>
<Error :onError="form.errors.email" />
</div>
<!-- Campo Password -->
<div class="space-y-2">
<label for="password" class="block text-xs font-semibold text-page-t/70 dark:text-page-dt/70 uppercase tracking-wider">
Contraseña
</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<GoogleIcon
name="lock"
class="text-page-t/40 dark:text-page-dt/40 group-focus-within:text-primary dark:group-focus-within:text-primary-dt transition-colors"
/>
</div>
<input
id="password"
:type="showPassword ? 'text' : 'password'"
v-model="form.password"
placeholder="••••••••••"
class="w-full pl-12 pr-12 py-3.5 bg-page dark:bg-page-d border border-primary/20 dark:border-primary-dt/20 rounded-lg
text-page-t dark:text-page-dt placeholder-page-t/40 dark:placeholder-page-dt/40
focus:outline-none focus:border-primary/50 dark:focus:border-primary-dt/50 focus:ring-2 focus:ring-primary/10 dark:focus:ring-primary-dt/10
hover:border-primary/30 dark:hover:border-primary-dt/30 transition-all duration-200"
:class="{ 'border-danger! focus:border-danger! focus:ring-danger/20!': form.errors.password }"
autocomplete="current-password"
/>
<button
type="button"
@click="togglePassword"
class="absolute inset-y-0 right-0 pr-4 flex items-center text-page-t/40 dark:text-page-dt/40 hover:text-page-t dark:hover:text-page-dt transition-colors"
>
<GoogleIcon :name="showPassword ? 'visibility_off' : 'visibility'" />
</button>
</div>
<Error :onError="form.errors.password" />
</div>
<!-- Boton de Login -->
<PrimaryButton
class="w-full! py-4! text-sm! font-semibold! rounded-lg! mt-2!
bg-primary! hover:bg-primary/90! dark:bg-primary-d! dark:hover:bg-primary-d/90!
transition-all duration-200"
:disabled="isLoading || form.processing"
:class="{ 'opacity-70 cursor-not-allowed': isLoading || form.processing }"
>
<span v-if="isLoading || form.processing" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Verificando...
</span>
<span v-else>
Acceder al Sistema
</span>
</PrimaryButton>
</form>
</div>
</template>

View File

@ -1,9 +1,270 @@
<script setup>
import PageHeader from '@Holos/PageHeader.vue';
import { ref, onMounted, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { formatCurrency } from '@/utils/formatters';
import reportService from '@Services/reportService';
import useCashRegister from '@Stores/cashRegister';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import OpenModal from '@/pages/POS/CashRegister/OpenModal.vue';
import Table from '@Holos/Table.vue';
// State
const router = useRouter();
const topProduct = ref(null);
const stagnantProducts = ref({ data: [], total: 0 });
const loadingTopProduct = ref(true);
const loadingStagnantProducts = ref(true);
const today = new Date();
const daysThreshold = new Date();
daysThreshold.setDate(today.getDate() - 30);
const filters = ref({
from_date: daysThreshold.toISOString().split('T')[0],
to_date: today.toISOString().split('T')[0],
});
// Methods
const cashRegisterStore = useCashRegister();
const showOpenModal = ref(false);
// Computed
const isCashRegisterOpen = computed(() => cashRegisterStore.hasOpenRegister);
const fetchTopProduct = async () => {
loadingTopProduct.value = true;
try {
const data = await reportService.getTopSellingProduct(
filters.value.from_date,
filters.value.to_date
);
topProduct.value = data.product;
} catch (error) {
window.Notify.error('Error al cargar el producto más vendido.');
} finally {
loadingTopProduct.value = false;
}
};
const fetchStagnantProducts = async (page = 1) => {
if (!filters.value.from_date || !filters.value.to_date) {
window.Notify.warning('Por favor selecciona ambas fechas para consultar el reporte.');
return;
}
loadingStagnantProducts.value = true;
try {
const data = await reportService.getProductsWithoutMovement(
filters.value.from_date,
filters.value.to_date,
true,
page
);
stagnantProducts.value = data.products || { data: [], total: 0 };
} catch (error) {
window.Notify.error('Error al cargar productos sin movimiento.');
stagnantProducts.value = { data: [], total: 0 };
} finally {
loadingStagnantProducts.value = false;
}
};
const handleGoToPOS = () => {
if (isCashRegisterOpen.value) {
router.push({ name: 'pos.point' });
} else {
showOpenModal.value = true;
}
};
const handleOpenCashRegister = async (initialCash) => {
const result = await cashRegisterStore.openRegister(initialCash);
if (result.success) {
Notify.success('Caja abierta exitosamente. Redirigiendo al punto de venta...');
showOpenModal.value = false;
router.push({ name: 'pos.point' });
} else {
Notify.error(result.error || 'Error al abrir la caja');
}
};
watch(filters, () => {
fetchTopProduct();
fetchStagnantProducts();
}, { deep: true });
// Lifecycle
onMounted(() => {
fetchTopProduct();
fetchStagnantProducts();
cashRegisterStore.loadCurrentRegister();
});
</script>
<template>
<PageHeader title="Dashboard" />
<p><b>{{ $t('welcome') }}</b>, {{ $page.user.name }}.</p>
<div class="p-6 bg-gray-50 dark:bg-gray-900 min-h-full">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3">
<GoogleIcon name="dashboard" class="text-4xl text-indigo-600" />
Dashboard
</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Resumen de ventas y rendimiento de productos.
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Columna Principal (2/3) -->
<div class="lg:col-span-2 space-y-8">
<!-- Producto más vendido -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3">
<GoogleIcon name="star" class="text-2xl text-yellow-500" />
Producto Estrella
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">El producto más vendido.</p>
</div>
<!-- Loading State -->
<div v-if="loadingTopProduct" class="p-12 flex flex-col items-center justify-center text-gray-500">
<GoogleIcon name="sync" class="text-5xl animate-spin mb-4" />
<p>Cargando producto...</p>
</div>
<!-- Empty State -->
<div v-else-if="!topProduct" class="p-12 flex flex-col items-center justify-center text-center">
<GoogleIcon name="sentiment_dissatisfied" class="text-6xl text-gray-300 dark:text-gray-600 mb-4" />
<h3 class="font-semibold text-gray-700 dark:text-gray-300">No hay datos de ventas</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Aún no se han registrado ventas para determinar un producto estrella.</p>
</div>
<!-- Content -->
<div v-else class="p-6 grid grid-cols-1 md:grid-cols-2 gap-6 items-center">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ topProduct.category_name }}</p>
<h3 class="text-2xl font-bold text-indigo-600 dark:text-indigo-400 mt-1">
{{ topProduct.name }}
</h3>
<p class="text-sm font-mono text-gray-500 dark:text-gray-400 mt-1">SKU: {{ topProduct.sku }}</p>
</div>
<div class="grid grid-cols-2 gap-4 text-center">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Unidades Vendidas</p>
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">{{ topProduct.total_quantity_sold }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Ingresos Totales</p>
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-1">{{ formatCurrency(topProduct.total_revenue) }}</p>
</div>
</div>
</div>
</div>
<!-- Productos sin movimiento -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3">
<GoogleIcon name="inventory_2" class="text-2xl text-orange-500" />
Inventario sin Rotación
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Productos sin movimiento en el rango de fechas seleccionado.</p>
<div class="pt-2 space-x-4 flex items-center">
<label class="text-sm font-medium text-gray-700">Fecha de inicio:</label>
<input
type="date"
v-model="filters.from_date"
@change="fetchStagnantProducts()"
class="px-2 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<label class="text-sm font-medium text-gray-700">Fecha de fin:</label>
<input
type="date"
v-model="filters.to_date"
@change="fetchStagnantProducts()"
class="px-2 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<!-- Loading State -->
<div v-if="loadingStagnantProducts" class="p-12 flex flex-col items-center justify-center text-gray-500">
<GoogleIcon name="sync" class="text-5xl animate-spin mb-4" />
<p>Cargando inventario...</p>
</div>
<!-- Table -->
<Table
:items="stagnantProducts"
:processing="loadingStagnantProducts"
@send-pagination="(page) => fetchStagnantProducts(page)"
>
<template #head>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Producto</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Stock</th>
</template>
<template #body="{items}">
<tr
v-for="product in items"
:key="product.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ product.name }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">{{ product.sku }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm font-bold" :class="product.stock > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-gray-500'">
{{ product.stock }}
</span>
</td>
</tr>
</template>
<template #empty>
<td colspan="2" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-12">
<GoogleIcon name="celebration" class="text-6xl text-green-400 mb-4" />
<h3 class="font-semibold text-gray-700 dark:text-gray-300">¡Excelente rotación!</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Todos los productos se han vendido en el período reciente.</p>
</div>
</td>
</template>
</Table>
</div>
</div>
<!-- Columna Lateral (1/3) -->
<div class="lg:col-span-1 space-y-8">
<!-- Quick Actions -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3 mb-4">
<GoogleIcon name="bolt" class="text-2xl text-indigo-500" />
Acciones Rápidas
</h2>
<div class="space-y-3">
<button @click="handleGoToPOS" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition-colors shadow-lg shadow-indigo-500/30">
<GoogleIcon name="point_of_sale" class="text-xl" />
<span>Ir al Punto de Venta</span>
</button>
<router-link :to="{ name: 'pos.inventory.index' }" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-800 dark:text-gray-200 font-semibold rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
<GoogleIcon name="inventory" class="text-xl" />
<span>Gestionar Inventario</span>
</router-link>
<router-link :to="{ name: 'pos.sales.index' }" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-800 dark:text-gray-200 font-semibold rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
<GoogleIcon name="receipt_long" class="text-xl" />
<span>Ver Historial de Ventas</span>
</router-link>
</div>
</div>
</div>
</div>
<!-- Modal para abrir caja -->
<OpenModal
:show="showOpenModal"
@close="showOpenModal = false"
@confirm="handleOpenCashRegister"
/>
</div>
</template>

View File

@ -0,0 +1,255 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useForm } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import { apiTo } from './Module.js';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import ProductSelector from './ProductSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Formulario */
const form = useForm({
name: '',
sku: '',
barcode: '',
items: [],
retail_price: '',
tax: ''
});
const selectedProducts = ref([]);
/** Computed */
const totalCost = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + ((item.product.price?.cost || 0) * item.quantity);
}, 0);
});
const suggestedPrice = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + ((item.product.price?.retail_price || 0) * item.quantity);
}, 0);
});
/** Métodos */
const handleProductSelect = (product) => {
if (selectedProducts.value.find(item => item.product.id === product.id)) {
Notify.warning('Este producto ya está agregado');
return;
}
selectedProducts.value.push({ product, quantity: 1 });
};
const removeProduct = (index) => {
selectedProducts.value.splice(index, 1);
};
const updateQuantity = (index, quantity) => {
if (quantity >= 1) {
selectedProducts.value[index].quantity = parseInt(quantity);
}
};
const createBundle = () => {
if (selectedProducts.value.length < 2) {
Notify.error('Debes agregar al menos 2 productos al paquete');
return;
}
form.items = selectedProducts.value.map(item => ({
inventory_id: item.product.id,
quantity: item.quantity
}));
form.post(apiTo('store'), {
onSuccess: () => {
Notify.success('Paquete creado exitosamente');
emit('created');
closeModal();
},
onError: () => {
Notify.error('Error al crear el paquete');
}
});
};
const closeModal = () => {
form.reset();
selectedProducts.value = [];
emit('close');
};
/** Observadores */
watch(() => props.show, (val) => {
if (val) {
form.reset();
selectedProducts.value = [];
}
});
</script>
<template>
<Modal :show="show" max-width="2xl" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Crear Paquete
</h3>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createBundle" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Ej: Kit gols Pro"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- SKU -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SKU
</label>
<FormInput
v-model="form.sku"
type="text"
placeholder="KIT-001"
required
/>
<FormError :message="form.errors?.sku" />
</div>
<!-- Código de barras -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CÓDIGO DE BARRAS
</label>
<FormInput
v-model="form.barcode"
type="text"
placeholder="Opcional"
/>
<FormError :message="form.errors?.barcode" />
</div>
<!-- Componentes -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
COMPONENTES <span class="text-gray-400 normal-case font-normal">(mínimo 2)</span>
</label>
<ProductSelector
:exclude-ids="selectedProducts.map(item => item.product.id)"
@select="handleProductSelect"
/>
<div v-if="selectedProducts.length > 0" class="mt-2 space-y-2">
<div
v-for="(item, index) in selectedProducts"
:key="item.product.id"
class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ item.product.name }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SKU: {{ item.product.sku }}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<span class="text-xs text-gray-500">Cant:</span>
<input
:value="item.quantity"
@input="updateQuantity(index, $event.target.value)"
type="number"
min="1"
class="w-16 px-2 py-1 text-center text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-indigo-500 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 min-w-20 text-right shrink-0">
{{ formatCurrency(item.product.price?.retail_price) }}
</span>
<button type="button" @click="removeProduct(index)" class="text-red-500 hover:text-red-700 shrink-0">
<GoogleIcon name="close" class="text-lg" />
</button>
</div>
</div>
<FormError :message="form.errors?.items" />
</div>
<!-- Precio de Venta -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PRECIO VENTA
</label>
<FormInput
v-model.number="form.retail_price"
type="number"
step="0.01"
min="0"
placeholder="0.00"
/>
<FormError :message="form.errors?.retail_price" />
</div>
<!-- Impuesto -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
IMPUESTO (%)
</label>
<FormInput
v-model.number="form.tax"
type="number"
step="0.01"
min="0"
placeholder="16.00"
/>
<FormError :message="form.errors?.tax" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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">Guardando...</span>
<span v-else>Guardar</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,88 @@
<script setup>
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
show: Boolean,
bundle: Object
});
const emit = defineEmits(['close', 'confirm']);
const handleConfirm = () => {
emit('confirm', props.bundle.id);
};
const handleClose = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @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-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Eliminar Paquete
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<GoogleIcon name="close" class="text-xl" />
</button>
</div>
<!-- Contenido -->
<div class="space-y-4">
<p class="text-gray-700 dark:text-gray-300">
¿Estás seguro de que deseas eliminar este paquete?
</p>
<!-- Información del bundle -->
<div v-if="bundle" class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p class="font-bold text-gray-900 dark:text-gray-100 mb-1">
{{ bundle.name }}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
SKU: {{ bundle.sku }}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ bundle.items?.length || 0 }} componentes
</p>
</div>
<!-- Advertencia -->
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0" />
<p class="text-sm text-red-800 dark:text-red-300">
Esta acción no afectará los productos individuales del inventario.
</p>
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<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
@click="handleConfirm"
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors"
>
<GoogleIcon name="delete" class="text-xl" />
Eliminar Paquete
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,270 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useForm } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import { apiTo } from './Module.js';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import ProductSelector from './ProductSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
bundle: Object
});
/** Formulario */
const form = useForm({
name: '',
sku: '',
barcode: '',
items: [],
retail_price: '',
tax: '',
recalculate_price: false
});
const selectedProducts = ref([]);
/** Computed */
const totalCost = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + ((item.product.price?.cost || 0) * item.quantity);
}, 0);
});
const suggestedPrice = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + ((item.product.price?.retail_price || 0) * item.quantity);
}, 0);
});
/** Métodos */
const handleProductSelect = (product) => {
if (selectedProducts.value.find(item => item.product.id === product.id)) {
Notify.warning('Este producto ya está agregado');
return;
}
selectedProducts.value.push({ product, quantity: 1 });
};
const removeProduct = (index) => {
selectedProducts.value.splice(index, 1);
};
const updateQuantity = (index, quantity) => {
if (quantity >= 1) {
selectedProducts.value[index].quantity = parseInt(quantity);
}
};
const useSuggestedPrice = () => {
form.retail_price = suggestedPrice.value.toFixed(2);
};
const updateBundle = () => {
if (selectedProducts.value.length < 2) {
Notify.error('Debes agregar al menos 2 productos al paquete');
return;
}
form.items = selectedProducts.value.map(item => ({
inventory_id: item.product.id,
quantity: item.quantity
}));
form.put(apiTo('update', { bundle: props.bundle.id }), {
onSuccess: () => {
Notify.success('Paquete actualizado exitosamente');
emit('updated');
closeModal();
},
onError: () => {
Notify.error('Error al actualizar el paquete');
}
});
};
const closeModal = () => {
form.reset();
selectedProducts.value = [];
emit('close');
};
/** Observadores */
watch(() => props.bundle, (bundle) => {
if (bundle) {
form.name = bundle.name || '';
form.sku = bundle.sku || '';
form.barcode = bundle.barcode || '';
form.retail_price = bundle.price?.retail_price || '';
form.tax = bundle.price?.tax || '';
form.recalculate_price = false;
selectedProducts.value = (bundle.items || []).map(item => ({
product: item.inventory,
quantity: item.quantity
}));
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="2xl" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Editar Paquete
</h3>
<button @click="closeModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="updateBundle" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Ej: Kit gols Pro"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- SKU -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SKU
</label>
<FormInput
v-model="form.sku"
type="text"
placeholder="KIT-001"
required
/>
<FormError :message="form.errors?.sku" />
</div>
<!-- Código de barras -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CÓDIGO DE BARRAS
</label>
<FormInput
v-model="form.barcode"
type="text"
placeholder="Opcional"
/>
<FormError :message="form.errors?.barcode" />
</div>
<!-- Componentes -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
COMPONENTES <span class="text-gray-400 normal-case font-normal">(mínimo 2)</span>
</label>
<ProductSelector
:exclude-ids="selectedProducts.map(item => item.product.id)"
@select="handleProductSelect"
/>
<div v-if="selectedProducts.length > 0" class="mt-2 space-y-2">
<div
v-for="(item, index) in selectedProducts"
:key="item.product.id"
class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{{ item.product.name }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SKU: {{ item.product.sku }}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<span class="text-xs text-gray-500">Cant:</span>
<input
:value="item.quantity"
@input="updateQuantity(index, $event.target.value)"
type="number"
min="1"
class="w-16 px-2 py-1 text-center text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-indigo-500 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 min-w-20 text-right shrink-0">
{{ formatCurrency(item.product.price?.retail_price) }}
</span>
<button type="button" @click="removeProduct(index)" class="text-red-500 hover:text-red-700 shrink-0">
<GoogleIcon name="close" class="text-lg" />
</button>
</div>
</div>
<FormError :message="form.errors?.items" />
</div>
<!-- Precio de Venta -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PRECIO VENTA
</label>
<FormInput
v-model.number="form.retail_price"
type="number"
step="0.01"
min="0"
placeholder="0.00"
/>
<FormError :message="form.errors?.retail_price" />
</div>
<!-- Impuesto -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
IMPUESTO (%)
</label>
<FormInput
v-model.number="form.tax"
type="number"
step="0.01"
min="0"
placeholder="16.00"
/>
<FormError :message="form.errors?.tax" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,177 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useSearcher } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import { can, apiTo } from './Module.js';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Create from './Create.vue';
import Edit from './Edit.vue';
import Delete from './Delete.vue';
/** Estado */
const bundles = ref({ data: [] });
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const bundleToEdit = ref(null);
const bundleToDelete = ref(null);
/** Buscador */
const searcher = useSearcher({
url: apiTo('index'),
onSuccess: (data) => {
bundles.value = data.bundles || { data: [] };
},
onError: () => {
Notify.error('Error al cargar los paquetes');
}
});
/** Métodos */
const openEditModal = (bundle) => {
bundleToEdit.value = bundle;
showEditModal.value = true;
};
const openDeleteModal = (bundle) => {
bundleToDelete.value = bundle;
showDeleteModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
bundleToEdit.value = null;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
bundleToDelete.value = null;
};
const handleDelete = (bundleId) => {
window.axios.delete(apiTo('destroy', { bundle: bundleId })).then(() => {
Notify.success('Paquete eliminado exitosamente');
closeDeleteModal();
searcher.refresh();
}).catch(() => {
Notify.error('Error al eliminar el paquete');
});
};
/** Ciclos */
onMounted(() => {
searcher.search('');
});
</script>
<template>
<div>
<SearcherHead
title="Paquetes / Kits"
placeholder="Buscar por nombre, SKU o código de barras..."
@search="(q) => searcher.search(q)"
>
<button
v-if="can('create')"
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="showCreateModal = true"
>
<GoogleIcon name="add" class="text-xl" />
Nuevo Paquete
</button>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="bundles"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOMBRE</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">SKU</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">COMPONENTES</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PAQ. ESTIMADO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRECIO</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="bundle in items"
:key="bundle.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ bundle.name }}</p>
<p v-if="bundle.barcode" class="text-xs text-gray-500 dark:text-gray-400">{{ bundle.barcode }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ bundle.sku }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ bundle.items?.length || 0 }} productos</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span
class="px-2 py-1 text-xs font-semibold rounded-full"
:class="bundle.available_stock > 0
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
>
{{ bundle.available_stock }} paq.
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ formatCurrency(bundle.price?.retail_price) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Costo: {{ formatCurrency(bundle.total_cost) }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
v-if="can('edit')"
@click="openEditModal(bundle)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Editar"
>
<GoogleIcon name="edit" class="text-xl" />
</button>
<button
v-if="can('destroy')"
@click="openDeleteModal(bundle)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Eliminar"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
</Table>
</div>
<!-- Modales -->
<Create
:show="showCreateModal"
@close="showCreateModal = false"
@created="showCreateModal = false; searcher.refresh()"
/>
<Edit
:show="showEditModal"
:bundle="bundleToEdit"
@close="closeEditModal"
@updated="closeEditModal(); searcher.refresh()"
/>
<Delete
:show="showDeleteModal"
:bundle="bundleToDelete"
@close="closeDeleteModal"
@confirm="handleDelete"
/>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API para bundles
const apiTo = (name, params = {}) => route(`bundles.${name}`, params)
// Ruta visual para bundles
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.bundles.${name}`, params, query })
// Usa permisos de inventario (no existen permisos específicos para bundles)
const can = (permission) => hasPermission(`bundles.${permission}`)
export {
can,
viewTo,
apiTo
}

View File

@ -0,0 +1,190 @@
<script setup>
import { ref, watch } from 'vue';
import { useApi, apiURL } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
excludeIds: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: 'Buscar producto por nombre o SKU...'
},
disabled: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'select']);
const api = useApi();
const productSuggestions = ref([]);
const showSuggestions = ref(false);
const searchingProduct = ref(false);
const productNotFound = ref(false);
let debounceTimer = null;
const localValue = ref(props.modelValue);
watch(() => props.modelValue, (newVal) => {
localValue.value = newVal;
});
watch(localValue, (newVal) => {
emit('update:modelValue', newVal);
onProductInput();
});
const onProductInput = () => {
productNotFound.value = false;
const searchValue = localValue.value?.trim();
if (!searchValue || searchValue.length < 2) {
productSuggestions.value = [];
showSuggestions.value = false;
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
showSuggestions.value = true;
searchProduct();
}, 300);
};
const searchProduct = () => {
const searchValue = localValue.value?.trim();
if (!searchValue) {
productSuggestions.value = [];
showSuggestions.value = false;
return;
}
searchingProduct.value = true;
productNotFound.value = false;
api.get(apiURL(`inventario?q=${encodeURIComponent(searchValue)}`), {
onSuccess: (data) => {
const foundProducts = data.products?.data || data.data || [];
// Filtrar productos ya agregados
const filteredProducts = foundProducts.filter(
p => !props.excludeIds.includes(p.id)
);
if (filteredProducts.length > 0) {
productSuggestions.value = filteredProducts;
showSuggestions.value = true;
} else {
productSuggestions.value = [];
showSuggestions.value = false;
productNotFound.value = true;
}
},
onFail: () => {
productSuggestions.value = [];
showSuggestions.value = false;
productNotFound.value = true;
},
onError: () => {
productSuggestions.value = [];
showSuggestions.value = false;
productNotFound.value = true;
},
onFinish: () => {
searchingProduct.value = false;
}
});
};
const selectProduct = (product) => {
emit('select', product);
localValue.value = '';
productSuggestions.value = [];
showSuggestions.value = false;
productNotFound.value = false;
};
const closeSuggestions = () => {
setTimeout(() => {
showSuggestions.value = false;
}, 200);
};
</script>
<template>
<div class="relative">
<div class="relative">
<input
v-model="localValue"
type="text"
:placeholder="placeholder"
:disabled="disabled"
@blur="closeSuggestions"
@keydown.enter.prevent="searchProduct"
class="w-full px-3 py-2 pr-10 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<GoogleIcon
v-if="searchingProduct"
name="hourglass_empty"
class="text-gray-400 text-lg animate-spin"
/>
<GoogleIcon
v-else
name="search"
class="text-gray-400 text-lg"
/>
</div>
</div>
<!-- Sugerencias -->
<div
v-if="showSuggestions && productSuggestions.length > 0"
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto"
>
<button
v-for="product in productSuggestions"
:key="product.id"
@click="selectProduct(product)"
class="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border-b border-gray-200 dark:border-gray-700 last:border-b-0"
>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ product.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
SKU: {{ product.sku }} | Stock: {{ product.stock }}
</p>
</div>
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">Precio</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(product.price?.retail_price) }}
</p>
</div>
</div>
</button>
</div>
<!-- Mensaje de no encontrado -->
<div
v-if="productNotFound && !searchingProduct"
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg p-4"
>
<div class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
<GoogleIcon name="search_off" class="text-xl" />
<p class="text-sm">No se encontraron productos</p>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,357 @@
<script setup>
import { ref, computed } from 'vue';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
cashRegister: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Estado */
const finalCash = ref(0);
const notes = ref('');
const loading = ref(false);
/** Computed */
const initialCash = computed(() => parseFloat(props.cashRegister?.initial_cash || 0));
const totalSales = computed(() => parseFloat(props.cashRegister?.total_sales || 0));
const cashSales = computed(() => parseFloat(props.cashRegister?.cash_sales || 0));
const cardSales = computed(() => parseFloat(props.cashRegister?.card_sales || 0));
const transactionCount = computed(() => parseInt(props.cashRegister?.transaction_count || 0));
// Devoluciones
const totalReturns = computed(() => parseFloat(props.cashRegister?.total_returns || 0));
const cashReturns = computed(() => parseFloat(props.cashRegister?.cash_returns || 0));
const cardReturns = computed(() => parseFloat(props.cashRegister?.card_returns || 0));
const hasReturns = computed(() => totalReturns.value > 0);
// Ventas netas (ventas - devoluciones)
const netSales = computed(() => totalSales.value - totalReturns.value);
const netCashSales = computed(() => cashSales.value - cashReturns.value);
const expectedCash = computed(() => initialCash.value + netCashSales.value);
const difference = computed(() => finalCash.value - expectedCash.value);
const hasDifference = computed(() => Math.abs(difference.value) > 0.01);
const totalCashReceived = computed(() =>
parseFloat(props.cashRegister?.total_cash_received || 0)
);
const totalChangeGiven = computed(() =>
parseFloat(props.cashRegister?.total_change_given || 0)
);
/** Métodos */
const handleSubmit = () => {
emit('confirm', {
final_cash: finalCash.value,
notes: notes.value
});
};
const handleClose = () => {
finalCash.value = 0;
notes.value = '';
loading.value = false;
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="2xl" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-orange-100 dark:bg-orange-900/30">
<GoogleIcon name="lock" class="text-2xl text-orange-600 dark:text-orange-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Cerrar Caja Registradora - Corte de Caja
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Resumen de Ventas -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Total Ventas -->
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="shopping_cart" class="text-blue-600 dark:text-blue-400" />
<p class="text-xs font-semibold text-blue-700 dark:text-blue-300 uppercase">Total Ventas</p>
</div>
<p class="text-2xl font-bold text-blue-900 dark:text-blue-100">
${{ (totalSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
{{ transactionCount }} transacciones
</p>
</div>
<!-- Ventas Efectivo -->
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 rounded-xl p-4">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="payments" class="text-green-600 dark:text-green-400" />
<p class="text-xs font-semibold text-green-700 dark:text-green-300 uppercase">Efectivo</p>
</div>
<p class="text-2xl font-bold text-green-900 dark:text-green-100">
${{ (cashSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
<p class="text-xs text-green-600 dark:text-green-400 mt-1">
Ventas en efectivo
</p>
</div>
<!-- Ventas Tarjeta -->
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="credit_card" class="text-purple-600 dark:text-purple-400" />
<p class="text-xs font-semibold text-purple-700 dark:text-purple-300 uppercase">Tarjeta</p>
</div>
<p class="text-2xl font-bold text-purple-900 dark:text-purple-100">
${{ (cardSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">
Ventas con tarjeta
</p>
</div>
</div>
<!-- Devoluciones (si existen) -->
<div v-if="hasReturns" class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-xl p-4">
<div class="flex items-center gap-2 mb-3">
<GoogleIcon name="assignment_return" class="text-orange-600 dark:text-orange-400" />
<h4 class="text-sm font-bold text-orange-900 dark:text-orange-100">
Devoluciones
</h4>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<p class="text-xs text-orange-700 dark:text-orange-300 uppercase mb-1">Total Devoluciones</p>
<p class="text-lg font-bold text-orange-900 dark:text-orange-100">
-${{ (totalReturns || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
</div>
<div>
<p class="text-xs text-orange-700 dark:text-orange-300 uppercase mb-1">Efectivo</p>
<p class="text-lg font-bold text-orange-900 dark:text-orange-100">
-${{ (cashReturns || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
</div>
<div>
<p class="text-xs text-orange-700 dark:text-orange-300 uppercase mb-1">Tarjeta</p>
<p class="text-lg font-bold text-orange-900 dark:text-orange-100">
-${{ (cardReturns || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
</div>
</div>
<div class="mt-3 pt-3 border-t border-orange-300 dark:border-orange-700">
<div class="flex justify-between items-center">
<span class="text-sm font-semibold text-orange-900 dark:text-orange-100">Ventas Netas:</span>
<span class="text-xl font-bold text-orange-900 dark:text-orange-100">
${{ (netSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
</div>
</div>
<!-- Cálculo de Efectivo -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-5 space-y-4">
<h4 class="text-sm font-bold text-gray-900 dark:text-gray-100">
Resumen de Efectivo
</h4>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Efectivo Inicial:</span>
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">
${{ (initialCash || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
<!-- Desglose de flujo de efectivo -->
<div class="pl-4 border-l-2 border-green-500 space-y-2">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 dark:text-gray-400">Efectivo Recibido:</span>
<span class="text-green-600 dark:text-green-400 font-semibold">
+ ${{ (totalCashReceived || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-600 dark:text-gray-400">Cambio Devuelto:</span>
<span class="text-red-600 dark:text-red-400 font-semibold">
- ${{ (totalChangeGiven || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
</div>
<div class="flex justify-between items-center pt-2 border-t border-gray-300">
<span class="text-sm text-gray-600 dark:text-gray-400">Ventas en Efectivo:</span>
<span class="text-base font-semibold text-green-600 dark:text-green-400">
= ${{ (cashSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
<!-- Devoluciones en Efectivo -->
<div v-if="hasReturns" class="flex justify-between items-center text-sm">
<span class="text-gray-600 dark:text-gray-400">Devoluciones en Efectivo:</span>
<span class="text-orange-600 dark:text-orange-400 font-semibold">
- ${{ (cashReturns || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
<!-- Ventas Netas en Efectivo -->
<div v-if="hasReturns" class="flex justify-between items-center pt-2 border-t border-gray-300">
<span class="text-sm font-bold text-gray-900 dark:text-gray-100">Ventas Netas en Efectivo:</span>
<span class="text-base font-bold text-green-600 dark:text-green-400">
= ${{ (netCashSales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
</div>
<div class="border-t-2 border-gray-300 dark:border-gray-600 pt-3">
<div class="flex justify-between items-center">
<span class="text-base font-bold text-gray-900 dark:text-gray-100">
Efectivo Esperado:
</span>
<span class="text-xl font-bold text-blue-600 dark:text-blue-400">
${{ (expectedCash || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
</div>
</div>
<!-- Ingresar Efectivo Final -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-2">
Efectivo Real en Caja *
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<span class="text-gray-500 dark:text-gray-400 text-lg font-semibold">$</span>
</div>
<input
v-model.number="finalCash"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full pl-7 pr-4 py-2.5 text-lg font-semibold border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
required
autofocus
/>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
Cuenta el efectivo real que tienes en caja ahora
</p>
</div>
<!-- Diferencia -->
<div v-if="finalCash > 0" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Diferencia</p>
<p
class="text-3xl font-bold"
:class="{
'text-green-600 dark:text-green-400': difference > 0,
'text-red-600 dark:text-red-400': difference < 0,
'text-gray-600 dark:text-gray-400': difference === 0
}"
>
{{ difference >= 0 ? '+' : '' }}${{ Math.abs(difference || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
</div>
<div class="text-right">
<div v-if="!hasDifference" class="flex items-center gap-2 text-green-600 dark:text-green-400">
<GoogleIcon name="check_circle" class="text-3xl" />
<span class="text-sm font-semibold">Cuadra</span>
</div>
<div v-else-if="difference > 0" class="flex items-center gap-2 text-green-600 dark:text-green-400">
<GoogleIcon name="trending_up" class="text-3xl" />
<span class="text-sm font-semibold">Sobrante</span>
</div>
<div v-else class="flex items-center gap-2 text-red-600 dark:text-red-400">
<GoogleIcon name="trending_down" class="text-3xl" />
<span class="text-sm font-semibold">Faltante</span>
</div>
</div>
</div>
</div>
<!-- Advertencia si hay diferencia -->
<div v-if="hasDifference && finalCash > 0" class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<div class="flex items-start gap-2">
<GoogleIcon name="warning" class="text-orange-600 dark:text-orange-400 text-xl shrink-0 mt-0.5" />
<div>
<p class="text-sm text-orange-800 dark:text-orange-300 font-medium">
Se detectó una diferencia en el efectivo
</p>
<p class="text-xs text-orange-700 dark:text-orange-400 mt-1">
Por favor verifica el conteo antes de cerrar la caja. Puedes agregar una nota explicativa abajo.
</p>
</div>
</div>
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-2">
Notas / Observaciones
</label>
<textarea
v-model="notes"
rows="3"
maxlength="500"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 dark:bg-gray-800 dark:text-gray-100 resize-none"
placeholder="Agrega cualquier observación sobre el turno (opcional)"
></textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 text-right">
{{ notes.length }}/500 caracteres
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-5 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
>
Cancelar
</button>
<button
type="submit"
:disabled="loading || finalCash <= 0"
class="flex items-center gap-2 px-5 py-2.5 bg-orange-600 hover:bg-orange-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-600 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-orange-600/30 transition-all"
>
<GoogleIcon name="lock" class="text-xl" />
<span v-if="loading">Cerrando...</span>
<span v-else>Cerrar Caja</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,399 @@
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import cashRegisterService from '@Services/cashRegisterService';
import salesService from '@Services/salesService';
import ticketService from '@Services/ticketService';
import { formatCurrency, formatDate, safeParseFloat, PAYMENT_METHODS } from '@/utils/formatters';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SaleDetailModal from '@Pages/POS/Sales/DetailModal.vue';
/** Router */
const route = useRoute();
const router = useRouter();
/** Estado */
const cashRegister = ref(null);
const sales = ref([]);
const loading = ref(true);
const showSaleModal = ref(false);
const selectedSale = ref(null);
/** Computed */
const getSalesByMethod = (method) => {
return sales.value
.filter(sale => sale.status === 'completed' && sale.payment_method === method)
.reduce((sum, sale) => sum + safeParseFloat(sale.total), 0);
};
const totalCashSales = computed(() => getSalesByMethod('cash'));
const totalCreditCard = computed(() => getSalesByMethod('credit_card'));
const totalDebitCard = computed(() => getSalesByMethod('debit_card'));
const totalCardSales = computed(() => totalCreditCard.value + totalDebitCard.value);
const difference = computed(() => {
if (!cashRegister.value) return 0;
// Si ya se calculó en loadData, usar ese valor
if (cashRegister.value.difference !== undefined) {
return safeParseFloat(cashRegister.value.difference);
}
// Fallback: calcular aquí
const finalCash = safeParseFloat(cashRegister.value.final_cash);
const initialCash = safeParseFloat(cashRegister.value.initial_cash);
const expectedCash = initialCash + totalCashSales.value;
return finalCash - expectedCash;
});
/** Métodos */
const loadData = async () => {
loading.value = true;
try {
// Cargar datos del corte de caja
const registerData = await cashRegisterService.getCashRegisterDetail(route.params.id);
cashRegister.value = registerData;
// Cargar ventas del período (filtradas por cash_register_id en el backend)
const salesData = await salesService.getSales({
cash_register_id: route.params.id
});
const completedSales = salesData.data.filter(sale => sale.status === 'completed');
sales.value = salesData.data || []; // Muestra todas las ventas
// Calcula solo con ventas completadas
const cashSales = completedSales
.filter(s => s.payment_method === 'cash')
.reduce((sum, s) => sum + safeParseFloat(s.total), 0);
const expectedCash = safeParseFloat(cashRegister.value.initial_cash) + cashSales;
const calculatedDifference = safeParseFloat(cashRegister.value.final_cash) - expectedCash;
// Actualiza los valores calculados
cashRegister.value.expected_cash = expectedCash;
cashRegister.value.difference = calculatedDifference;
} catch (error) {
window.Notify.error('Error al cargar el detalle del corte');
} finally {
loading.value = false;
}
};
const goBack = () => {
router.push({ name: 'pos.cashRegister.history' });
};
const openSaleDetail = async (sale) => {
try {
const saleData = await salesService.getSaleDetails(sale.id);
selectedSale.value = saleData;
showSaleModal.value = true;
} catch (error) {
window.Notify.error('Error al cargar el detalle de la venta');
}
};
const closeSaleModal = () => {
showSaleModal.value = false;
selectedSale.value = null;
};
const downloadTicket = () => {
try {
ticketService.generateCashRegisterTicket(cashRegister.value, {
businessName: 'HIKVISION DISTRIBUIDOR',
autoDownload: true
});
window.Notify.success('Ticket de corte descargado');
} catch (error) {
window.Notify.error('Error al generar el ticket');
}
};
const getDifferenceColor = () => {
const diff = difference.value;
if (Math.abs(diff) < 0.01) return 'text-gray-600 dark:text-gray-400';
return diff > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
};
const getDifferenceIcon = () => {
const diff = difference.value;
if (Math.abs(diff) < 0.01) return 'check_circle';
return diff > 0 ? 'trending_up' : 'trending_down';
};
const getPaymentMethodLabel = (method) => {
return PAYMENT_METHODS[method] || method;
};
const getPaymentMethodIcon = (method) => {
const icons = {
cash: 'payments',
credit_card: 'credit_card',
debit_card: 'credit_card'
};
return icons[method] || 'payment';
};
/** Ciclo */
onMounted(() => {
loadData();
});
</script>
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<!-- Header -->
<div class="max-w-7xl mx-auto mb-6">
<div class="flex items-center justify-between flex-wrap gap-4">
<div>
<button
@click="goBack"
class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 mb-2 transition-colors"
>
<GoogleIcon name="arrow_back" class="text-lg" />
Volver al historial
</button>
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3">
<GoogleIcon name="receipt_long" class="text-4xl text-indigo-600" />
Detalle de Corte de Caja
</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Caja #{{ route.params.id }}
</p>
</div>
<button
v-if="!loading && cashRegister"
@click="downloadTicket"
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg shadow-md transition-colors"
>
<GoogleIcon name="download" class="text-xl" />
Descargar Ticket
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="max-w-7xl mx-auto">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-12">
<div class="flex flex-col items-center justify-center">
<GoogleIcon name="sync" class="text-6xl text-gray-400 animate-spin mb-4" />
<p class="text-gray-600 dark:text-gray-400">Cargando información...</p>
</div>
</div>
</div>
<!-- Content -->
<div v-else-if="cashRegister" class="max-w-7xl mx-auto space-y-6">
<!-- Información del Corte -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6 flex items-center gap-2">
<GoogleIcon name="info" class="text-2xl text-indigo-600" />
Información del Corte
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Cajero -->
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Cajero</p>
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ cashRegister.user?.name || 'N/A' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ cashRegister.user?.email || '' }}
</p>
</div>
<!-- Apertura -->
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Apertura</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatDate(cashRegister.opened_at, { includeTime: true }) }}
</p>
</div>
<!-- Cierre -->
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Cierre</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatDate(cashRegister.closed_at, { includeTime: true }) }}
</p>
</div>
</div>
<!-- Notas -->
<div v-if="cashRegister.notes" class="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-xs font-semibold text-yellow-800 dark:text-yellow-300 mb-1">Notas del Cierre</p>
<p class="text-sm text-yellow-700 dark:text-yellow-400">{{ cashRegister.notes }}</p>
</div>
</div>
<!-- Resumen Financiero -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Efectivo Inicial -->
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border border-blue-200 dark:border-blue-800 rounded-xl p-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white">
<GoogleIcon name="account_balance_wallet" class="text-2xl" />
</div>
<div>
<p class="text-xs font-semibold text-blue-700 dark:text-blue-300 uppercase">Inicial</p>
<p class="text-2xl font-bold text-blue-900 dark:text-blue-100">
{{ formatCurrency(cashRegister.initial_cash) }}
</p>
</div>
</div>
</div>
<!-- Efectivo (Ventas) -->
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 rounded-xl p-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center text-white">
<GoogleIcon name="payments" class="text-2xl" />
</div>
<div>
<p class="text-xs font-semibold text-green-700 dark:text-green-300 uppercase">Efectivo</p>
<p class="text-2xl font-bold text-green-900 dark:text-green-100">
{{ formatCurrency(totalCashSales) }}
</p>
</div>
</div>
<p class="text-xs text-green-600 dark:text-green-400">Ventas en efectivo</p>
</div>
<!-- Tarjetas -->
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border border-purple-200 dark:border-purple-800 rounded-xl p-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center text-white">
<GoogleIcon name="credit_card" class="text-2xl" />
</div>
<div>
<p class="text-xs font-semibold text-purple-700 dark:text-purple-300 uppercase">Tarjetas</p>
<p class="text-2xl font-bold text-purple-900 dark:text-purple-100">
{{ formatCurrency(totalCardSales) }}
</p>
</div>
</div>
<p class="text-xs text-purple-600 dark:text-purple-400">
Crédito: {{ formatCurrency(totalCreditCard) }} | Débito: {{ formatCurrency(totalDebitCard) }}
</p>
</div>
<!-- Diferencia -->
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900/20 dark:to-gray-800/20 border border-gray-200 dark:border-gray-700 rounded-xl p-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-12 h-12 bg-gray-500 rounded-lg flex items-center justify-center text-white">
<GoogleIcon :name="getDifferenceIcon()" class="text-2xl" />
</div>
<div>
<p class="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">Diferencia</p>
<p class="text-2xl font-bold" :class="getDifferenceColor()">
{{ difference >= 0 ? '+' : '' }}{{ formatCurrency(difference) }}
</p>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400">
{{ Math.abs(difference) < 0.01 ? 'Cuadra exacto' : (difference > 0 ? 'Sobrante' : 'Faltante') }}
</p>
</div>
</div>
<!-- Tabla de Ventas -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
Ventas Realizadas
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">
({{ sales.length }} transacciones)
</span>
</h2>
</div>
<div v-if="sales.length === 0" class="p-12 text-center">
<GoogleIcon name="receipt_long" class="text-6xl text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-500 dark:text-gray-400">No hay ventas registradas en este período</p>
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Folio</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Hora</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Método</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Estado</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Total</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Acciones</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="sale in sales"
:key="sale.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
{{ sale.invoice_number || `#${String(sale.id).padStart(6, '0')}` }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ new Date(sale.created_at).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit'
}) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center gap-2">
<GoogleIcon
:name="getPaymentMethodIcon(sale.payment_method)"
class="text-gray-500 dark:text-gray-400"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ getPaymentMethodLabel(sale.payment_method) }}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': sale.status === 'completed',
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400': sale.status === 'cancelled'
}"
>
{{ sale.status === 'completed' ? 'Completada' : 'Cancelada' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm font-bold text-gray-900 dark:text-gray-100">
{{ formatCurrency(sale.total) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<button
@click="openSaleDetail(sale)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Ver detalle"
>
<GoogleIcon name="visibility" class="text-xl" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Modal de Detalle de Venta -->
<SaleDetailModal
:show="showSaleModal"
:sale="selectedSale"
@close="closeSaleModal"
/>
</div>
</template>

View File

@ -0,0 +1,236 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import cashRegisterService from '@Services/cashRegisterService';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
/** Router */
const router = useRouter();
/** Estado */
const cashRegisters = ref({
data: [],
total: 0
});
const loading = ref(false);
const searchQuery = ref('');
/** Métodos */
const loadHistory = async (query = '') => {
loading.value = true;
try {
const response = await cashRegisterService.getCashRegisterHistory({
search: query
});
// El servicio devuelve el objeto de paginación completo
if (response && response.data) {
cashRegisters.value = response;
} else {
cashRegisters.value = {
data: [],
total: 0
};
}
} catch (error) {
window.Notify.error('Error al cargar el historial');
cashRegisters.value = {
data: [],
total: 0
};
} finally {
loading.value = false;
}
};
const search = (query) => {
searchQuery.value = query;
loadHistory(query);
};
const goBack = () => {
router.push({ name: 'pos.cashRegister.index' });
};
const viewDetail = (cashRegister) => {
router.push({
name: 'pos.cashRegister.detail',
params: { id: cashRegister.id }
});
};
const getStatusColor = (status) => {
return status === 'closed' ? 'text-gray-600' : 'text-green-600';
};
const getStatusBadge = (status) => {
return status === 'closed'
? 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
};
const getDifferenceColor = (difference) => {
const diff = parseFloat(difference || 0);
if (Math.abs(diff) < 0.01) return 'text-gray-600 dark:text-gray-400';
return diff > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
};
/** Ciclos */
onMounted(() => {
loadHistory();
});
</script>
<template>
<div>
<SearcherHead
title="Historial de Cajas"
placeholder="Buscar por usuario o fecha..."
@search="search"
>
<button
@click="goBack"
class="flex items-center gap-2 px-3 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
>
<GoogleIcon name="arrow_back" class="text-xl" />
Volver a Caja
</button>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="cashRegisters"
:processing="loading"
>
<template #head>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Usuario</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Apertura</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Cierre</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Inicial</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Ventas</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Diferencia</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Estado</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Acciones</th>
</template>
<template #body="{items}">
<tr
v-for="register in items"
:key="register.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">
#{{ register.id }}
</span>
</td>
<td class="px-6 py-4">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ register.user?.name || 'N/A' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ register.user?.email || '' }}
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm text-gray-900 dark:text-gray-100">
{{ new Date(register.opened_at).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ new Date(register.opened_at).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit'
}) }}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p v-if="register.closed_at" class="text-sm text-gray-900 dark:text-gray-100">
{{ new Date(register.closed_at).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) }}
</p>
<p v-if="register.closed_at" class="text-xs text-gray-500 dark:text-gray-400">
{{ new Date(register.closed_at).toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit'
}) }}
</p>
<p v-else class="text-sm text-green-600 dark:text-green-400 font-medium">
Abierta
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
${{ parseFloat(register.initial_cash || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<p class="text-sm font-bold text-blue-600 dark:text-blue-400">
${{ parseFloat(register.total_sales || 0).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<p
v-if="register.status === 'closed'"
class="text-sm font-bold"
:class="getDifferenceColor(register.difference)"
>
{{ parseFloat(register.difference || 0) >= 0 ? '+' : '' }}${{ Math.abs(parseFloat(register.difference || 0)).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
<p v-else class="text-sm text-gray-400 dark:text-gray-600">
-
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
:class="getStatusBadge(register.status)"
>
{{ register.status === 'closed' ? 'Cerrada' : 'Abierta' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<button
v-if="register.status === 'closed'"
@click="viewDetail(register)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Ver detalle"
>
<GoogleIcon name="visibility" class="text-xl" />
</button>
<span v-else class="text-gray-400 dark:text-gray-600">
<GoogleIcon name="lock_open" class="text-xl" />
</span>
</td>
</tr>
</template>
<template #empty>
<td colspan="9" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="history"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
No hay registros de cajas
</p>
<p class="text-sm text-gray-400 mt-1">
Abre una caja para comenzar a registrar transacciones
</p>
</div>
</td>
</template>
</Table>
</div>
</div>
</template>

View File

@ -0,0 +1,355 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import useCashRegister from '@Stores/cashRegister';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import OpenModal from './OpenModal.vue';
import CloseModal from './CloseModal.vue';
/** Router */
const router = useRouter();
/** Store */
const cashRegisterStore = useCashRegister();
/** Estado */
const showOpenModal = ref(false);
const showCloseModal = ref(false);
const refreshing = ref(false);
/** Computed */
const isOpen = computed(() => cashRegisterStore.hasOpenRegister);
const currentRegister = computed(() => cashRegisterStore.currentRegister);
const loading = computed(() => cashRegisterStore.loading);
/** M\u00e9todos */
const openCashRegister = async (initialCash) => {
const result = await cashRegisterStore.openRegister(initialCash);
if (result.success) {
Notify.success('Caja abierta exitosamente');
showOpenModal.value = false;
} else {
Notify.error(result.error || 'Error al abrir la caja');
}
};
const closeCashRegister = async (closeData) => {
const result = await cashRegisterStore.closeRegister(closeData);
if (result.success) {
Notify.success('Caja cerrada exitosamente - Corte generado');
showCloseModal.value = false;
// Opcional: Redirigir al historial o mostrar el resumen
setTimeout(() => {
router.push({ name: 'pos.cashRegister.history' });
}, 1500);
} else {
Notify.error(result.error || 'Error al cerrar la caja');
}
};
const refreshData = async () => {
refreshing.value = true;
await cashRegisterStore.refreshCurrentRegister();
refreshing.value = false;
};
const goToHistory = () => {
router.push({ name: 'pos.cashRegister.history' });
};
const goToPoint = () => {
router.push({ name: 'pos.point' });
};
/** Ciclos */
onMounted(async () => {
await cashRegisterStore.loadCurrentRegister();
});
</script>
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<!-- Header -->
<div class="max-w-7xl mx-auto mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3">
<GoogleIcon name="point_of_sale" class="text-4xl" />
Caja Registradora
</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Gestión de apertura y cierre de caja
</p>
</div>
<div class="flex items-center gap-3">
<button
@click="refreshData"
:disabled="refreshing"
class="flex items-center gap-2 px-4 py-2 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"
title="Actualizar"
>
<GoogleIcon :name="refreshing ? 'sync' : 'refresh'" :class="{ 'animate-spin': refreshing }" />
</button>
<button
@click="goToHistory"
class="flex items-center gap-2 px-4 py-2 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"
>
<GoogleIcon name="history" />
Historial
</button>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading && !currentRegister" class="max-w-7xl mx-auto">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-12">
<div class="flex flex-col items-center justify-center">
<GoogleIcon name="sync" class="text-6xl text-gray-400 animate-spin mb-4" />
<p class="text-gray-600 dark:text-gray-400">Cargando información de caja...</p>
</div>
</div>
</div>
<!-- Caja Cerrada - Mostrar opción para abrir -->
<div v-else-if="!isOpen" class="max-w-4xl mx-auto">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Estado -->
<div class="bg-gradient-to-r from-red-500 to-red-600 p-6">
<div class="flex items-center gap-4 text-white">
<div class="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center">
<GoogleIcon name="lock" class="text-4xl" />
</div>
<div>
<h2 class="text-2xl font-bold">Caja Cerrada</h2>
<p class="text-red-100 mt-1">No hay ninguna caja abierta actualmente</p>
</div>
</div>
</div>
<!-- Contenido -->
<div class="p-8">
<div class="text-center mb-8">
<GoogleIcon name="info" class="text-6xl text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
Necesitas abrir la caja para comenzar
</h3>
<p class="text-gray-600 dark:text-gray-400">
Al abrir la caja podrás registrar ventas y realizar transacciones.
Ingresa el monto inicial en efectivo para comenzar tu turno.
</p>
</div>
<div class="flex justify-center gap-4">
<button
@click="goToHistory"
class="flex items-center gap-2 px-6 py-3 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<GoogleIcon name="history" class="text-xl" />
Ver Historial
</button>
<button
@click="showOpenModal = true"
class="flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg shadow-lg shadow-green-600/30 transition-all"
>
<GoogleIcon name="lock_open" class="text-xl" />
Abrir Caja
</button>
</div>
</div>
</div>
</div>
<!-- Caja Abierta - Mostrar resumen -->
<div v-else class="max-w-7xl mx-auto space-y-6">
<!-- Estado Actual -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-green-500 to-green-600 p-6">
<div class="flex items-center justify-between text-white">
<div class="flex items-center gap-4">
<div class="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center">
<GoogleIcon name="lock_open" class="text-4xl" />
</div>
<div>
<h2 class="text-2xl font-bold">Caja Abierta</h2>
<p class="text-green-100 mt-1">Operando normalmente</p>
</div>
</div>
<div class="text-right">
<p class="text-green-100 text-sm">Abierta por</p>
<p class="text-xl font-semibold">{{ cashRegisterStore.openedBy }}</p>
</div>
</div>
</div>
<!-- Información de la Caja -->
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Información General -->
<div class="space-y-4">
<h3 class="text-sm font-bold text-gray-900 dark:text-gray-100 uppercase tracking-wide border-b border-gray-200 dark:border-gray-700 pb-2">
Información General
</h3>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">ID de Caja:</span>
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">
#{{ cashRegisterStore.currentRegisterId }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Fecha de Apertura:</span>
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">
{{ new Date(cashRegisterStore.openedAt).toLocaleString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}) }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Efectivo Inicial:</span>
<span class="text-lg font-bold text-green-600 dark:text-green-400">
${{ cashRegisterStore.initialCash.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
</div>
</div>
<!-- Estadísticas de Ventas -->
<div class="space-y-4">
<h3 class="text-sm font-bold text-gray-900 dark:text-gray-100 uppercase tracking-wide border-b border-gray-200 dark:border-gray-700 pb-2">
Resumen de Ventas
</h3>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Total de Transacciones:</span>
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">
{{ cashRegisterStore.transactionCount }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Total Vendido:</span>
<span class="text-lg font-bold text-blue-600 dark:text-blue-400">
${{ cashRegisterStore.totalSales.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Balance Estimado:</span>
<span class="text-xl font-bold text-green-600 dark:text-green-400">
${{ cashRegisterStore.currentBalance.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tarjetas de Resumen -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Ventas en Efectivo -->
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 rounded-xl p-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center text-white">
<GoogleIcon name="payments" class="text-2xl" />
</div>
<div>
<p class="text-xs font-semibold text-green-700 dark:text-green-300 uppercase">Efectivo</p>
<p class="text-2xl font-bold text-green-900 dark:text-green-100">
${{ cashRegisterStore.expectedCash.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
</div>
</div>
<p class="text-xs text-green-600 dark:text-green-400">
Efectivo esperado en caja
</p>
</div>
<!-- Ventas con Tarjeta -->
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border border-purple-200 dark:border-purple-800 rounded-xl p-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center text-white">
<GoogleIcon name="credit_card" class="text-2xl" />
</div>
<div>
<p class="text-xs font-semibold text-purple-700 dark:text-purple-300 uppercase">Tarjeta</p>
<p class="text-2xl font-bold text-purple-900 dark:text-purple-100">
${{ cashRegisterStore.cardSales.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
</div>
</div>
<p class="text-xs text-purple-600 dark:text-purple-400">
Pagos con tarjeta
</p>
</div>
<!-- Total General -->
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border border-blue-200 dark:border-blue-800 rounded-xl p-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white">
<GoogleIcon name="shopping_cart" class="text-2xl" />
</div>
<div>
<p class="text-xs font-semibold text-blue-700 dark:text-blue-300 uppercase">Total</p>
<p class="text-2xl font-bold text-blue-900 dark:text-blue-100">
${{ cashRegisterStore.totalSales.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</p>
</div>
</div>
<p class="text-xs text-blue-600 dark:text-blue-400">
{{ cashRegisterStore.transactionCount }} transacciones
</p>
</div>
</div>
<!-- Acciones -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">Acciones Rápidas</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
@click="goToPoint"
class="flex items-center justify-center gap-2 px-6 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg shadow-md transition-colors"
>
<GoogleIcon name="storefront" class="text-2xl" />
<span>Ir al Punto de Venta</span>
</button>
<button
@click="goToHistory"
class="flex items-center justify-center gap-2 px-6 py-4 bg-gray-600 hover:bg-gray-700 text-white font-semibold rounded-lg shadow-md transition-colors"
>
<GoogleIcon name="history" class="text-2xl" />
<span>Ver Historial</span>
</button>
<button
@click="showCloseModal = true"
class="flex items-center justify-center gap-2 px-6 py-4 bg-orange-600 hover:bg-orange-700 text-white font-semibold rounded-lg shadow-md transition-colors"
>
<GoogleIcon name="lock" class="text-2xl" />
<span>Cerrar Caja</span>
</button>
</div>
</div>
</div>
<!-- Modales -->
<OpenModal
:show="showOpenModal"
@close="showOpenModal = false"
@confirm="openCashRegister"
/>
<CloseModal
:show="showCloseModal"
:cash-register="currentRegister"
@close="showCloseModal = false"
@confirm="closeCashRegister"
/>
</div>
</template>

View File

@ -0,0 +1,161 @@
<script setup>
import { ref } from 'vue';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Estado */
const initialCash = ref(0);
const loading = ref(false);
/** Métodos */
const handleSubmit = () => {
if (initialCash.value < 0) {
Notify.error('El monto inicial no puede ser negativo');
return;
}
emit('confirm', initialCash.value);
};
const handleClose = () => {
initialCash.value = 0;
loading.value = false;
emit('close');
};
</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-14 h-14 rounded-full bg-green-100 dark:bg-green-900/30">
<GoogleIcon name="point_of_sale" class="text-3xl text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
Abrir Caja Registradora
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Inicio de turno</p>
</div>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<form @submit.prevent="handleSubmit" class="space-y-6">
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex items-start gap-3">
<GoogleIcon name="info" class="text-blue-600 dark:text-blue-400 text-2xl shrink-0" />
<div class="flex-1">
<p class="text-sm text-blue-800 dark:text-blue-300 font-semibold mb-1">
Inicio de Turno
</p>
<p class="text-sm text-blue-700 dark:text-blue-400">
Ingresa el monto inicial en efectivo con el que comenzarás tu turno.
Este monto se utilizará como base para el corte de caja al finalizar.
</p>
</div>
</div>
</div>
<!-- Monto Inicial -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Monto Inicial en Efectivo *
</label>
<div class="bg-gray-50 dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-xl p-5">
<div class="flex items-center gap-2 mb-2">
<span class="text-4xl font-bold text-gray-900 dark:text-gray-100">$</span>
<input
v-model.number="initialCash"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="flex-1 text-4xl font-bold bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400"
required
autofocus
/>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">
Ejemplo: Si comienzas con $1,000.00 en efectivo para dar cambio
</p>
</div>
</div>
<!-- Preview -->
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 rounded-xl p-5">
<div class="grid grid-cols-2 gap-6">
<div>
<p class="text-xs text-green-700 dark:text-green-400 uppercase font-semibold tracking-wide mb-2">
Efectivo Inicial
</p>
<p class="text-3xl font-bold text-green-900 dark:text-green-100">
${{ (initialCash || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
</p>
</div>
<div>
<p class="text-xs text-green-700 dark:text-green-400 uppercase font-semibold tracking-wide mb-2">
Fecha y Hora
</p>
<p class="text-base font-semibold text-green-800 dark:text-green-300">
{{ new Date().toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) }}
</p>
<p class="text-sm text-green-700 dark:text-green-400">
{{ new Date().toLocaleTimeString('es-MX', {
hour: '2-digit',
minute: '2-digit'
}) }}
</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-6 py-3 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
>
Cancelar
</button>
<button
type="submit"
:disabled="loading || initialCash <= 0"
class="flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-600 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-green-600/30 transition-all"
>
<GoogleIcon name="lock_open" class="text-xl" />
<span v-if="loading">Abriendo...</span>
<span v-else>Abrir Caja</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,127 @@
<script setup>
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';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Formulario */
const form = useForm({
name: '',
description: '',
is_active: true
});
/** Métodos */
const createCategory = () => {
form.post(apiURL('categorias'), {
onSuccess: (data) => {
Notify.success('Clasificación creada exitosamente');
emit('created', data?.model);
closeModal();
},
onError: () => {
Notify.error('Error al crear la clasificación');
}
});
};
const closeModal = () => {
form.reset();
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Crear Clasificación
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createCategory" class="space-y-4">
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Nombre de la clasificación"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Descripción -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DESCRIPCIÓN
</label>
<textarea
v-model="form.description"
rows="3"
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"
placeholder="Descripción de la clasificación"
></textarea>
<FormError :message="form.errors?.description" />
</div>
<!-- Estado -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ESTADO
</label>
<select
v-model="form.is_active"
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"
>
<option :value="true">Activo</option>
<option :value="false">Inactivo</option>
</select>
<FormError :message="form.errors?.is_active" />
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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">Guardando...</span>
<span v-else>Guardar</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,117 @@
<script setup>
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
category: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Métodos */
const handleConfirm = () => {
emit('confirm', props.category.id);
};
const handleClose = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @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-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Eliminar Clasificación
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-5">
<p class="text-gray-700 dark:text-gray-300 text-base">
¿Estás seguro de que deseas eliminar esta clasificación?
</p>
<div v-if="category" class="bg-linear-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-2">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
<GoogleIcon name="category" class="text-xl text-indigo-600 dark:text-indigo-400" />
</div>
<div class="flex-1">
<p class="text-base font-bold text-gray-900 dark:text-gray-100">
{{ category.name }}
</p>
<p v-if="category.description" class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">
{{ category.description }}
</p>
<p v-else class="text-sm text-gray-500 dark:text-gray-500 italic mt-0.5">
Sin descripción
</p>
</div>
<div v-if="category.is_active !== undefined">
<span
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': category.is_active,
'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400': !category.is_active
}"
>
{{ category.is_active ? 'Activo' : 'Inactivo' }}
</span>
</div>
</div>
</div>
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl shrink-0 mt-0.5" />
<p class="text-sm text-red-800 dark:text-red-300 font-medium">
Esta acción es permanente y no se puede deshacer. Los productos asociados a esta clasificación perderán su clasificación.
</p>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-5 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
>
Cancelar
</button>
<button
type="button"
@click="handleConfirm"
class="flex items-center gap-2 px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 shadow-lg shadow-red-600/30 transition-all"
>
<GoogleIcon name="delete" class="text-xl" />
Eliminar Clasificación
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,138 @@
<script setup>
import { watch } from 'vue';
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';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
category: Object
});
/** Formulario */
const form = useForm({
name: '',
description: '',
is_active: true
});
/** Métodos */
const updateCategory = () => {
form.put(apiURL(`categorias/${props.category.id}`), {
onSuccess: () => {
Notify.success('Clasificación actualizada exitosamente');
emit('updated');
closeModal();
},
onError: () => {
Notify.error('Error al actualizar la clasificación');
}
});
};
const closeModal = () => {
form.reset();
emit('close');
};
/** Observadores */
watch(() => props.category, (newCategory) => {
if (newCategory) {
form.name = newCategory.name || '';
form.description = newCategory.description || '';
form.is_active = newCategory.is_active ?? true;
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Editar Clasificación
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="updateCategory" class="space-y-4">
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Nombre de la clasificación"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Descripción -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DESCRIPCIÓN
</label>
<textarea
v-model="form.description"
rows="3"
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"
placeholder="Descripción de la clasificación"
></textarea>
<FormError :message="form.errors?.description" />
</div>
<!-- Estado -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ESTADO
</label>
<select
v-model="form.is_active"
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"
>
<option :value="true">Activo</option>
<option :value="false">Inactivo</option>
</select>
<FormError :message="form.errors?.is_active" />
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,209 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useSearcher, apiURL } from '@Services/Api';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import CreateModal from './CreateModal.vue';
import EditModal from './EditModal.vue';
import DeleteModal from './DeleteModal.vue';
const router = useRouter();
/** Estado */
const models = ref([]);
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const editingCategory = ref(null);
const deletingCategory = ref(null);
/** Métodos */
const searcher = useSearcher({
url: apiURL('categorias'),
onSuccess: (r) => {
models.value = r.categories || { data: [], total: 0 };
},
onError: () => models.value = { data: [], total: 0 }
});
const openCreateModal = () => {
showCreateModal.value = true;
};
const closeCreateModal = () => {
showCreateModal.value = false;
};
const openEditModal = (category) => {
editingCategory.value = category;
showEditModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
editingCategory.value = null;
};
const openDeleteModal = (category) => {
deletingCategory.value = category;
showDeleteModal.value = true;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
deletingCategory.value = null;
};
const onCategorySaved = () => {
searcher.search();
};
const confirmDelete = async (id) => {
try {
const response = await fetch(apiURL(`categorias/${id}`), {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
if (response.ok) {
Notify.success('Clasificación eliminada exitosamente');
closeDeleteModal();
searcher.search();
} else {
Notify.error('Error al eliminar la clasificación');
}
} catch (error) {
console.error('Error:', error);
Notify.error('Error al eliminar la clasificación');
}
};
/** Ciclos */
onMounted(() => {
searcher.search();
});
</script>
<template>
<div>
<SearcherHead
:title="$t('category.title')"
placeholder="Buscar por nombre..."
@search="(x) => searcher.search(x)"
>
<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" />
Nueva Clasificación
</button>
</SearcherHead>
<!-- Modal de Crear Categoría -->
<CreateModal
:show="showCreateModal"
@close="closeCreateModal"
@created="onCategorySaved"
/>
<!-- Modal de Editar Categoría -->
<EditModal
:show="showEditModal"
:category="editingCategory"
@close="closeEditModal"
@updated="onCategorySaved"
/>
<!-- Modal de Eliminar Categoría -->
<DeleteModal
:show="showDeleteModal"
:category="deletingCategory"
@close="closeDeleteModal"
@confirm="confirmDelete"
/>
<div class="pt-2 w-full">
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOMBRE</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DESCRIPCIÓN</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ACCIONES</th>
</template>
<template #body="{items}">
<tr
v-for="model in items"
:key="model.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ model.name }}</p>
</td>
<td class="px-6 py-4">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ model.description || '-' }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-50 text-green-700': model.is_active,
'bg-red-50 text-red-700': !model.is_active
}"
>
{{ model.is_active ? 'Activo' : 'Inactivo' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
@click="router.push({ name: 'pos.category.subcategories', params: { id: model.id } })"
class="text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
title="Gestionar subclasificaciones"
>
<GoogleIcon name="account_tree" class="text-xl" />
</button>
<button
@click="openEditModal(model)"
class="text-indigo-600 hover:text-indigo-900 transition-colors"
>
<GoogleIcon name="edit" class="text-xl" />
</button>
<button
@click="openDeleteModal(model)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Eliminar clasificación"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="4" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="category"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
{{ $t('registers.empty') }}
</p>
</div>
</td>
</template>
</Table>
</div>
</div>
</template>

View File

@ -0,0 +1,186 @@
<script setup>
import { ref } from 'vue';
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';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean,
categoryId: {
type: [String, Number],
required: true
}
});
/** Estado: lista de subcategorías a crear */
const emptyEntry = () => ({ name: '', description: '', is_active: true });
const entries = ref([emptyEntry()]);
const addEntry = () => entries.value.push(emptyEntry());
const removeEntry = (index) => {
if (entries.value.length > 1) {
entries.value.splice(index, 1);
}
};
/** Formulario */
const form = useForm({});
/** Métodos */
const createSubcategory = () => {
const payload = entries.value.length === 1
? entries.value[0]
: entries.value;
form.transform(() => payload).post(apiURL(`categorias/${props.categoryId}/subcategorias`), {
onSuccess: (data) => {
Notify.success(
entries.value.length === 1
? 'Subclasificación creada exitosamente'
: 'Subclasificaciones creadas exitosamente'
);
emit('created', data?.models ?? data?.model);
closeModal();
},
onError: () => {
Notify.error('Error al crear la subclasificación');
}
});
};
const closeModal = () => {
entries.value = [emptyEntry()];
form.reset();
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Crear Subclasificación
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createSubcategory" class="space-y-4">
<!-- Entradas dinámicas -->
<div
v-for="(entry, index) in entries"
:key="index"
class="space-y-3"
:class="{ 'border-t border-gray-200 dark:border-gray-700 pt-4': index > 0 }"
>
<div v-if="entries.length > 1" class="flex items-center justify-between">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Subclasificación {{ index + 1 }}
</span>
<button
type="button"
@click="removeEntry(index)"
class="text-red-400 hover:text-red-600 transition-colors"
title="Eliminar entrada"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="entry.name"
type="text"
placeholder="Nombre de la subclasificación"
required
/>
<FormError :message="form.errors?.[`${index}.name`] ?? form.errors?.name" />
</div>
<!-- Descripción -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DESCRIPCIÓN
</label>
<textarea
v-model="entry.description"
rows="2"
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"
placeholder="Descripción de la subclasificación"
></textarea>
<FormError :message="form.errors?.[`${index}.description`] ?? form.errors?.description" />
</div>
<!-- Estado -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ESTADO
</label>
<select
v-model="entry.is_active"
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"
>
<option :value="true">Activo</option>
<option :value="false">Inactivo</option>
</select>
<FormError :message="form.errors?.[`${index}.is_active`] ?? form.errors?.is_active" />
</div>
</div>
<!-- Agregar otra -->
<button
type="button"
@click="addEntry"
class="flex items-center gap-1.5 text-sm font-medium text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Agregar otra subclasificación
</button>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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">Guardando...</span>
<span v-else>Guardar</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,132 @@
<script setup>
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';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean,
categoryId: {
type: [String, Number],
required: true
}
});
/** Formulario */
const form = useForm({
name: '',
description: '',
is_active: true
});
/** Métodos */
const createSubcategory = () => {
form.post(apiURL(`categorias/${props.categoryId}/subcategorias`), {
onSuccess: (data) => {
Notify.success('Subclasificación creada exitosamente');
const created = data?.model ?? (data?.models ? data.models[0] : []);
emit('created', created);
closeModal();
},
onError: () => {
Notify.error('Error al crear la subclasificación');
}
});
};
const closeModal = () => {
form.reset();
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Crear Subclasificación
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createSubcategory" class="space-y-4">
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Nombre de la subclasificación"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Descripción -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DESCRIPCIÓN
</label>
<textarea
v-model="form.description"
rows="3"
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"
placeholder="Descripción de la subclasificación"
></textarea>
<FormError :message="form.errors?.description" />
</div>
<!-- Estado -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ESTADO
</label>
<select
v-model="form.is_active"
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"
>
<option :value="true">Activo</option>
<option :value="false">Inactivo</option>
</select>
<FormError :message="form.errors?.is_active" />
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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">Guardando...</span>
<span v-else>Guardar</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,117 @@
<script setup>
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
subcategory: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Métodos */
const handleConfirm = () => {
emit('confirm', props.subcategory.id);
};
const handleClose = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @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-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Eliminar Subclasificación
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-5">
<p class="text-gray-700 dark:text-gray-300 text-base">
¿Estás seguro de que deseas eliminar esta subclasificación?
</p>
<div v-if="subcategory" class="bg-linear-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-2">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
<GoogleIcon name="category" class="text-xl text-indigo-600 dark:text-indigo-400" />
</div>
<div class="flex-1">
<p class="text-base font-bold text-gray-900 dark:text-gray-100">
{{ subcategory.name }}
</p>
<p v-if="subcategory.description" class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">
{{ subcategory.description }}
</p>
<p v-else class="text-sm text-gray-500 dark:text-gray-500 italic mt-0.5">
Sin descripción
</p>
</div>
<div v-if="subcategory.is_active !== undefined">
<span
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': subcategory.is_active,
'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400': !subcategory.is_active
}"
>
{{ subcategory.is_active ? 'Activo' : 'Inactivo' }}
</span>
</div>
</div>
</div>
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl shrink-0 mt-0.5" />
<p class="text-sm text-red-800 dark:text-red-300 font-medium">
Los productos que tenían asignada esta subclasificación quedarán sin subclasificación automáticamente.
</p>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-5 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
>
Cancelar
</button>
<button
type="button"
@click="handleConfirm"
class="flex items-center gap-2 px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 shadow-lg shadow-red-600/30 transition-all"
>
<GoogleIcon name="delete" class="text-xl" />
Eliminar Subclasificación
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,142 @@
<script setup>
import { watch } from 'vue';
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';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
categoryId: {
type: [String, Number],
required: true
},
subcategory: Object
});
/** Formulario */
const form = useForm({
name: '',
description: '',
is_active: true
});
/** Métodos */
const updateSubcategory = () => {
form.put(apiURL(`categorias/${props.categoryId}/subcategorias/${props.subcategory.id}`), {
onSuccess: () => {
Notify.success('Subclasificación actualizada exitosamente');
emit('updated');
closeModal();
},
onError: () => {
Notify.error('Error al actualizar la subclasificación');
}
});
};
const closeModal = () => {
form.reset();
emit('close');
};
/** Observadores */
watch(() => props.subcategory, (newSubcategory) => {
if (newSubcategory) {
form.name = newSubcategory.name || '';
form.description = newSubcategory.description || '';
form.is_active = newSubcategory.is_active ?? true;
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Editar Subclasificación
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="updateSubcategory" class="space-y-4">
<!-- Nombre -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Nombre de la subclasificación"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Descripción -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DESCRIPCIÓN
</label>
<textarea
v-model="form.description"
rows="3"
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"
placeholder="Descripción de la subclasificación"
></textarea>
<FormError :message="form.errors?.description" />
</div>
<!-- Estado -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ESTADO
</label>
<select
v-model="form.is_active"
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"
>
<option :value="true">Activo</option>
<option :value="false">Inactivo</option>
</select>
<FormError :message="form.errors?.is_active" />
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,236 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSearcher, apiURL } from '@Services/Api';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import CreateModal from './CreateBulkModal.vue';
import EditModal from './EditModal.vue';
import DeleteModal from './DeleteModal.vue';
const route = useRoute();
const router = useRouter();
const categoryId = route.params.id;
/** Estado */
const models = ref([]);
const categoryName = ref('');
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const editingSubcategory = ref(null);
const deletingSubcategory = ref(null);
/** Cargar nombre de la categoría padre */
const loadCategory = async () => {
try {
const response = await fetch(apiURL(`categorias/${categoryId}`), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.data?.model) {
categoryName.value = result.data.model.name;
}
} catch (error) {
console.error('Error loading category:', error);
}
};
/** Métodos */
const searcher = useSearcher({
url: apiURL(`categorias/${categoryId}/subcategorias`),
onSuccess: (r) => {
models.value = r.subcategories || { data: [], total: 0 };
},
onError: () => models.value = { data: [], total: 0 }
});
const openCreateModal = () => {
showCreateModal.value = true;
};
const closeCreateModal = () => {
showCreateModal.value = false;
};
const openEditModal = (subcategory) => {
editingSubcategory.value = subcategory;
showEditModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
editingSubcategory.value = null;
};
const openDeleteModal = (subcategory) => {
deletingSubcategory.value = subcategory;
showDeleteModal.value = true;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
deletingSubcategory.value = null;
};
const onSubcategorySaved = () => {
searcher.search();
};
const confirmDelete = async (id) => {
try {
const response = await fetch(apiURL(`categorias/${categoryId}/subcategorias/${id}`), {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
if (response.ok) {
Notify.success('Subclasificación eliminada exitosamente');
closeDeleteModal();
searcher.search();
} else {
Notify.error('Error al eliminar la subclasificación');
}
} catch (error) {
console.error('Error:', error);
Notify.error('Error al eliminar la subclasificación');
}
};
/** Ciclos */
onMounted(() => {
loadCategory();
searcher.search();
});
</script>
<template>
<div>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<button
@click="router.push({ name: 'pos.category.index' })"
class="flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800 transition-colors"
title="Volver a clasificaciones"
>
<GoogleIcon name="arrow_back" class="text-xl" />
</button>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">Clasificación</p>
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">
{{ categoryName || 'Subclasificaciones' }}
</h1>
</div>
</div>
<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" />
Nueva Subclasificación
</button>
</div>
<!-- Modales -->
<CreateModal
:show="showCreateModal"
:category-id="categoryId"
@close="closeCreateModal"
@created="onSubcategorySaved"
/>
<EditModal
:show="showEditModal"
:category-id="categoryId"
:subcategory="editingSubcategory"
@close="closeEditModal"
@updated="onSubcategorySaved"
/>
<DeleteModal
:show="showDeleteModal"
:subcategory="deletingSubcategory"
@close="closeDeleteModal"
@confirm="confirmDelete"
/>
<!-- Tabla -->
<div class="pt-2 w-full">
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOMBRE</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DESCRIPCIÓN</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ACCIONES</th>
</template>
<template #body="{items}">
<tr
v-for="model in items"
:key="model.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ model.name }}</p>
</td>
<td class="px-6 py-4">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ model.description || '-' }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-50 text-green-700': model.is_active,
'bg-red-50 text-red-700': !model.is_active
}"
>
{{ model.is_active ? 'Activo' : 'Inactivo' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
@click="openEditModal(model)"
class="text-indigo-600 hover:text-indigo-900 transition-colors"
title="Editar subclasificación"
>
<GoogleIcon name="edit" class="text-xl" />
</button>
<button
@click="openDeleteModal(model)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Eliminar subclasificación"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="4" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="category"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">No hay subclasificaciones registradas</p>
</div>
</td>
</template>
</Table>
</div>
</div>
</template>

View File

@ -0,0 +1,680 @@
<script setup>
import { ref, computed } from 'vue';
import { useForm, apiURL } from '@Services/Api';
import { formatCurrency, formatDate } from '@/utils/formatters';
import whatsappService from '@Services/whatsappService';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Input from '@Holos/Form/Input.vue';
import UploadFiles from '@Components/POS/UploadFiles.vue';
/** Props */
const props = defineProps({
request: {
type: Object,
required: true
}
});
/** Emits */
const emit = defineEmits(['close', 'refresh']);
/** Estado */
const processing = ref(false);
const showProcessModal = ref(false);
const showRejectModal = ref(false);
const showUploadModal = ref(false);
const sendingWhatsapp = ref(false);
const processForm = useForm({
notes: ''
});
const rejectForm = useForm({
notes: ''
});
/** Computed */
const isPending = computed(() => props.request.status === 'pending');
const statusBadge = computed(() => {
const badges = {
pending: {
class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 border-yellow-300 dark:border-yellow-700',
icon: 'schedule',
label: 'Pendiente'
},
processed: {
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 border-green-300 dark:border-green-700',
icon: 'check_circle',
label: 'Procesada'
},
rejected: {
class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-300 dark:border-red-700',
icon: 'cancel',
label: 'Rechazada'
}
};
return badges[props.request.status] || badges.pending;
});
const paymentMethods = [
{
value: 'cash',
label: 'Efectivo',
},
{
value: 'credit_card',
label: 'Tarjeta de Crédito',
},
{
value: 'debit_card',
label: 'Tarjeta de Débito',
}
];
const paymentMethodLabel = computed(() => {
const method = paymentMethods.find(m => props.request.sale?.payment_method?.includes(m.value));
return method?.label || props.request.sale?.payment_method || 'N/A';
});
/** Métodos */
const closeModal = () => {
emit('close');
};
const openProcessModal = () => {
processForm.reset();
showProcessModal.value = true;
};
const openRejectModal = () => {
rejectForm.reset();
showRejectModal.value = true;
};
const openUploadModal = () => {
showUploadModal.value = true;
};
const closeUploadModal = () => {
showUploadModal.value = false;
};
const submitProcess = () => {
processing.value = true;
processForm.put(apiURL(`invoice-requests/${props.request.id}/process`), {
onSuccess: () => {
window.Notify.success('Solicitud marcada como procesada correctamente');
showProcessModal.value = false;
emit('refresh');
emit('close');
},
onError: () => {
window.Notify.error('Error al procesar la solicitud');
},
onFinish: () => {
processing.value = false;
}
});
};
const submitReject = () => {
processing.value = true;
rejectForm.put(apiURL(`invoice-requests/${props.request.id}/reject`), {
onSuccess: () => {
window.Notify.success('Solicitud rechazada correctamente');
showRejectModal.value = false;
emit('refresh');
emit('close');
},
onError: () => {
window.Notify.error('Error al rechazar la solicitud');
},
onFinish: () => {
processing.value = false;
}
});
};
/**
* Enviar factura por WhatsApp
*/
const sendInvoiceByWhatsapp = async () => {
const request = props.request;
if (!request.client?.phone) {
window.Notify.warning('El cliente no tiene número de teléfono registrado');
return;
}
if (!request.invoice_pdf_url) {
window.Notify.warning('La solicitud no tiene PDF de factura adjunto');
return;
}
sendingWhatsapp.value = true;
try {
const ticket = request.sale?.invoice_number || `SOL-${request.id}`;
await whatsappService.sendDocument({
phone_number: request.client.phone,
document_url: request.invoice_pdf_url,
filename: `${ticket}.pdf`,
ticket,
customer_name: request.client.name
});
window.Notify.success('Factura enviada por WhatsApp correctamente');
} catch (error) {
console.error('WhatsApp Error:', error);
// Mostrar el error específico del backend
const errorMsg = error?.error?.message
|| error?.message
|| 'Error desconocido al enviar por WhatsApp';
window.Notify.error(`Error: ${errorMsg}`);
} finally {
sendingWhatsapp.value = false;
}
};
</script>
<template>
<Modal :show="true" max-width="5xl" @close="closeModal">
<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-blue-100 dark:bg-blue-900/30">
<GoogleIcon name="receipt_long" class="text-2xl text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
Solicitud de Facturación #{{ request.id }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Folio: {{ request.sale?.invoice_number || 'N/A' }}
</p>
</div>
</div>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Estado Badge -->
<div class="mb-6">
<div :class="['inline-flex items-center gap-2 px-4 py-2 rounded-lg border-2', statusBadge.class]">
<GoogleIcon :name="statusBadge.icon" class="text-xl" />
<span class="font-semibold">{{ statusBadge.label }}</span>
</div>
</div>
<!-- Content -->
<div class="space-y-6 max-h-[70vh] overflow-y-auto">
<!-- Información del Estado -->
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 mb-4">
<GoogleIcon name="info" class="text-xl text-gray-600 dark:text-gray-400" />
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Estado de la Solicitud
</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Fecha Solicitada
</label>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formatDate(request.requested_at) }}
</p>
</div>
<div v-if="request.processed_at">
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Fecha Procesada
</label>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formatDate(request.processed_at) }}
</p>
</div>
<div v-if="request.processed_by_user">
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Procesada Por
</label>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ request.processed_by_user.name }}
</p>
</div>
</div>
<div v-if="request.notes" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
Notas
</label>
<p class="text-sm text-gray-700 dark:text-gray-300 italic">
{{ request.notes }}
</p>
</div>
</div>
<!-- Información del CFDI -->
<div v-if="request.cfdi_uuid || request.invoice_xml_url || request.invoice_pdf_url" class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-xl p-5 border border-purple-200 dark:border-purple-800">
<div class="flex items-center gap-2 mb-4">
<GoogleIcon name="verified" class="text-xl text-purple-600 dark:text-purple-400" />
<h4 class="text-sm font-bold text-purple-700 dark:text-purple-300 uppercase tracking-wide">
Información del CFDI
</h4>
</div>
<!-- UUID -->
<div v-if="request.cfdi_uuid" class="mb-4">
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-2">
UUID del CFDI
</label>
<div class="flex items-center gap-2 p-3 bg-white/50 dark:bg-gray-800/50 rounded-lg">
<p class="flex-1 text-sm font-mono text-purple-900 dark:text-purple-100 break-all">
{{ request.cfdi_uuid }}
</p>
<button
@click="navigator.clipboard.writeText(request.cfdi_uuid); window.Notify.success('UUID copiado al portapapeles')"
class="flex-shrink-0 p-2 rounded-lg bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
title="Copiar UUID"
>
<GoogleIcon name="content_copy" class="text-lg text-purple-600 dark:text-purple-400" />
</button>
</div>
</div>
<!-- Archivos -->
<div v-if="request.invoice_xml_url || request.invoice_pdf_url">
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-2">
Archivos de Facturación
</label>
<div class="flex gap-3">
<!-- XML -->
<a
v-if="request.invoice_xml_url"
:href="request.invoice_xml_url"
target="_blank"
download
class="flex-1 flex items-center gap-3 p-3 bg-white/50 dark:bg-gray-800/50 rounded-lg hover:bg-white dark:hover:bg-gray-800 transition-colors border border-blue-200 dark:border-blue-800"
>
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<GoogleIcon name="description" class="text-2xl text-blue-600 dark:text-blue-400" />
</div>
<div class="flex-1">
<p class="text-xs font-semibold text-blue-700 dark:text-blue-300">
Archivo XML
</p>
<p class="text-xs text-blue-600 dark:text-blue-400">
Descargar CFDI
</p>
</div>
<GoogleIcon name="download" class="text-xl text-blue-600 dark:text-blue-400" />
</a>
<!-- PDF -->
<a
v-if="request.invoice_pdf_url"
:href="request.invoice_pdf_url"
target="_blank"
download
class="flex-1 flex items-center gap-3 p-3 bg-white/50 dark:bg-gray-800/50 rounded-lg hover:bg-white dark:hover:bg-gray-800 transition-colors border border-red-200 dark:border-red-800"
>
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30">
<GoogleIcon name="picture_as_pdf" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<div class="flex-1">
<p class="text-xs font-semibold text-red-700 dark:text-red-300">
Archivo PDF
</p>
<p class="text-xs text-red-600 dark:text-red-400">
Descargar Factura
</p>
</div>
<GoogleIcon name="download" class="text-xl text-red-600 dark:text-red-400" />
</a>
</div>
<button
v-if="request.invoice_pdf_url && request.client?.phone"
@click="sendInvoiceByWhatsapp"
:disabled="sendingWhatsapp"
class="mt-3 w-full flex items-center justify-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors border border-green-300 dark:border-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg class="w-5 h-5 text-green-600 dark:text-green-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
<span class="text-sm font-semibold text-green-700 dark:text-green-300">
{{ sendingWhatsapp ? 'Enviando...' : 'Enviar Factura por WhatsApp' }}
</span>
<span v-if="!sendingWhatsapp" class="text-xs text-green-600 dark:text-green-400">
({{ request.client.phone }})
</span>
<GoogleIcon v-if="sendingWhatsapp" name="sync" class="animate-spin text-green-600" />
</button>
<!-- Aviso si no tiene teléfono -->
<p v-else-if="request.invoice_pdf_url && !request.client?.phone"
class="mt-2 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
<GoogleIcon name="warning" class="text-sm" />
No se puede enviar por WhatsApp: el cliente no tiene teléfono registrado.
</p>
</div>
</div>
<!-- Información de la Venta -->
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 rounded-xl p-5 border border-green-200 dark:border-green-800">
<div class="flex items-center gap-2 mb-4">
<GoogleIcon name="shopping_cart" class="text-xl text-green-600 dark:text-green-400" />
<h4 class="text-sm font-bold text-green-700 dark:text-green-300 uppercase tracking-wide">
Información de la Venta
</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-xs font-semibold text-green-600 dark:text-green-400 uppercase mb-1">
Folio
</label>
<p class="text-sm font-bold font-mono text-green-900 dark:text-green-100">
{{ request.sale?.invoice_number }}
</p>
</div>
<div>
<label class="block text-xs font-semibold text-green-600 dark:text-green-400 uppercase mb-1">
Total
</label>
<p class="text-lg font-bold text-green-600 dark:text-green-400">
{{ formatCurrency(request.sale?.total) }}
</p>
</div>
<div>
<label class="block text-xs font-semibold text-green-600 dark:text-green-400 uppercase mb-1">
Método de Pago
</label>
<p class="text-sm font-medium text-green-900 dark:text-green-100 capitalize">
{{ paymentMethodLabel }}
</p>
</div>
</div>
<!-- Detalle de productos -->
<div v-if="request.sale?.details && request.sale.details.length > 0" class="mt-4 pt-4 border-t border-green-200 dark:border-green-700">
<label class="block text-xs font-semibold text-green-600 dark:text-green-400 uppercase mb-3">
Productos Vendidos
</label>
<div class="space-y-2">
<div
v-for="item in request.sale.details"
:key="item.id"
class="p-3 bg-white/50 dark:bg-gray-800/50 rounded-lg"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<p class="text-sm font-semibold text-green-900 dark:text-green-100">
{{ item.product_name }}
</p>
<p class="text-xs text-green-700 dark:text-green-300">
SKU: {{ item.inventory?.sku || 'N/A' }} {{ item.inventory?.category?.name || 'Sin clasificación' }}
</p>
<div v-if="item.serials && item.serials.length > 0" class="mt-1">
<p class="text-xs text-green-600 dark:text-green-400">
<span class="font-semibold">Series:</span>
<span v-for="(serial, idx) in item.serials" :key="idx" class="ml-1 font-mono">
{{ serial.serial_number }}{{ idx < item.serials.length - 1 ? ',' : '' }}
</span>
</p>
</div>
</div>
<div class="text-right ml-4">
<p class="text-sm font-bold text-green-900 dark:text-green-100">
{{ formatCurrency(item.subtotal) }}
</p>
<p class="text-xs text-green-700 dark:text-green-300">
{{ item.quantity }} × {{ formatCurrency(item.unit_price) }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Información del Cliente -->
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-xl p-5 border border-blue-200 dark:border-blue-800">
<div class="flex items-center gap-2 mb-4">
<GoogleIcon name="person" class="text-xl text-blue-600 dark:text-blue-400" />
<h4 class="text-sm font-bold text-blue-700 dark:text-blue-300 uppercase tracking-wide">
Datos del Cliente
</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">
Nombre
</label>
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
{{ request.client?.name || 'N/A' }}
</p>
</div>
<div>
<label class="block text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase mb-1">
Email
</label>
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
{{ request.client?.email || 'N/A' }}
</p>
</div>
</div>
</div>
<!-- Información Fiscal -->
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-xl p-5 border border-purple-200 dark:border-purple-800">
<div class="flex items-center gap-2 mb-4">
<GoogleIcon name="account_balance" class="text-xl text-purple-600 dark:text-purple-400" />
<h4 class="text-sm font-bold text-purple-700 dark:text-purple-300 uppercase tracking-wide">
Datos Fiscales
</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
RFC
</label>
<p class="text-sm font-mono font-bold text-purple-900 dark:text-purple-100">
{{ request.client?.rfc || 'N/A' }}
</p>
</div>
<div>
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
Razón Social
</label>
<p class="text-sm font-medium text-purple-900 dark:text-purple-100">
{{ request.client?.razon_social || 'N/A' }}
</p>
</div>
<div>
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
Régimen Fiscal
</label>
<p class="text-sm font-medium text-purple-900 dark:text-purple-100">
{{ request.client?.regimen_fiscal || 'N/A' }}
</p>
</div>
<div>
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
C.P. Fiscal
</label>
<p class="text-sm font-medium text-purple-900 dark:text-purple-100">
{{ request.client?.cp_fiscal || 'N/A' }}
</p>
</div>
<div class="md:col-span-2">
<label class="block text-xs font-semibold text-purple-600 dark:text-purple-400 uppercase mb-1">
Uso de CFDI
</label>
<p class="text-sm font-medium text-purple-900 dark:text-purple-100">
{{ request.client?.uso_cfdi || 'N/A' }}
</p>
</div>
</div>
</div>
</div>
<!-- Footer con acciones -->
<div class="flex items-center justify-between gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeModal"
:disabled="processing"
class="px-5 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cerrar
</button>
<!-- Acciones solo para solicitudes pendientes -->
<div v-if="isPending" class="flex gap-3">
<button
type="button"
@click="openRejectModal"
:disabled="processing"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-red-600 hover:bg-red-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<GoogleIcon name="cancel" class="text-lg" />
Rechazar
</button>
<button
type="button"
@click="openUploadModal"
:disabled="processing"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<GoogleIcon name="upload_file" class="text-lg" />
Subir Archivos
</button>
<button
type="button"
@click="openProcessModal"
:disabled="processing"
class="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-green-600 hover:bg-green-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<GoogleIcon name="check_circle" class="text-lg" />
Marcar como Procesada
</button>
</div>
</div>
</div>
</Modal>
<!-- Modal para procesar -->
<Modal :show="showProcessModal" max-width="md" @close="showProcessModal = false">
<div class="p-6">
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/30">
<GoogleIcon name="check_circle" class="text-2xl text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Marcar como Procesada
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Solicitud #{{ request.id }}
</p>
</div>
</div>
<form @submit.prevent="submitProcess" class="space-y-4">
<Input
v-model="processForm.notes"
id="process_notes"
title="Notas (opcional)"
placeholder="Ej: Factura timbrada. UUID: 123456-ABCD-7890"
type="textarea"
rows="3"
/>
<div class="flex items-center justify-end gap-3 pt-4">
<button
type="button"
@click="showProcessModal = false"
:disabled="processing"
class="px-4 py-2 text-sm font-semibold 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 disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
:disabled="processing"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50"
>
<GoogleIcon name="check" class="text-lg" />
{{ processing ? 'Procesando...' : 'Confirmar' }}
</button>
</div>
</form>
</div>
</Modal>
<!-- Modal para rechazar -->
<Modal :show="showRejectModal" max-width="md" @close="showRejectModal = false">
<div class="p-6">
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30">
<GoogleIcon name="cancel" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Rechazar Solicitud
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Solicitud #{{ request.id }}
</p>
</div>
</div>
<form @submit.prevent="submitReject" class="space-y-4">
<Input
v-model="rejectForm.notes"
id="reject_notes"
title="Motivo del rechazo *"
placeholder="Ej: RFC inválido, no coincide con constancia fiscal del SAT"
type="textarea"
rows="3"
required
/>
<div class="flex items-center justify-end gap-3 pt-4">
<button
type="button"
@click="showRejectModal = false"
:disabled="processing"
class="px-4 py-2 text-sm font-semibold 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 disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
:disabled="processing"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
>
<GoogleIcon name="cancel" class="text-lg" />
{{ processing ? 'Procesando...' : 'Rechazar' }}
</button>
</div>
</form>
</div>
</Modal>
<UploadFiles
:show="showUploadModal"
:request="request"
@close="closeUploadModal"
@refresh="emit('refresh')"
/>
</template>

View File

@ -0,0 +1,358 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useApi, useSearcher, apiURL } from '@Services/Api';
import { formatDate, formatCurrency } from '@/utils/formatters';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import BillingRequestDetailModal from './BillingRequestDetailModal.vue';
/** Estado */
const billingRequests = ref({});
const stats = ref({
pending: 0,
processed: 0,
rejected: 0,
total: 0,
today_pending: 0,
this_month: 0
});
const selectedStatus = ref('pending');
const showDetailModal = ref(false);
const selectedRequest = ref(null);
const loadingStats = ref(false);
const api = useApi();
/** Computed */
const statusOptions = [
{ value: 'all', label: 'Todas', icon: 'list', color: 'gray' },
{ value: 'pending', label: 'Pendientes', icon: 'schedule', color: 'yellow' },
{ value: 'processed', label: 'Procesadas', icon: 'check_circle', color: 'green' },
{ value: 'rejected', label: 'Rechazadas', icon: 'cancel', color: 'red' }
];
const getStatusBadge = (status) => {
const badges = {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
processed: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
rejected: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
};
return badges[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300';
};
/** Métodos */
const searcher = useSearcher({
url: apiURL('invoice-requests'),
filters: computed(() => ({
status: selectedStatus.value === 'all' ? undefined : selectedStatus.value
})),
onSuccess: (r) => {
// El componente Table espera el objeto de paginación completo, no solo el array
billingRequests.value = r.invoice_requests || {};
},
onError: (error) => {
billingRequests.value = {};
window.Notify.error('Error al cargar solicitudes de facturación');
}
});
const fetchStats = () => {
loadingStats.value = true;
api.get(apiURL('invoice-requests/stats'), {
onSuccess: (data) => {
stats.value = data.stats || {};
},
onError: () => {
window.Notify.error('Error al cargar estadísticas');
},
onFinish: () => {
loadingStats.value = false;
}
});
};
const openDetailModal = async (request) => {
// Cargar detalles completos
api.get(apiURL(`invoice-requests/${request.id}`), {
onSuccess: (data) => {
selectedRequest.value = data.invoice_request;
showDetailModal.value = true;
},
onError: () => {
window.Notify.error('Error al cargar detalles de la solicitud');
}
});
};
const closeDetailModal = () => {
showDetailModal.value = false;
selectedRequest.value = null;
};
const refreshList = () => {
searcher.search();
fetchStats();
};
/** Ciclo de vida */
onMounted(() => {
fetchStats();
searcher.search();
});
</script>
<template>
<div>
<!-- Estadísticas -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Total</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ stats.total }}
</p>
</div>
<GoogleIcon name="receipt_long" class="text-3xl text-gray-400" />
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Pendientes</p>
<p class="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{{ stats.pending }}
</p>
</div>
<GoogleIcon name="schedule" class="text-3xl text-yellow-400" />
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Procesadas</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ stats.processed }}
</p>
</div>
<GoogleIcon name="check_circle" class="text-3xl text-green-400" />
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Rechazadas</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ stats.rejected }}
</p>
</div>
<GoogleIcon name="cancel" class="text-3xl text-red-400" />
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Hoy</p>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{{ stats.today_pending }}
</p>
</div>
<GoogleIcon name="today" class="text-3xl text-blue-400" />
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Este mes</p>
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400">
{{ stats.this_month }}
</p>
</div>
<GoogleIcon name="calendar_month" class="text-3xl text-purple-400" />
</div>
</div>
</div>
<SearcherHead
:title="'Solicitudes de Facturación'"
placeholder="Buscar por folio, cliente o RFC..."
@search="(x) => searcher.search(x)"
>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="billingRequests"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FOLIO</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CLIENTE</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RFC</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TOTAL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">UUID CFDI</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ARCHIVOS</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA SOLICITUD</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="request in items"
:key="request.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 text-left">
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
{{ request.sale?.invoice_number || 'N/A' }}
</p>
</td>
<td class="px-6 py-4 text-left">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ request.client?.name || 'N/A' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ request.client?.email || 'N/A' }}
</p>
</td>
<td class="px-6 py-4 text-left">
<p class="text-sm text-gray-700 dark:text-gray-300 font-mono">
{{ request.client?.rfc || 'N/A' }}
</p>
</td>
<td class="px-6 py-4 text-right">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(request.sale?.total) }}
</p>
</td>
<td class="px-6 py-4 text-center">
<span
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold',
getStatusBadge(request.status)
]"
>
{{ statusOptions.find(o => o.value === request.status)?.label }}
</span>
</td>
<!-- UUID del CFDI -->
<td class="px-6 py-4 text-center">
<div v-if="request.cfdi_uuid" class="flex flex-col items-center">
<p class="text-xs font-mono text-gray-700 dark:text-gray-300 break-all max-w-[200px]">
{{ request.cfdi_uuid }}
</p>
</div>
<span v-else class="text-xs text-gray-400 dark:text-gray-500">
Sin UUID
</span>
</td>
<!-- Archivos disponibles -->
<td class="px-6 py-4 text-center">
<div class="flex items-center justify-center gap-2">
<!-- XML -->
<a
v-if="request.invoice_xml_url"
:href="request.invoice_xml_url"
target="_blank"
download
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-blue-100 hover:bg-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 transition-colors"
title="Descargar XML"
>
<GoogleIcon name="description" class="text-lg text-blue-600 dark:text-blue-400" />
</a>
<div
v-else
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800"
title="Sin XML"
>
<GoogleIcon name="description" class="text-lg text-gray-400" />
</div>
<!-- PDF -->
<a
v-if="request.invoice_pdf_url"
:href="request.invoice_pdf_url"
target="_blank"
download
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-red-100 hover:bg-red-200 dark:bg-red-900/30 dark:hover:bg-red-900/50 transition-colors"
title="Descargar PDF"
>
<GoogleIcon name="picture_as_pdf" class="text-lg text-red-600 dark:text-red-400" />
</a>
<div
v-else
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800"
title="Sin PDF"
>
<GoogleIcon name="picture_as_pdf" class="text-lg text-gray-400" />
</div>
</div>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ formatDate(request.requested_at) }}
</p>
</td>
<td class="px-6 py-4 text-center">
<div class="flex items-center justify-center space-x-2">
<button
@click="openDetailModal(request)"
class="inline-flex items-center px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-md transition-colors"
title="Ver detalles"
>
<GoogleIcon name="visibility" class="text-base mr-1" />
Ver
</button>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="9" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-12 text-gray-500">
<GoogleIcon
name="receipt_long"
class="text-6xl mb-3 opacity-50"
/>
<p class="font-semibold text-lg mb-1">
No hay solicitudes de facturación
</p>
<p class="text-sm text-gray-400">
Las solicitudes de facturación aparecerán aquí
</p>
</div>
</td>
</template>
</Table>
</div>
<!-- Modal de detalle -->
<BillingRequestDetailModal
v-if="showDetailModal"
:request="selectedRequest"
@close="closeDetailModal"
@refresh="refreshList"
/>
</div>
</template>
<style scoped>
/* Estilos adicionales si son necesarios */
</style>

View File

@ -0,0 +1,204 @@
<script setup>
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 SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
import SelectRegimenFiscal from '@Components/POS/RegimenSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Formulario */
const form = useForm({
name: '',
email: '',
phone: '',
address: '',
rfc: '',
razon_social: '',
regimen_fiscal: '',
cp_fiscal: '',
uso_cfdi: ''
});
/** Métodos */
const createClient = () => {
form.post(apiURL('clients'), {
onSuccess: () => {
window.Notify.success('Cliente creado exitosamente');
emit('created');
closeModal();
},
onError: () => {
window.Notify.error('Error al crear el cliente');
}
});
};
const closeModal = () => {
form.reset();
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Crear Cliente
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createClient" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Nombre del cliente"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Email -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
EMAIL
</label>
<FormInput
v-model="form.email"
type="text"
placeholder="Email"
required
/>
<FormError :message="form.errors?.email" />
</div>
<!-- Teléfono -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
TELÉFONO
</label>
<FormInput
v-model="form.phone"
type="text"
placeholder="9933428818"
maxlength="10"
/>
<FormError :message="form.errors?.phone" />
</div>
<!-- Dirección -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DIRECCIÓN
</label>
<FormInput
v-model="form.address"
type="text"
placeholder="Dirección"
required
/>
<FormError :message="form.errors?.address" />
</div>
<!-- RFC -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RFC
</label>
<FormInput
v-model="form.rfc"
type="text"
placeholder="RFC"
required
/>
<FormError :message="form.errors?.rfc" />
</div>
<!-- RAZÓN SOCIAL -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RAZÓN SOCIAL
</label>
<FormInput
v-model="form.razon_social"
type="text"
placeholder="Razón Social"
required
/>
<FormError :message="form.errors?.razon_social" />
</div>
<!-- REGIMEN FISCAL-->
<SelectRegimenFiscal
v-model="form.regimen_fiscal"
:error="form.errors?.regimen_fiscal"
/>
<!-- CP FISCAL-->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CP FISCAL
</label>
<FormInput
v-model="form.cp_fiscal"
type="text"
placeholder="CP Fiscal"
required
/>
<FormError :message="form.errors?.cp_fiscal" />
</div>
<!-- USO CFDI -->
<SelectUsoCfdi
v-model="form.uso_cfdi"
:error="form.errors?.uso_cfdi"
/>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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">Guardando...</span>
<span v-else>Guardar</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

View File

@ -0,0 +1,97 @@
<script setup>
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
client: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Métodos */
const handleConfirm = () => {
emit('confirm', props.client.id);
};
const handleClose = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @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-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Eliminar Cliente
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-5">
<p class="text-gray-700 dark:text-gray-300 text-base">
¿Estás seguro de que deseas eliminar este cliente?
</p>
<div v-if="client" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-base font-bold text-gray-900 dark:text-gray-100 mb-1">
{{ client.name }}
</p>
</div>
</div>
</div>
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-800 dark:text-red-300 font-medium">
Esta acción es permanente y no se puede deshacer.
</p>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-5 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
>
Cancelar
</button>
<button
type="button"
@click="handleConfirm"
class="flex items-center gap-2 px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 shadow-lg shadow-red-600/30 transition-all"
>
<GoogleIcon name="delete" class="text-xl" />
Eliminar Cliente
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,225 @@
<script setup>
import { watch } from 'vue';
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 SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
import SelectRegimenFiscal from '@Components/POS/RegimenSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
client: Object
});
/** Formulario */
const form = useForm({
name: '',
email: '',
phone: '',
address: '',
rfc: '',
razon_social: '',
regimen_fiscal: '',
cp_fiscal: '',
uso_cfdi: ''
});
/** Métodos */
const updateClient = () => {
form.put(apiURL(`clients/${props.client.id}`), {
onSuccess: () => {
window.Notify.success('Cliente actualizado exitosamente');
emit('updated');
closeModal();
},
onError: () => {
window.Notify.error('Error al actualizar el cliente');
}
});
};
const closeModal = () => {
form.reset();
emit('close');
};
/** Observadores */
watch(() => props.client, (newClient) => {
if (newClient) {
form.name = newClient.name || '';
form.email = newClient.email || '';
form.phone = newClient.phone || '';
form.address = newClient.address || '';
form.rfc = newClient.rfc || '';
form.razon_social = newClient.razon_social || '';
form.regimen_fiscal = newClient.regimen_fiscal || '';
form.cp_fiscal = newClient.cp_fiscal || '';
form.uso_cfdi = newClient.uso_cfdi || '';
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Editar Cliente
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="updateClient" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Nombre del cliente"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- EMAIL -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
EMAIL
</label>
<FormInput
v-model="form.email"
type="email"
placeholder="Correo electrónico"
required
/>
<FormError :message="form.errors?.email" />
</div>
<!-- Teléfono -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
TELÉFONO
</label>
<FormInput
v-model="form.phone"
type="tel"
placeholder="9922334455"
maxlength="10"
/>
<FormError :message="form.errors?.phone" />
</div>
<!-- Dirección -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DIRECCIÓN
</label>
<FormInput
v-model="form.address"
type="text"
placeholder="Dirección del cliente"
required
/>
<FormError :message="form.errors?.address" />
</div>
<!-- RFC -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RFC
</label>
<FormInput
v-model="form.rfc"
type="text"
maxlength="13"
placeholder="RFC del cliente"
required
/>
<FormError :message="form.errors?.rfc" />
</div>
<!-- RAZÓN SOCIAL -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RAZÓN SOCIAL
</label>
<FormInput
v-model="form.razon_social"
type="text"
placeholder="Razón social del cliente"
required
/>
<FormError :message="form.errors?.razon_social" />
</div>
<!-- CP FISCAL-->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CP FISCAL
</label>
<FormInput
v-model="form.cp_fiscal"
type="text"
placeholder="CP fiscal del cliente"
required
/>
<FormError :message="form.errors?.cp_fiscal" />
</div>
<!-- REGIMEN FISCAL-->
<SelectRegimenFiscal
v-model="form.regimen_fiscal"
:error="form.errors?.regimen_fiscal"
/>
<!-- USO CFDI -->
<SelectUsoCfdi
v-model="form.uso_cfdi"
:error="form.errors?.uso_cfdi"
/>
</div>
<!-- Botones -->
<div class="flex items-center justify-between mt-6">
<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>
</Modal>
</template>

View File

@ -0,0 +1,303 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useSearcher, apiURL } from '@Services/Api';
import { can } from './Module.js';
import { regimenFiscalOptions, usoCfdiOptions } from '@/utils/fiscalData';
const regimenFiscalLabel = (value) => regimenFiscalOptions.find(o => o.value === value)?.label ?? value;
const usoCfdiLabel = (value) => usoCfdiOptions.find(o => o.value === value)?.label ?? value;
import SearcherHead from '@Holos/Searcher.vue';
import ExcelModal from '@Components/POS/ExcelClient.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import CreateModal from './Create.vue';
import EditModal from './Edit.vue';
import DeleteModal from './Delete.vue';
import StatsModal from './Stats.vue';
/** Estado */
const clients = ref([]);
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const showStatsModal = ref(false);
const showExcelModal = ref(false);
const editingClient = ref(null);
const deletingClient = ref(null);
const statsClient = ref(null);
/** Métodos */
const searcher = useSearcher({
url: apiURL('clients'),
onSuccess: (r) => {
clients.value = r.clients;
},
onError: () => clients.value = []
});
/** Métodos auxiliares */
const copyClientInfo = (client) => {
const info = `
Nombre: ${client.name}
Email: ${client.email || 'N/A'}
Teléfono: ${client.phone || 'N/A'}
Dirección: ${client.address || 'N/A'}
RFC: ${client.rfc || 'N/A'}
`.trim();
navigator.clipboard.writeText(info).then(() => {
window.Notify.success(`Información de ${client.name} copiada al portapapeles`);
}).catch(() => {
window.Notify.error('No se pudo copiar la información');
});
};
const confirmDelete = async (id) => {
try {
const response = await fetch(apiURL(`clients/${id}`), {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
if (response.ok) {
window.Notify.success('Cliente eliminado exitosamente');
closeDeleteModal();
searcher.search();
} else {
window.Notify.error('Error al eliminar el cliente');
}
} catch (error) {
console.error('Error:', error);
window.Notify.error('Error al eliminar el cliente');
}
};
const openCreateModal = () => {
showCreateModal.value = true;
};
const closeCreateModal = () => {
showCreateModal.value = false;
};
const openEditModal = (client) => {
editingClient.value = client;
showEditModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
editingClient.value = null;
};
const openDeleteModal = (client) => {
deletingClient.value = client;
showDeleteModal.value = true;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
deletingClient.value = null;
};
const openStatsModal = (client) => {
statsClient.value = client;
showStatsModal.value = true;
};
const closeStatsModal = () => {
showStatsModal.value = false;
statsClient.value = null;
};
const openExcelModal = () => {
showExcelModal.value = true;
};
const closeExcelModal = () => {
showExcelModal.value = false;
};
const onClientSaved = () => {
searcher.search();
};
/** Ciclo de vida */
onMounted(() => {
searcher.search();
});
</script>
<template>
<div>
<SearcherHead
:title="$t('clients.title')"
placeholder="Buscar por nombre..."
@search="(x) => searcher.search(x)"
>
<button
v-if="can('create')"
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" />
Nuevo Cliente
</button>
<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="openExcelModal"
>
<GoogleIcon name="add" class="text-xl" />
Generar excel
</button>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="clients"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOMBRE</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CÓDIGO DE CLIENTE</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CORREO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TELEFONO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DIRECCIÓN</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RFC</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">RAZÓN SOCIAL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">REGIMEN FISCAL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CP FISCAL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">USO CFDI</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="client in items"
:key="client.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ client.name }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ client.client_number }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.email }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.phone }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.address }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.rfc }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.razon_social }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ regimenFiscalLabel(client.regimen_fiscal) }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ client.cp_fiscal }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ usoCfdiLabel(client.uso_cfdi) }}</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
@click.stop="copyClientInfo(client)"
class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
title="Copiar información"
>
<GoogleIcon name="content_copy" class="text-xl" />
</button>
<button
@click.stop="openStatsModal(client)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Estadísticas del cliente"
>
<GoogleIcon name="bar_chart" class="text-xl" />
</button>
<button
v-if="can('edit')"
@click.stop="openEditModal(client)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Editar cliente"
>
<GoogleIcon name="edit" class="text-xl" />
</button>
<button
v-if="can('destroy')"
@click.stop="openDeleteModal(client)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Eliminar cliente"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="11" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="person"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
{{ $t('registers.empty') }}
</p>
</div>
</td>
</template>
</Table>
</div>
<!-- Modal de Crear Cliente -->
<CreateModal
v-if="can('create')"
:show="showCreateModal"
@close="closeCreateModal"
@created="onClientSaved"
/>
<!-- Modal de Editar Cliente -->
<EditModal
v-if="can('edit')"
:show="showEditModal"
:client="editingClient"
@close="closeEditModal"
@updated="onClientSaved"
/>
<!-- Modal de Eliminar Cliente -->
<DeleteModal
v-if="can('destroy')"
:show="showDeleteModal"
:client="deletingClient"
@close="closeDeleteModal"
@confirm="confirmDelete"
/>
<!-- Modal de Estadísticas del Cliente -->
<StatsModal
:show="showStatsModal"
:client="statsClient"
@close="closeStatsModal"
/>
<!-- Modal de Excel de Clientes -->
<ExcelModal
:show="showExcelModal"
@close="closeExcelModal"
/>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`clients.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.clients.${name}`, params, query })
// Determina si un usuario puede hacer algo en base a los permisos
const can = (permission) => hasPermission(`clients.${permission}`)
export {
can,
viewTo,
apiTo
}

View File

@ -0,0 +1,213 @@
<script setup>
import { ref, watch } from 'vue';
import { api, apiURL } from '@Services/Api';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Loader from '@Shared/Loader.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
required: true
},
client: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close']);
/** Estado */
const loading = ref(false);
const error = ref(null);
const stats = ref(null);
/** Métodos */
const fetchStats = () => {
if (!props.client?.id) return;
loading.value = true;
error.value = null;
stats.value = null;
api.get(apiURL(`clients/${props.client.id}/stats`), {
onSuccess: (data) => {
stats.value = data.stats;
loading.value = false;
},
onFail: (data) => {
error.value = data.message || 'Error al obtener estadísticas del cliente.';
loading.value = false;
},
onError: () => {
error.value = 'Error de conexión. Por favor intente más tarde.';
loading.value = false;
}
});
};
const formatCurrency = (value) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(value || 0);
};
const formatDate = (date) => {
if (!date) return 'N/A';
return new Date(date).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const close = () => {
emit('close');
};
/** Watchers */
watch(() => props.show, (newVal) => {
if (newVal) {
fetchStats();
}
});
</script>
<template>
<Modal :show="show" max-width="3xl" @close="close">
<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-10 h-10 rounded-full bg-indigo-100 dark:bg-indigo-900/30">
<GoogleIcon name="bar_chart" class="text-xl text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Estadísticas del Cliente
</h3>
<p v-if="client" class="text-sm text-gray-500 dark:text-gray-400">
{{ client.name }}
</p>
</div>
</div>
<button
@click="close"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="max-h-[70vh] overflow-y-auto">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<Loader />
</div>
<!-- Error -->
<div v-else-if="error" class="text-center py-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<GoogleIcon name="error" class="text-3xl text-red-500" />
</div>
<p class="text-gray-600 dark:text-gray-400">{{ error }}</p>
</div>
<!-- Stats -->
<div v-else-if="stats" class="space-y-6">
<!-- Tier Actual -->
<div v-if="stats.current_tier" class="bg-gradient-to-br from-indigo-500 to-purple-600 rounded-lg p-6 text-white">
<div class="flex items-center justify-between">
<div>
<p class="text-sm opacity-90 mb-1">Nivel Actual</p>
<h3 class="text-2xl font-bold">{{ stats.current_tier.name }}</h3>
</div>
<div class="text-right">
<p class="text-sm opacity-90 mb-1">Descuento</p>
<h3 class="text-3xl font-bold">{{ stats.current_tier.discount }}%</h3>
</div>
</div>
</div>
<!-- Resumen de Compras -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Total Compras -->
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-green-700 dark:text-green-400 mb-1">Total Compras</p>
<p class="text-2xl font-bold text-green-900 dark:text-green-300">
{{ formatCurrency(stats.total_purchases) }}
</p>
</div>
<GoogleIcon name="shopping_cart" class="text-2xl text-green-600 dark:text-green-400" />
</div>
</div>
<!-- Devoluciones -->
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 border border-red-200 dark:border-red-800">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-red-700 dark:text-red-400 mb-1">Devoluciones</p>
<p class="text-2xl font-bold text-red-900 dark:text-red-300">
{{ formatCurrency(stats.lifetime_returns) }}
</p>
</div>
<GoogleIcon name="keyboard_return" class="text-2xl text-red-600 dark:text-red-400" />
</div>
</div>
<!-- Compras Netas -->
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-blue-700 dark:text-blue-400 mb-1">Compras Netas</p>
<p class="text-2xl font-bold text-blue-900 dark:text-blue-300">
{{ formatCurrency(stats.net_purchases) }}
</p>
</div>
<GoogleIcon name="payments" class="text-2xl text-blue-600 dark:text-blue-400" />
</div>
</div>
</div>
<!-- Información Adicional -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total de Transacciones</p>
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">
{{ stats.total_transactions }}
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Promedio por Compra</p>
<p class="text-xl font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(stats.average_purchase) }}
</p>
</div>
</div>
<!-- Última Compra -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="schedule" class="text-lg text-gray-600 dark:text-gray-400" />
<p class="text-sm font-semibold text-gray-600 dark:text-gray-400">Última Compra</p>
</div>
<p class="text-lg text-gray-900 dark:text-gray-100">
{{ formatDate(stats.last_purchase_at) }}
</p>
</div>
</div>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,679 @@
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { apiURL } from '@Services/Api';
import { formatDate, formatCurrency } from '@/utils/formatters';
import { regimenFiscalOptions, usoCfdiOptions } from '@/utils/fiscalData';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Loader from '@Shared/Loader.vue';
import Input from '@Holos/Form/Input.vue';
import PrimaryButton from '@Holos/Button/Primary.vue';
import SelectUsoCfdi from '@Components/POS/CfdiSelector.vue';
import SelectRegimenFiscal from '@Components/POS/RegimenSelector.vue';
import GoogleForm from '@Components/POS/GoogleForm.vue';
/** Definidores */
const route = useRoute();
/** Estado */
const loading = ref(true);
const submitting = ref(false);
const submitted = ref(false);
const error = ref(null);
const saleData = ref(null);
const clientData = ref(null);
const existingInvoiceRequest = ref(null);
const formErrors = ref({});
const searchingRfc = ref(false);
const rfcSearch = ref('');
const rfcSearchError = ref('');
const showGoogleForm = ref(true);
const form = ref({
name: '',
email: '',
phone: '',
address: '',
rfc: '',
razon_social: '',
regimen_fiscal: '',
cp_fiscal: '',
uso_cfdi: ''
});
const paymentMethods = [
{ value: 'cash', label: 'Efectivo' },
{ value: 'credit_card', label: 'Tarjeta de Crédito' },
{ value: 'debit_card', label: 'Tarjeta de Débito' }
];
const paymentMethodLabel = computed(() => {
const method = paymentMethods.find(m => saleData.value?.payment_method?.includes(m.value));
return method?.label || saleData.value?.payment_method || 'N/A';
});
/** Computed */
const invoiceNumber = computed(() => route.params.invoiceNumber);
const hasClient = computed(() => !!clientData.value);
const hasExistingRequest = computed(() => {
if (!saleData.value?.invoice_requests) return false;
return saleData.value.invoice_requests.length > 0;
});
const latestRequest = computed(() => {
if (!hasExistingRequest.value) return null;
return saleData.value.invoice_requests[0];
});
const canRequestInvoice = computed(() => {
if (!latestRequest.value) return true;
return latestRequest.value.status === 'rejected';
});
/** Helpers */
const getRegimenFiscalLabel = (value) => regimenFiscalOptions.find(o => o.value === value)?.label ?? value;
const getUsoCfdiLabel = (value) => usoCfdiOptions.find(o => o.value === value)?.label ?? value;
const statusLabels = { pending: 'Pendiente', processed: 'Completada', rejected: 'Rechazada' };
const getStatusLabel = (status) => statusLabels[status] ?? status;
/** Métodos */
const fetchSaleData = () => {
loading.value = true;
error.value = null;
window.axios.get(apiURL(`facturacion/${invoiceNumber.value}`))
.then(({ data }) => {
if (data.status === 'success') {
saleData.value = data.data.sale;
if (data.data.client) {
clientData.value = data.data.client;
fillFormWithClient(data.data.client);
}
}
})
.catch(({ response }) => {
if (response?.status === 404) {
error.value = 'No se encontró la venta con el folio proporcionado.';
} else if (response?.status === 400 || response?.status === 422) {
error.value = response.data?.data?.message || response.data?.message || 'Esta venta ya tiene una solicitud de facturación.';
existingInvoiceRequest.value = response.data?.data?.invoice_request || null;
} else {
error.value = response?.data?.message || 'Error al obtener los datos de la venta.';
}
})
.finally(() => {
loading.value = false;
});
};
const fillFormWithClient = (client) => {
form.value = {
name: client.name || '',
email: client.email || '',
phone: client.phone || '',
address: client.address || '',
rfc: client.rfc || '',
razon_social: client.razon_social || '',
regimen_fiscal: client.regimen_fiscal || '',
cp_fiscal: client.cp_fiscal || '',
uso_cfdi: client.uso_cfdi || ''
};
};
const searchClientByRfc = () => {
const rfc = rfcSearch.value?.trim().toUpperCase();
if (!rfc) {
rfcSearchError.value = 'Por favor ingrese un RFC';
return;
}
if (rfc.length < 12 || rfc.length > 13) {
rfcSearchError.value = 'El RFC debe tener entre 12 y 13 caracteres';
return;
}
searchingRfc.value = true;
rfcSearchError.value = '';
window.axios.get(apiURL(`facturacion/check-rfc?rfc=${rfc}`))
.then(({ data }) => {
if (data.status === 'success' && data.data?.exists && data.data?.client) {
clientData.value = data.data.client;
fillFormWithClient(data.data.client);
rfcSearch.value = '';
window.Notify.success('Cliente encontrado. Verifica los datos antes de continuar.');
} else if (data.status === 'success' && !data.data?.exists) {
rfcSearchError.value = 'No se encontró ningún cliente con este RFC';
window.Notify.warning('RFC no encontrado. Complete el formulario manualmente.');
} else {
rfcSearchError.value = 'No se encontró ningún cliente con este RFC';
}
})
.catch(() => {
rfcSearchError.value = 'Error al buscar el RFC. Intente nuevamente.';
})
.finally(() => {
searchingRfc.value = false;
});
};
const handleSearchKeypress = (event) => {
if (event.key === 'Enter') {
event.preventDefault();
searchClientByRfc();
}
};
const clearFoundClient = () => {
clientData.value = null;
rfcSearchError.value = '';
form.value = {
name: '',
email: '',
phone: '',
address: '',
rfc: '',
razon_social: '',
regimen_fiscal: '',
cp_fiscal: '',
uso_cfdi: ''
};
window.Notify.info('Formulario limpio. Puede ingresar nuevos datos.');
};
const submitForm = () => {
submitting.value = true;
formErrors.value = {};
window.axios.post(apiURL(`facturacion/${invoiceNumber.value}`), form.value)
.then(() => {
submitted.value = true;
showGoogleForm.value = true;
})
.catch(({ response }) => {
if (response?.status === 422 && response.data?.errors) {
formErrors.value = response.data.errors;
} else if (response?.data?.data?.errors) {
formErrors.value = response.data.data.errors;
} else {
error.value = response?.data?.message || response?.data?.data?.message || 'Error al enviar los datos.';
}
})
.finally(() => {
submitting.value = false;
});
};
onMounted(() => {
fetchSaleData();
});
</script>
<template>
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 py-8 px-4">
<div class="max-w-3xl mx-auto">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary dark:bg-primary-d mb-4">
<GoogleIcon name="receipt_long" class="text-3xl text-white" />
</div>
<h1 class="text-xl md:text-2xl font-bold text-gray-900 dark:text-white">
Solicitud de Factura
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
Complete sus datos fiscales para generar su factura
</p>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<Loader />
</div>
<!-- Error con detalles de solicitud existente -->
<div v-else-if="error" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 md:p-8 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<GoogleIcon name="error" class="text-3xl text-red-500" />
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
No se puede procesar
</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
{{ error }}
</p>
<div v-if="existingInvoiceRequest" class="mt-6 p-4 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50">
<h3 class="text-sm font-semibold mb-3 text-gray-800 dark:text-gray-200">
Detalles de la solicitud:
</h3>
<div class="text-sm space-y-2 text-gray-700 dark:text-gray-300">
<div class="flex items-center justify-center gap-2">
<span class="font-medium">Estado:</span>
<span class="px-3 py-1 rounded-full text-xs font-semibold">
{{ getStatusLabel(existingInvoiceRequest.status) }}
</span>
</div>
<p>
<span class="font-medium">Solicitada:</span>
<span class="ml-1">{{ formatDate(existingInvoiceRequest.requested_at) }}</span>
</p>
<p v-if="existingInvoiceRequest.processed_at">
<span class="font-medium">Procesada:</span>
<span class="ml-1">{{ formatDate(existingInvoiceRequest.processed_at) }}</span>
</p>
<p v-if="existingInvoiceRequest.notes" class="mt-2 pt-2 border-t border-gray-300 dark:border-gray-600 italic">
{{ existingInvoiceRequest.notes }}
</p>
</div>
</div>
</div>
<!-- Success State -->
<div v-else-if="submitted" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 md:p-8 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 dark:bg-green-900/30 mb-4">
<GoogleIcon name="check_circle" class="text-3xl text-green-500" />
</div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Solicitud Enviada
</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Sus datos han sido recibidos correctamente. Su solicitud será procesada a la brevedad y recibirá su factura en el correo electrónico proporcionado.
</p>
<p class="text-sm text-gray-500 dark:text-gray-500">
Folio: <span class="font-mono font-semibold">{{ invoiceNumber }}</span>
</p>
</div>
<!-- Content -->
<div v-else class="space-y-6">
<!-- Sale Info Card -->
<div v-if="saleData" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<GoogleIcon name="shopping_cart" class="text-xl" />
Datos de la Venta
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Folio:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white font-mono">{{ saleData.invoice_number }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Fecha:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white">
{{ new Date(saleData.created_at).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) }}
</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Método de pago:</span>
<span class="ml-2 font-semibold text-gray-900 dark:text-white capitalize">
{{ paymentMethodLabel }}
</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Total:</span>
<span class="ml-2 font-bold text-lg text-green-600 dark:text-green-400">
{{ formatCurrency(saleData.total) }}
</span>
</div>
</div>
<!-- Items de la venta -->
<div v-if="saleData.items && saleData.items.length > 0" class="mt-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Artículos Comprados
</h3>
<div class="space-y-2">
<div v-for="item in saleData.items" :key="item.id"
class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-sm">
<div class="flex justify-between items-start">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">
{{ item.product_name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ item.category }} SKU: {{ item.sku }}
</p>
<div v-if="item.serial_numbers && item.serial_numbers.length > 0"
class="mt-1 text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium">Serie(s):</span>
<span v-for="(serial, idx) in item.serial_numbers" :key="idx"
class="ml-1 font-mono">
{{ serial.serial_number }}{{ idx < item.serial_numbers.length - 1 ? ',' : '' }}
</span>
</div>
</div>
<div class="text-right ml-4">
<p class="font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(item.subtotal) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ item.quantity }} × {{ formatCurrency(item.unit_price) }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Historial de solicitudes -->
<div v-if="hasExistingRequest" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<GoogleIcon name="history" class="text-xl" />
Historial de Solicitudes
</h2>
<div class="space-y-3">
<div v-for="request in saleData.invoice_requests" :key="request.id"
class="p-4 border rounded-lg border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold text-gray-800 dark:text-gray-200">
Solicitud #{{ request.id }}
</span>
<span class="px-3 py-1 rounded-full text-xs font-semibold bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200">
{{ getStatusLabel(request.status) }}
</span>
</div>
<div class="text-xs space-y-1 text-gray-700 dark:text-gray-300">
<p class="flex items-center gap-2">
<GoogleIcon name="event" class="text-sm" />
<span class="font-medium">Solicitada:</span>
<span>{{ formatDate(request.requested_at) }}</span>
</p>
<p v-if="request.processed_at" class="flex items-center gap-2">
<GoogleIcon name="check" class="text-sm" />
<span class="font-medium">Procesada:</span>
<span>{{ formatDate(request.processed_at) }}</span>
</p>
<p v-if="request.notes" class="mt-2 pt-2 border-t border-gray-300 dark:border-gray-600 italic">
<GoogleIcon name="note" class="text-sm inline mr-1" />
{{ request.notes }}
</p>
</div>
</div>
</div>
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-blue-800 dark:text-blue-200">
<GoogleIcon name="info" class="inline mr-1" />
<span v-if="latestRequest?.status === 'pending'">
Su solicitud está siendo procesada. Recibirá su factura por correo electrónico.
</span>
<span v-else-if="latestRequest?.status === 'processed'">
Su factura ya fue emitida y enviada. Revise su correo electrónico.
</span>
<span v-else-if="latestRequest?.status === 'rejected'">
Su solicitud fue rechazada. Puede enviar una nueva solicitud corrigiendo los datos.
</span>
</div>
</div>
<!-- No puede solicitar factura -->
<div v-if="!canRequestInvoice && !submitted"
class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-lg p-6 text-center">
<GoogleIcon name="info" class="text-3xl text-yellow-600 dark:text-yellow-400 mb-2" />
<p class="text-yellow-800 dark:text-yellow-200 font-medium">
Ya existe una solicitud de factura en proceso para esta venta.
</p>
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-2">
No es posible crear una nueva solicitud hasta que la actual sea procesada o rechazada.
</p>
</div>
<!-- Buscador de cliente por RFC -->
<div v-if="canRequestInvoice && !hasClient" class="bg-gradient-to-br from-gray-50 to-indigo-50 dark:from-gray-900/20 dark:to-indigo-900/20 rounded-lg shadow-lg p-6 border border-gray-200 dark:border-gray-800">
<div class="flex items-center gap-2 mb-4">
<GoogleIcon name="person_search" class="text-2xl text-gray-600 dark:text-gray-400" />
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
¿Ya eres cliente?
</h2>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Busca tu RFC para autocompletar tus datos fiscales
</p>
<div class="flex gap-2">
<div class="flex-1">
<input
v-model="rfcSearch"
type="text"
placeholder="Ingresa tu RFC (ej: XAXX010101000)"
maxlength="13"
@keypress="handleSearchKeypress"
:disabled="searchingRfc"
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 uppercase"
/>
</div>
<button
@click="searchClientByRfc"
type="button"
:disabled="searchingRfc || !rfcSearch"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors font-medium flex items-center gap-2 shadow-md hover:shadow-lg"
>
<GoogleIcon
:name="searchingRfc ? 'sync' : 'search'"
:class="{ 'animate-spin': searchingRfc }"
class="text-xl"
/>
<span class="hidden sm:inline">
{{ searchingRfc ? 'Buscando...' : 'Buscar' }}
</span>
</button>
</div>
<p v-if="rfcSearchError" class="mt-3 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
<GoogleIcon name="error" class="text-lg" />
{{ rfcSearchError }}
</p>
<div class="mt-4 p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<p class="text-xs text-blue-800 dark:text-blue-300 flex items-start gap-2">
<GoogleIcon name="info" class="text-sm mt-0.5" />
<span>Si no encuentras tu RFC, no te preocupes. Podrás llenar el formulario manualmente más abajo.</span>
</p>
</div>
</div>
<!-- Cliente encontrado -->
<div v-if="hasClient && canRequestInvoice" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<GoogleIcon name="check_circle" class="text-xl text-green-600 dark:text-green-400" />
Cliente Identificado
</h2>
<button
@click="clearFoundClient"
type="button"
class="text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 flex items-center gap-1 font-medium"
>
<GoogleIcon name="close" class="text-lg" />
Usar otros datos
</button>
</div>
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
<p class="text-sm text-green-800 dark:text-green-300 flex items-center gap-2">
<GoogleIcon name="verified" class="text-lg" />
<span class="font-medium">Datos fiscales encontrados. Verifica que sean correctos antes de continuar.</span>
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm mb-6">
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span class="text-gray-500 dark:text-gray-400 block mb-1">Nombre:</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ form.name }}</span>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span class="text-gray-500 dark:text-gray-400 block mb-1">RFC:</span>
<span class="font-semibold font-mono text-gray-900 dark:text-white">{{ form.rfc || 'No registrado' }}</span>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span class="text-gray-500 dark:text-gray-400 block mb-1">Email:</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ form.email || 'No registrado' }}</span>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span class="text-gray-500 dark:text-gray-400 block mb-1">Teléfono:</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ form.phone || 'No registrado' }}</span>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span class="text-gray-500 dark:text-gray-400 block mb-1">Razón Social:</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ form.razon_social || 'No registrado' }}</span>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span class="text-gray-500 dark:text-gray-400 block mb-1">Régimen Fiscal:</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ getRegimenFiscalLabel(form.regimen_fiscal) }}</span>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span class="text-gray-500 dark:text-gray-400 block mb-1">C.P. Fiscal:</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ form.cp_fiscal || 'No registrado' }}</span>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span class="text-gray-500 dark:text-gray-400 block mb-1">Uso CFDI:</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ getUsoCfdiLabel(form.uso_cfdi) }}</span>
</div>
<div class="sm:col-span-2 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span class="text-gray-500 dark:text-gray-400 block mb-1">Dirección:</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ form.address || 'No registrado' }}</span>
</div>
</div>
<div class="flex justify-center">
<PrimaryButton
@click="submitForm"
:class="{ 'opacity-25': submitting }"
:disabled="submitting"
class="px-8 py-3"
>
<template v-if="submitting">
<GoogleIcon name="sync" class="animate-spin mr-2" />
Enviando solicitud...
</template>
<template v-else>
<GoogleIcon name="send" class="mr-2" />
Confirmar y Solicitar Factura
</template>
</PrimaryButton>
</div>
<p class="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
La factura se generará con los datos mostrados y se enviará a tu correo electrónico.
</p>
</div>
<!-- Formulario manual -->
<form v-else-if="canRequestInvoice && !hasClient" @submit.prevent="submitForm" class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6 flex items-center gap-2">
<GoogleIcon name="description" class="text-xl" />
Datos de Facturación
</h2>
<div class="grid gap-4 grid-cols-1 md:grid-cols-2">
<Input
v-model="form.rfc"
id="rfc"
title="RFC *"
placeholder="XAXX010101000"
maxlength="13"
:onError="formErrors.rfc"
/>
<Input
v-model="form.name"
id="name"
title="Nombre Completo *"
placeholder="Juan Pérez García"
:onError="formErrors.name"
/>
<Input
v-model="form.email"
id="email"
title="Correo Electrónico *"
type="email"
placeholder="correo@ejemplo.com"
:onError="formErrors.email"
/>
<Input
v-model="form.phone"
id="phone"
title="Teléfono *"
type="tel"
placeholder="55 1234 5678"
:onError="formErrors.phone"
/>
<Input
v-model="form.razon_social"
id="razon_social"
title="Razón Social *"
placeholder="Como aparece en la Constancia Fiscal"
:onError="formErrors.razon_social"
/>
<SelectRegimenFiscal
v-model="form.regimen_fiscal"
:error="formErrors.regimen_fiscal?.[0]"
required
/>
<Input
v-model="form.cp_fiscal"
id="cp_fiscal"
title="Código Postal Fiscal *"
placeholder="06600"
maxlength="5"
:onError="formErrors.cp_fiscal"
/>
<SelectUsoCfdi
v-model="form.uso_cfdi"
:error="formErrors.uso_cfdi?.[0]"
required
/>
<div class="md:col-span-2">
<Input
v-model="form.address"
id="address"
title="Dirección *"
placeholder="Calle, Número, Colonia, Ciudad, Estado"
:onError="formErrors.address"
/>
</div>
</div>
<div class="mt-6 flex justify-center">
<PrimaryButton
:class="{ 'opacity-25': submitting }"
:disabled="submitting"
>
<template v-if="submitting">
<GoogleIcon name="sync" class="animate-spin mr-2" />
Enviando...
</template>
<template v-else>
<GoogleIcon name="send" class="mr-2" />
Enviar Solicitud de Factura
</template>
</PrimaryButton>
</div>
<p class="mt-4 text-xs text-gray-500 dark:text-gray-400 text-center">
* Campos obligatorios. Al enviar este formulario, acepta que sus datos serán utilizados únicamente para la emisión de su factura fiscal.
</p>
</form>
</div>
<!-- Footer -->
<div class="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
<p>¿Tiene dudas? Visitanos para poder ayudarte.</p>
</div>
</div>
</div>
<GoogleForm
:show="showGoogleForm"
form-url="https://docs.google.com/forms/d/e/1FAIpQLSdhHLJ3SNbuIza9TZ6rQNAdI3sibmMjRJ8Udghiz08DnQvuSg/viewform?usp=publish-editor"
title="¡Queremos escucharte!"
message="Cuéntanos cómo fue tu experiencia. Tu feedback nos ayuda a mejorar y solo te tomará <strong class='underline'>menos de un minuto</strong>."
button-text="Ir a la encuesta"
:open-in-new-tab="true"
@close="showGoogleForm = false"
/>
</template>

View File

@ -0,0 +1,16 @@
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`Solicitudes de factura.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.factura.${name}`, params, query })
// Determina si un usuario puede hacer algo en base a los permisos
const can = (permission) => hasPermission(`invoice-requests.${permission}`)
export {
can,
viewTo,
apiTo
}

View File

@ -0,0 +1,418 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useForm, useApi, 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';
import CategoryCreateModal from '@Pages/POS/Category/CreateModal.vue';
import SubcategoryCreateModal from '@Pages/POS/Category/Subcategories/CreateModal.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Estado */
const categories = ref([]);
const subcategories = ref([]);
const units = ref([]);
const showCategoryCreate = ref(false);
const showSubcategoryCreate = ref(false);
const api = useApi();
/** Formulario */
const form = useForm({
name: '',
key_sat: '',
sku: '',
barcode: '',
category_id: '',
subcategory_id: '',
unit_of_measure_id: null,
retail_price: 0,
tax: 16,
track_serials: false
});
/** Computed */
const selectedUnit = computed(() => {
if (!form.unit_of_measure_id) return null;
return units.value.find(u => u.id === form.unit_of_measure_id);
});
const canUseSerials = computed(() => {
if (!selectedUnit.value) return true;
return !selectedUnit.value.allows_decimals;
});
/** Métodos */
const loadCategories = async () => {
try {
const response = await fetch(apiURL('categorias'), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.data && result.data.categories && result.data.categories.data) {
categories.value = result.data.categories.data;
}
} catch (error) {
console.error('Error loading categories:', error);
}
};
const onCategoryChange = () => {
form.subcategory_id = '';
subcategories.value = [];
if (!form.category_id) return;
api.get(apiURL(`categorias/${form.category_id}/subcategorias`), {
onSuccess: (data) => {
subcategories.value = data.subcategories?.data || data.subcategories || [];
}
});
};
const loadUnits = async () => {
try {
const response = await fetch(apiURL('unidades-medida/active'), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.data && result.data.units) {
units.value = result.data.units;
// Pre-seleccionar "Pieza" si existe
const defaultUnit = units.value.find(u => u.abbreviation === 'u');
if (defaultUnit && !form.unit_of_measure_id) {
form.unit_of_measure_id = defaultUnit.id;
}
}
} catch (error) {
console.error('Error loading units:', error);
}
};
const validateSerialsAndUnit = () => {
if (!selectedUnit.value) return;
if (selectedUnit.value.allows_decimals) {
if (form.track_serials) {
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
}
form.track_serials = false;
return;
}
const unitName = (selectedUnit.value.name || '').toLowerCase();
const unitAbbr = (selectedUnit.value.abbreviation || '').toLowerCase();
if (unitName.includes('serial') || unitAbbr.includes('serial')) {
form.track_serials = true;
}
};
const createProduct = () => {
form.transform((data) => ({
...data,
track_serials: selectedUnit.value ? !selectedUnit.value.allows_decimals && !!data.track_serials : false,
category_id: data.category_id || null,
subcategory_id: data.category_id ? (data.subcategory_id || null) : null,
})).post(apiURL('inventario'), {
onSuccess: () => {
Notify.success('Producto creado exitosamente');
emit('created');
closeModal();
},
onError: () => {
Notify.error('Error al crear el producto');
}
});
};
const onCategoryCreated = async (newCategory) => {
if (newCategory) {
form.category_id = newCategory.id;
}
await loadCategories();
};
const onSubcategoryCreated = (newSubcategory) => {
if (newSubcategory) {
subcategories.value.push(newSubcategory);
form.subcategory_id = newSubcategory.id;
}
};
const closeModal = () => {
form.reset();
subcategories.value = [];
emit('close');
};
/** Observadores */
watch(() => props.show, (newValue) => {
if (newValue) {
loadCategories();
loadUnits();
}
});
watch(() => form.unit_of_measure_id, () => {
validateSerialsAndUnit();
});
watch(() => form.track_serials, () => {
validateSerialsAndUnit();
});
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Crear Producto
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createProduct" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Nombre del producto"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Clave SAT -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CLAVE SAT
</label>
<FormInput
v-model="form.key_sat"
type="string"
placeholder="Clave SAT del producto"
maxlength="8"
/>
<FormError :message="form.errors?.key_sat" />
</div>
<!-- SKU -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SKU
</label>
<FormInput
v-model="form.sku"
type="text"
placeholder="SKU"
required
/>
<FormError :message="form.errors?.sku" />
</div>
<!-- Código de Barras -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CÓDIGO DE BARRAS
</label>
<FormInput
v-model="form.barcode"
type="text"
placeholder="1234567890123"
maxlength="14"
/>
<FormError :message="form.errors?.barcode" />
</div>
<!-- Clasificación -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CLASIFICACIÓN
</label>
<div class="flex gap-2">
<select
v-model="form.category_id"
class="flex-1 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"
@change="onCategoryChange"
>
<option value="">Seleccionar clasificación</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
<button
type="button"
@click="showCategoryCreate = true"
class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-600 border border-indigo-200 dark:bg-indigo-900/20 dark:hover:bg-indigo-900/40 dark:text-indigo-400 dark:border-indigo-800 transition-colors shrink-0"
title="Crear nueva clasificación"
>
<GoogleIcon name="add" class="text-xl" />
</button>
</div>
<FormError :message="form.errors?.category_id" />
</div>
<!-- Subclasificación -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SUBCLASIFICACIÓN
</label>
<div class="flex gap-2">
<select
v-model="form.subcategory_id"
class="flex-1 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 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!form.category_id || !subcategories.length"
:required="!!form.category_id"
>
<option value="">
{{ !form.category_id ? 'Selecciona una clasificación primero' : subcategories.length ? 'Seleccionar subclasificación...' : 'Sin subclasificaciones disponibles' }}
</option>
<option
v-for="subcategory in subcategories"
:key="subcategory.id"
:value="subcategory.id"
>
{{ subcategory.name }}
</option>
</select>
<button
type="button"
@click="showSubcategoryCreate = true"
:disabled="!form.category_id"
class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-600 border border-indigo-200 dark:bg-indigo-900/20 dark:hover:bg-indigo-900/40 dark:text-indigo-400 dark:border-indigo-800 transition-colors shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
title="Crear nueva subclasificación"
>
<GoogleIcon name="add" class="text-xl" />
</button>
</div>
<FormError :message="form.errors?.subcategory_id" />
</div>
<!-- Unidad de Medida -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
UNIDAD DE MEDIDA *
</label>
<select
v-model="form.unit_of_measure_id"
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"
required
>
<option :value="null">Seleccionar unidad</option>
<option
v-for="unit in units"
:key="unit.id"
:value="unit.id"
>
{{ unit.name }} ({{ unit.abbreviation }})
</option>
</select>
<FormError :message="form.errors?.unit_of_measure_id" />
<p v-if="selectedUnit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span v-if="selectedUnit.allows_decimals">Esta unidad permite cantidades decimales (ej: 25.750)</span>
<span v-else>Esta unidad solo permite cantidades enteras</span>
</p>
</div>
<!-- Precio de Venta -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PRECIO VENTA <span class="text-gray-500 dark:text-gray-400">(Subtotal)</span>
</label>
<FormInput
v-model.number="form.retail_price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
required
/>
<FormError :message="form.errors?.retail_price" />
</div>
<!-- Impuesto/Tax -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
IMPUESTO (%)
</label>
<FormInput
v-model.number="form.tax"
type="number"
min="0"
max="100"
step="0.01"
placeholder="16.00"
required
/>
<FormError :message="form.errors?.tax" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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">Guardando...</span>
<span v-else>Guardar</span>
</button>
</div>
</form>
</div>
</Modal>
<CategoryCreateModal
:show="showCategoryCreate"
@close="showCategoryCreate = false"
@created="onCategoryCreated"
/>
<SubcategoryCreateModal
:show="showSubcategoryCreate"
:category-id="form.category_id"
@close="showSubcategoryCreate = false"
@created="onSubcategoryCreated"
/>
</template>

View File

@ -0,0 +1,115 @@
<script setup>
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
product: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Métodos */
const handleConfirm = () => {
emit('confirm', props.product.id);
};
const handleClose = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @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-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Eliminar Producto
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-5">
<p class="text-gray-700 dark:text-gray-300 text-base">
¿Estás seguro de que deseas eliminar este producto?
</p>
<div v-if="product" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-base font-bold text-gray-900 dark:text-gray-100 mb-1">
{{ product.name }}
</p>
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span class="font-mono font-medium">SKU: {{ product.sku }}</span>
<span class="text-gray-400"></span>
<span>{{ product.category?.name || 'Sin clasificación' }}</span>
</div>
</div>
<div class="text-right">
<p class="text-sm text-gray-500 dark:text-gray-400">Stock</p>
<p class="text-2xl font-bold" :class="product.stock > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-gray-400'">
{{ product.stock }}
</p>
</div>
</div>
<div v-if="product.stock > 0" class="flex items-start gap-2 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-3">
<GoogleIcon name="warning" class="text-orange-600 dark:text-orange-400 text-xl flex-shrink-0 mt-0.5" />
<p class="text-sm text-orange-800 dark:text-orange-300 font-medium">
Este producto tiene <strong>{{ product.stock }} unidades en stock</strong>. Al eliminarlo, se perderá el inventario registrado.
</p>
</div>
</div>
<div class="flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<GoogleIcon name="info" class="text-red-600 dark:text-red-400 text-xl flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-800 dark:text-red-300 font-medium">
Esta acción es permanente y no se puede deshacer.
</p>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-5 py-2.5 text-sm font-semibold 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
>
Cancelar
</button>
<button
type="button"
@click="handleConfirm"
class="flex items-center gap-2 px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 shadow-lg shadow-red-600/30 transition-all"
>
<GoogleIcon name="delete" class="text-xl" />
Eliminar Producto
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,861 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useForm, useApi, 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';
import CategoryCreateModal from '@Pages/POS/Category/CreateModal.vue';
import SubcategoryCreateModal from '@Pages/POS/Category/Subcategories/CreateModal.vue';
const router = useRouter();
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
product: Object
});
/** Estado */
const categories = ref([]);
const subcategories = ref([]);
const units = ref([]);
const activeTab = ref('general');
const showCategoryCreate = ref(false);
const showSubcategoryCreate = ref(false);
// Estado de equivalencias
const equivalences = ref([]);
const baseUnit = ref(null);
const loadingEquivalences = ref(false);
const showEquivalenceForm = ref(false);
const editingEquivalence = ref(null);
const api = useApi();
/** Formulario principal */
const form = useForm({
name: '',
key_sat: '',
sku: '',
barcode: '',
category_id: '',
subcategory_id: '',
unit_of_measure_id: null,
retail_price: 0,
tax: 16,
track_serials: false
});
/** Formulario de equivalencia */
const eqForm = useForm({
unit_of_measure_id: null,
conversion_factor: '',
retail_price: ''
});
/** Computed */
const selectedUnit = computed(() => {
if (!form.unit_of_measure_id) return null;
return units.value.find(u => u.id === form.unit_of_measure_id);
});
const canUseSerials = computed(() => {
if (!selectedUnit.value) return true;
if (selectedUnit.value.allows_decimals) return false;
return equivalences.value.length === 0; // No puede tener seriales si ya tiene equivalencias
});
const canHaveEquivalences = computed(() => {
return props.product && !props.product.track_serials;
});
// La unidad se bloquea si el producto ya tiene movimientos de inventario
const unitLocked = computed(() => {
if (!props.product?.id) return false;
return (
Number(props.product?.movements_count || 0) > 0 ||
Number(props.product?.stock || 0) > 0 ||
Number(props.product?.serials_count || 0) > 0
);
});
// Unidades disponibles para agregar equivalencia (excluir la base y las ya usadas)
const availableUnitsForEquivalence = computed(() => {
const usedIds = equivalences.value.map(e => e.unit_of_measure_id);
const editingId = editingEquivalence.value?.unit_of_measure_id;
return units.value.filter(u => {
if (u.id === form.unit_of_measure_id) return false; // excluir unidad base
if (usedIds.includes(u.id) && u.id !== editingId) return false; // excluir ya usadas (excepto la que se edita)
const name = (u.name || '').toLowerCase();
const abbr = (u.abbreviation || '').toLowerCase();
if (name.includes('serial') || abbr.includes('serial')) return false; // no aplica como equivalencia
return true;
});
});
/** Métodos */
const loadCategories = async () => {
try {
const response = await fetch(apiURL('categorias'), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.data && result.data.categories && result.data.categories.data) {
categories.value = result.data.categories.data;
// Cargar subcategorías si ya hay una categoría seleccionada (producto existente)
if (form.category_id) {
api.get(apiURL(`categorias/${form.category_id}/subcategorias`), {
onSuccess: (data) => {
subcategories.value = data.subcategories?.data || data.subcategories || [];
}
});
}
}
} catch (error) {
console.error('Error loading categories:', error);
}
};
const onCategoryChange = () => {
form.subcategory_id = '';
subcategories.value = [];
if (!form.category_id) return;
api.get(apiURL(`categorias/${form.category_id}/subcategorias`), {
onSuccess: (data) => {
subcategories.value = data.subcategories?.data || data.subcategories || [];
}
});
};
const loadUnits = async () => {
try {
const response = await fetch(apiURL('unidades-medida/active'), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.data && result.data.units) {
units.value = result.data.units;
}
} catch (error) {
console.error('Error loading units:', error);
}
};
const loadEquivalences = () => {
if (!props.product?.id) return;
loadingEquivalences.value = true;
api.get(apiURL(`inventario/${props.product.id}/equivalencias`), {
onSuccess: (data) => {
equivalences.value = data.equivalences || [];
baseUnit.value = data.base_unit || null;
},
onFail: () => {
equivalences.value = [];
},
onFinish: () => {
loadingEquivalences.value = false;
}
});
};
const validateSerialsAndUnit = () => {
if (!selectedUnit.value) return;
if (selectedUnit.value.allows_decimals) {
if (form.track_serials) {
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
}
form.track_serials = false;
return;
}
const unitName = (selectedUnit.value.name || '').toLowerCase();
const unitAbbr = (selectedUnit.value.abbreviation || '').toLowerCase();
if (unitName.includes('serial') || unitAbbr.includes('serial')) {
form.track_serials = true;
}
};
const updateProduct = () => {
const hasSerials = Number(props.product?.serials_count || 0) > 0;
form.transform((data) => ({
...data,
track_serials: selectedUnit.value && !selectedUnit.value.allows_decimals
? (hasSerials || !!data.track_serials)
: false,
category_id: data.category_id || null,
subcategory_id: data.category_id ? (data.subcategory_id || null) : null,
})).put(apiURL(`inventario/${props.product.id}`), {
onSuccess: () => {
Notify.success('Producto actualizado exitosamente');
emit('updated');
closeModal();
},
onError: () => {
Notify.error('Error al actualizar el producto');
}
});
};
const closeModal = () => {
form.reset();
activeTab.value = 'general';
showEquivalenceForm.value = false;
editingEquivalence.value = null;
equivalences.value = [];
baseUnit.value = null;
emit('close');
};
const onCategoryCreated = async (newCategory) => {
await loadCategories();
if (newCategory) {
form.category_id = newCategory.id;
onCategoryChange();
}
};
const onSubcategoryCreated = (newSubcategory) => {
if (newSubcategory) {
subcategories.value.push(newSubcategory);
form.subcategory_id = newSubcategory.id;
}
};
const openSerials = () => {
if (selectedUnit.value && selectedUnit.value.allows_decimals) {
Notify.warning('No se pueden usar números de serie con esta unidad de medida.');
return;
}
form.track_serials = true;
closeModal();
router.push({ name: 'pos.inventory.serials', params: { id: props.product.id } });
};
// Equivalencias
const openAddEquivalence = () => {
editingEquivalence.value = null;
eqForm.unit_of_measure_id = null;
eqForm.conversion_factor = '';
eqForm.retail_price = '';
showEquivalenceForm.value = true;
};
const openEditEquivalence = (eq) => {
editingEquivalence.value = eq;
eqForm.unit_of_measure_id = eq.unit_of_measure_id;
eqForm.conversion_factor = parseFloat(eq.conversion_factor);
eqForm.retail_price = eq.retail_price ? parseFloat(eq.retail_price) : '';
showEquivalenceForm.value = true;
};
const cancelEquivalenceForm = () => {
showEquivalenceForm.value = false;
editingEquivalence.value = null;
eqForm.reset();
};
const saveEquivalence = () => {
const productId = props.product.id;
if (editingEquivalence.value) {
const eqId = editingEquivalence.value.id;
eqForm.transform((data) => ({
conversion_factor: data.conversion_factor,
retail_price: data.retail_price || undefined,
})).put(apiURL(`inventario/${productId}/equivalencias/${eqId}`), {
onSuccess: () => {
Notify.success('Equivalencia actualizada');
cancelEquivalenceForm();
loadEquivalences();
},
onError: () => {
Notify.error('Error al actualizar la equivalencia');
}
});
} else {
eqForm.transform((data) => ({
unit_of_measure_id: data.unit_of_measure_id,
conversion_factor: data.conversion_factor,
retail_price: data.retail_price || undefined,
})).post(apiURL(`inventario/${productId}/equivalencias`), {
onSuccess: () => {
Notify.success('Equivalencia creada');
cancelEquivalenceForm();
loadEquivalences();
},
onError: () => {
Notify.error('Error al crear la equivalencia');
}
});
}
};
const deleteEquivalence = (eq) => {
if (!confirm(`¿Eliminar la equivalencia con "${eq.unit_name}"?`)) return;
api.delete(apiURL(`inventario/${props.product.id}/equivalencias/${eq.id}`), {
onSuccess: () => {
Notify.success('Equivalencia eliminada');
loadEquivalences();
},
onFail: (data) => {
Notify.error(data.message || 'Error al eliminar la equivalencia');
},
onError: () => {
Notify.error('Error de conexión');
}
});
};
/** Observadores */
watch(() => props.product, (newProduct) => {
if (newProduct) {
form.name = newProduct.name || '';
form.key_sat = newProduct.key_sat || '';
form.sku = newProduct.sku || '';
form.barcode = newProduct.barcode || '';
form.category_id = newProduct.category_id || '';
form.subcategory_id = newProduct.subcategory_id || '';
form.unit_of_measure_id = newProduct.unit_of_measure_id || null;
form.cost = parseFloat(newProduct.price?.cost || 0);
form.retail_price = parseFloat(newProduct.price?.retail_price || 0);
form.tax = parseFloat(newProduct.price?.tax || 16);
const serialCount = Number(newProduct.serials_count || 0);
form.track_serials = !!newProduct.track_serials || serialCount > 0;
}
}, { immediate: true });
watch(() => props.show, async (newValue) => {
if (newValue) {
loadUnits();
activeTab.value = 'general';
await loadCategories();
}
});
watch(selectedUnit, () => {
validateSerialsAndUnit();
});
watch(activeTab, (tab) => {
if (tab === 'equivalences' && props.product?.id) {
loadEquivalences();
}
});
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Editar Producto
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Tabs -->
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-5">
<button
@click="activeTab = 'general'"
:class="[
'px-4 py-2 text-sm font-medium border-b-2 transition-colors',
activeTab === 'general'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
Información General
</button>
<button
v-if="canHaveEquivalences"
@click="activeTab = 'equivalences'"
:class="[
'px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5',
activeTab === 'equivalences'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
Equivalencias
<span
v-if="equivalences.length > 0"
class="inline-flex items-center justify-center w-4 h-4 text-xs font-bold rounded-full bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
>
{{ equivalences.length }}
</span>
</button>
<div
v-else
class="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-300 dark:text-gray-600 cursor-not-allowed"
title="No disponible para productos con rastreo de seriales"
>
Equivalencias
</div>
</div>
<!-- Tab: Información General -->
<div v-if="activeTab === 'general'">
<form @submit.prevent="updateProduct" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE
</label>
<FormInput
v-model="form.name"
type="text"
placeholder="Nombre del producto"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- Clave SAT -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CLAVE SAT
</label>
<FormInput
v-model="form.key_sat"
type="string"
placeholder="Clave SAT del producto"
/>
<FormError :message="form.errors?.key_sat" />
</div>
<!-- SKU -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SKU
</label>
<FormInput
v-model="form.sku"
type="text"
placeholder="SKU"
required
/>
<FormError :message="form.errors?.sku" />
</div>
<!-- Código de Barras -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CÓDIGO DE BARRAS
</label>
<FormInput
v-model="form.barcode"
type="text"
placeholder="1234567890123"
maxlength="100"
/>
<FormError :message="form.errors?.barcode" />
</div>
<!-- Categoría -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CLASIFICACIÓN
</label>
<div class="flex gap-2">
<select
v-model="form.category_id"
class="flex-1 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"
@change="onCategoryChange"
>
<option value="">Seleccionar clasificación</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
<button
type="button"
@click="showCategoryCreate = true"
class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-600 border border-indigo-200 dark:bg-indigo-900/20 dark:hover:bg-indigo-900/40 dark:text-indigo-400 dark:border-indigo-800 transition-colors shrink-0"
title="Crear nueva clasificación"
>
<GoogleIcon name="add" class="text-xl" />
</button>
</div>
<FormError :message="form.errors?.category_id" />
</div>
<!-- Subclasificación -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
SUBCLASIFICACIÓN
</label>
<div class="flex gap-2">
<select
v-model="form.subcategory_id"
class="flex-1 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 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="!form.category_id || !subcategories.length"
:required="!!form.category_id"
>
<option value="">
{{ !form.category_id ? 'Selecciona una clasificación primero' : subcategories.length ? 'Seleccionar subclasificación...' : 'Sin subclasificaciones disponibles' }}
</option>
<option
v-for="subcategory in subcategories"
:key="subcategory.id"
:value="subcategory.id"
>
{{ subcategory.name }}
</option>
</select>
<button
type="button"
@click="showSubcategoryCreate = true"
:disabled="!form.category_id"
class="flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-50 hover:bg-indigo-100 text-indigo-600 border border-indigo-200 dark:bg-indigo-900/20 dark:hover:bg-indigo-900/40 dark:text-indigo-400 dark:border-indigo-800 transition-colors shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
title="Crear nueva subclasificación"
>
<GoogleIcon name="add" class="text-xl" />
</button>
</div>
<FormError :message="form.errors?.subcategory_id" />
</div>
<!-- Unidad de Medida -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
UNIDAD DE MEDIDA *
</label>
<select
v-model="form.unit_of_measure_id"
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 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="unitLocked"
required
>
<option :value="null">Seleccionar unidad</option>
<option
v-for="unit in units"
:key="unit.id"
:value="unit.id"
>
{{ unit.name }} ({{ unit.abbreviation }})
</option>
</select>
<FormError :message="form.errors?.unit_of_measure_id" />
<p v-if="unitLocked" class="mt-1 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
<GoogleIcon name="lock" class="text-xs" />
No se puede cambiar: el producto ya tiene movimientos de inventario
</p>
<p v-else-if="selectedUnit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span v-if="selectedUnit.allows_decimals">Esta unidad permite cantidades decimales (ej: 25.750)</span>
<span v-else>Esta unidad solo permite cantidades enteras</span>
</p>
</div>
<!-- Precio de Venta -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PRECIO VENTA
</label>
<FormInput
v-model.number="form.retail_price"
type="number"
min="0"
step="0.01"
placeholder="0.00"
required
/>
<FormError :message="form.errors?.retail_price" />
</div>
<!-- Impuesto/Tax -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
IMPUESTO (%)
</label>
<FormInput
v-model.number="form.tax"
type="number"
min="0"
max="100"
step="0.01"
placeholder="16.00"
required
/>
<FormError :message="form.errors?.tax" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-between mt-6">
<button
v-if="canUseSerials"
type="button"
@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"
>
<GoogleIcon name="qr_code_2" class="text-lg" />
Gestionar Seriales
</button>
<div
v-else
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-400 bg-gray-50 border border-gray-200 rounded-lg cursor-not-allowed"
:title="equivalences.length > 0 ? 'No disponible: el producto tiene equivalencias de unidad' : selectedUnit?.allows_decimals ? `No disponible: ${selectedUnit.name} permite decimales` : 'Selecciona una unidad de medida'"
>
<GoogleIcon name="qr_code_2" class="text-lg opacity-50" />
Gestionar Seriales
</div>
<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>
<!-- Tab: Equivalencias -->
<div v-else-if="activeTab === 'equivalences'">
<!-- Unidad base -->
<div v-if="baseUnit" class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg flex items-center gap-2">
<GoogleIcon name="info" class="text-gray-400 text-sm" />
<p class="text-sm text-gray-600 dark:text-gray-400">
Unidad base:
<span class="font-semibold text-gray-900 dark:text-gray-100">
{{ baseUnit.name }} ({{ baseUnit.abbreviation }})
</span>
</p>
</div>
<!-- Loading -->
<div v-if="loadingEquivalences" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
</div>
<div v-else>
<!-- Lista de equivalencias -->
<div v-if="equivalences.length > 0" class="space-y-2 mb-4">
<div
v-for="eq in equivalences"
:key="eq.id"
class="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-lg"
:class="{ 'opacity-50': !eq.is_active }"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ eq.unit_name }}
<span class="font-mono text-gray-500 dark:text-gray-400 text-xs">({{ eq.unit_abbreviation }})</span>
</p>
<span
v-if="!eq.is_active"
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400"
>
Inactivo
</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1 {{ eq.unit_name }} = {{ parseFloat(eq.conversion_factor) }} {{ baseUnit?.abbreviation }}
&nbsp;·&nbsp;
Precio: ${{ parseFloat(eq.retail_price).toFixed(2) }}
</p>
</div>
<div class="flex items-center gap-1 ml-2">
<button
@click="openEditEquivalence(eq)"
class="p-1.5 text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-900 rounded-lg transition-colors"
title="Editar equivalencia"
>
<GoogleIcon name="edit" class="text-base" />
</button>
<button
@click="deleteEquivalence(eq)"
class="p-1.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900 rounded-lg transition-colors"
title="Eliminar equivalencia"
>
<GoogleIcon name="delete" class="text-base" />
</button>
</div>
</div>
</div>
<div
v-else-if="!showEquivalenceForm"
class="flex flex-col items-center justify-center py-6 text-gray-400"
>
<GoogleIcon name="straighten" class="text-4xl mb-2 opacity-50" />
<p class="text-sm">Este producto no tiene equivalencias</p>
</div>
<!-- Formulario inline de equivalencia -->
<div v-if="showEquivalenceForm" class="border border-indigo-200 dark:border-indigo-800 rounded-lg p-4 bg-indigo-50 dark:bg-indigo-950 space-y-3">
<h4 class="text-sm font-semibold text-indigo-800 dark:text-indigo-200">
{{ editingEquivalence ? 'Editar equivalencia' : 'Nueva equivalencia' }}
</h4>
<!-- Unidad (solo en creación) -->
<div v-if="!editingEquivalence">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
UNIDAD
</label>
<select
v-model="eqForm.unit_of_measure_id"
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"
required
>
<option :value="null">Seleccionar unidad</option>
<option
v-for="unit in availableUnitsForEquivalence"
:key="unit.id"
:value="unit.id"
>
{{ unit.name }} ({{ unit.abbreviation }})
</option>
</select>
<FormError :message="eqForm.errors?.unit_of_measure_id" />
</div>
<!-- Si estamos editando, mostrar la unidad como texto -->
<div v-else>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
UNIDAD
</label>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">
{{ editingEquivalence.unit_name }} ({{ editingEquivalence.unit_abbreviation }})
</p>
</div>
<div class="grid grid-cols-2 gap-3">
<!-- Factor de conversión -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
FACTOR DE CONVERSIÓN
</label>
<FormInput
v-model.number="eqForm.conversion_factor"
type="number"
min="0"
step="any"
placeholder="Ej: 24"
required
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
1 unidad = X {{ baseUnit?.abbreviation || 'base' }}
</p>
<FormError :message="eqForm.errors?.conversion_factor" />
</div>
<!-- Precio sugerido -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PRECIO SUGERIDO
</label>
<FormInput
v-model.number="eqForm.retail_price"
type="number"
min="0"
step="0.01"
placeholder="Automático"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Dejar vacío para calcular automáticamente
</p>
<FormError :message="eqForm.errors?.retail_price" />
</div>
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
@click="cancelEquivalenceForm"
class="px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
type="button"
@click="saveEquivalence"
:disabled="eqForm.processing"
class="px-3 py-1.5 text-xs font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
<span v-if="eqForm.processing">Guardando...</span>
<span v-else>{{ editingEquivalence ? 'Actualizar' : 'Agregar' }}</span>
</button>
</div>
</div>
<!-- Botón agregar equivalencia -->
<button
v-if="!showEquivalenceForm"
type="button"
@click="openAddEquivalence"
class="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-indigo-700 bg-indigo-50 border border-dashed border-indigo-300 rounded-lg hover:bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-300 dark:border-indigo-700 dark:hover:bg-indigo-800 transition-colors"
>
<GoogleIcon name="add" class="text-lg" />
Agregar equivalencia
</button>
</div>
<!-- Botón cerrar en tab equivalencias -->
<div class="flex justify-end 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 transition-colors"
>
Cerrar
</button>
</div>
</div>
</div>
</Modal>
<CategoryCreateModal
:show="showCategoryCreate"
@close="showCategoryCreate = false"
@created="onCategoryCreated"
/>
<SubcategoryCreateModal
:show="showSubcategoryCreate"
:category-id="form.category_id"
@close="showSubcategoryCreate = false"
@created="onSubcategoryCreated"
/>
</template>

View File

@ -0,0 +1,292 @@
<script setup>
import { ref } from 'vue';
import { apiURL } from '@Services/Api';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: Boolean
});
/** Eventos */
const emit = defineEmits(['close', 'imported']);
/** Estado */
const selectedFile = ref(null);
const uploading = ref(false);
const importResults = ref(null);
const fileInput = ref(null);
/** Métodos */
const handleFileSelect = (event) => {
const file = event.target.files[0];
if (!file) {
selectedFile.value = null;
return;
}
// Validar extensión
const validExtensions = ['.xlsx', '.xls'];
const fileExtension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!validExtensions.includes(fileExtension)) {
window.Notify.error('Por favor selecciona un archivo Excel (.xlsx o .xls)');
selectedFile.value = null;
event.target.value = '';
return;
}
selectedFile.value = file;
importResults.value = null;
};
const downloadTemplate = async () => {
try {
window.Notify.info('Descargando plantilla...');
const response = await fetch(apiURL('inventario/template/download'), {
method: 'GET',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
}
});
if (!response.ok) {
throw new Error('Error al descargar la plantilla');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'plantilla_productos.xlsx';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
window.Notify.success('Plantilla descargada exitosamente');
} catch (error) {
console.error('Error:', error);
window.Notify.error('Error al descargar la plantilla');
}
};
const importProducts = async () => {
if (!selectedFile.value) {
window.Notify.warning('Por favor selecciona un archivo');
return;
}
uploading.value = true;
importResults.value = null;
try {
const formData = new FormData();
formData.append('file', selectedFile.value);
const response = await fetch(apiURL('inventario/import'), {
method: 'POST',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json',
},
body: formData
});
const data = await response.json();
if (response.ok && data.status === 'success') {
importResults.value = data.data;
const { imported, skipped, errors } = data.data;
if (imported > 0) {
window.Notify.success(`${imported} producto(s) creado(s)/actualizado(s) exitosamente. Recuerda registrar el stock mediante entradas de almacén.`);
}
if (skipped > 0) {
window.Notify.warning(`${skipped} producto(s) omitido(s)`);
}
if (errors && errors.length > 0) {
console.error('Errores de importación:', errors);
}
// Resetear formulario
selectedFile.value = null;
if (fileInput.value) {
fileInput.value.value = '';
}
// Notificar al componente padre
emit('imported');
} else {
// Manejar errores de validación
if (data.data?.errors) {
const errorMessages = data.data.errors.map(err =>
`Fila ${err.row}: ${err.errors.join(', ')}`
).join('\n');
console.error('Errores de validación:', errorMessages);
window.Notify.error('Error de validación en el archivo.');
} else {
window.Notify.error(data.data?.message || 'Error al importar productos');
}
}
} catch (error) {
console.error('Error:', error);
window.Notify.error('Error al importar productos');
} finally {
uploading.value = false;
}
};
const closeModal = () => {
selectedFile.value = null;
importResults.value = null;
if (fileInput.value) {
fileInput.value.value = '';
}
emit('close');
};
</script>
<template>
<div v-if="show" class="fixed inset-0 z-50 overflow-y-auto">
<!-- Overlay -->
<div class="fixed inset-0 bg-black/50 dark:bg-black/50 transition-opacity" @click="closeModal"></div>
<!-- Modal -->
<div class="flex min-h-screen items-center justify-center p-4">
<div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
Importar Productos desde Excel
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 transition-colors"
>
<GoogleIcon name="close" class="text-2xl" />
</button>
</div>
<!-- Body -->
<div class="p-6">
<!-- Instrucciones -->
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex items-start gap-3">
<GoogleIcon name="info" class="text-blue-600 dark:text-blue-400 text-xl mt-0.5" />
<div class="text-sm text-blue-700 dark:text-blue-300">
<p class="font-semibold mb-2">Instrucciones:</p>
<ol class="list-decimal ml-4 space-y-1">
<li>Descarga la plantilla de Excel haciendo clic en el botón de abajo</li>
<li>Completa la plantilla con los datos básicos de tus productos y precios</li>
<li>Guarda el archivo y súbelo usando el botón "Seleccionar archivo"</li>
<li>Haz clic en "Importar" para procesar el archivo</li>
</ol>
<div class="mt-3 pt-3 border-t border-blue-300 dark:border-blue-700">
<p class="font-semibold mb-1"> Importante:</p>
<ul class="list-disc ml-4 space-y-0.5">
<li>Esta importación solo crea/actualiza información básica de productos</li>
<li>El costo se inicializa en $0 y se actualiza con las entradas de almacén</li>
<li>Para gestionar stock y números de serie, utiliza "Movimientos" "Entradas"</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Botón Descargar Plantilla -->
<div class="mb-6">
<button
@click="downloadTemplate"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors"
>
<GoogleIcon name="download" class="text-xl" />
Descargar Plantilla Excel
</button>
</div>
<!-- Selector de Archivo -->
<div class="mb-6">
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Archivo Excel (.xlsx, .xls)
</label>
<div class="flex items-center gap-3">
<label class="flex-1 cursor-pointer">
<div class="flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-indigo-500 dark:hover:border-indigo-400 transition-colors">
<GoogleIcon name="upload_file" class="text-2xl text-gray-400" />
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ selectedFile ? selectedFile.name : 'Seleccionar archivo Excel...' }}
</span>
</div>
<input
ref="fileInput"
type="file"
accept=".xlsx,.xls"
@change="handleFileSelect"
class="hidden"
/>
</label>
<button
v-if="selectedFile"
@click="selectedFile = null; fileInput.value = ''"
class="p-3 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Eliminar archivo"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</div>
<!-- Resultados de Importación -->
<div v-if="importResults" class="mb-6 p-4 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-semibold text-gray-900 dark:text-gray-100 mb-3">Resultados de la Importación</h4>
<div class="space-y-2 text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Productos importados:</span>
<span class="font-semibold text-green-600 dark:text-green-400">{{ importResults.imported }}</span>
</div>
<div v-if="importResults.skipped > 0" class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Productos omitidos:</span>
<span class="font-semibold text-yellow-600 dark:text-yellow-400">{{ importResults.skipped }}</span>
</div>
<div v-if="importResults.errors && importResults.errors.length > 0">
<p class="text-red-600 dark:text-red-400 font-semibold mb-1">Errores:</p>
<ul class="list-disc ml-5 text-red-600 dark:text-red-400">
<li v-for="(error, index) in importResults.errors" :key="index">{{ error }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
<button
@click="closeModal"
class="px-4 py-2 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancelar
</button>
<button
@click="importProducts"
:disabled="!selectedFile || uploading"
class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<GoogleIcon
:name="uploading ? 'hourglass_empty' : 'upload'"
:class="{ 'animate-spin': uploading }"
class="text-xl"
/>
{{ uploading ? 'Importando...' : 'Importar Productos' }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,494 @@
<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';
import reportService from '@Services/reportService';
const router = useRouter();
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import CreateModal from './CreateModal.vue';
import EditModal from './EditModal.vue';
import DeleteModal from './DeleteModal.vue';
import ImportModal from './ImportModal.vue';
/** Estado */
const models = ref([]);
const totalInventoryValue = ref(0);
const categories = ref([]);
const selectedCategory = ref('');
const showCreateModal = ref(false);
const showEditModal = ref(false);
const showDeleteModal = ref(false);
const showImportModal = ref(false);
const editingProduct = ref(null);
const deletingProduct = ref(null);
const isExporting = ref(false);
const fecha_inicio = ref('');
const fecha_fin = ref('');
const currentSearch = ref('');
/** Métodos */
const searcher = useSearcher({
url: apiURL('inventario'),
onSuccess: (r) => {
models.value = r.products || { data: [], total: 0 };
totalInventoryValue.value = r.total_inventory_value || 0;
},
onError: () => {
models.value = { data: [], total: 0 };
totalInventoryValue.value = 0;
}
});
const loadCategories = async () => {
try {
const response = await fetch(apiURL('categorias'), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.data && result.data.categories && result.data.categories.data) {
categories.value = result.data.categories.data;
}
} catch (error) {
console.error('Error al cargar clasificaciones:', error);
}
};
const loadSubcategories = async () => {
try {
const response = await fetch(apiURL('subcategorias'), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.data && result.data.subcategories && result.data.subcategories.data) {
categories.value = result.data.subcategories.data;
}
} catch (error) {
console.error('Error al cargar subclasificaciones:', error);
}
};
const openCreateModal = () => {
showCreateModal.value = true;
};
const closeCreateModal = () => {
showCreateModal.value = false;
};
const openEditModal = (product) => {
editingProduct.value = product;
showEditModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
editingProduct.value = null;
};
const openDeleteModal = (product) => {
deletingProduct.value = product;
showDeleteModal.value = true;
};
const closeDeleteModal = () => {
showDeleteModal.value = false;
deletingProduct.value = null;
};
const openImportModal = () => {
showImportModal.value = true;
};
const closeImportModal = () => {
showImportModal.value = false;
};
const onProductSaved = () => {
applyFilters();
};
const onProductsImported = () => {
applyFilters();
};
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}`), {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
if (response.ok) {
Notify.success('Producto eliminado exitosamente');
closeDeleteModal();
applyFilters();
} else {
Notify.error('Error al eliminar el producto');
}
} catch (error) {
console.error('Error:', error);
Notify.error('Error al eliminar el producto');
}
};
const applyFilters = () => {
const filters = {
category_id: selectedCategory.value || '',
};
if (fecha_inicio.value) {
filters.fecha_inicio = fecha_inicio.value;
}
if (fecha_fin.value) {
filters.fecha_fin = fecha_fin.value;
}
searcher.search('', filters);
};
const handleCategoryChange = () => {
applyFilters();
};
const exportReport = async () => {
try {
isExporting.value = true;
const filters = {
category_id: selectedCategory.value || null,
};
if (fecha_inicio.value) {
filters.fecha_inicio = fecha_inicio.value;
}
if (fecha_fin.value) {
filters.fecha_fin = fecha_fin.value;
}
if (currentSearch.value) {
filters.q = currentSearch.value;
}
await reportService.exportInventoryToExcel(filters);
Notify.success('Reporte exportado exitosamente');
} catch (error) {
console.error('Error al exportar:', error);
// Mostrar errores de validación específicos del backend
if (error.response?.data?.errors) {
const errors = error.response.data.errors;
const errorMessages = Object.values(errors).flat();
errorMessages.forEach(msg => Notify.error(msg));
} else if (error.response?.data?.message) {
Notify.error(error.response.data.message);
} else if (error.message) {
Notify.error(error.message);
} else {
Notify.error('Error al exportar el reporte');
}
} finally {
isExporting.value = false;
}
};
/** Ciclos */
onMounted(() => {
loadCategories();
loadSubcategories();
applyFilters();
});
</script>
<template>
<div>
<SearcherHead
:title="$t('inventory.title')"
placeholder="Buscar por nombre o SKU..."
@search="(x) => {
currentSearch = x;
const filters = { category_id: selectedCategory || '' };
if (fecha_inicio.value) filters.fecha_inicio = fecha_inicio.value;
if (fecha_fin.value) filters.fecha_fin = fecha_fin.value;
searcher.search(x, filters);
}"
>
<button
class="flex items-center gap-2 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
@click="exportReport"
:disabled="isExporting"
title="Exportar inventario a Excel"
>
<GoogleIcon :name="isExporting ? 'hourglass_empty' : 'download'" class="text-xl" :class="{ 'animate-spin': isExporting }" />
{{ isExporting ? 'Exportando...' : 'Exportar' }}
</button>
<button
v-if="can('import')"
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="openImportModal"
title="Importar productos desde Excel"
>
<GoogleIcon name="upload" class="text-xl" />
Importar
</button>
<button
v-if="can('create')"
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" />
Nuevo Producto
</button>
</SearcherHead>
<!-- Filtros -->
<div class="pt-4 pb-2">
<div class="flex items-end gap-4">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Filtrar por clasificación:</label>
<select
v-model="selectedCategory"
@change="handleCategoryChange"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">Todas las clasificaciones</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
</div>
<div class="ml-4">
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Desde</label>
<input
v-model="fecha_inicio"
@change="applyFilters"
type="date"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div class="ml-4">
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Hasta</label>
<input
v-model="fecha_fin"
@change="applyFilters"
type="date"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<button
@click="selectedCategory = ''; fecha_inicio = ''; fecha_fin = ''; currentSearch = ''; applyFilters();"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors bg-gray-100 dark:bg-gray-700 rounded-lg"
>
Limpiar filtros
</button>
</div>
</div>
</div>
<!-- Estadísticas del Inventario -->
<div class="pt-4 pb-2">
<div class="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg shadow-lg p-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="bg-white/20 backdrop-blur-sm rounded-full p-3">
<GoogleIcon name="inventory_2" class="text-3xl text-white" />
</div>
<div>
<p class="text-indigo-100 text-sm font-medium">Valor Total del Inventario</p>
<p class="text-white text-3xl font-bold">
{{ formatCurrency(totalInventoryValue) }}
</p>
</div>
</div>
<div class="text-right">
<p class="text-indigo-100 text-sm font-medium">Total de Productos</p>
<p class="text-white text-2xl font-bold">{{ models.total || 0 }}</p>
</div>
</div>
</div>
</div>
<div class="pt-2 w-full">
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">SKU / CÓDIGO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRODUCTO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CLAVE SAT</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CLASIFICACIÓN</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRECIO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">STOCK</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TOTAL</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="model in items"
:key="model.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-sm font-mono text-gray-600 dark:text-gray-400">{{ model.sku }}</span>
</td>
<td class="px-6 py-4 text-center">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ model.name }}</p>
</div>
</td>
<td class="px-6 py-4 text-center">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ model.key_sat }}</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center flex flex-col items-center gap-1">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ model.category?.name || '-' }}
</span>
<template v-if="model.subcategory">
<span class="text-gray-400 text-xs dark:text-gray-600"> {{ model.subcategory.name }} </span>
</template>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="text-sm">
<p class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(model.price?.retail_price) }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Costo: {{ formatCurrency(model.price?.cost) }}
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span
class="font-bold text-base"
:class="{
'text-red-500': model.stock < 10,
'text-green-600': model.stock >= 10
}"
>
{{ model.stock }}
</span>
<p v-if="model.unit_of_measure" class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{{ model.unit_of_measure.abbreviation }}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="text-sm">
<p class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(model.inventory_value) }}
</p>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
v-if="!model.unit_of_measure?.allows_decimals"
@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>
<span
v-else
class="text-gray-400 dark:text-gray-600 cursor-not-allowed"
:title="`No disponible: ${model.unit_of_measure.name} (${model.unit_of_measure.abbreviation}) permite decimales`"
>
<GoogleIcon name="qr_code_2" class="text-xl opacity-30" />
</span>
<button
v-if="can('edit')"
@click="openEditModal(model)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Editar producto"
>
<GoogleIcon name="edit" class="text-xl" />
</button>
<button
v-if="can('destroy')"
@click="openDeleteModal(model)"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Eliminar producto"
>
<GoogleIcon name="delete" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="8" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="inventory_2"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
{{ $t('registers.empty') }}
</p>
</div>
</td>
</template>
</Table>
</div>
<!-- Modal de Crear Producto -->
<CreateModal
v-if="can('create')"
:show="showCreateModal"
@close="closeCreateModal"
@created="onProductSaved"
/>
<!-- Modal de Editar Producto -->
<EditModal
v-if="can('edit')"
:show="showEditModal"
:product="editingProduct"
@close="closeEditModal"
@updated="onProductSaved"
/>
<!-- Modal de Eliminar Producto -->
<DeleteModal
v-if="can('destroy')"
:show="showDeleteModal"
:product="deletingProduct"
@close="closeDeleteModal"
@confirm="confirmDelete"
/>
<!-- Modal de Importar Productos -->
<ImportModal
v-if="can('import')"
:show="showImportModal"
@close="closeImportModal"
@imported="onProductsImported"
/>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`inventario.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.inventory.${name}`, params, query })
// Determina si un usuario puede hacer algo en base a los permisos
const can = (permission) => hasPermission(`inventario.${permission}`)
export {
can,
viewTo,
apiTo
}

View File

@ -0,0 +1,241 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSearcher, apiURL } from '@Services/Api';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const route = useRoute();
const router = useRouter();
/** Estado */
const inventoryId = computed(() => route.params.id);
const warehouseId = computed(() => route.query.warehouse_id || null);
const isMainWarehouse = computed(() => !warehouseId.value || route.query.is_main === '1');
const inventory = ref(null);
const serials = ref({ data: [], total: 0 });
const activeTab = ref('disponible');
/** 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 warehouseFilters = () => {
if (!warehouseId.value) return {};
if (route.query.is_main === '1') return { main_warehouse: 1 };
return { warehouse_id: warehouseId.value };
};
const loadSerials = (filters = {}) => {
searcher.load({
url: apiURL(`inventario/${inventoryId.value}/serials`),
filters: {
...warehouseFilters(),
...filters,
status: activeTab.value
}
});
};
const onSearch = (query) => {
searcher.search(query, { ...warehouseFilters(), status: activeTab.value });
};
const switchTab = (tab) => {
activeTab.value = tab;
loadSerials({ q: searcher.query });
};
// Navegación
const goBack = () => {
router.go(-1);
};
// 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">
| Clasificación: {{ 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 -->
<SearcherHead
title="Números de Serie"
placeholder="Buscar por número de serie..."
@search="onSearch"
/>
<!-- Pestañas -->
<div class="mt-4 border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button
@click="switchTab('disponible')"
:class="[
activeTab === 'disponible'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors'
]"
>
<GoogleIcon
name="check_circle"
:class="[
activeTab === 'disponible' ? 'text-indigo-500 dark:text-indigo-400' : 'text-gray-400 group-hover:text-gray-500',
'mr-2 text-xl'
]"
/>
Disponibles
</button>
<button
v-if="isMainWarehouse"
@click="switchTab('vendido')"
:class="[
activeTab === 'vendido'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors'
]"
>
<GoogleIcon
name="shopping_cart"
:class="[
activeTab === 'vendido' ? 'text-indigo-500 dark:text-indigo-400' : 'text-gray-400 group-hover:text-gray-500',
'mr-2 text-xl'
]"
/>
Vendidos
</button>
</nav>
</div>
<!-- Tabla de seriales -->
<div class="pt-2 w-full">
<Table
:items="serials"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page, { status: activeTab })"
>
<template #head>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NÚMERO DE SERIE</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOTAS</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA</th>
<th v-if="activeTab === 'vendido'" class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">VENTA</th>
</template>
<template #body="{items}">
<tr
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 text-center 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 v-if="activeTab === 'vendido'" class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-gray-500 dark:text-gray-400 text-xs">
{{ serial.sale_detail?.sale?.invoice_number || `#${serial.sale_detail_id}` }}
</span>
</td>
</tr>
</template>
<template #empty>
<td :colspan="activeTab === 'vendido' ? 5 : 4" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
:name="activeTab === 'disponible' ? 'qr_code_2' : 'shopping_cart'"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
{{ activeTab === 'disponible' ? 'No hay seriales disponibles' : 'No hay seriales vendidos' }}
</p>
<p class="text-sm mt-1">
{{ activeTab === 'disponible' ? 'Agrega seriales para comenzar' : 'Los seriales vendidos aparecerán aquí' }}
</p>
</div>
</td>
</template>
</Table>
</div>
</div>
</template>

View File

@ -0,0 +1,400 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useApi, apiURL } from '@Services/Api';
import { formatDate, formatCurrency } from '@/utils/formatters';
import { hasPermission } from '@Plugins/RolePermission';
import TicketDetailMovement from '@Services/TicketDetailMovement';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Loader from '@Shared/Loader.vue';
/** Propiedades */
const props = defineProps({
show: Boolean,
movementId: Number,
movementData: Object
});
/** Eventos */
const emit = defineEmits(['close', 'edit']);
/** Estado */
const movement = ref(null);
const loading = ref(false);
const api = useApi();
/** Computed */
const isMultiProduct = computed(() => {
return movement.value?.products && movement.value.products.length > 0;
});
const totalQuantity = computed(() => {
if (!isMultiProduct.value) return movement.value?.quantity || 0;
return movement.value.products.reduce((sum, p) => sum + Number(p.quantity), 0);
});
const totalCost = computed(() => {
if (!isMultiProduct.value) return 0;
return movement.value.products.reduce((sum, p) => sum + (Number(p.quantity) * Number(p.unit_cost || 0)), 0);
});
const canEdit = computed(() => {
return hasPermission('movements.edit');
});
const canEditSingle = computed(() => {
return hasPermission('movements.edit') && !isMultiProduct.value;
});
/** Métodos */
const fetchDetail = () => {
if (!props.movementId) return;
if(props.movementData && props.movementData.products && props.movementData.products.length > 0) {
movement.value = props.movementData;
return;
}
loading.value = true;
movement.value = null;
api.get(apiURL(`movimientos/${props.movementId}`), {
onSuccess: (data) => {
movement.value = data.movement || data;
},
onError: () => {
window.Notify.error('Error al cargar el detalle del movimiento');
},
onFinish: () => {
loading.value = false;
}
});
};
const handleClose = () => {
emit('close');
};
const handleEdit = () => {
// Si es un movimiento múltiple, usar el valor actual
if (isMultiProduct.value) {
emit('edit', movement.value);
return;
}
// Para movimientos individuales, hacer fetch para obtener datos completos (incluidos seriales)
loading.value = true;
api.get(apiURL(`movimientos/${movement.value.id}`), {
onSuccess: (data) => {
emit('edit', data.movement || data);
},
onError: () => {
window.Notify.error('Error al cargar el movimiento');
},
onFinish: () => {
loading.value = false;
}
});
};
const handleEditProduct = (product) => {
// Crear un objeto de movimiento individual a partir del producto
const individualMovement = {
id: product.movement_id,
movement_type: movement.value.movement_type,
quantity: product.quantity,
unit_cost: product.unit_cost,
warehouse_id: movement.value.warehouse_to?.id,
invoice_reference: movement.value.invoice_reference,
notes: movement.value.notes,
inventory: product.inventory,
warehouse_to: movement.value.warehouse_to,
warehouse_from: movement.value.warehouse_from,
user: movement.value.user,
created_at: movement.value.created_at
};
emit('edit', individualMovement);
};
const getTypeBadge = (type) => {
const badges = {
entry: { label: 'Entrada', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: 'add_circle' },
exit: { label: 'Salida', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', icon: 'remove_circle' },
transfer: { label: 'Traspaso', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: 'swap_horiz' },
sale: { label: 'Venta', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', icon: 'point_of_sale' },
return: { label: 'Devolución', class: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300', icon: 'undo' },
};
return badges[type] || { label: type, class: 'bg-gray-100 text-gray-800', icon: 'help' };
};
const handleDownloadTicket = async () => {
try {
await TicketDetailMovement.generateMovementTicket(movement.value, {
autoDownload: true,
});
window.Notify.success('Ticket descargado correctamente');
} catch (error) {
console.error('Error generando ticket:', error);
window.Notify.error('Error al generar el ticket PDF');
}
};
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown && props.movementId) {
fetchDetail();
}
});
</script>
<template>
<Modal :show="show" max-width="2xl" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Detalle del Movimiento
</h3>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<Loader />
</div>
<!-- Content -->
<div v-else-if="movement" class="space-y-5">
<!-- Tipo badge -->
<div class="flex items-center gap-3">
<span :class="['inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-semibold', getTypeBadge(movement.movement_type).class]">
<GoogleIcon :name="getTypeBadge(movement.movement_type).icon" class="text-lg" />
{{ getTypeBadge(movement.movement_type).label }}
</span>
</div>
<!-- Productos (múltiples) -->
<div v-if="isMultiProduct" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 mb-3">
<GoogleIcon name="inventory_2" class="text-lg text-gray-600 dark:text-gray-400" />
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase">Productos</h4>
<span class="ml-auto text-xs font-semibold text-gray-500 dark:text-gray-400">
{{ movement.products.length }} producto(s)
</span>
</div>
<!-- Tabla de productos -->
<div class="space-y-2">
<div
v-for="(product, index) in movement.products"
:key="index"
class="p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center gap-3">
<div class="flex-1 grid grid-cols-12 gap-3 items-center text-sm">
<div class="col-span-12 sm:col-span-5">
<p class="font-semibold text-gray-900 dark:text-gray-100">
{{ product.inventory?.name || 'N/A' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">
{{ product.inventory?.sku || 'N/A' }}
</p>
</div>
<div class="col-span-4 sm:col-span-2 text-center">
<span class="text-gray-500 dark:text-gray-400 text-xs">Cantidad:</span>
<p class="font-bold text-gray-900 dark:text-gray-100">{{ product.quantity }}</p>
</div>
<div class="col-span-4 sm:col-span-2 text-center">
<span class="text-gray-500 dark:text-gray-400 text-xs">Costo unit.:</span>
<p class="font-semibold text-gray-900 dark:text-gray-100">
${{ Number(product.unit_cost || 0).toFixed(2) }}
</p>
</div>
<div class="col-span-4 sm:col-span-3 text-right">
<span class="text-gray-500 dark:text-gray-400 text-xs">Subtotal:</span>
<p class="font-bold text-indigo-900 dark:text-indigo-100">
${{ (product.quantity * Number(product.unit_cost || 0)).toFixed(2) }}
</p>
</div>
</div>
<!-- Botón editar -->
<button
v-if="canEdit"
type="button"
@click="handleEditProduct(product)"
class="flex items-center justify-center w-8 h-8 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded-lg transition-colors shrink-0"
title="Editar producto"
>
<GoogleIcon name="edit" class="text-lg" />
</button>
</div>
</div>
</div>
<!-- Total -->
<div class="mt-4 pt-4 border-t border-gray-300 dark:border-gray-600">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
Cantidad total: <span class="text-gray-900 dark:text-gray-100">{{ totalQuantity }}</span>
</span>
</div>
<div class="text-right">
<span class="text-sm text-gray-600 dark:text-gray-400">Costo total:</span>
<p class="text-xl font-bold text-indigo-900 dark:text-indigo-100">
${{ totalCost.toFixed(2) }}
</p>
</div>
</div>
</div>
</div>
<!-- Producto (individual) -->
<div v-else class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 mb-3">
<GoogleIcon name="inventory_2" class="text-lg text-gray-600 dark:text-gray-400" />
<h4 class="text-sm font-bold text-gray-700 dark:text-gray-300 uppercase">Producto</h4>
</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Nombre:</span>
<p class="font-semibold text-gray-900 dark:text-gray-100">{{ movement.inventory?.name || 'N/A' }}</p>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">SKU:</span>
<p class="font-mono font-semibold text-gray-900 dark:text-gray-100">{{ movement.inventory?.sku || 'N/A' }}</p>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Cantidad:</span>
<p class="font-bold text-lg text-gray-900 dark:text-gray-100">{{ movement.quantity }}</p>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Costo:</span>
<p class="font-bold text-lg text-gray-900 dark:text-gray-100">{{ formatCurrency(movement.unit_cost) }}</p>
</div>
</div>
</div>
<!-- Almacenes -->
<div class="grid grid-cols-2 gap-4">
<!-- Origen -->
<div v-if="movement.warehouse_from" class="bg-red-50 dark:bg-red-900/10 rounded-xl p-4 border border-red-200 dark:border-red-800">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="logout" class="text-lg text-red-600 dark:text-red-400" />
<h4 class="text-xs font-bold text-red-700 dark:text-red-300 uppercase">Origen</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_from.name }}</p>
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_from.code }}</p>
</div>
<!-- Destino -->
<div v-if="movement.warehouse_to" class="bg-green-50 dark:bg-green-900/10 rounded-xl p-4 border border-green-200 dark:border-green-800">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="login" class="text-lg text-green-600 dark:text-green-400" />
<h4 class="text-xs font-bold text-green-700 dark:text-green-300 uppercase">Destino</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_to.name }}</p>
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_to.code }}</p>
</div>
</div>
<!-- Info adicional -->
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Usuario:</span>
<p class="font-semibold text-gray-900 dark:text-gray-100">{{ movement.user?.name || 'N/A' }}</p>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Fecha:</span>
<p class="font-semibold text-gray-900 dark:text-gray-100">{{ formatDate(movement.created_at) }}</p>
</div>
<div v-if="movement.notes" class="col-span-2">
<span class="text-gray-500 dark:text-gray-400">Notas:</span>
<p class="text-gray-900 dark:text-gray-100 italic">{{ movement.notes }}</p>
</div>
</div>
</div>
<!-- Referencia de factura -->
<div v-if="movement.invoice_reference" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="receipt" class="text-lg text-gray-600 dark:text-gray-400" />
<h4 class="text-xs font-bold text-gray-700 dark:text-gray-300 uppercase">Referencia de Factura</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.invoice_reference }}</p>
</div>
<!-- Proveedor -->
<div v-if="movement.supplier" class="bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 rounded-xl p-5 border border-indigo-200 dark:border-indigo-700">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="store" class="text-lg text-indigo-600 dark:text-indigo-400" />
<h4 class="text-xs font-bold text-indigo-700 dark:text-indigo-300 uppercase">Proveedor</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.supplier.business_name }}</p>
<p v-if="movement.supplier.rfc" class="text-xs text-gray-500 dark:text-gray-400 mt-1">RFC: {{ movement.supplier.rfc }}</p>
</div>
<!-- Almacenes -->
<div v-if="movement.movement_type === 'transfer' && (movement.warehouse_from || movement.warehouse_to)" class="grid grid-cols-2 gap-3">
<!-- Origen -->
<div v-if="movement.warehouse_from" class="bg-red-50 dark:bg-red-900/10 rounded-xl p-4 border border-red-200 dark:border-red-800">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="logout" class="text-lg text-red-600 dark:text-red-400" />
<h4 class="text-xs font-bold text-red-700 dark:text-red-300 uppercase">Origen</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_from.name }}</p>
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_from.code }}</p>
</div>
<!-- Destino -->
<div v-if="movement.warehouse_to" class="bg-green-50 dark:bg-green-900/10 rounded-xl p-4 border border-green-200 dark:border-green-800">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="login" class="text-lg text-green-600 dark:text-green-400" />
<h4 class="text-xs font-bold text-green-700 dark:text-green-300 uppercase">Destino</h4>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.warehouse_to.name }}</p>
<p class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ movement.warehouse_to.code }}</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-5 py-2.5 text-sm font-semibold 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"
>
Cerrar
</button>
<button
v-if="canEditSingle"
type="button"
@click="handleEdit"
class="flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
>
<GoogleIcon name="edit" class="text-lg" />
Editar
</button>
<button
type="button"
@click="handleDownloadTicket"
class="flex items-center gap-2 px-5 py-2.5 text-sm font-semibold text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
>
<GoogleIcon name="download" class="text-lg" />
Descargar ticket
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,667 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { formatCurrency } from '@/utils/formatters';
import { useForm, useApi, 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';
import SerialInputList from '@Components/POS/SerialInputList.vue';
import SerialSelector from '@Components/POS/SerialSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
movement: Object
});
/** Estado */
const warehouses = ref([]);
const suppliers = ref([]);
const loading = ref(false);
const serialsText = ref('');
const showSerialSelector = ref(false);
const selectedSerialObjects = ref([]);
const api = useApi();
/** Formulario */
const form = useForm({
quantity: 0,
unit_cost: 0,
warehouse_id: '',
supplier_id: null,
origin_warehouse_id: '',
destination_warehouse_id: '',
invoice_reference: '',
notes: '',
serial_numbers: [] // Array de números de serie
});
/** Estado para manejo de seriales */
const serialsList = ref([]); // Array de { serial_number, locked }
/** Computed */
const movementTypeInfo = computed(() => {
const types = {
entry: {
label: 'Entrada',
icon: 'add_circle',
color: 'green',
bgClass: 'bg-green-100 dark:bg-green-900/30',
textClass: 'text-green-800 dark:text-green-300'
},
exit: {
label: 'Salida',
icon: 'remove_circle',
color: 'red',
bgClass: 'bg-red-100 dark:bg-red-900/30',
textClass: 'text-red-800 dark:text-red-300'
},
transfer: {
label: 'Traspaso',
icon: 'swap_horiz',
color: 'blue',
bgClass: 'bg-blue-100 dark:bg-blue-900/30',
textClass: 'text-blue-800 dark:text-blue-300'
},
};
return types[props.movement?.movement_type] || types.entry;
});
const totalCost = computed(() => {
return form.quantity * form.unit_cost;
});
const allowsDecimals = computed(() => {
return props.movement?.inventory?.unit_of_measure?.allows_decimals || false;
});
const hasSerials = computed(() => {
return props.movement?.inventory?.track_serials || false;
});
const serialsArray = computed(() => {
return serialsList.value
.map(s => s.serial_number.trim())
.filter(s => s.length > 0);
});
// Si tiene seriales y no permite decimales, la cantidad se controla por seriales
const quantityLockedBySerials = computed(() => {
return hasSerials.value && !allowsDecimals.value;
});
const serialsValidation = computed(() => {
if (!hasSerials.value) return { valid: true, message: '' };
const quantity = Number(form.quantity);
const serialCount = serialsArray.value.length;
if (quantity > 0 && serialCount === 0) {
return { valid: false, message: 'Debe ingresar números de serie' };
}
if (serialCount !== quantity) {
return {
valid: false,
message: `Debe ingresar ${quantity} número(s) de serie (actual: ${serialCount})`
};
}
// Verificar duplicados
const uniqueSerials = new Set(serialsArray.value);
if (uniqueSerials.size !== serialCount) {
return { valid: false, message: 'Hay números de serie duplicados' };
}
return { valid: true, message: '' };
});
// Actualizar cantidad automáticamente cuando cambian los seriales
const updateQuantityFromSerials = () => {
if (!quantityLockedBySerials.value) return;
const count = serialsArray.value.length;
form.quantity = count > 0 ? count : 1;
};
// Seriales pre-seleccionados para SerialSelector (objetos completos con id)
const serialSelectorPreSelected = computed(() => selectedSerialObjects.value);
/** Métodos de selección de seriales (para traspasos y salidas) */
const openSerialSelector = () => {
showSerialSelector.value = true;
};
const handleSerialsConfirmed = ({ serials, serialNumbers }) => {
selectedSerialObjects.value = serials;
serialsList.value = serialNumbers.map(sn => ({ serial_number: sn, locked: false }));
updateQuantityFromSerials();
showSerialSelector.value = false;
};
/** Métodos */
const loadWarehouses = () => {
loading.value = true;
Promise.all([
api.get(apiURL('almacenes'), {
onSuccess: (data) => {
warehouses.value = data.warehouses?.data || data.data || [];
if (props.movement?.movement_type === 'entry' && !form.destination_warehouse_id) {
const mainWarehouse = warehouses.value.find(w => w.is_main || w.is_principal);
if (mainWarehouse) {
form.destination_warehouse_id = mainWarehouse.id;
}
}
}
}),
api.get(apiURL('proveedores'), {
onSuccess: (data) => {
suppliers.value = data.suppliers?.data || data.suppliers || [];
}
})
]).finally(() => {
loading.value = false;
});
};
const updateMovement = () => {
// Validar seriales si el producto los requiere
if (hasSerials.value && !serialsValidation.value.valid) {
window.Notify.warning(serialsValidation.value.message);
return;
}
// Preparar datos según el tipo de movimiento
const data = {
quantity: Number(form.quantity), // Común para todos los tipos
notes: form.notes || null
};
// Siempre enviar serial_numbers si el producto requiere seriales
if (hasSerials.value) {
// Validar que haya seriales
if (serialsArray.value.length === 0) {
window.Notify.warning('Debe ingresar números de serie para este producto');
return;
}
data.serial_numbers = serialsArray.value;
}
// Campos específicos por tipo
if (props.movement.movement_type === 'entry') {
data.unit_cost = Number(form.unit_cost);
data.warehouse_to_id = form.destination_warehouse_id;
data.supplier_id = form.supplier_id || null;
data.invoice_reference = form.invoice_reference || null;
} else if (props.movement.movement_type === 'exit') {
data.warehouse_from_id = form.origin_warehouse_id;
} else if (props.movement.movement_type === 'transfer') {
data.warehouse_from_id = form.origin_warehouse_id;
data.warehouse_to_id = form.destination_warehouse_id;
}
api.put(apiURL(`movimientos/${props.movement.id}`), {
data,
onSuccess: () => {
window.Notify.success('Movimiento actualizado correctamente');
emit('updated');
closeModal();
},
onFail: (response) => {
window.Notify.error(response.message || 'Error al actualizar el movimiento');
},
onError: () => {
window.Notify.error('Error al actualizar el movimiento');
}
});
};
const closeModal = () => {
form.reset();
showSerialSelector.value = false;
emit('close');
};
/** Helpers */
const applySerials = (movement) => {
const isTransfer = movement.movement_type === 'transfer';
const isExit = movement.movement_type === 'exit';
const rawSerials = isTransfer
? (movement.transferred_serials || [])
: isExit
? (movement.exited_serials || [])
: (movement.serials || []);
if (isTransfer || isExit) {
selectedSerialObjects.value = rawSerials;
}
if (rawSerials.length > 0) {
const movementWarehouseId = movement.warehouse_to?.id || movement.warehouse_to_id;
serialsList.value = rawSerials.map(s => {
let locked = false;
let lock_reason = null;
const isOwnExitSerial = isExit && s.status === 'salida';
if (!isOwnExitSerial && s.status !== 'disponible') {
locked = true;
lock_reason = s.status;
} else if (
!isTransfer && !isExit &&
s.warehouse_id &&
movementWarehouseId &&
s.warehouse_id !== movementWarehouseId
) {
locked = true;
lock_reason = 'traspasado';
}
return { serial_number: s.serial_number, locked, lock_reason };
});
} else {
serialsList.value = [{ serial_number: '', locked: false }];
}
};
/** Observadores */
watch(() => props.show, (isShown) => {
if (isShown) {
loadWarehouses();
if (props.movement) {
// Cargar datos básicos del formulario desde el prop
form.quantity = props.movement.quantity || 0;
form.unit_cost = props.movement.unit_cost || 0;
form.invoice_reference = props.movement.invoice_reference || '';
form.notes = props.movement.notes || '';
form.supplier_id = props.movement.supplier_id || null;
// Almacenes según tipo
if (props.movement.movement_type === 'entry') {
form.destination_warehouse_id = props.movement.warehouse_to_id || props.movement.warehouse_to?.id || '';
} else if (props.movement.movement_type === 'exit') {
form.origin_warehouse_id = props.movement.warehouse_from_id || props.movement.warehouse_from?.id || '';
} else if (props.movement.movement_type === 'transfer') {
form.origin_warehouse_id = props.movement.warehouse_from_id || props.movement.warehouse_from?.id || '';
form.destination_warehouse_id = props.movement.warehouse_to_id || props.movement.warehouse_to?.id || '';
}
// Limpiar seriales mientras se carga el detalle
serialsList.value = [{ serial_number: '', locked: false }];
selectedSerialObjects.value = [];
// Obtener el movimiento completo con seriales desde el endpoint de detalle
api.get(apiURL(`movimientos/${props.movement.id}`), {
onSuccess: (data) => {
applySerials(data.movement);
}
});
}
} else {
// Limpiar al cerrar
serialsText.value = '';
}
}, { immediate: true });
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<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-10 h-10 rounded-full',
movementTypeInfo.bgClass
]"
>
<GoogleIcon :name="movementTypeInfo.icon" :class="['text-xl', movementTypeInfo.textClass]" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Editar {{ movementTypeInfo.label }}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
Movimiento #{{ movement?.id }}
</p>
</div>
</div>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<GoogleIcon name="close" class="text-xl" />
</button>
</div>
<!-- Información del producto -->
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<GoogleIcon name="inventory_2" class="text-gray-500 dark:text-gray-400 text-sm" />
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Producto</p>
<span
v-if="hasSerials"
class="ml-auto px-2 py-0.5 text-xs font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full flex items-center gap-1"
>
<GoogleIcon name="qr_code_scanner" class="text-xs" />
Con seriales
</span>
</div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ movement?.inventory?.name || 'N/A' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
SKU: {{ movement?.inventory?.sku || 'N/A' }}
</p>
</div>
<!-- Formulario -->
<form @submit.prevent="updateMovement" class="space-y-4">
<div class="space-y-4">
<!-- Cantidad -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
CANTIDAD
</label>
<FormInput
v-model="form.quantity"
type="number"
min="1"
step="1"
placeholder="0"
:disabled="quantityLockedBySerials"
required
/>
<p v-if="quantityLockedBySerials" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Controlado por seriales
</p>
<FormError :message="form.errors?.quantity" />
</div>
<!-- Números de Serie: traspasos y salidas usan selector -->
<div
v-if="hasSerials && (movement?.movement_type === 'transfer' || movement?.movement_type === 'exit')"
class="p-3 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg"
>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 flex-1">
<GoogleIcon name="qr_code_2" class="text-blue-600 dark:text-blue-400 text-lg" />
<div>
<p class="text-xs font-semibold text-blue-900 dark:text-blue-100">
Este producto requiere números de serie
</p>
<p class="text-xs text-blue-700 dark:text-blue-300">
{{ serialsArray.length }} serial(es) seleccionado(s)
</p>
</div>
</div>
<button
type="button"
@click="openSerialSelector"
class="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
>
<GoogleIcon name="qr_code_scanner" class="text-sm" />
{{ serialsArray.length > 0 ? 'Cambiar' : 'Seleccionar' }}
</button>
</div>
<!-- Badges de seriales seleccionados -->
<div v-if="serialsArray.length > 0" class="mt-2 pt-2 border-t border-blue-200 dark:border-blue-800">
<p class="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">Seriales seleccionados:</p>
<div class="flex flex-wrap gap-1">
<span
v-for="(serial, idx) in serialsArray.slice(0, 5)"
:key="idx"
class="inline-flex items-center px-2 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded"
>
{{ serial }}
</span>
<span
v-if="serialsArray.length > 5"
class="inline-flex items-center px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded"
>
+{{ serialsArray.length - 5 }} más
</span>
</div>
</div>
<!-- Validación -->
<div v-if="serialsArray.length > 0" class="mt-2 flex items-center justify-end">
<p v-if="!serialsValidation.valid" class="text-xs text-red-600 dark:text-red-400 font-medium">
{{ serialsValidation.message }}
</p>
<p v-else class="text-xs text-green-600 dark:text-green-400 font-medium flex items-center gap-1">
<GoogleIcon name="check_circle" class="text-sm" />
Válido
</p>
</div>
</div>
<!-- Números de Serie: entradas usan lista de inputs -->
<div v-else-if="hasSerials" class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<GoogleIcon name="qr_code_scanner" class="text-amber-600 dark:text-amber-400" />
<label class="text-xs font-semibold text-amber-700 dark:text-amber-300 uppercase">
NÚMEROS DE SERIE
</label>
</div>
<SerialInputList
v-model="serialsList"
@update:model-value="updateQuantityFromSerials"
/>
<!-- Validación -->
<div class="mt-2 flex items-center justify-between">
<p class="text-xs text-amber-600 dark:text-amber-400">
<span class="font-semibold">{{ serialsArray.length }}</span> de {{ form.quantity }} seriales
</p>
<p
v-if="!serialsValidation.valid && serialsArray.length > 0"
class="text-xs text-red-600 dark:text-red-400 font-medium"
>
{{ serialsValidation.message }}
</p>
<p
v-else-if="serialsValidation.valid && serialsArray.length > 0"
class="text-xs text-green-600 dark:text-green-400 font-medium flex items-center gap-1"
>
<GoogleIcon name="check_circle" class="text-sm" />
Válido
</p>
</div>
</div>
<!-- Costo unitario (solo para entradas) -->
<div v-if="movement?.movement_type === 'entry'">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
COSTO UNITARIO
</label>
<FormInput
v-model="form.unit_cost"
type="number"
min="0"
step="0.01"
placeholder="0.00"
required
/>
<FormError :message="form.errors?.unit_cost" />
<!-- Mostrar costo total -->
<p v-if="form.quantity && form.unit_cost" class="mt-1.5 text-xs text-gray-600 dark:text-gray-400">
Costo total:
<span class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(totalCost) }}
</span>
</p>
</div>
<!-- Almacén destino (para entradas) -->
<div v-if="movement?.movement_type === 'entry'">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ALMACÉN DESTINO
</label>
<select
v-model="form.destination_warehouse_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
required
disabled
>
<option value="">Seleccionar almacén...</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
{{ wh.name }} ({{ wh.code }})
</option>
</select>
<FormError :message="form.errors?.warehouse_id" />
</div>
<!-- Almacén origen (para salidas) -->
<div v-if="movement?.movement_type === 'exit'">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ALMACÉN ORIGEN
</label>
<select
v-model="form.origin_warehouse_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled
required
>
<option value="">Seleccionar almacén...</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
{{ wh.name }} ({{ wh.code }})
</option>
</select>
<FormError :message="form.errors?.warehouse_id" />
</div>
<!-- Almacenes origen y destino (para traspasos) -->
<div v-if="movement?.movement_type === 'transfer'" class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ALMACÉN ORIGEN
</label>
<select
v-model="form.origin_warehouse_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled
required
>
<option value="">Seleccionar...</option>
<option
v-for="wh in warehouses"
:key="wh.id"
:value="wh.id"
>
{{ wh.name }} ({{ wh.code }})
</option>
</select>
<FormError :message="form.errors?.origin_warehouse_id" />
</div>
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ALMACÉN DESTINO
</label>
<select
v-model="form.destination_warehouse_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
required
disabled
>
<option value="">Seleccionar...</option>
<option
v-for="wh in warehouses"
:key="wh.id"
:value="wh.id"
:disabled="wh.id === form.origin_warehouse_id"
>
{{ wh.name }} ({{ wh.code }})
</option>
</select>
<FormError :message="form.errors?.destination_warehouse_id" />
</div>
</div>
<!-- Proveedor y Referencia (solo para entradas) -->
<div v-if="movement?.movement_type === 'entry'" class="space-y-4">
<!-- Proveedor (solo lectura) -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PROVEEDOR
</label>
<div class="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-900 text-sm text-gray-600 dark:text-gray-400 cursor-not-allowed">
{{ movement?.supplier?.business_name || 'Sin proveedor' }}
</div>
</div>
<!-- Referencia de factura -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
REFERENCIA DE FACTURA
</label>
<FormInput
v-model="form.invoice_reference"
type="text"
placeholder="Ej: FAC-2026-001"
/>
<FormError :message="form.errors?.invoice_reference" />
</div>
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
</label>
<textarea
v-model="form.notes"
rows="2"
placeholder="Notas adicionales..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none disabled:opacity-50 disabled:cursor-not-allowed"
></textarea>
<FormError :message="form.errors?.notes" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeModal"
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 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="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
:class="{
'bg-green-600 hover:bg-green-700 focus:ring-green-600': movement?.movement_type === 'entry',
'bg-red-600 hover:bg-red-700 focus:ring-red-600': movement?.movement_type === 'exit',
'bg-blue-600 hover:bg-blue-700 focus:ring-blue-600': movement?.movement_type === 'transfer'
}"
>
<GoogleIcon name="check_circle" class="text-lg" />
<span v-if="form.processing">Actualizando...</span>
<span v-else>Actualizar Movimiento</span>
</button>
</div>
</form>
</div>
<!-- Selector de seriales (para traspasos y salidas) -->
<SerialSelector
v-if="movement?.inventory && showSerialSelector"
:show="showSerialSelector"
:product="{ id: movement.inventory?.id || movement.inventory_id, name: movement.inventory?.name, sku: movement.inventory?.sku, track_serials: movement.inventory?.track_serials }"
:warehouse-id="Number(form.origin_warehouse_id) || null"
:pre-selected-serials="serialSelectorPreSelected"
@close="showSerialSelector = false"
@confirm="handleSerialsConfirmed"
/>
</Modal>
</template>

View File

@ -0,0 +1,696 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { formatCurrency } from '@/utils/formatters';
import { useForm, useApi, 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';
import SerialInputList from '@Components/POS/SerialInputList.vue';
import BillCreateModal from '@Pages/Admin/Bills/Create.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Estado */
const showBillModal = ref(false);
const products = ref([]);
const warehouses = ref([]);
const suppliers = ref([]);
const loading = ref(false);
const selectedProducts = ref([]);
// Estado para búsqueda de productos
let debounceTimer = null;
const productSearch = ref('');
const searchingProduct = ref(false);
const productNotFound = ref(false);
const productSuggestions = ref([]);
const showProductSuggestions = ref(false);
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
// Cache de equivalencias por producto
const equivalencesCache = ref({});
const api = useApi();
/** Formulario */
const form = useForm({
warehouse_id: '',
supplier_id: null,
invoice_reference: '',
notes: '',
products: []
});
/** Computed */
const totalCost = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + (item.quantity * item.unit_cost);
}, 0);
});
const totalQuantity = computed(() => {
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
});
// Determina si un producto puede usar seriales (no puede si la unidad permite decimales)
const canUseSerials = (item) => {
if (!item.unit_of_measure) return true;
return !item.allows_decimals;
};
/** Métodos */
const loadData = () => {
loading.value = true;
Promise.all([
api.get(apiURL('almacenes'), {
onSuccess: (data) => {
warehouses.value = data.warehouses?.data || data.data || [];
// Establecer el almacén principal por defecto
const mainWarehouse = warehouses.value.find(w => w.is_main || w.is_principal);
if (mainWarehouse && !form.warehouse_id) {
form.warehouse_id = mainWarehouse.id;
}
}
}),
api.get(apiURL('proveedores'), {
onSuccess: (data) => {
suppliers.value = data.suppliers?.data || data.data || [];
}
}),
api.get(apiURL('inventario'), {
onSuccess: (data) => {
products.value = data.products?.data || data.data || [];
}
})
]).finally(() => {
loading.value = false;
});
};
const loadEquivalencesForProduct = async (productId) => {
if (equivalencesCache.value[productId]) {
return equivalencesCache.value[productId];
}
try {
const response = await fetch(apiURL(`inventario/${productId}/equivalencias`), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
const json = await response.json();
const result = {
equivalences: (json.data?.equivalences || []).filter(e => e.is_active),
base_unit: json.data?.base_unit || null
};
equivalencesCache.value[productId] = result;
return result;
} catch {
return { equivalences: [], base_unit: null };
}
};
const getSelectedEquivalence = (item) => {
if (!item.selected_unit_id || !item.equivalences?.length) return null;
return item.equivalences.find(e => e.unit_of_measure_id === item.selected_unit_id) || null;
};
const addProduct = () => {
selectedProducts.value.push({
inventory_id: '',
product_name: '',
product_sku: '',
quantity: 1,
unit_cost: 0,
track_serials: false,
unit_of_measure: null,
allows_decimals: false,
serial_numbers_list: [{ serial_number: '', locked: false }],
serial_validation_error: '',
// Equivalencias
equivalences: [],
base_unit: null,
selected_unit_id: null,
});
};
const removeProduct = (index) => {
selectedProducts.value.splice(index, 1);
};
/** Métodos de búsqueda de productos */
const onProductInput = (index) => {
currentSearchIndex.value = index;
productNotFound.value = false;
const searchValue = productSearch.value?.trim();
if (!searchValue || searchValue.length < 1) {
productSuggestions.value = [];
showProductSuggestions.value = false;
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
showProductSuggestions.value = true;
searchProduct();
}, 300);
};
const searchProduct = () => {
const searchValue = productSearch.value?.trim();
if (!searchValue) {
productSuggestions.value = [];
showProductSuggestions.value = false;
return;
}
searchingProduct.value = true;
productNotFound.value = false;
api.get(apiURL(`inventario?q=${encodeURIComponent(searchValue)}`), {
onSuccess: (data) => {
const foundProducts = data.products?.data || data.data || data.products || [];
if (foundProducts.length > 0) {
productSuggestions.value = foundProducts;
showProductSuggestions.value = true;
} else {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
}
},
onFail: (data) => {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
window.Notify.error(data.message || 'Error al buscar producto');
},
onError: () => {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
},
onFinish: () => {
searchingProduct.value = false;
}
});
};
const selectProduct = async (product) => {
if (currentSearchIndex.value !== null) {
const item = selectedProducts.value[currentSearchIndex.value];
item.inventory_id = product.id;
item.product_name = product.name;
item.product_sku = product.sku;
item.track_serials = product.track_serials || false;
item.unit_of_measure = product.unit_of_measure || null;
item.allows_decimals = product.unit_of_measure?.allows_decimals || false;
item.selected_unit_id = null;
// Limpiar seriales si la unidad permite decimales
if (product.unit_of_measure?.allows_decimals) {
item.serial_numbers_list = [{ serial_number: '', locked: false }];
}
// Cargar equivalencias (solo si no tiene seriales)
if (!product.track_serials) {
const { equivalences, base_unit } = await loadEquivalencesForProduct(product.id);
item.equivalences = equivalences;
item.base_unit = base_unit;
}
}
productSearch.value = '';
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = false;
currentSearchIndex.value = null;
window.Notify.success(`Producto ${product.name} agregado`);
};
// Contar seriales ingresados (solo para mostrar feedback visual)
const countSerials = (item) => {
if (!item.serial_numbers_list) return 0;
return item.serial_numbers_list.filter(s => s.serial_number.trim()).length;
};
// Actualizar cantidad según seriales ingresados (llamado por el watcher del v-model)
const updateQuantityFromSerials = (item) => {
// Solo actualizar cantidad automáticamente si el producto requiere seriales y puede usarlos
if (!item.track_serials || !canUseSerials(item)) {
return;
}
const serialCount = countSerials(item);
if (serialCount > 0) {
item.quantity = serialCount;
} else {
item.quantity = 1;
}
};
const createEntry = () => {
// Preparar datos del formulario
form.products = selectedProducts.value.map(item => {
const productData = {
inventory_id: item.inventory_id,
quantity: Number(item.quantity),
unit_cost: Number(item.unit_cost),
serial_numbers: []
};
// Incluir unidad de equivalencia si se seleccionó una distinta a la base
if (item.selected_unit_id) {
productData.unit_of_measure_id = item.selected_unit_id;
}
// Agregar seriales solo si la unidad lo permite y hay seriales ingresados
if (canUseSerials(item) && item.serial_numbers_list) {
const serials = item.serial_numbers_list
.map(s => s.serial_number.trim())
.filter(s => s.length > 0)
.filter((s, index, self) => self.indexOf(s) === index);
if (serials.length > 0) {
productData.serial_numbers = serials;
}
}
return productData;
});
form.post(apiURL('movimientos/entrada'), {
onSuccess: () => {
window.Notify.success('Entrada registrada correctamente');
emit('created');
closeModal();
},
onFail: (data) => {
window.Notify.error(data.message || 'Error al registrar la entrada');
},
onError: () => {
window.Notify.error('Error al registrar la entrada');
}
});
};
const closeModal = () => {
form.reset();
selectedProducts.value = [];
productSearch.value = '';
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = false;
currentSearchIndex.value = null;
emit('close');
};
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown) {
loadData();
if (selectedProducts.value.length === 0) {
addProduct();
}
}
});
// Limpiar productos seleccionados cuando se cambia el almacén
watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
if (newWarehouseId !== oldWarehouseId && oldWarehouseId && selectedProducts.value.length > 0) {
selectedProducts.value = [];
addProduct();
}
});
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<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-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30">
<GoogleIcon name="add_circle" class="text-xl text-green-600 dark:text-green-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Registrar Entrada
</h3>
</div>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createEntry" class="space-y-4">
<div class="space-y-4">
<!-- Almacén destino -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ALMACÉN DESTINO
</label>
<select
v-model="form.warehouse_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Seleccionar almacén...</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
{{ wh.name }} ({{ wh.code }})
</option>
</select>
<FormError :message="form.errors?.warehouse_id" />
</div>
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
PROVEEDOR <span class="text-gray-400 text-xs normal-case">(opcional)</span>
</label>
<select
v-model="form.supplier_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option :value="null">Seleccionar proveedor...</option>
<option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">
{{ supplier.business_name }}
</option>
</select>
<FormError :message="form.errors?.supplier_id" />
</div>
<!-- Lista de productos -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
PRODUCTOS
</label>
<button
type="button"
@click="addProduct"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
>
<GoogleIcon name="add" class="text-sm" />
Agregar producto
</button>
</div>
<div class="space-y-3">
<div
v-for="(item, index) in selectedProducts"
:key="index"
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
>
<!-- Selector de unidad (si el producto tiene equivalencias activas) -->
<div v-if="item.equivalences?.length > 0" class="mb-3 flex items-center gap-3">
<label class="shrink-0 text-xs font-medium text-gray-600 dark:text-gray-400">Unidad:</label>
<select
v-model="item.selected_unit_id"
class="flex-1 px-2 py-1.5 text-sm border border-indigo-300 dark:border-indigo-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option :value="null">{{ item.base_unit?.name }} ({{ item.base_unit?.abbreviation }}) unidad base</option>
<option
v-for="eq in item.equivalences"
:key="eq.unit_of_measure_id"
:value="eq.unit_of_measure_id"
>
{{ eq.unit_name }} 1 {{ eq.unit_abbreviation }} = {{ parseFloat(eq.conversion_factor) }} {{ item.base_unit?.abbreviation }}
</option>
</select>
</div>
<div class="grid grid-cols-12 gap-3 items-start">
<!-- Producto -->
<div class="col-span-12 sm:col-span-5">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Producto
</label>
<!-- Si ya se seleccionó un producto -->
<div v-if="item.inventory_id && item.product_name" class="flex items-center gap-2 px-2 py-1.5 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded text-sm">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ item.product_name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ item.product_sku }}</p>
</div>
<button
type="button"
@click="item.inventory_id = ''; item.product_name = ''; item.product_sku = ''"
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<GoogleIcon name="close" class="text-sm" />
</button>
</div>
<!-- Input de búsqueda -->
<div v-else class="relative">
<input
v-model="productSearch"
@input="onProductInput(index)"
@focus="() => { currentSearchIndex = index; productSuggestions.length > 0 && (showProductSuggestions = true); }"
type="text"
placeholder="Buscar por código de barras o nombre..."
class="w-full px-2 py-1.5 pr-8 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
:class="{
'border-red-500 focus:ring-red-500 focus:border-red-500': productNotFound && currentSearchIndex === index
}"
:readonly="searchingProduct"
/>
<div v-if="searchingProduct && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
<GoogleIcon name="hourglass_empty" class="text-sm text-gray-400 animate-spin" />
</div>
<div v-else-if="productSearch && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
<GoogleIcon name="search" class="text-sm text-gray-400" />
</div>
<!-- Dropdown de sugerencias -->
<div
v-if="showProductSuggestions && productSuggestions.length > 0 && currentSearchIndex === index"
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<button
v-for="product in productSuggestions"
:key="product.id"
type="button"
@click="selectProduct(product)"
class="w-full flex items-center gap-2 px-3 py-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ product.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
SKU: {{ product.sku }}
</p>
</div>
</button>
</div>
<!-- Error de producto no encontrado -->
<div v-if="productNotFound && currentSearchIndex === index" class="absolute z-50 w-full mt-1">
<div class="flex items-start gap-1 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2">
<GoogleIcon name="error" class="text-red-600 dark:text-red-400 text-sm shrink-0" />
<p class="text-xs text-red-800 dark:text-red-300">
Producto no encontrado
</p>
</div>
</div>
</div>
</div>
<!-- Cantidad -->
<div class="col-span-5 sm:col-span-3">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Cantidad
<span v-if="getSelectedEquivalence(item)" class="text-indigo-600 dark:text-indigo-400">
({{ getSelectedEquivalence(item).unit_abbreviation }})
</span>
</label>
<input
v-model="item.quantity"
type="number"
min="1"
:step="item.allows_decimals && !item.selected_unit_id ? '0.001' : '1'"
placeholder="0"
:disabled="item.track_serials && canUseSerials(item)"
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100 dark:disabled:bg-gray-900"
/>
<!-- Conversión a unidad base -->
<p v-if="getSelectedEquivalence(item) && item.quantity > 0" class="mt-1 text-xs text-indigo-600 dark:text-indigo-400">
= {{ (item.quantity * parseFloat(getSelectedEquivalence(item).conversion_factor)).toLocaleString('es-MX') }} {{ item.base_unit?.abbreviation }}
</p>
<p v-else-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Controlado por seriales
</p>
</div>
<!-- Costo unitario -->
<div class="col-span-5 sm:col-span-3">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Costo
<span v-if="getSelectedEquivalence(item)">/ {{ getSelectedEquivalence(item).unit_abbreviation }}</span>
<span v-else>unit.</span>
</label>
<input
v-model="item.unit_cost"
type="number"
min="0"
step="0.01"
placeholder="0.00"
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<!-- Botón eliminar -->
<div class="col-span-2 sm:col-span-1">
<label class="block text-xs font-medium text-transparent mb-1">.</label>
<button
type="button"
@click="removeProduct(index)"
:disabled="selectedProducts.length === 1"
class="w-full px-2 py-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:title="selectedProducts.length === 1 ? 'Debe haber al menos un producto' : 'Eliminar producto'"
>
<GoogleIcon name="delete" class="text-lg" />
</button>
</div>
</div>
<!-- Números de serie -->
<div v-if="item.inventory_id" class="mt-3">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Números de Serie
<span v-if="item.track_serials && canUseSerials(item)" class="text-red-500">*</span>
<span v-else-if="canUseSerials(item)" class="text-gray-500 font-normal">(opcional)</span>
</label>
<!-- Advertencia si la unidad permite decimales -->
<div v-if="!canUseSerials(item)" class="p-2 mb-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded">
<div class="flex items-start gap-2">
<GoogleIcon name="warning" class="text-amber-600 dark:text-amber-400 text-sm shrink-0 mt-0.5" />
<p class="text-xs text-amber-800 dark:text-amber-200">
Este producto usa la unidad <strong>{{ item.unit_of_measure?.name }} ({{ item.unit_of_measure?.abbreviation }})</strong> que permite cantidades decimales. No se pueden agregar números de serie.
</p>
</div>
</div>
<SerialInputList
v-model="item.serial_numbers_list"
:disabled="!canUseSerials(item)"
@update:model-value="updateQuantityFromSerials(item)"
/>
</div>
<!-- Subtotal del producto -->
<div v-if="item.quantity && item.unit_cost" class="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-600 dark:text-gray-400">
Subtotal:
<span class="font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(item.quantity * item.unit_cost) }}
</span>
</p>
</div>
</div>
</div>
<!-- Resumen total -->
<div v-if="selectedProducts.length > 0" class="mt-3 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-300">
Total de productos: {{ selectedProducts.length }}
</span>
<span class="font-medium text-gray-700 dark:text-gray-300">
Cantidad total: {{ totalQuantity }}
</span>
<span class="font-bold text-indigo-900 dark:text-indigo-100">
Costo total: {{ formatCurrency(totalCost) }}
</span>
</div>
</div>
<FormError :message="form.errors?.products" />
</div>
<!-- Referencia de factura -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
REFERENCIA DE FACTURA
</label>
<div class="flex items-center gap-2">
<FormInput
v-model="form.invoice_reference"
type="text"
placeholder="Ej: FAC-2026-001"
required
class="flex-1"
/>
<button
type="button"
@click="showBillModal = true"
class="flex items-center justify-center w-9 h-9 shrink-0 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
title="Crear nueva factura"
>
<GoogleIcon name="add" class="text-lg" />
</button>
</div>
<FormError :message="form.errors?.invoice_reference" />
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
</label>
<textarea
v-model="form.notes"
rows="2"
placeholder="Ej: Compra de proveedor X"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
></textarea>
<FormError :message="form.errors?.notes" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6">
<button
type="button"
@click="closeModal"
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Cancelar
</button>
<button
type="submit"
:disabled="form.processing || selectedProducts.length === 0"
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<GoogleIcon name="add_circle" class="text-lg" />
<span v-if="form.processing">Registrando...</span>
<span v-else>Registrar Entrada</span>
</button>
</div>
</form>
</div>
</Modal>
<BillCreateModal
:show="showBillModal"
@close="showBillModal = false"
@created="(bill) => { form.invoice_reference = bill.name; showBillModal = false; }"
/>
</template>

View File

@ -0,0 +1,622 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useForm, useApi, apiURL } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SerialSelector from '@Components/POS/SerialSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Estado */
const products = ref([]);
const warehouses = ref([]);
const loading = ref(false);
const selectedProducts = ref([]);
const warehouseFromStock = ref(null);
const api = useApi();
// Estado para búsqueda de productos
let debounceTimer = null;
const productSearch = ref('');
const searchingProduct = ref(false);
const productNotFound = ref(false);
const productSuggestions = ref([]);
const showProductSuggestions = ref(false);
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
// Estado para selección de seriales
const showSerialSelector = ref(false);
const currentProductForSerials = ref(null);
const currentProductIndex = ref(null);
/** Formulario */
const form = useForm({
warehouse_id: '',
reference: '',
notes: '',
products: []
});
/** Computed */
const totalQuantity = computed(() => {
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
});
const totalCost = computed(() => {
return selectedProducts.value.reduce((sum, item) => {
return sum + (item.quantity * (item.unit_cost || 0));
}, 0);
});
// Determina si un producto puede usar seriales (no puede si la unidad permite decimales)
const canUseSerials = (item) => {
if (!item.unit_of_measure) return true;
return !item.allows_decimals;
};
/** Métodos */
const loadData = () => {
loading.value = true;
api.get(apiURL('almacenes'), {
onSuccess: (data) => {
warehouses.value = data.warehouses?.data || data.data || [];
},
onFinish: () => {
loading.value = false;
}
});
};
const loadProducts = (warehouseId) => {
if (!warehouseId) {
products.value = [];
warehouseFromStock.value = null;
return;
}
loading.value = true;
api.get(apiURL(`inventario/almacen/${warehouseId}`), {
onSuccess: (data) => {
const productList = data.products || [];
products.value = productList;
warehouseFromStock.value = productList.length;
},
onFinish: () => {
loading.value = false;
}
});
};
const addProduct = () => {
selectedProducts.value.push({
inventory_id: '',
product_name: '',
product_sku: '',
quantity: 1,
unit_cost: 0,
track_serials: false,
unit_of_measure: null,
allows_decimals: false,
selected_serials: [], // Array de números de serie seleccionados
available_serials_count: 0
});
};
const removeProduct = (index) => {
selectedProducts.value.splice(index, 1);
};
/** Métodos de búsqueda de productos */
const onProductInput = (index) => {
currentSearchIndex.value = index;
productNotFound.value = false;
const searchValue = productSearch.value?.trim();
if (!searchValue || searchValue.length < 2) {
productSuggestions.value = [];
showProductSuggestions.value = false;
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
showProductSuggestions.value = true;
searchProduct();
}, 300);
};
const searchProduct = () => {
const searchValue = productSearch.value?.trim();
if (!searchValue) {
productSuggestions.value = [];
showProductSuggestions.value = false;
return;
}
// Validar que haya un almacén de origen seleccionado
if (!form.warehouse_id) {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
window.Notify.warning('Primero selecciona el almacén de origen');
return;
}
searchingProduct.value = true;
productNotFound.value = false;
api.get(apiURL(`inventario/almacen/${form.warehouse_id}?q=${encodeURIComponent(searchValue)}`), {
onSuccess: (data) => {
const foundProducts = data.products?.data || data.data || data.products || [];
if (foundProducts.length > 0) {
productSuggestions.value = foundProducts;
showProductSuggestions.value = true;
} else {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
}
},
onFail: (data) => {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
window.Notify.error(data.message || 'Error al buscar producto');
},
onError: () => {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
},
onFinish: () => {
searchingProduct.value = false;
}
});
};
const selectProduct = (product) => {
if (currentSearchIndex.value !== null) {
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
selectedProducts.value[currentSearchIndex.value].available_serials_count = product.warehouse_stock || 0;
selectedProducts.value[currentSearchIndex.value].unit_of_measure = product.unit_of_measure || null;
selectedProducts.value[currentSearchIndex.value].allows_decimals = product.unit_of_measure?.allows_decimals || false;
// Limpiar seriales si la unidad permite decimales
if (product.unit_of_measure?.allows_decimals) {
selectedProducts.value[currentSearchIndex.value].selected_serials = [];
}
}
productSearch.value = '';
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = false;
currentSearchIndex.value = null;
window.Notify.success(`Producto ${product.name} agregado`);
};
// Gestión de seriales
const openSerialSelector = (item, index) => {
// Crear objeto compatible con SerialSelector (necesita 'id' no 'inventory_id')
currentProductForSerials.value = {
id: item.inventory_id,
name: item.product_name,
sku: item.product_sku,
track_serials: item.track_serials
};
currentProductIndex.value = index;
showSerialSelector.value = true;
};
const closeSerialSelector = () => {
showSerialSelector.value = false;
currentProductForSerials.value = null;
currentProductIndex.value = null;
};
const handleSerialsConfirmed = ({ serialNumbers, quantity }) => {
if (currentProductIndex.value !== null) {
selectedProducts.value[currentProductIndex.value].selected_serials = serialNumbers;
selectedProducts.value[currentProductIndex.value].quantity = quantity;
}
closeSerialSelector();
};
const createExit = () => {
// Validar que productos con seriales (y que pueden usarlos) tengan seriales seleccionados
const invalidProducts = selectedProducts.value.filter(
item => item.track_serials && canUseSerials(item) && (!item.selected_serials || item.selected_serials.length === 0)
);
if (invalidProducts.length > 0) {
window.Notify.error('Debes seleccionar los números de serie para los productos que lo requieren');
return;
}
// Preparar datos del formulario
form.products = selectedProducts.value.map(item => {
const productData = {
inventory_id: item.inventory_id,
quantity: Number(item.quantity),
serial_numbers: [] // Inicializar siempre como array vacío
};
// Agregar seriales solo si la unidad lo permite y hay seriales seleccionados
if (item.track_serials && canUseSerials(item) && item.selected_serials && item.selected_serials.length > 0) {
productData.serial_numbers = item.selected_serials;
}
return productData;
});
form.post(apiURL('movimientos/salida'), {
data: {
warehouse_id: form.warehouse_id,
reference: form.reference,
notes: form.notes,
products: form.products
},
onSuccess: () => {
window.Notify.success('Salida registrada correctamente');
emit('created');
closeModal();
},
onFail: (data) => {
window.Notify.error(data.message || 'Error al registrar la salida');
},
onError: () => {
window.Notify.error('Error al registrar la salida');
}
});
};
const closeModal = () => {
form.reset();
selectedProducts.value = [];
productSearch.value = '';
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = false;
currentSearchIndex.value = null;
emit('close');
};
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown) {
loadData();
if (selectedProducts.value.length === 0) {
addProduct();
}
}
});
// Cargar productos cuando se selecciona un almacén de origen
watch(() => form.warehouse_id, (newWarehouseId, oldWarehouseId) => {
if (newWarehouseId !== oldWarehouseId) {
loadProducts(newWarehouseId);
// Limpiar productos seleccionados si había alguno y se cambió el almacén
if (oldWarehouseId && selectedProducts.value.length > 0) {
selectedProducts.value = [];
addProduct();
}
}
});
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<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-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30">
<GoogleIcon name="remove_circle" class="text-xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Registrar Salida
</h3>
</div>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createExit" class="space-y-4">
<div class="space-y-4">
<!-- Almacén origen -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ALMACÉN ORIGEN
</label>
<select
v-model="form.warehouse_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Seleccionar almacén...</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
{{ wh.name }} ({{ wh.code }})
</option>
</select>
<p v-if="warehouseFromStock !== null" class="mt-1 text-xs text-gray-600 dark:text-gray-400">
<span v-if="warehouseFromStock > 0" class="text-green-600 dark:text-green-400">
{{ warehouseFromStock }} producto(s) en inventario
</span>
<span v-else class="text-amber-600 dark:text-amber-400">
Sin productos en inventario
</span>
</p>
<FormError :message="form.errors?.warehouse_id" />
</div>
<!-- Lista de productos -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
PRODUCTOS
</label>
<button
type="button"
@click="addProduct"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
>
<GoogleIcon name="add" class="text-sm" />
Agregar producto
</button>
</div>
<div class="space-y-3">
<div
v-for="(item, index) in selectedProducts"
:key="index"
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
>
<div class="grid grid-cols-12 gap-3 items-start">
<!-- Producto -->
<div class="col-span-12 sm:col-span-5">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Producto
</label>
<!-- Si ya se seleccionó un producto -->
<div v-if="item.inventory_id && item.product_name" class="flex items-center gap-2 px-2 py-1.5 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded text-sm">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ item.product_name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ item.product_sku }}</p>
</div>
<button
type="button"
@click="item.inventory_id = ''; item.product_name = ''; item.product_sku = ''"
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<GoogleIcon name="close" class="text-sm" />
</button>
</div>
<!-- Input de búsqueda -->
<div v-else class="relative">
<input
v-model="productSearch"
@input="onProductInput(index)"
@focus="() => { currentSearchIndex = index; productSuggestions.length > 0 && (showProductSuggestions = true); }"
type="text"
placeholder="Buscar por código de barras o nombre..."
class="w-full px-2 py-1.5 pr-8 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
:class="{
'border-red-500 focus:ring-red-500 focus:border-red-500': productNotFound && currentSearchIndex === index
}"
:disabled="searchingProduct"
/>
<div v-if="searchingProduct && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
<GoogleIcon name="hourglass_empty" class="text-sm text-gray-400 animate-spin" />
</div>
<div v-else-if="productSearch && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
<GoogleIcon name="search" class="text-sm text-gray-400" />
</div>
<!-- Dropdown de sugerencias -->
<div
v-if="showProductSuggestions && productSuggestions.length > 0 && currentSearchIndex === index"
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<button
v-for="product in productSuggestions"
:key="product.id"
type="button"
@click="selectProduct(product)"
class="w-full flex items-center gap-2 px-3 py-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ product.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
SKU: {{ product.sku }}
</p>
</div>
</button>
</div>
<!-- Error de producto no encontrado -->
<div v-if="productNotFound && currentSearchIndex === index" class="absolute z-50 w-full mt-1">
<div class="flex items-start gap-1 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2">
<GoogleIcon name="error" class="text-red-600 dark:text-red-400 text-sm shrink-0" />
<p class="text-xs text-red-800 dark:text-red-300">
Producto no encontrado
</p>
</div>
</div>
</div>
</div>
<!-- Cantidad -->
<div class="col-span-5 sm:col-span-3">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Cantidad
</label>
<input
v-model="item.quantity"
type="number"
min="1"
step="1"
placeholder="0"
:disabled="item.track_serials && canUseSerials(item)"
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<p v-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Controlado por seriales
</p>
</div>
<!-- Botón eliminar -->
<div class="col-span-2 sm:col-span-1">
<label class="block text-xs font-medium text-transparent mb-1">.</label>
<button
type="button"
@click="removeProduct(index)"
:disabled="selectedProducts.length === 1"
class="w-full px-2 py-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:title="selectedProducts.length === 1 ? 'Debe haber al menos un producto' : 'Eliminar producto'"
>
<GoogleIcon name="delete" class="text-lg" />
</button>
</div>
<!-- Selección de seriales (solo si el producto requiere seriales) -->
<div v-if="item.inventory_id && item.track_serials" class="col-span-12">
<!-- Advertencia si la unidad permite decimales -->
<div v-if="!canUseSerials(item)" class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div class="flex items-start gap-2">
<GoogleIcon name="warning" class="text-red-600 dark:text-red-400 text-lg shrink-0 mt-0.5" />
<div>
<p class="text-xs font-semibold text-red-900 dark:text-red-100">
No se pueden usar números de serie con esta unidad de medida
</p>
<p class="text-xs text-red-700 dark:text-red-300 mt-1">
Este producto usa la unidad <strong>{{ item.unit_of_measure?.name }} ({{ item.unit_of_measure?.abbreviation }})</strong> que permite cantidades decimales.
</p>
</div>
</div>
</div>
<div v-else class="mt-2 p-2 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-lg">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 flex-1">
<GoogleIcon name="qr_code_2" class="text-amber-600 dark:text-amber-400 text-lg" />
<div>
<p class="text-xs font-semibold text-amber-900 dark:text-amber-100">
Este producto requiere números de serie
</p>
<p class="text-xs text-amber-700 dark:text-amber-300">
{{ item.selected_serials.length }} de {{ item.available_serials_count }} seriales seleccionados
</p>
</div>
</div>
<button
type="button"
@click="openSerialSelector(item, index)"
class="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-700 rounded transition-colors"
>
<GoogleIcon name="qr_code_scanner" class="text-sm" />
{{ item.selected_serials.length > 0 ? 'Cambiar' : 'Seleccionar' }}
</button>
</div>
<!-- Lista de seriales seleccionados -->
<div v-if="item.selected_serials.length > 0" class="mt-2 pt-2 border-t border-amber-200 dark:border-amber-800">
<p class="text-xs font-medium text-amber-900 dark:text-amber-100 mb-1">Seriales seleccionados:</p>
<div class="flex flex-wrap gap-1">
<span
v-for="(serial, sIndex) in item.selected_serials.slice(0, 5)"
:key="sIndex"
class="inline-flex items-center px-2 py-0.5 text-xs font-mono bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded"
>
{{ serial }}
</span>
<span
v-if="item.selected_serials.length > 5"
class="inline-flex items-center px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded"
>
+{{ item.selected_serials.length - 5 }} más
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<FormError :message="form.errors?.products" />
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
</label>
<textarea
v-model="form.notes"
rows="2"
placeholder="Ej: Producto dañado"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
></textarea>
<FormError :message="form.errors?.notes" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6">
<button
type="button"
@click="closeModal"
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Cancelar
</button>
<button
type="submit"
:disabled="form.processing || selectedProducts.length === 0"
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<GoogleIcon name="remove_circle" class="text-lg" />
<span v-if="form.processing">Registrando...</span>
<span v-else>Registrar Salida</span>
</button>
</div>
</form>
</div>
<!-- Modal de Selección de Seriales -->
<SerialSelector
v-if="currentProductForSerials && currentProductIndex !== null"
:show="showSerialSelector"
:product="currentProductForSerials"
:warehouse-id="Number(form.warehouse_id)"
:initial-serials="selectedProducts[currentProductIndex]?.selected_serials || []"
@close="closeSerialSelector"
@confirm="handleSerialsConfirmed"
/>
</Modal>
</template>

View File

@ -0,0 +1,428 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useSearcher, apiURL } from '@Services/Api';
import { can } from './Module.js';
import { formatDate } from '@/utils/formatters';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import EntryModal from './EntryModal.vue';
import ExitModal from './ExitModal.vue';
import TransferModal from './TransferModal.vue';
import DetailModal from './DetailModal.vue';
import KardexModal from './KardexModal.vue';
import EditModal from './Edit.vue';
/** Estado */
const movements = ref({});
const selectedType = ref('');
const selectedWarehouse = ref('');
const fromDate = ref('');
const toDate = ref('');
const warehouses = ref([]);
const showEntryModal = ref(false);
const showExitModal = ref(false);
const showTransferModal = ref(false);
const showDetailModal = ref(false);
const showEditModal = ref(false);
const selectedMovementId = ref(null);
const selectedMovement = ref(null);
/** Kardex */
const showKardexModal = ref(false);
/** Filtros computados */
const filters = computed(() => {
const f = {};
if (selectedType.value) f.movement_type = selectedType.value;
if (selectedWarehouse.value) f.warehouse_id = selectedWarehouse.value;
if (fromDate.value) f.from_date = fromDate.value;
if (toDate.value) f.to_date = toDate.value;
return f;
});
/** Agrupar movimientos por invoice_reference para entradas múltiples */
const groupedMovements = computed(() => {
const data = movements.value?.data || [];
const grouped = [];
const processedRefs = new Set();
data.forEach(movement => {
// Si es una entrada con invoice_reference y no ha sido procesada
if (movement.movement_type === 'entry' && movement.invoice_reference && !processedRefs.has(movement.invoice_reference)) {
// Buscar todos los movimientos con el mismo invoice_reference
const relatedMovements = data.filter(m =>
m.movement_type === 'entry' &&
m.invoice_reference === movement.invoice_reference
);
if (relatedMovements.length > 1) {
// Crear un movimiento agrupado
grouped.push({
...movement,
is_grouped: true,
grouped_count: relatedMovements.length,
products: relatedMovements.map(m => ({
inventory: m.inventory,
quantity: m.quantity,
unit_cost: m.unit_cost || 0,
movement_id: m.id
}))
});
processedRefs.add(movement.invoice_reference);
} else {
// Es una entrada individual
grouped.push(movement);
}
} else if (movement.movement_type !== 'entry' || !movement.invoice_reference) {
// Otros tipos de movimientos o entradas sin invoice_reference
if (!processedRefs.has(movement.invoice_reference)) {
grouped.push(movement);
}
}
});
return {
...movements.value,
data: grouped
};
});
/** Tipos de movimiento */
const movementTypes = [
{ value: '', label: 'Todos', icon: 'list', active: 'bg-gray-600 text-white shadow-sm', inactive: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-900/50' },
{ value: 'entry', label: 'Entradas', icon: 'add_circle', active: 'bg-green-600 text-white shadow-sm', inactive: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50' },
{ value: 'exit', label: 'Salidas', icon: 'remove_circle', active: 'bg-red-600 text-white shadow-sm', inactive: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/50' },
{ value: 'transfer', label: 'Traspasos', icon: 'swap_horiz', active: 'bg-blue-600 text-white shadow-sm', inactive: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/50' },
];
const getTypeBadge = (type) => {
const badges = {
entry: { label: 'Entrada', icon: 'add_circle', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' },
exit: { label: 'Salida', icon: 'remove_circle', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' },
transfer: { label: 'Traspaso', icon: 'swap_horiz', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' },
};
return badges[type] || { label: type, icon: 'help_outline', class: 'bg-gray-100 text-gray-800' };
};
/** Searcher */
const searcher = useSearcher({
url: apiURL('movimientos'),
onSuccess: (r) => {
movements.value = r.movements || r;
},
onError: () => movements.value = {}
});
/** Métodos */
const loadWarehouses = async () => {
try {
const response = await fetch(apiURL('almacenes'), {
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json'
}
});
const result = await response.json();
if (result.data) {
warehouses.value = result.data.warehouses?.data || result.data.data || [];
}
} catch (error) {
console.error('Error loading warehouses:', error);
}
};
const applyFilters = () => {
searcher.search('', filters.value);
};
const selectType = (type) => {
selectedType.value = type;
applyFilters();
};
const openDetail = (movement) => {
selectedMovementId.value = movement.id;
selectedMovement.value = movement;
showDetailModal.value = true;
};
const closeDetailModal = () => {
showDetailModal.value = false;
selectedMovementId.value = null;
selectedMovement.value = null;
};
const openEdit = (movement) => {
selectedMovement.value = movement;
showDetailModal.value = false;
showEditModal.value = true;
};
const closeEditModal = () => {
showEditModal.value = false;
selectedMovement.value = null;
};
const onMovementCreated = () => {
applyFilters();
};
const onMovementUpdated = () => {
applyFilters();
};
/** Ciclo de vida */
onMounted(() => {
searcher.search();
loadWarehouses();
});
</script>
<template>
<div>
<SearcherHead
:title="$t('movements.title')"
placeholder="Buscar movimientos..."
@search="(x) => searcher.search(x, filters)"
>
<div class="flex items-center gap-2">
<button
class="flex items-center gap-1.5 px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="showKardexModal = true"
>
<GoogleIcon name="download" class="text-lg" />
Reporte
</button>
<button
v-if="can('create')"
class="flex items-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="showEntryModal = true"
>
<GoogleIcon name="add_circle" class="text-lg" />
Entrada
</button>
<button
v-if="can('create')"
class="flex items-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="showExitModal = true"
>
<GoogleIcon name="remove_circle" class="text-lg" />
Salida
</button>
<button
v-if="can('create')"
class="flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="showTransferModal = true"
>
<GoogleIcon name="swap_horiz" class="text-lg" />
Traspaso
</button>
</div>
</SearcherHead>
<!-- Filtros -->
<div class="mb-4 space-y-3">
<!-- Chips de tipo -->
<div class="flex flex-wrap gap-2">
<button
v-for="type in movementTypes"
:key="type.value"
@click="selectType(type.value)"
:class="[
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-all',
selectedType === type.value ? type.active : type.inactive
]"
>
<GoogleIcon :name="type.icon" class="text-sm" />
{{ type.label }}
</button>
</div>
<!-- Filtros adicionales -->
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Almacén</label>
<select
v-model="selectedWarehouse"
@change="applyFilters"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
>
<option value="">Todos</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
{{ wh.name }}
</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Desde</label>
<input
v-model="fromDate"
@change="applyFilters"
type="date"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">Hasta</label>
<input
v-model="toDate"
@change="applyFilters"
type="date"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
v-if="selectedWarehouse || fromDate || toDate"
@click="selectedWarehouse = ''; fromDate = ''; toDate = ''; selectedType = ''; applyFilters();"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
Limpiar filtros
</button>
</div>
</div>
<!-- Tabla -->
<div class="pt-2 w-full">
<Table
:items="groupedMovements"
@send-pagination="(page) => searcher.pagination(page, filters)"
>
<template #head>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PRODUCTO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TIPO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">PROVEEDOR</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CANTIDAD</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ORIGEN</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">DESTINO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">USUARIO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA</th>
</template>
<template #body="{items}">
<tr
v-for="movement in items"
:key="movement.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
@click="openDetail(movement)"
>
<td class="px-6 py-4 text-left">
<!-- Múltiples productos -->
<div v-if="movement.products && movement.products.length > 0">
<div class="flex items-center gap-2">
<GoogleIcon name="inventory" class="text-indigo-600 dark:text-indigo-400 text-lg" />
<p class="text-sm font-semibold text-indigo-900 dark:text-indigo-100">
Productos
</p>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ movement.products.length }} producto(s)
</p>
</div>
<!-- Producto individual -->
<div v-else>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ movement.inventory?.name || 'N/A' }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">{{ movement.inventory?.sku || '' }}</p>
</div>
</td>
<td class="px-6 py-4 text-center">
<span
:class="[
'inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-semibold',
getTypeBadge(movement.movement_type).class
]"
>
<GoogleIcon :name="getTypeBadge(movement.movement_type).icon" class="text-sm" />
{{ getTypeBadge(movement.movement_type).label }}
</span>
</td>
<td class="px-6 py-4 text-center">
<p v-if="movement.supplier" class="text-sm text-gray-700 dark:text-gray-300">
{{ movement.supplier.business_name }}
</p>
<span v-else class="text-sm text-gray-400 dark:text-gray-500"></span>
</td>
<td class="px-6 py-4 text-center">
<!-- Cantidad total para múltiples productos -->
<p v-if="movement.products && movement.products.length > 0" class="text-sm font-bold text-gray-900 dark:text-gray-100">
{{ movement.products.reduce((sum, p) => sum + Number(p.quantity), 0) }}
</p>
<!-- Cantidad individual -->
<p v-else class="text-sm font-bold text-gray-900 dark:text-gray-100">{{ movement.quantity }}</p>
</td>
<td class="px-6 py-4 text-center">
<p v-if="movement.warehouse_from" class="text-sm text-gray-700 dark:text-gray-300">{{ movement.warehouse_from.name }}</p>
<span v-else class="text-sm text-gray-400 dark:text-gray-500"></span>
</td>
<td class="px-6 py-4 text-center">
<p v-if="movement.warehouse_to" class="text-sm text-gray-700 dark:text-gray-300">{{ movement.warehouse_to.name }}</p>
<span v-else class="text-sm text-gray-400 dark:text-gray-500"></span>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ movement.user?.name || 'N/A' }}</p>
</td>
<td class="px-6 py-4 text-center">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDate(movement.created_at) }}</p>
</td>
</tr>
</template>
<template #empty>
<td colspan="8" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="swap_horiz"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
{{ $t('registers.empty') }}
</p>
</div>
</td>
</template>
</Table>
</div>
<!-- Modales -->
<EntryModal
v-if="can('create')"
:show="showEntryModal"
@close="showEntryModal = false"
@created="onMovementCreated"
/>
<ExitModal
v-if="can('create')"
:show="showExitModal"
@close="showExitModal = false"
@created="onMovementCreated"
/>
<TransferModal
v-if="can('create')"
:show="showTransferModal"
@close="showTransferModal = false"
@created="onMovementCreated"
/>
<DetailModal
:show="showDetailModal"
:movement-id="selectedMovementId"
:movement-data="selectedMovement"
@close="closeDetailModal"
@edit="openEdit"
/>
<!-- Modal de Edición -->
<EditModal
v-if="can('edit')"
:show="showEditModal"
:movement="selectedMovement"
@close="closeEditModal"
@updated="onMovementUpdated"
/>
<!-- Modal Kardex -->
<KardexModal
:show="showKardexModal"
@close="showKardexModal = false"
/>
</div>
</template>

View File

@ -0,0 +1,259 @@
<script setup>
import { ref, watch } from 'vue';
import { useApi, apiURL } from '@Services/Api';
import reportService from '@Services/reportService';
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Eventos */
const emit = defineEmits(['close']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Estado */
const api = useApi();
const warehouses = ref([]);
const isExporting = ref(false);
const form = ref({
inventory_id: '',
fecha_inicio: '',
fecha_fin: '',
warehouse_id: '',
movement_type: '',
});
// Búsqueda de producto
let debounceTimer = null;
const productSearch = ref('');
const productSuggestions = ref([]);
const selectedProduct = ref(null);
const showSuggestions = ref(false);
/** Tipos de movimiento */
const movementTypes = [
{ value: '', label: 'Todos' },
{ value: 'entry', label: 'Entrada' },
{ value: 'exit', label: 'Salida' },
{ value: 'transfer', label: 'Traspaso' },
/* { value: 'sale', label: 'Venta' },
{ value: 'return', label: 'Devolución' }, */
];
/** Métodos */
const loadWarehouses = () => {
api.get(apiURL('almacenes'), {
onSuccess: (data) => {
warehouses.value = data.warehouses?.data || data.data || [];
}
});
};
const searchProduct = () => {
const q = productSearch.value.trim();
if (q.length < 2) {
productSuggestions.value = [];
showSuggestions.value = false;
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
api.get(apiURL(`inventario?q=${encodeURIComponent(q)}`), {
onSuccess: (data) => {
productSuggestions.value = data.products?.data || data.data || data.products || [];
showSuggestions.value = productSuggestions.value.length > 0;
}
});
}, 300);
};
const selectProduct = (product) => {
selectedProduct.value = product;
form.value.inventory_id = product.id;
productSearch.value = `${product.name} (${product.sku})`;
showSuggestions.value = false;
};
const clearProduct = () => {
selectedProduct.value = null;
form.value.inventory_id = '';
productSearch.value = '';
productSuggestions.value = [];
};
const exportKardex = async () => {
try {
isExporting.value = true;
const filters = {
fecha_inicio: form.value.fecha_inicio,
fecha_fin: form.value.fecha_fin,
};
if (form.value.inventory_id) {
filters.inventory_id = form.value.inventory_id;
}
if (form.value.warehouse_id) {
filters.warehouse_id = form.value.warehouse_id;
}
if (form.value.movement_type) {
filters.movement_type = form.value.movement_type;
}
await reportService.exportKardexToExcel(filters);
Notify.success('Kardex exportado exitosamente');
emit('close');
} catch (error) {
if (error.message) {
Notify.error(error.message);
} else {
Notify.error('Error al exportar el kardex');
}
} finally {
isExporting.value = false;
}
};
const resetForm = () => {
form.value = { inventory_id: '', fecha_inicio: '', fecha_fin: '', warehouse_id: '', movement_type: '' };
productSearch.value = '';
selectedProduct.value = null;
productSuggestions.value = [];
showSuggestions.value = false;
};
/** Observadores */
watch(() => props.show, (val) => {
if (val) {
resetForm();
loadWarehouses();
}
});
</script>
<template>
<Modal :show="show" max-width="lg" @close="emit('close')">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-2">
<GoogleIcon name="assignment" class="text-2xl text-emerald-600" />
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">Exportar reporte</h3>
</div>
<button @click="emit('close')" 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">
<!-- Producto -->
<div class="relative">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Producto <span class="text-gray-400 text-xs normal-nums">(Opcional)</span></label>
<div class="relative">
<input
v-model="productSearch"
@input="searchProduct"
@focus="showSuggestions = productSuggestions.length > 0"
type="text"
placeholder="Buscar producto por nombre o SKU..."
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
:class="{ 'pr-8': selectedProduct }"
/>
<button
v-if="selectedProduct"
@click="clearProduct"
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-red-500"
>
<GoogleIcon name="close" class="text-base" />
</button>
</div>
<div v-if="selectedProduct" class="mt-1 text-xs text-green-600 dark:text-green-400">
Seleccionado: {{ selectedProduct.name }} ({{ selectedProduct.sku }})
</div>
<!-- Sugerencias -->
<div
v-if="showSuggestions"
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<button
v-for="product in productSuggestions"
:key="product.id"
@click="selectProduct(product)"
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm"
>
<p class="font-medium text-gray-900 dark:text-gray-100">{{ product.name }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">SKU: {{ product.sku }}</p>
</button>
</div>
</div>
<!-- Fechas (requeridas) -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Desde *</label>
<input
v-model="form.fecha_inicio"
type="date"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Hasta *</label>
<input
v-model="form.fecha_fin"
type="date"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
/>
</div>
</div>
<!-- Almacén (opcional) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Almacén (opcional)</label>
<select
v-model="form.warehouse_id"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
>
<option value="">Todos los almacenes</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">{{ wh.name }}</option>
</select>
</div>
<!-- Tipo de movimiento (opcional) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo de movimiento (opcional)</label>
<select
v-model="form.movement_type"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500"
>
<option v-for="type in movementTypes" :key="type.value" :value="type.value">{{ type.label }}</option>
</select>
</div>
</div>
<!-- Botones -->
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="emit('close')"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Cancelar
</button>
<button
@click="exportKardex"
:disabled="!form.fecha_inicio || !form.fecha_fin || isExporting"
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<GoogleIcon :name="isExporting ? 'hourglass_empty' : 'download'" class="text-lg" />
{{ isExporting ? 'Exportando...' : 'Exportar Kardex' }}
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,16 @@
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`movements.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.movements.${name}`, params, query })
// Determina si un usuario puede hacer algo en base a los permisos
const can = (permission) => hasPermission(`movements.${permission}`)
export {
can,
viewTo,
apiTo
}

View File

@ -0,0 +1,678 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useForm, useApi, apiURL } from '@Services/Api';
import { formatCurrency } from '@/utils/formatters';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SerialSelector from '@Components/POS/SerialSelector.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Estado */
const products = ref([]);
const warehouses = ref([]);
const loading = ref(false);
const selectedProducts = ref([]);
const warehouseFromStock = ref(null);
const warehouseToStock = ref(null);
const api = useApi();
// Estado para búsqueda de productos
let debounceTimer = null;
const productSearch = ref('');
const searchingProduct = ref(false);
const productNotFound = ref(false);
const productSuggestions = ref([]);
const showProductSuggestions = ref(false);
const currentSearchIndex = ref(null); // Índice del producto que se está buscando
// Estado para selección de seriales
const showSerialSelector = ref(false);
const currentProductForSerials = ref(null);
const currentProductIndex = ref(null);
/** Formulario */
const form = useForm({
warehouse_from_id: '',
warehouse_to_id: '',
reference: '',
notes: '',
products: []
});
/** Computed */
const availableDestinations = computed(() => {
return warehouses.value.filter(wh => wh.id !== Number(form.warehouse_from_id));
});
const totalQuantity = computed(() => {
return selectedProducts.value.reduce((sum, item) => sum + Number(item.quantity), 0);
});
// Determina si un producto puede usar seriales (no puede si la unidad permite decimales)
const canUseSerials = (item) => {
if (!item.unit_of_measure) return true;
return !item.allows_decimals;
};
/** Métodos */
const loadData = () => {
loading.value = true;
api.get(apiURL('almacenes'), {
onSuccess: (data) => {
warehouses.value = data.warehouses?.data || data.data || [];
},
onFinish: () => {
loading.value = false;
}
});
};
const loadProducts = (warehouseId, isOrigin = true) => {
if (!warehouseId) {
products.value = [];
if (isOrigin) {
warehouseFromStock.value = null;
} else {
warehouseToStock.value = null;
}
return;
}
loading.value = true;
api.get(apiURL(`inventario/almacen/${warehouseId}`), {
onSuccess: (data) => {
const productList = data.products || [];
if (isOrigin) {
products.value = productList;
warehouseFromStock.value = productList.length;
} else {
warehouseToStock.value = productList.length;
}
},
onFinish: () => {
loading.value = false;
}
});
};
const addProduct = () => {
selectedProducts.value.push({
inventory_id: '',
product_name: '',
product_sku: '',
quantity: 1,
track_serials: false,
unit_of_measure: null,
allows_decimals: false,
selected_serials: [], // Array de números de serie seleccionados
available_serials_count: 0
});
};
const removeProduct = (index) => {
selectedProducts.value.splice(index, 1);
};
/** Métodos de búsqueda de productos */
const onProductInput = (index) => {
currentSearchIndex.value = index;
productNotFound.value = false;
const searchValue = productSearch.value?.trim();
if (!searchValue || searchValue.length < 2) {
productSuggestions.value = [];
showProductSuggestions.value = false;
return;
}
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
showProductSuggestions.value = true;
searchProduct();
}, 300);
};
const searchProduct = () => {
const searchValue = productSearch.value?.trim();
if (!searchValue) {
productSuggestions.value = [];
showProductSuggestions.value = false;
return;
}
// Validar que haya un almacén de origen seleccionado
if (!form.warehouse_from_id) {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
window.Notify.warning('Primero selecciona el almacén de origen');
return;
}
searchingProduct.value = true;
productNotFound.value = false;
api.get(apiURL(`inventario/almacen/${form.warehouse_from_id}?q=${encodeURIComponent(searchValue)}`), {
onSuccess: (data) => {
const foundProducts = data.products?.data || data.data || data.products || [];
if (foundProducts.length > 0) {
productSuggestions.value = foundProducts;
showProductSuggestions.value = true;
} else {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
}
},
onFail: (data) => {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
window.Notify.error(data.message || 'Error al buscar producto');
},
onError: () => {
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = true;
},
onFinish: () => {
searchingProduct.value = false;
}
});
};
const selectProduct = (product) => {
if (currentSearchIndex.value !== null) {
selectedProducts.value[currentSearchIndex.value].inventory_id = product.id;
selectedProducts.value[currentSearchIndex.value].product_name = product.name;
selectedProducts.value[currentSearchIndex.value].product_sku = product.sku;
selectedProducts.value[currentSearchIndex.value].track_serials = product.track_serials || false;
selectedProducts.value[currentSearchIndex.value].available_serials_count = product.warehouse_stock || 0;
selectedProducts.value[currentSearchIndex.value].unit_of_measure = product.unit_of_measure || null;
selectedProducts.value[currentSearchIndex.value].allows_decimals = product.unit_of_measure?.allows_decimals || false;
// Limpiar seriales si la unidad permite decimales
if (product.unit_of_measure?.allows_decimals) {
selectedProducts.value[currentSearchIndex.value].selected_serials = [];
}
}
productSearch.value = '';
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = false;
currentSearchIndex.value = null;
window.Notify.success(`Producto ${product.name} agregado`);
};
// Gestión de seriales
const openSerialSelector = (item, index) => {
// Crear objeto compatible con SerialSelector (necesita 'id' no 'inventory_id')
currentProductForSerials.value = {
id: item.inventory_id,
name: item.product_name,
sku: item.product_sku,
track_serials: item.track_serials
};
currentProductIndex.value = index;
showSerialSelector.value = true;
};
const closeSerialSelector = () => {
showSerialSelector.value = false;
currentProductForSerials.value = null;
currentProductIndex.value = null;
};
const handleSerialsConfirmed = ({ serialNumbers, quantity }) => {
if (currentProductIndex.value !== null) {
selectedProducts.value[currentProductIndex.value].selected_serials = serialNumbers;
selectedProducts.value[currentProductIndex.value].quantity = quantity;
}
closeSerialSelector();
};
const createTransfer = () => {
// Validar que productos con seriales (y que pueden usarlos) tengan seriales seleccionados
const invalidProducts = selectedProducts.value.filter(
item => item.track_serials && canUseSerials(item) && (!item.selected_serials || item.selected_serials.length === 0)
);
if (invalidProducts.length > 0) {
window.Notify.error('Debes seleccionar los números de serie para los productos que lo requieren');
return;
}
// Preparar datos del formulario
form.products = selectedProducts.value.map(item => {
const productData = {
inventory_id: item.inventory_id,
quantity: Number(item.quantity),
serial_numbers: [] // Inicializar siempre como array vacío
};
// Agregar seriales solo si la unidad lo permite y hay seriales seleccionados
if (item.track_serials && canUseSerials(item) && item.selected_serials && item.selected_serials.length > 0) {
// Filtrar seriales válidos (no null, no undefined, no vacíos)
const validSerials = item.selected_serials.filter(s => s && s.trim());
if (validSerials.length !== item.quantity) {
window.Notify.error(`El producto "${item.product_name}" tiene ${validSerials.length} seriales pero cantidad ${item.quantity}`);
throw new Error('Serial count mismatch');
}
productData.serial_numbers = validSerials;
}
return productData;
});
form.post(apiURL('movimientos/traspaso'), {
data: {
warehouse_from_id: form.warehouse_from_id,
warehouse_to_id: form.warehouse_to_id,
reference: form.reference,
notes: form.notes,
products: form.products
},
onSuccess: () => {
window.Notify.success('Traspaso registrado correctamente');
emit('created');
closeModal();
},
onFail: (data) => {
window.Notify.error(data.message || 'Error al registrar el traspaso');
},
onError: () => {
window.Notify.error('Error al registrar el traspaso');
}
});
};
const closeModal = () => {
form.reset();
selectedProducts.value = [];
productSearch.value = '';
productSuggestions.value = [];
showProductSuggestions.value = false;
productNotFound.value = false;
currentSearchIndex.value = null;
emit('close');
};
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown) {
loadData();
if (selectedProducts.value.length === 0) {
addProduct();
}
}
});
// Limpiar destino si cambia el origen
watch(() => form.warehouse_from_id, (newWarehouseId, oldWarehouseId) => {
if (form.warehouse_to_id && Number(form.warehouse_to_id) === Number(newWarehouseId)) {
form.warehouse_to_id = '';
}
// Cargar productos del almacén de origen
if (newWarehouseId !== oldWarehouseId) {
loadProducts(newWarehouseId, true);
// Limpiar productos seleccionados si había alguno y se cambió el almacén
if (oldWarehouseId && selectedProducts.value.length > 0) {
selectedProducts.value = [];
addProduct();
}
}
});
// Cargar stock del almacén destino cuando cambia
watch(() => form.warehouse_to_id, (newWarehouseId, oldWarehouseId) => {
if (newWarehouseId !== oldWarehouseId) {
loadProducts(newWarehouseId, false);
}
});
</script>
<template>
<Modal :show="show" max-width="lg" @close="closeModal">
<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-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30">
<GoogleIcon name="swap_horiz" class="text-xl text-blue-600 dark:text-blue-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Registrar Traspaso
</h3>
</div>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createTransfer" class="space-y-4">
<div class="space-y-4">
<!-- Almacenes -->
<div class="grid grid-cols-2 gap-4">
<!-- Almacén origen -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ALMACÉN ORIGEN
</label>
<select
v-model="form.warehouse_from_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Seleccionar origen...</option>
<option v-for="wh in warehouses" :key="wh.id" :value="wh.id">
{{ wh.name }} ({{ wh.code }})
</option>
</select>
<p v-if="warehouseFromStock !== null" class="mt-1 text-xs text-gray-600 dark:text-gray-400">
<span v-if="warehouseFromStock > 0" class="text-green-600 dark:text-green-400">
{{ warehouseFromStock }} producto(s) en inventario
</span>
<span v-else class="text-amber-600 dark:text-amber-400">
Sin productos en inventario
</span>
</p>
<FormError :message="form.errors?.warehouse_from_id" />
</div>
<!-- Almacén destino -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
ALMACÉN DESTINO
</label>
<select
v-model="form.warehouse_to_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Seleccionar destino...</option>
<option v-for="wh in availableDestinations" :key="wh.id" :value="wh.id">
{{ wh.name }} ({{ wh.code }})
</option>
</select>
<p v-if="warehouseToStock !== null" class="mt-1 text-xs text-gray-600 dark:text-gray-400">
<span v-if="warehouseToStock > 0" class="text-blue-600 dark:text-blue-400">
{{ warehouseToStock }} producto(s) en inventario
</span>
<span v-else class="text-gray-500 dark:text-gray-500">
Inventario vacío
</span>
</p>
<FormError :message="form.errors?.warehouse_to_id" />
</div>
</div>
<!-- Lista de productos -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase">
PRODUCTOS
</label>
<button
type="button"
@click="addProduct"
class="flex items-center gap-1 px-2 py-1 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors"
>
<GoogleIcon name="add" class="text-sm" />
Agregar producto
</button>
</div>
<div class="space-y-3">
<div
v-for="(item, index) in selectedProducts"
:key="index"
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800/50"
>
<div class="grid grid-cols-12 gap-3 items-start">
<!-- Producto -->
<div class="col-span-12 sm:col-span-5">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Producto
</label>
<!-- Si ya se seleccionó un producto -->
<div v-if="item.inventory_id && item.product_name" class="flex items-center gap-2 px-2 py-1.5 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded text-sm">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ item.product_name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ item.product_sku }}</p>
</div>
<button
type="button"
@click="item.inventory_id = ''; item.product_name = ''; item.product_sku = ''"
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<GoogleIcon name="close" class="text-sm" />
</button>
</div>
<!-- Input de búsqueda -->
<div v-else class="relative">
<input
v-model="productSearch"
@input="onProductInput(index)"
@focus="() => { currentSearchIndex = index; productSuggestions.length > 0 && (showProductSuggestions = true); }"
type="text"
placeholder="Buscar por código de barras o nombre..."
class="w-full px-2 py-1.5 pr-8 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
:class="{
'border-red-500 focus:ring-red-500 focus:border-red-500': productNotFound && currentSearchIndex === index
}"
:disabled="searchingProduct"
/>
<div v-if="searchingProduct && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
<GoogleIcon name="hourglass_empty" class="text-sm text-gray-400 animate-spin" />
</div>
<div v-else-if="productSearch && currentSearchIndex === index" class="absolute right-2 top-1/2 -translate-y-1/2">
<GoogleIcon name="search" class="text-sm text-gray-400" />
</div>
<!-- Dropdown de sugerencias -->
<div
v-if="showProductSuggestions && productSuggestions.length > 0 && currentSearchIndex === index"
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<button
v-for="product in productSuggestions"
:key="product.id"
type="button"
@click="selectProduct(product)"
class="w-full flex items-center gap-2 px-3 py-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
{{ product.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
SKU: {{ product.sku }}
</p>
</div>
</button>
</div>
<!-- Error de producto no encontrado -->
<div v-if="productNotFound && currentSearchIndex === index" class="absolute z-50 w-full mt-1">
<div class="flex items-start gap-1 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-2">
<GoogleIcon name="error" class="text-red-600 dark:text-red-400 text-sm shrink-0" />
<p class="text-xs text-red-800 dark:text-red-300">
Producto no encontrado
</p>
</div>
</div>
</div>
</div>
<!-- Cantidad -->
<div class="col-span-5 sm:col-span-3">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Cantidad
</label>
<input
v-model="item.quantity"
type="number"
min="1"
step="1"
placeholder="0"
:disabled="item.track_serials && canUseSerials(item)"
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<p v-if="item.track_serials && canUseSerials(item)" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Controlado por seriales
</p>
</div>
<!-- Botón eliminar -->
<div class="col-span-2 sm:col-span-1">
<label class="block text-xs font-medium text-transparent mb-1">.</label>
<button
type="button"
@click="removeProduct(index)"
:disabled="selectedProducts.length === 1"
class="w-full px-2 py-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:title="selectedProducts.length === 1 ? 'Debe haber al menos un producto' : 'Eliminar producto'"
>
<GoogleIcon name="delete" class="text-lg" />
</button>
</div>
<!-- Selección de seriales (solo si el producto requiere seriales) -->
<div v-if="item.inventory_id && item.track_serials" class="col-span-12">
<!-- Advertencia si la unidad permite decimales -->
<div v-if="!canUseSerials(item)" class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div class="flex items-start gap-2">
<GoogleIcon name="warning" class="text-red-600 dark:text-red-400 text-lg shrink-0 mt-0.5" />
<div>
<p class="text-xs font-semibold text-red-900 dark:text-red-100">
No se pueden usar números de serie con esta unidad de medida
</p>
<p class="text-xs text-red-700 dark:text-red-300 mt-1">
Este producto usa la unidad <strong>{{ item.unit_of_measure?.name }} ({{ item.unit_of_measure?.abbreviation }})</strong> que permite cantidades decimales.
</p>
</div>
</div>
</div>
<div v-else class="mt-2 p-2 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2 flex-1">
<GoogleIcon name="qr_code_2" class="text-blue-600 dark:text-blue-400 text-lg" />
<div>
<p class="text-xs font-semibold text-blue-900 dark:text-blue-100">
Este producto requiere números de serie
</p>
<p class="text-xs text-blue-700 dark:text-blue-300">
{{ item.selected_serials.length }} de {{ item.available_serials_count }} seriales seleccionados
</p>
</div>
</div>
<button
type="button"
@click="openSerialSelector(item, index)"
class="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
>
<GoogleIcon name="qr_code_scanner" class="text-sm" />
{{ item.selected_serials.length > 0 ? 'Cambiar' : 'Seleccionar' }}
</button>
</div>
<!-- Lista de seriales seleccionados -->
<div v-if="item.selected_serials.length > 0" class="mt-2 pt-2 border-t border-blue-200 dark:border-blue-800">
<p class="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">Seriales seleccionados:</p>
<div class="flex flex-wrap gap-1">
<span
v-for="(serial, sIndex) in item.selected_serials.slice(0, 5)"
:key="sIndex"
class="inline-flex items-center px-2 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded"
>
{{ serial }}
</span>
<span
v-if="item.selected_serials.length > 5"
class="inline-flex items-center px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded"
>
+{{ item.selected_serials.length - 5 }} más
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<FormError :message="form.errors?.products" />
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOTAS <span class="text-gray-400 text-xs normal-case">(opcional)</span>
</label>
<textarea
v-model="form.notes"
rows="2"
placeholder="Ej: Traspaso a bodega"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
></textarea>
<FormError :message="form.errors?.notes" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 mt-6">
<button
type="button"
@click="closeModal"
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
>
Cancelar
</button>
<button
type="submit"
:disabled="form.processing || selectedProducts.length === 0"
class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<GoogleIcon name="swap_horiz" class="text-lg" />
<span v-if="form.processing">Registrando...</span>
<span v-else>Registrar Traspaso</span>
</button>
</div>
</form>
</div>
<!-- Modal de Selección de Seriales -->
<SerialSelector
v-if="currentProductForSerials && currentProductIndex !== null"
:show="showSerialSelector"
:product="currentProductForSerials"
:warehouse-id="Number(form.warehouse_from_id)"
:initial-serials="selectedProducts[currentProductIndex]?.selected_serials || []"
@close="closeSerialSelector"
@confirm="handleSerialsConfirmed"
/>
</Modal>
</template>

1007
src/pages/POS/Point.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,493 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { formatCurrency, formatDate } from '@/utils/formatters';
import { page } from '@Services/Page';
import returnsService from '@Services/returnsService';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import useCashRegister from '@/stores/cashRegister';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
sale: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'created']);
/** Store */
const cashRegisterStore = useCashRegister();
/** Estado */
const loading = ref(false);
const saleData = ref(null);
const returnableItems = ref([]);
const selectedItems = ref([]);
const reason = ref('');
const reasonText = ref('');
const notes = ref('');
/** Opciones de motivo */
const reasonOptions = [
{ value: 'defective', label: 'Producto defectuoso' },
{ value: 'wrong_product', label: 'Producto incorrecto' },
{ value: 'change_of_mind', label: 'Cambio de opinión' },
{ value: 'damaged', label: 'Producto dañado' },
{ value: 'other', label: 'Otro' }
];
/** Computados */
const hasSelectedItems = computed(() => {
return selectedItems.value.some(item => item.selected && item.quantity > 0);
});
const subtotalReturn = computed(() => {
return selectedItems.value
.filter(item => item.selected && item.quantity > 0)
.reduce((total, item) => {
return total + (parseFloat(item.unit_price) * item.quantity);
}, 0);
});
const taxReturn = computed(() => {
if (!saleData.value || !saleData.value.subtotal || !saleData.value.tax) {
return 0;
}
// Calcular impuesto proporcional basado en la tasa de la venta original
const taxRate = parseFloat(saleData.value.tax) / parseFloat(saleData.value.subtotal);
return subtotalReturn.value * taxRate;
});
const totalReturn = computed(() => {
return subtotalReturn.value + taxReturn.value;
});
const canSubmit = computed(() => {
return hasSelectedItems.value && reason.value !== '';
});
/** Watchers */
watch(() => props.show, async (isShown) => {
if (isShown) {
resetForm();
// Cargar el estado de la caja registradora
await cashRegisterStore.loadCurrentRegister();
if (props.sale) {
searchSale();
}
}
});
/** Métodos */
const resetForm = () => {
saleData.value = null;
returnableItems.value = [];
selectedItems.value = [];
reason.value = '';
reasonText.value = '';
notes.value = '';
loading.value = false;
};
const searchSale = async () => {
if (!props.sale?.id) {
window.Notify.error('No se proporcionó una venta válida');
return;
}
loading.value = true;
try {
const response = await returnsService.getReturnableItems(props.sale.id);
// Guardar datos de la venta
saleData.value = response.sale || null;
// Obtener items devolvibles
const items = response.returnable_items || [];
if (!items || items.length === 0) {
window.Notify.warning('Esta venta no tiene items disponibles para devolución');
loading.value = false;
return;
}
returnableItems.value = items;
// Inicializar items seleccionables con estructura del API
selectedItems.value = items.map(item => ({
sale_detail_id: item.sale_detail_id,
inventory_id: item.inventory_id,
product_name: item.product_name,
unit_price: item.unit_price,
quantity_sold: item.quantity_sold,
quantity_already_returned: item.quantity_already_returned,
quantity_returnable: item.quantity_returnable,
available_serials: item.available_serials || [],
// Estado de selección
selected: false,
quantity: 0,
selectedSerials: []
}));
} catch (error) {
console.error('Error al buscar venta:', error);
window.Notify.error('No se encontró la venta o no está disponible para devolución');
} finally {
loading.value = false;
}
};
const toggleItem = (index) => {
const item = selectedItems.value[index];
item.selected = !item.selected;
if (item.selected) {
// Si tiene seriales, no asignar cantidad automáticamente
if (item.available_serials.length > 0) {
item.quantity = 0;
item.selectedSerials = [];
} else {
item.quantity = item.quantity_returnable;
}
} else {
item.quantity = 0;
item.selectedSerials = [];
}
};
const updateQuantity = (index, value) => {
const item = selectedItems.value[index];
const qty = parseInt(value) || 0;
item.quantity = Math.min(Math.max(0, qty), item.quantity_returnable);
item.selected = item.quantity > 0;
};
const toggleSerial = (itemIndex, serial) => {
const item = selectedItems.value[itemIndex];
const serialIndex = item.selectedSerials.findIndex(s => s.serial_number === serial.serial_number);
if (serialIndex > -1) {
item.selectedSerials.splice(serialIndex, 1);
} else {
if (item.selectedSerials.length < item.quantity_returnable) {
item.selectedSerials.push(serial);
}
}
item.quantity = item.selectedSerials.length;
item.selected = item.quantity > 0;
};
const isSerialSelected = (itemIndex, serial) => {
const item = selectedItems.value[itemIndex];
return item.selectedSerials.some(s => s.serial_number === serial.serial_number);
};
const hasSerials = (item) => {
return item.available_serials && item.available_serials.length > 0;
};
const handleClose = () => {
resetForm();
emit('close');
};
const submitReturn = async () => {
if (!canSubmit.value) {
window.Notify.error('Selecciona al menos un item y proporciona un motivo');
return;
}
// Validar que haya una caja abierta
if (!cashRegisterStore.hasOpenRegister) {
window.Notify.error('Debes tener una caja abierta para procesar devoluciones');
return;
}
loading.value = true;
try {
const items = selectedItems.value
.filter(item => item.selected && item.quantity > 0)
.map(item => ({
sale_detail_id: item.sale_detail_id,
quantity_returned: item.quantity,
serial_numbers: item.selectedSerials.map(s => s.serial_number)
}));
const returnData = {
sale_id: saleData.value?.id,
user_id: page.user.id,
cash_register_id: cashRegisterStore.currentRegisterId || null,
refund_method: saleData.value?.payment_method || 'cash',
reason: reason.value,
reason_text: reasonText.value.trim() || null,
notes: notes.value.trim() || null,
items: items
};
const response = await returnsService.createReturn(returnData);
window.Notify.success(response.message || 'Devolución procesada exitosamente');
emit('created');
} catch (error) {
console.error('Error al crear devolución:', error);
window.Notify.error(error.message || 'Error al procesar la devolución');
} finally {
loading.value = false;
}
};
</script>
<template>
<Modal :show="show" max-width="3xl" @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="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center">
<GoogleIcon name="sync" class="text-2xl text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Nueva Devolución
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Seleccionar productos a devolver
</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-2xl" />
</button>
</div>
<!-- Seleccionar Items -->
<div class="space-y-4">
<!-- Info de la venta -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Venta seleccionada:</p>
<p class="text-lg font-semibold text-indigo-600 dark:text-indigo-400 font-mono">
{{ saleData?.invoice_number || '-' }}
</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-500 dark:text-gray-400">Total de venta:</p>
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(saleData?.total) }}
</p>
</div>
</div>
</div>
<!-- Lista de items -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden max-h-64 overflow-y-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-10">
<span class="sr-only">Seleccionar</span>
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Producto
</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-32">
Cantidad
</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-28">
Precio
</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-28">
Subtotal
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<template v-for="(item, index) in selectedItems" :key="item.sale_detail_id">
<tr
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
:class="{ 'bg-indigo-50 dark:bg-indigo-900/20': item.selected }"
>
<td class="px-4 py-3">
<input
type="checkbox"
:checked="item.selected"
@change="toggleItem(index)"
class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
</td>
<td class="px-4 py-3">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ item.product_name }}
<span></span>
</p>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>Vendido: {{ item.quantity_sold }}</span>
<span v-if="item.quantity_already_returned > 0" class="text-orange-600 dark:text-orange-400">
Ya devuelto: {{ item.quantity_already_returned }}
</span>
<span class="text-green-600 dark:text-green-400">
Disponible: {{ item.quantity_returnable }}
</span>
</div>
</td>
<td class="px-4 py-3 text-center">
<input
v-if="!hasSerials(item)"
type="number"
:value="item.quantity"
:max="item.quantity_returnable"
:min="0"
@input="updateQuantity(index, $event.target.value)"
class="w-20 px-2 py-1 text-center text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-indigo-500 focus:border-indigo-500"
/>
<span v-else class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ item.quantity }} / {{ item.quantity_returnable }}
</span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ formatCurrency(item.unit_price) }}
</span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(parseFloat(item.unit_price) * item.quantity) }}
</span>
</td>
</tr>
<!-- Fila de seriales si el producto tiene -->
<tr v-if="hasSerials(item) && item.selected">
<td colspan="5" class="px-4 py-3 bg-gray-50 dark:bg-gray-800/50">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
Selecciona los seriales a devolver:
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="serial in item.available_serials"
:key="serial.serial_number"
@click="toggleSerial(index, serial)"
class="px-3 py-1 text-xs font-mono rounded-lg border transition-colors"
:class="isSerialSelected(index, serial)
? 'bg-indigo-100 border-indigo-300 text-indigo-700 dark:bg-indigo-900/30 dark:border-indigo-700 dark:text-indigo-400'
: 'bg-white border-gray-300 text-gray-700 hover:border-indigo-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300'"
>
{{ serial.serial_number }}
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Motivo y Método de Reembolso -->
<div class="grid grid-cols-1 md:grid-cols-1 gap-4">
<!-- Motivo de devolución -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Motivo de devolución <span class="text-red-500">*</span>
</label>
<select
v-model="reason"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Selecciona un motivo</option>
<option v-for="opt in reasonOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
<!-- Descripción adicional del motivo -->
<div v-if="reason === 'other'">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Describe el motivo
</label>
<textarea
v-model="reasonText"
rows="2"
placeholder="Describe el motivo de la devolución..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
></textarea>
</div>
<!-- Notas adicionales -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Notas adicionales (opcional)
</label>
<textarea
v-model="notes"
rows="2"
placeholder="Información adicional sobre la devolución..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
></textarea>
</div>
<!-- Desglose de totales a devolver -->
<div class="flex justify-end">
<div class="bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg px-6 py-4 min-w-64">
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-indigo-600 dark:text-indigo-400">Subtotal:</span>
<span class="text-indigo-700 dark:text-indigo-300 font-medium">
{{ formatCurrency(subtotalReturn) }}
</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-indigo-600 dark:text-indigo-400">IVA:</span>
<span class="text-indigo-700 dark:text-indigo-300 font-medium">
{{ formatCurrency(taxReturn) }}
</span>
</div>
<div class="flex justify-between pt-2 border-t border-indigo-200 dark:border-indigo-700">
<span class="text-xs text-indigo-600 dark:text-indigo-400 uppercase font-semibold">Total a Devolver</span>
<span class="text-2xl font-bold text-indigo-700 dark:text-indigo-300">
{{ formatCurrency(totalReturn) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-semibold rounded-lg transition-colors"
@click="handleClose"
>
Cancelar
</button>
<button
type="button"
:disabled="!canSubmit || loading"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors flex items-center gap-2"
@click="submitReturn"
>
<GoogleIcon v-if="loading" name="sync" class="animate-spin" />
<GoogleIcon v-else name="check" />
Procesar Devolución
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,182 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useSearcher, apiURL } from '@Services/Api';
import returnsService from '@Services/returnsService';
import { formatCurrency, formatDate } from '@/utils/formatters';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import ReturnDetailModal from './ReturnDetail.vue';
/** Estado */
const models = ref([]);
const showDetailModal = ref(false);
const showCreateModal = ref(false);
const selectedReturn = ref(null);
/** Buscador de devoluciones */
const searcher = useSearcher({
url: apiURL('returns'),
onSuccess: (r) => {
models.value = r.returns || r;
},
onError: (error) => {
models.value = { data: [], total: 0 };
window.Notify.error('Error al cargar devoluciones');
}
});
/** Métodos */
const openDetailModal = async (returnItem) => {
try {
const response = await returnsService.getReturnDetails(returnItem.id);
// Backend retorna: { model: {...} }
selectedReturn.value = response.model || response;
showDetailModal.value = true;
} catch (error) {
window.Notify.error('Error al cargar los detalles de la devolución');
}
};
const closeDetailModal = () => {
showDetailModal.value = false;
selectedReturn.value = null;
};
const onReturnCancelled = () => {
closeDetailModal();
searcher.search();
};
/** Helpers de método de reembolso */
const getRefundMethodLabel = (method) => {
const labels = {
cash: 'Efectivo',
credit_card: 'Credito',
debit_card: 'Debito'
};
return labels[method] || method || '-';
};
const getRefundMethodIcon = (method) => {
const icons = {
cash: 'payments',
card: 'credit_card',
store_credit: 'account_balance_wallet'
};
return icons[method] || 'payment';
};
/** Ciclo de vida */
onMounted(() => {
searcher.search();
});
</script>
<template>
<div>
<SearcherHead
title="Devoluciones"
placeholder="Buscar por folio de venta o usuario..."
@search="(x) => searcher.search(x)"
>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FOLIO</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">VENTA ORIGINAL</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">USUARIO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">REEMBOLSO</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TOTAL</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="model in items"
:key="model.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
{{ model.return_number }}
</span>
<span v-if="model.deleted_at"
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">
Cancelada
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-mono text-indigo-600 dark:text-indigo-400">
{{ model.sale?.invoice_number || '-' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ formatDate(model.created_at) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ model.user?.name || '-' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
<GoogleIcon :name="getRefundMethodIcon(model.refund_method)" class="text-sm" />
{{ getRefundMethodLabel(model.refund_method) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(model.total) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<button
@click="openDetailModal(model)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Ver detalles"
>
<GoogleIcon name="visibility" class="text-xl" />
</button>
</td>
</tr>
</template>
<template #empty>
<td colspan="7" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="sync"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
No hay devoluciones registradas
</p>
<p class="text-sm mt-1">
Las devoluciones aparecerán aquí
</p>
</div>
</td>
</template>
</Table>
</div>
</div>
<!-- Modal de Detalle -->
<ReturnDetailModal
:show="showDetailModal"
:return-data="selectedReturn"
@close="closeDetailModal"
@cancelled="onReturnCancelled"
/>
</template>

View File

@ -0,0 +1,16 @@
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`returns.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => ({ name: `pos.returns.${name}`, params, query })
// Determina si un usuario puede hacer algo en base a los permisos
const can = (permission) => hasPermission(`returns.${permission}`)
export {
can,
viewTo,
apiTo
}

View File

@ -0,0 +1,294 @@
<script setup>
import { computed } from 'vue';
import { formatCurrency, formatDate } from '@/utils/formatters';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import returnsService from '@Services/returnsService';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
returnData: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'cancelled']);
/** Computados */
const returnItems = computed(() => {
const items = props.returnData?.details || [];
return items;
});
const hasItems = computed(() => {
return returnItems.value.length > 0;
});
const formattedTotal = computed(() => {
return formatCurrency(props.returnData?.total || 0);
});
const formattedSubtotal = computed(() => {
return formatCurrency(props.returnData?.subtotal || 0);
});
const formattedTax = computed(() => {
return formatCurrency(props.returnData?.tax || 0);
});
const formattedDate = computed(() => {
if (!props.returnData?.created_at) return '-';
return formatDate(props.returnData.created_at);
});
/** Helpers de estado */
const getRefundMethodBadge = (method) => {
const badges = {
cash: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
card: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
store_credit: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300'
};
return badges[method] || 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300';
};
const getRefundMethodLabel = (method) => {
const labels = {
cash: 'Efectivo',
credit_card: 'Credito',
debit_card: 'Debito'
};
return labels[method] || method || '-';
};
const getRefundMethodIcon = (method) => {
const icons = {
cash: 'payments',
credit_card: 'credit_card',
debit_card: 'credit_card'
};
return icons[method] || 'payment';
};
const getReasonLabel = (reason) => {
const labels = {
defective: 'Producto defectuoso',
wrong_product: 'Producto incorrecto',
change_of_mind: 'Cambio de opinión',
damaged: 'Producto dañado',
other: 'Otro'
};
return labels[reason] || reason || '-';
};
/** Métodos */
const handleClose = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="2xl" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center">
<GoogleIcon name="sync" class="text-2xl text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ returnData?.return_number || `Devolución #${returnData?.id}` }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ formattedDate }}
</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-2xl" />
</button>
</div>
<!-- Información General -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Venta Original -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Venta Original</p>
<p class="text-lg font-semibold text-indigo-600 dark:text-indigo-400 font-mono">
{{ returnData?.sale?.invoice_number || `#${returnData?.sale_id}` }}
</p>
</div>
<div v-if="returnData?.deleted_at" class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300">
<GoogleIcon name="cancel" class="text-sm" />
Cancelada
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{{ formatDate(returnData.deleted_at) }}
</p>
</div>
<!-- Usuario -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Procesado por</p>
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ returnData?.user?.name || '-' }}
</p>
</div>
<!-- Método de Reembolso -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-1">Método de Reembolso</p>
<span
class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-full"
:class="getRefundMethodBadge(returnData?.refund_method)"
>
<GoogleIcon :name="getRefundMethodIcon(returnData?.refund_method)" class="text-sm" />
{{ getRefundMethodLabel(returnData?.refund_method) }}
</span>
</div>
</div>
<!-- Motivo de devolución -->
<div v-if="returnData?.reason || returnData?.reason_text" class="mb-6">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-2">Motivo de Devolución</p>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p class="text-sm font-semibold text-yellow-800 dark:text-yellow-300 mb-1">
{{ getReasonLabel(returnData?.reason) }}
</p>
<p v-if="returnData?.reason_text" class="text-sm text-yellow-700 dark:text-yellow-400">
{{ returnData.reason_text }}
</p>
</div>
</div>
<!-- Notas adicionales -->
<div v-if="returnData?.notes" class="mb-6">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-2">Notas</p>
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ returnData.notes }}
</p>
</div>
</div>
<!-- Items Devueltos -->
<div class="mb-6">
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase mb-3">
Productos Devueltos ({{ returnItems.length }})
</p>
<div v-if="hasItems" class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Producto
</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Cantidad
</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Precio Unit.
</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Subtotal
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="item in returnItems" :key="item.id">
<td class="px-4 py-3">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ item.product_name || item.inventory?.name || 'Producto' }}
</p>
<p v-if="item.inventory?.sku" class="text-xs text-gray-500 dark:text-gray-400">
SKU: {{ item.inventory.sku }}
</p>
<!-- Seriales devueltos -->
<div v-if="item.serials && item.serials.length > 0" class="mt-1">
<p class="text-xs text-gray-500 dark:text-gray-400">Seriales:</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="serial in item.serials"
:key="serial.id || serial.serial_number"
class="inline-flex px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded font-mono"
>
{{ serial.serial_number }}
</span>
</div>
</div>
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ item.quantity_returned || item.quantity }}
</span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ formatCurrency(item.unit_price) }}
</span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(item.subtotal) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="text-center py-8 text-gray-500 dark:text-gray-400">
<GoogleIcon name="inventory_2" class="text-4xl mb-2 opacity-50" />
<p>No hay items en esta devolución</p>
</div>
</div>
<!-- Totales -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex justify-end">
<div class="w-64 space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Subtotal:</span>
<span class="text-gray-900 dark:text-gray-100">{{ formattedSubtotal }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">IVA:</span>
<span class="text-gray-900 dark:text-gray-100">{{ formattedTax }}</span>
</div>
<div class="flex justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">Total Devuelto:</span>
<span class="text-xl font-bold text-indigo-600 dark:text-indigo-400">{{ formattedTotal }}</span>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex justify-between items-center mt-6">
<div></div>
<button
type="button"
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-semibold rounded-lg transition-colors"
@click="handleClose"
>
Cerrar
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,255 @@
<script setup>
import { ref, watch } from 'vue';
import { useSearcher, apiURL } from '@Services/Api';
import { formatCurrency, formatDate } from '@/utils/formatters';
import Modal from '@Holos/Modal.vue';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
}
});
/** Emits */
const emit = defineEmits(['close', 'select-sale']);
/** Estado */
const models = ref([]);
const selectedSale = ref(null);
/** Buscador de ventas completadas */
const searcher = useSearcher({
url: apiURL('sales'),
onSuccess: (r) => {
models.value = r.sales || r.models || { data: [], total: 0 };
},
onError: (error) => {
console.error('❌ ERROR al cargar ventas:', error);
models.value = { data: [], total: 0 };
window.Notify.error('Error al cargar ventas');
}
});
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown) {
selectedSale.value = null;
searcher.search();
}
});
/** Métodos */
const handleSearch = (query) => {
searcher.search({ q: query, status: 'completed' });
};
const selectSale = (sale) => {
selectedSale.value = sale;
};
const isSelected = (sale) => {
return selectedSale.value?.id === sale.id;
};
const confirmSelection = () => {
if (!selectedSale.value) {
window.Notify.warning('Selecciona una venta para continuar');
return;
}
emit('select-sale', selectedSale.value);
};
const handleClose = () => {
selectedSale.value = null;
emit('close');
};
/** Helpers de método de pago */
const getPaymentMethodLabel = (method) => {
const labels = {
cash: 'Efectivo',
card: 'Debito',
credit: 'Crédito'
};
return labels[method] || method || '-';
};
const getPaymentMethodIcon = (method) => {
const icons = {
cash: 'payments',
card: 'credit_card',
credit: 'request_quote'
};
return icons[method] || 'payment';
};
</script>
<template>
<Modal :show="show" max-width="4xl" @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="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl flex items-center justify-center">
<GoogleIcon name="receipt_long" class="text-2xl text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Seleccionar Venta
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
Busca y selecciona la venta original para la devolución
</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-2xl" />
</button>
</div>
<!-- Buscador -->
<div class="mb-4">
<SearcherHead
title=""
placeholder="Buscar por folio, cliente o cajero..."
@search="handleSearch"
/>
</div>
<!-- Venta seleccionada preview -->
<div
v-if="selectedSale"
class="mb-4 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 rounded-lg p-4"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<GoogleIcon name="check_circle" class="text-2xl text-indigo-600 dark:text-indigo-400" />
<div>
<p class="text-sm text-indigo-600 dark:text-indigo-400">Venta seleccionada:</p>
<p class="font-semibold text-indigo-700 dark:text-indigo-300 font-mono">
#{{ selectedSale.invoice_number || selectedSale.id }}
</p>
</div>
</div>
<p class="text-lg font-bold text-indigo-700 dark:text-indigo-300">
{{ formatCurrency(selectedSale.total) }}
</p>
</div>
</div>
<!-- Tabla de ventas -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase w-10">
<span class="sr-only">Seleccionar</span>
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Folio
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Fecha
</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Cajero
</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Método de Pago
</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Total
</th>
</template>
<template #body="{ items }">
<tr
v-for="sale in items"
:key="sale.id"
@click="selectSale(sale)"
class="cursor-pointer transition-colors"
:class="isSelected(sale)
? 'bg-indigo-50 dark:bg-indigo-900/30 hover:bg-indigo-100 dark:hover:bg-indigo-900/40'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'"
>
<td class="px-4 py-3">
<input
type="radio"
:checked="isSelected(sale)"
@change="selectSale(sale)"
class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
</td>
<td class="px-4 py-3">
<span class="text-sm font-mono font-semibold text-indigo-600 dark:text-indigo-400">
#{{ sale.invoice_number || sale.id }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ formatDate(sale.created_at) }}
</span>
</td>
<td class="px-4 py-3">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ sale.user?.name || sale.cashier?.name || '-' }}
</span>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
<GoogleIcon :name="getPaymentMethodIcon(sale.payment_method)" class="text-sm" />
{{ getPaymentMethodLabel(sale.payment_method) }}
</span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatCurrency(sale.total) }}
</span>
</td>
</tr>
</template>
<template #empty>
<td colspan="6" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon name="receipt_long" class="text-6xl mb-2 opacity-50" />
<p class="font-semibold">No hay ventas disponibles</p>
<p class="text-sm mt-1">Intenta buscar con otros términos</p>
</div>
</td>
</template>
</Table>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-sm font-semibold rounded-lg transition-colors"
@click="handleClose"
>
Cancelar
</button>
<button
type="button"
:disabled="!selectedSale"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors flex items-center gap-2"
@click="confirmSelection"
>
<GoogleIcon name="arrow_forward" />
Continuar
</button>
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,417 @@
<script setup>
import { computed, watch, ref } from 'vue';
import ticketService from '@Services/ticketService';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import CreateReturnModal from '../Returns/CreateReturn.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
sale: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'cancel-sale']);
/** Computados */
const showReturnModal = ref(false);
const saleDetails = computed(() => {
return props.sale?.details || [];
});
const hasDetails = computed(() => {
return saleDetails.value.length > 0;
});
const formattedSubtotal = computed(() => {
return formatCurrency(props.sale?.subtotal || 0);
});
const formattedTax = computed(() => {
return formatCurrency(props.sale?.tax || 0);
});
const formattedTotal = computed(() => {
return formatCurrency(props.sale?.total || 0);
});
const formattedDate = computed(() => {
if (!props.sale?.created_at) return '-';
const date = new Date(props.sale.created_at);
return new Intl.DateTimeFormat('es-MX', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
});
const paymentMethodLabel = computed(() => {
const methods = {
'cash': 'Efectivo',
'credit_card': 'Tarjeta de Crédito',
'debit_card': 'Tarjeta de Débito'
};
return methods[props.sale?.payment_method] || props.sale?.payment_method || '-';
});
const paymentMethodIcon = computed(() => {
const icons = {
'cash': 'payments',
'credit_card': 'credit_card',
'debit_card': 'credit_card'
};
return icons[props.sale?.payment_method] || 'payment';
});
const canCancel = computed(() => {
return props.sale?.status === 'completed';
});
const hasReturns = computed(() => {
return props.sale?.returns && props.sale.returns.length > 0;
});
/** Métodos */
const formatCurrency = (amount) => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(amount || 0);
};
const handleClose = () => {
emit('close');
};
const handleCancelSale = () => {
if (confirm('¿Estás seguro de cancelar esta venta? Se restaurará el stock.')) {
emit('cancel-sale', props.sale.id);
}
};
const handleDownloadTicket = () => {
try {
ticketService.generateSaleTicket(props.sale, {
businessName: 'HIKVISION DISTRIBUIDOR',
businessAddress: 'Ciudad de México, México',
businessPhone: 'Tel: (55) 1234-5678',
autoDownload: true
});
window.Notify.success('Ticket descargado correctamente');
} catch (error) {
console.error('Error generando ticket:', error);
window.Notify.error('Error al generar el ticket PDF');
}
};
const handleOpenReturn = () => {
showReturnModal.value = true;
};
const handleReturnCreated = () => {
showReturnModal.value = false;
// Close detail modal to refresh list or prevent stale data
emit('close');
};
/** Watchers */
watch(() => props.show, () => {
// Modal opened
});
</script>
<template>
<Modal :show="show" max-width="4xl" @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-indigo-100 dark:bg-indigo-900/30">
<GoogleIcon name="receipt_long" class="text-2xl text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
{{ $t('sales.detail') }}
</h3>
<p v-if="sale" class="text-sm text-gray-500 dark:text-gray-400">
Folio: {{ sale.invoice_number || `#${String(sale.id).padStart(6, '0')}` }}
</p>
</div>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div v-if="sale" class="space-y-6">
<!-- Información de la venta -->
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 rounded-xl p-5 border border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
{{ $t('sales.date') }}
</p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ formattedDate }}
</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
{{ $t('sales.cashier') }}
</p>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ sale.user?.name || '-' }}
</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
{{ $t('sales.paymentMethod') }}
</p>
<div class="flex items-center gap-2">
<GoogleIcon
:name="paymentMethodIcon"
class="text-lg text-gray-500 dark:text-gray-400"
/>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ paymentMethodLabel }}
</p>
</div>
</div>
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-1">
{{ $t('sales.status') }}
</p>
<span
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
:class="{
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400': sale.status === 'completed',
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400': sale.status === 'cancelled'
}"
>
{{ sale.status === 'completed' ? 'Completada' : 'Cancelada' }}
</span>
</div>
</div>
</div>
<!-- Items de la venta -->
<div>
<h3 class="text-sm font-bold text-gray-900 dark:text-gray-100 uppercase tracking-wide mb-3">
Productos Vendidos
</h3>
<!-- Tabla con scroll -->
<div class="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div class="max-h-80 overflow-y-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Producto
</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Cant.
</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
P. Unit.
</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
Subtotal
</th>
</tr>
</thead>
<tbody v-if="hasDetails" class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="(item, index) in saleDetails"
:key="index"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-4 py-3">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ item.product_name }}
</p>
<!-- Muestra los seriales si existen -->
<p v-if="item.serials && item.serials.length > 0" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">
{{ item.serials.map(s => s.serial_number).join(', ') }}
</p>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center justify-center w-8 h-8 text-sm font-bold text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 rounded-full">
{{ item.quantity }}
</span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ formatCurrency(item.unit_price) }}
</span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm font-bold text-gray-900 dark:text-gray-100">
{{ formatCurrency(item.subtotal) }}
</span>
</td>
</tr>
</tbody>
<tbody v-else>
<tr>
<td colspan="4" class="px-4 py-8 text-center">
<GoogleIcon name="inventory_2" class="text-5xl text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p class="text-sm text-gray-500 dark:text-gray-400">
No hay productos en esta venta
</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Devoluciones Relacionadas -->
<div v-if="hasReturns" class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-xl p-5">
<div class="flex items-center gap-2 mb-4">
<GoogleIcon name="assignment_return" class="text-orange-600 dark:text-orange-400 text-xl" />
<h3 class="text-sm font-bold text-orange-900 dark:text-orange-100 uppercase tracking-wide">
Devoluciones ({{ sale.returns.length }})
</h3>
</div>
<div class="space-y-2">
<div
v-for="returnItem in sale.returns"
:key="returnItem.id"
class="flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-orange-200 dark:border-orange-700"
>
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<p class="text-sm font-mono font-bold text-orange-900 dark:text-orange-100">
{{ returnItem.return_number || `DEV-${String(returnItem.id).padStart(6, '0')}` }}
</p>
<span v-if="returnItem.deleted_at"
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">
Cancelada
</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ new Intl.DateTimeFormat('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(returnItem.created_at)) }}
</p>
<p v-if="returnItem.reason_text" class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{{ returnItem.reason_text }}
</p>
</div>
<div class="text-right">
<p class="text-lg font-bold text-orange-600 dark:text-orange-400">
-{{ formatCurrency(returnItem.total) }}
</p>
</div>
</div>
</div>
</div>
<!-- Totales -->
<div class="bg-gradient-to-br from-indigo-50 to-indigo-100 dark:from-indigo-900/20 dark:to-indigo-800/20 rounded-xl p-5 border border-indigo-200 dark:border-indigo-800">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $t('sales.subtotal') }}
</span>
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">
{{ formattedSubtotal }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ $t('sales.tax') }}
</span>
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">
{{ formattedTax }}
</span>
</div>
<div class="pt-3 border-t border-indigo-200 dark:border-indigo-700">
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ $t('sales.total') }}
</span>
<span class="text-3xl font-bold text-indigo-600 dark:text-indigo-400">
{{ formattedTotal }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between gap-3 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<!-- Botón Cancelar Venta (solo si está completada) -->
<button
v-if="canCancel"
type="button"
class="flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors border border-red-200 dark:border-red-800"
@click="handleCancelSale"
>
<GoogleIcon name="cancel" class="text-lg" />
{{ $t('sales.cancel') }}
</button>
<div v-else></div> <!-- Spacer -->
<!-- Botones de acción -->
<div class="flex items-center gap-2">
<button
type="button"
class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="handleDownloadTicket"
>
<GoogleIcon name="download" class="text-lg" />
Descargar Ticket
</button>
<button
type="button"
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded-lg transition-colors shadow-sm"
@click="handleOpenReturn"
>
<GoogleIcon name="assignment_return" class="text-lg" />
Devolución
</button>
<button
type="button"
class="px-4 py-2.5 text-sm font-semibold text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors"
@click="handleClose"
>
Cerrar
</button>
</div>
</div>
</div>
</Modal>
<!-- Modal de Devolución -->
<CreateReturnModal
:show="showReturnModal"
:sale="sale"
@close="showReturnModal = false"
@created="handleReturnCreated"
/>
</template>

View File

@ -0,0 +1,237 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useSearcher, apiURL } from '@Services/Api';
import ticketService from '@Services/ticketService';
import { formatCurrency, formatDate, PAYMENT_METHODS } from '@/utils/formatters';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import SaleDetailModal from './DetailModal.vue';
import ExcelModal from '@Components/POS/ExcelSale.vue';
/** Estado */
const models = ref([]);
const showDetailModal = ref(false);
const selectedSale = ref(null);
const showExcelModal = ref(false);
/** Buscador de ventas */
const searcher = useSearcher({
url: apiURL('sales'),
onSuccess: (r) => {
models.value = r.sales || r.models || { data: [], total: 0 };
},
onError: (error) => {
console.error('❌ ERROR al cargar ventas:', error);
models.value = { data: [], total: 0 };
window.Notify.error('Error al cargar ventas');
}
});
/** Métodos */
const openDetailModal = (sale) => {
selectedSale.value = sale;
showDetailModal.value = true;
};
const closeDetailModal = () => {
showDetailModal.value = false;
selectedSale.value = null;
};
const openExcelModal = () => {
showExcelModal.value = true;
};
const closeExcelModal = () => {
showExcelModal.value = false;
};
const handleCancelSale = async (saleId) => {
if (!confirm('¿Estás seguro de cancelar esta venta? Se restaurará el stock.')) {
return;
}
try {
const response = await fetch(apiURL(`sales/${saleId}/cancel`), {
method: 'PUT',
headers: {
'Authorization': `Bearer ${sessionStorage.token}`,
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
if (response.ok) {
window.Notify.success('Venta cancelada exitosamente');
closeDetailModal();
searcher.search();
} else {
window.Notify.error('Error al cancelar la venta');
}
} catch (error) {
console.error('Error:', error);
window.Notify.error('Error al cancelar la venta');
}
};
const getPaymentMethodLabel = (method) => {
return PAYMENT_METHODS[method] || method;
};
const getStatusColor = (status) => {
return status === 'completed'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
};
const getStatusLabel = (status) => {
return status === 'completed' ? 'Completada' : 'Cancelada';
};
const handleDownloadTicket = (sale) => {
try {
ticketService.generateSaleTicket(sale, {
autoDownload: true
});
window.Notify.success('Ticket descargado correctamente');
} catch (error) {
console.error('Error generando ticket:', error);
window.Notify.error('Error al generar el ticket PDF');
}
};
/** Ciclo de vida */
onMounted(() => {
searcher.search();
});
</script>
<template>
<div>
<SearcherHead
:title="$t('sales.title')"
placeholder="Buscar por folio o cajero..."
@search="(x) => searcher.search(x)"
>
<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="openExcelModal"
>
<GoogleIcon name="add" class="text-xl" />
Generar excel
</button>
</SearcherHead>
<div class="pt-2 w-full">
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FOLIO</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CAJERO</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">MÉTODO DE PAGO</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">TOTAL</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ACCIONES</th>
</template>
<template #body="{items}">
<tr
v-for="model in items"
:key="model.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">
#{{ model.invoice_number }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ formatDate(model.created_at) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ model.user?.name || '-' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
<GoogleIcon
:name="model.payment_method === 'cash' ? 'payments' : 'credit_card'"
class="text-gray-500 dark:text-gray-400"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ getPaymentMethodLabel(model.payment_method) }}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-sm font-bold text-gray-900 dark:text-gray-100">
{{ formatCurrency(model.total) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
:class="getStatusColor(model.status)"
>
{{ getStatusLabel(model.status) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
<button
@click="openDetailModal(model)"
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
title="Ver detalles"
>
<GoogleIcon name="visibility" class="text-xl" />
</button>
<button
@click="handleDownloadTicket(model)"
class="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300 transition-colors"
title="Descargar ticket PDF"
>
<GoogleIcon name="download" class="text-xl" />
</button>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="7" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
name="receipt_long"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
{{ $t('sales.empty') }}
</p>
</div>
</td>
</template>
</Table>
</div>
</div>
<!-- Modal de Detalle -->
<SaleDetailModal
:show="showDetailModal"
:sale="selectedSale"
@close="closeDetailModal"
@cancel-sale="handleCancelSale"
/>
<!-- Modal de Excel de Clientes -->
<ExcelModal
:show="showExcelModal"
@close="closeExcelModal"
/>
</template>

View File

@ -0,0 +1,190 @@
<script setup>
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';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Formulario */
const form = useForm({
business_name: '',
rfc: '',
email: '',
phone: '',
address: '',
postal_code: '',
notes: ''
});
/** Métodos */
const createSupplier = () => {
form.post(apiURL('proveedores'), {
onSuccess: () => {
window.Notify.success('Proveedor creado exitosamente');
emit('created');
closeModal();
},
onError: () => {
window.Notify.error('Error al crear el proveedor');
}
});
};
const closeModal = () => {
form.reset();
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="closeModal">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Crear Proveedor
</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Formulario -->
<form @submit.prevent="createSupplier" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<!-- Nombre -->
<div class="col-span-2">
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
NOMBRE (del negocio)
</label>
<FormInput
v-model="form.business_name"
type="text"
placeholder="Nombre del proveedor"
required
/>
<FormError :message="form.errors?.name" />
</div>
<!-- RFC -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
RFC
</label>
<FormInput
v-model="form.rfc"
type="text"
minlength="12"
maxlength="13"
placeholder="RFC"
required
/>
<FormError :message="form.errors?.rfc" />
</div>
<!-- Email -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
EMAIL
</label>
<FormInput
v-model="form.email"
type="text"
placeholder="Email"
required
/>
<FormError :message="form.errors?.email" />
</div>
<!-- Teléfono -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
TELÉFONO
</label>
<FormInput
v-model="form.phone"
type="text"
placeholder="9933428818"
maxlength="10"
/>
<FormError :message="form.errors?.phone" />
</div>
<!-- Dirección -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
DIRECCIÓN
</label>
<FormInput
v-model="form.address"
type="text"
placeholder="Dirección"
required
/>
<FormError :message="form.errors?.address" />
</div>
<!-- Código Postal -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
Código Postal
</label>
<FormInput
v-model="form.postal_code"
type="text"
maxlength="5"
placeholder="Código Postal"
required
/>
<FormError :message="form.errors?.postal_code" />
</div>
<!-- Notas -->
<div>
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
Notas
</label>
<FormInput
v-model="form.notes"
type="text"
placeholder="Notas"
/>
<FormError :message="form.errors?.notes" />
</div>
</div>
<!-- Botones -->
<div class="flex items-center justify-end gap-3 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"
>
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">Guardando...</span>
<span v-else>Guardar</span>
</button>
</div>
</form>
</div>
</Modal>
</template>

Some files were not shown because too many files have changed in this diff Show More