Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53a5208cdf | ||
|
|
5dbb52a9e9 | ||
|
|
847d2af7ef | ||
|
|
a5411a6318 | ||
|
|
71ac03309d | ||
|
|
f8e4421ffc | ||
|
|
393aa04150 | ||
| 6fac8d0797 | |||
|
|
18755271d3 | ||
|
|
a743ea73e7 | ||
|
|
581ce37449 | ||
|
|
68a3da8e3f | ||
|
|
0f622f5620 | ||
|
|
b7154af381 | ||
|
|
fe79f843f6 | ||
|
|
54f15ab7b4 | ||
|
|
cfd990ae0a | ||
| fccb425781 | |||
|
|
1909ebec68 | ||
| b2dea0785e | |||
|
|
44d86af459 | ||
| cf80e914fd | |||
|
|
e653add755 | ||
| e51f3fad0f | |||
|
|
2bb50c48c9 | ||
| fb37a2d62f | |||
|
|
9b8bf57abd | ||
| 32949fe13a | |||
|
|
1466cd2166 | ||
|
|
156c915403 | ||
|
|
898643cdab | ||
|
|
d521f0b2c2 | ||
| 99f190f61b | |||
|
|
cbf8ccb64c | ||
| 093cea3c4c | |||
|
|
6c70d1ba4f | ||
| 4307d97639 | |||
|
|
04e84f6241 | ||
| 2c7d2f2001 | |||
|
|
7c27200290 | ||
|
|
69c015d51b | ||
|
|
c9251e0c8f | ||
| 4dfeeeea20 | |||
|
|
21b28b5bff | ||
|
|
a45cc247c1 | ||
|
|
b895836849 | ||
|
|
8210d7dd2f | ||
|
|
992ecb07b7 | ||
| 27825a7dd4 | |||
|
|
fb2f7cb068 | ||
|
|
eb7fc0de14 | ||
| 5b7b6f2343 | |||
| d469b18bf5 | |||
|
|
5c3df890e4 | ||
|
|
83dd71f80f | ||
| 46b155c2c8 | |||
|
|
7a28a35f60 | ||
|
|
31e4cd9214 | ||
|
|
24b59f0b16 | ||
|
|
cd58a97f57 | ||
|
|
b1d0f73e94 | ||
|
|
0ffa93019c | ||
|
|
c6dadb22e8 | ||
| 0fd2d06db4 | |||
|
|
58a0e5b89e | ||
|
|
62cf6afc42 | ||
| 0c98dc0bb1 | |||
| 708cc31496 | |||
|
|
cabba3621e | ||
|
|
1daed06f35 | ||
| 173f5417b3 |
@ -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
1
.gitignore
vendored
@ -25,3 +25,4 @@ notes.md
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
CLAUDE.md
|
||||
@ -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
570
package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
BIN
public/Logo-hk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@ -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>
|
||||
|
||||
@ -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>
|
||||
©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>© {{ new Date().getFullYear() }} {{ APP_COPYRIGHT }}</span>
|
||||
<span>v{{ APP_VERSION }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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>
|
||||
@ -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];
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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"
|
||||
|
||||
70
src/components/Holos/Skeleton/Sidebar/DropdownMenu.vue
Normal file
70
src/components/Holos/Skeleton/Sidebar/DropdownMenu.vue
Normal 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>
|
||||
@ -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">
|
||||
© {{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>
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
62
src/components/Holos/Skeleton/Sidebar/SubLink.vue
Normal file
62
src/components/Holos/Skeleton/Sidebar/SubLink.vue
Normal 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>
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
300
src/components/POS/BundleSerialSelector.vue
Normal file
300
src/components/POS/BundleSerialSelector.vue
Normal 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>
|
||||
203
src/components/POS/CartItem.vue
Normal file
203
src/components/POS/CartItem.vue
Normal 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>
|
||||
38
src/components/POS/CfdiSelector.vue
Normal file
38
src/components/POS/CfdiSelector.vue
Normal 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>
|
||||
651
src/components/POS/CheckoutModal.vue
Normal file
651
src/components/POS/CheckoutModal.vue
Normal 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>
|
||||
350
src/components/POS/ClientModal.vue
Normal file
350
src/components/POS/ClientModal.vue
Normal 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>
|
||||
193
src/components/POS/ExcelClient.vue
Normal file
193
src/components/POS/ExcelClient.vue
Normal 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>
|
||||
229
src/components/POS/ExcelSale.vue
Normal file
229
src/components/POS/ExcelSale.vue
Normal 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>
|
||||
125
src/components/POS/GoogleForm.vue
Normal file
125
src/components/POS/GoogleForm.vue
Normal 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>
|
||||
104
src/components/POS/ProductCard.vue
Normal file
104
src/components/POS/ProductCard.vue
Normal 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>
|
||||
162
src/components/POS/QRscan.vue
Normal file
162
src/components/POS/QRscan.vue
Normal 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>
|
||||
38
src/components/POS/RegimenSelector.vue
Normal file
38
src/components/POS/RegimenSelector.vue
Normal 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>
|
||||
187
src/components/POS/SerialInputList.vue
Normal file
187
src/components/POS/SerialInputList.vue
Normal 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>
|
||||
300
src/components/POS/SerialSelector.vue
Normal file
300
src/components/POS/SerialSelector.vue
Normal 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>
|
||||
84
src/components/POS/Tiers/ToggleButton.vue
Normal file
84
src/components/POS/Tiers/ToggleButton.vue
Normal 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>
|
||||
168
src/components/POS/UnitEquivalenceSelector.vue
Normal file
168
src/components/POS/UnitEquivalenceSelector.vue
Normal 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>
|
||||
346
src/components/POS/UploadFiles.vue
Normal file
346
src/components/POS/UploadFiles.vue
Normal 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>
|
||||
45
src/composables/useBarcodeScanner.js
Normal file
45
src/composables/useBarcodeScanner.js
Normal 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 {};
|
||||
}
|
||||
173
src/lang/es.js
173
src/lang/es.js
@ -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',
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
206
src/pages/Admin/Bills/Create.vue
Normal file
206
src/pages/Admin/Bills/Create.vue
Normal 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>
|
||||
96
src/pages/Admin/Bills/Delete.vue
Normal file
96
src/pages/Admin/Bills/Delete.vue
Normal 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>
|
||||
233
src/pages/Admin/Bills/Edit.vue
Normal file
233
src/pages/Admin/Bills/Edit.vue
Normal 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>
|
||||
257
src/pages/Admin/Bills/Index.vue
Normal file
257
src/pages/Admin/Bills/Index.vue
Normal 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>
|
||||
21
src/pages/Admin/Bills/Module.js
Normal file
21
src/pages/Admin/Bills/Module.js
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
255
src/pages/POS/Bundles/Create.vue
Normal file
255
src/pages/POS/Bundles/Create.vue
Normal 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>
|
||||
88
src/pages/POS/Bundles/Delete.vue
Normal file
88
src/pages/POS/Bundles/Delete.vue
Normal 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>
|
||||
270
src/pages/POS/Bundles/Edit.vue
Normal file
270
src/pages/POS/Bundles/Edit.vue
Normal 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>
|
||||
177
src/pages/POS/Bundles/Index.vue
Normal file
177
src/pages/POS/Bundles/Index.vue
Normal 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>
|
||||
16
src/pages/POS/Bundles/Module.js
Normal file
16
src/pages/POS/Bundles/Module.js
Normal 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
|
||||
}
|
||||
190
src/pages/POS/Bundles/ProductSelector.vue
Normal file
190
src/pages/POS/Bundles/ProductSelector.vue
Normal 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>
|
||||
357
src/pages/POS/CashRegister/CloseModal.vue
Normal file
357
src/pages/POS/CashRegister/CloseModal.vue
Normal 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>
|
||||
399
src/pages/POS/CashRegister/Detail.vue
Normal file
399
src/pages/POS/CashRegister/Detail.vue
Normal 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>
|
||||
236
src/pages/POS/CashRegister/History.vue
Normal file
236
src/pages/POS/CashRegister/History.vue
Normal 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>
|
||||
355
src/pages/POS/CashRegister/Index.vue
Normal file
355
src/pages/POS/CashRegister/Index.vue
Normal 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>
|
||||
161
src/pages/POS/CashRegister/OpenModal.vue
Normal file
161
src/pages/POS/CashRegister/OpenModal.vue
Normal 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>
|
||||
127
src/pages/POS/Category/CreateModal.vue
Normal file
127
src/pages/POS/Category/CreateModal.vue
Normal 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>
|
||||
117
src/pages/POS/Category/DeleteModal.vue
Normal file
117
src/pages/POS/Category/DeleteModal.vue
Normal 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>
|
||||
138
src/pages/POS/Category/EditModal.vue
Normal file
138
src/pages/POS/Category/EditModal.vue
Normal 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>
|
||||
209
src/pages/POS/Category/Index.vue
Normal file
209
src/pages/POS/Category/Index.vue
Normal 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>
|
||||
186
src/pages/POS/Category/Subcategories/CreateBulkModal.vue
Normal file
186
src/pages/POS/Category/Subcategories/CreateBulkModal.vue
Normal 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>
|
||||
132
src/pages/POS/Category/Subcategories/CreateModal.vue
Normal file
132
src/pages/POS/Category/Subcategories/CreateModal.vue
Normal 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>
|
||||
117
src/pages/POS/Category/Subcategories/DeleteModal.vue
Normal file
117
src/pages/POS/Category/Subcategories/DeleteModal.vue
Normal 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>
|
||||
142
src/pages/POS/Category/Subcategories/EditModal.vue
Normal file
142
src/pages/POS/Category/Subcategories/EditModal.vue
Normal 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>
|
||||
236
src/pages/POS/Category/Subcategories/Index.vue
Normal file
236
src/pages/POS/Category/Subcategories/Index.vue
Normal 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>
|
||||
680
src/pages/POS/Clients/BillingRequestDetailModal.vue
Normal file
680
src/pages/POS/Clients/BillingRequestDetailModal.vue
Normal 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>
|
||||
358
src/pages/POS/Clients/BillingRequests.vue
Normal file
358
src/pages/POS/Clients/BillingRequests.vue
Normal 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>
|
||||
204
src/pages/POS/Clients/Create.vue
Normal file
204
src/pages/POS/Clients/Create.vue
Normal 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>
|
||||
97
src/pages/POS/Clients/Delete.vue
Normal file
97
src/pages/POS/Clients/Delete.vue
Normal 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>
|
||||
225
src/pages/POS/Clients/Edit.vue
Normal file
225
src/pages/POS/Clients/Edit.vue
Normal 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>
|
||||
303
src/pages/POS/Clients/Index.vue
Normal file
303
src/pages/POS/Clients/Index.vue
Normal 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>
|
||||
16
src/pages/POS/Clients/Module.js
Normal file
16
src/pages/POS/Clients/Module.js
Normal 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
|
||||
}
|
||||
213
src/pages/POS/Clients/Stats.vue
Normal file
213
src/pages/POS/Clients/Stats.vue
Normal 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>
|
||||
679
src/pages/POS/Factura/Index.vue
Normal file
679
src/pages/POS/Factura/Index.vue
Normal 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>
|
||||
16
src/pages/POS/Factura/Module.js
Normal file
16
src/pages/POS/Factura/Module.js
Normal 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
|
||||
}
|
||||
418
src/pages/POS/Inventory/CreateModal.vue
Normal file
418
src/pages/POS/Inventory/CreateModal.vue
Normal 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>
|
||||
115
src/pages/POS/Inventory/DeleteModal.vue
Normal file
115
src/pages/POS/Inventory/DeleteModal.vue
Normal 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>
|
||||
861
src/pages/POS/Inventory/EditModal.vue
Normal file
861
src/pages/POS/Inventory/EditModal.vue
Normal 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 }}
|
||||
·
|
||||
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>
|
||||
292
src/pages/POS/Inventory/ImportModal.vue
Normal file
292
src/pages/POS/Inventory/ImportModal.vue
Normal 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>
|
||||
494
src/pages/POS/Inventory/Index.vue
Normal file
494
src/pages/POS/Inventory/Index.vue
Normal 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>
|
||||
16
src/pages/POS/Inventory/Module.js
Normal file
16
src/pages/POS/Inventory/Module.js
Normal 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
|
||||
}
|
||||
241
src/pages/POS/Inventory/Serials.vue
Normal file
241
src/pages/POS/Inventory/Serials.vue
Normal 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>
|
||||
400
src/pages/POS/Movements/DetailModal.vue
Normal file
400
src/pages/POS/Movements/DetailModal.vue
Normal 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>
|
||||
667
src/pages/POS/Movements/Edit.vue
Normal file
667
src/pages/POS/Movements/Edit.vue
Normal 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>
|
||||
696
src/pages/POS/Movements/EntryModal.vue
Normal file
696
src/pages/POS/Movements/EntryModal.vue
Normal 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>
|
||||
622
src/pages/POS/Movements/ExitModal.vue
Normal file
622
src/pages/POS/Movements/ExitModal.vue
Normal 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>
|
||||
428
src/pages/POS/Movements/Index.vue
Normal file
428
src/pages/POS/Movements/Index.vue
Normal 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>
|
||||
259
src/pages/POS/Movements/KardexModal.vue
Normal file
259
src/pages/POS/Movements/KardexModal.vue
Normal 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>
|
||||
16
src/pages/POS/Movements/Module.js
Normal file
16
src/pages/POS/Movements/Module.js
Normal 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
|
||||
}
|
||||
678
src/pages/POS/Movements/TransferModal.vue
Normal file
678
src/pages/POS/Movements/TransferModal.vue
Normal 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
1007
src/pages/POS/Point.vue
Normal file
File diff suppressed because it is too large
Load Diff
493
src/pages/POS/Returns/CreateReturn.vue
Normal file
493
src/pages/POS/Returns/CreateReturn.vue
Normal 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>
|
||||
182
src/pages/POS/Returns/Index.vue
Normal file
182
src/pages/POS/Returns/Index.vue
Normal 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>
|
||||
16
src/pages/POS/Returns/Module.js
Normal file
16
src/pages/POS/Returns/Module.js
Normal 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
|
||||
}
|
||||
294
src/pages/POS/Returns/ReturnDetail.vue
Normal file
294
src/pages/POS/Returns/ReturnDetail.vue
Normal 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>
|
||||
255
src/pages/POS/Returns/SelectSale.vue
Normal file
255
src/pages/POS/Returns/SelectSale.vue
Normal 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>
|
||||
417
src/pages/POS/Sales/DetailModal.vue
Normal file
417
src/pages/POS/Sales/DetailModal.vue
Normal 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>
|
||||
237
src/pages/POS/Sales/Index.vue
Normal file
237
src/pages/POS/Sales/Index.vue
Normal 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>
|
||||
190
src/pages/POS/Suppliers/Create.vue
Normal file
190
src/pages/POS/Suppliers/Create.vue
Normal 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
Loading…
x
Reference in New Issue
Block a user