Compare commits

..

No commits in common. "main" and "develop" have entirely different histories.

135 changed files with 134 additions and 27225 deletions

View File

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

1
.gitignore vendored
View File

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

View File

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

570
package-lock.json generated
View File

@ -1,23 +1,21 @@
{
"name": "pdv.frontend",
"name": "notsoweb.frontend",
"version": "0.9.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pdv.frontend",
"name": "notsoweb.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",
@ -25,7 +23,6 @@
"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"
},
@ -92,15 +89,6 @@
"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",
@ -1236,50 +1224,18 @@
"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",
@ -1451,19 +1407,11 @@
"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"
@ -1544,26 +1492,6 @@
"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",
@ -1668,15 +1596,6 @@
"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",
@ -1698,26 +1617,6 @@
],
"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",
@ -1757,21 +1656,11 @@
"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"
@ -1784,6 +1673,7 @@
"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": {
@ -1854,28 +1744,6 @@
"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",
@ -1930,15 +1798,6 @@
}
}
},
"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",
@ -1957,12 +1816,6 @@
"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",
@ -2007,16 +1860,6 @@
"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",
@ -2103,12 +1946,6 @@
"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",
@ -2274,17 +2111,6 @@
"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",
@ -2309,12 +2135,6 @@
}
}
},
"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",
@ -2361,19 +2181,6 @@
"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",
@ -2461,15 +2268,6 @@
"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",
@ -2625,26 +2423,6 @@
"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",
@ -2655,15 +2433,6 @@
"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",
@ -2746,23 +2515,6 @@
"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",
@ -3004,18 +2756,6 @@
"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",
@ -3243,48 +2983,6 @@
"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",
@ -3307,15 +3005,6 @@
"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",
@ -3329,13 +3018,6 @@
"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",
@ -3375,15 +3057,6 @@
}
}
},
"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",
@ -3434,23 +3107,6 @@
"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",
@ -3484,23 +3140,6 @@
],
"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",
@ -3511,21 +3150,6 @@
"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",
@ -3543,16 +3167,6 @@
"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",
@ -3616,18 +3230,6 @@
"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",
@ -3697,42 +3299,6 @@
"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",
@ -3758,16 +3324,6 @@
"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",
@ -3826,16 +3382,6 @@
"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",
@ -3927,16 +3473,6 @@
"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",
@ -4105,19 +3641,6 @@
"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",
@ -4139,39 +3662,6 @@
"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",
@ -4203,12 +3693,6 @@
"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",
@ -4218,41 +3702,6 @@
"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",
@ -4262,15 +3711,6 @@
"@types/qs": "^6.9.17",
"qs": "~6.9.7"
}
},
"node_modules/zxing-wasm": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.39.10"
}
}
}
}

View File

@ -1,6 +1,6 @@
{
"name": "pdv.frontend",
"copyright": "Golsystems",
"name": "notsoweb.frontend",
"copyright": "Notsoweb Software Inc.",
"private": true,
"version": "0.9.10",
"type": "module",
@ -14,12 +14,10 @@
"@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",
@ -27,7 +25,6 @@
"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"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

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

View File

@ -4,6 +4,7 @@ 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()
@ -20,56 +21,49 @@ onMounted(() => {
</script>
<template>
<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 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>
<main class="bg-white/10 w-full backdrop-blur-xs text-white px-4 py-4 rounded-sm max-w-80">
<RouterView />
</div>
</div>
</main>
<!-- Footer del panel derecho -->
<footer class="py-4 px-6 border-t border-primary/10 dark:border-primary-dt/10">
<div class="flex items-center justify-between text-xs text-page-t/50 dark:text-page-dt/50">
<span>&copy; {{ new Date().getFullYear() }} {{ APP_COPYRIGHT }}</span>
<span>v{{ APP_VERSION }}</span>
<footer class="absolute bottom-0 flex w-full h-8 px-4 items-center justify-between bg-primary dark:bg-primary-d backdrop-blur-md text-white transition-colors duration-global">
<div>
<span>
&copy;2024 {{ APP_COPYRIGHT }}
</span>
</div>
<div>
<span>
APP {{ APP_VERSION }} API {{ $page.app.version }}
</span>
</div>
</footer>
</div>
</div>
</div>
</template>

View File

@ -1,30 +1,10 @@
<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()) {
@ -34,12 +14,11 @@ const home = () => {
}
}
</script>
<template>
<div
class="flex w-full justify-center items-center space-x-2 cursor-pointer"
@click="home"
>
<img :src="'/Logo-hk.png'" :class="sizeClass" />
<img :src="$page.app.logo" class="h-20" />
</div>
</template>

View File

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

View File

@ -38,36 +38,45 @@ const clear = () => {
v-text="title"
/>
</div>
<div class="flex w-full justify-between items-center py-4 mb-4">
<div class="flex w-full justify-between items-center border-y-2 border-page-t dark:border-page-dt">
<div>
<div class="relative z-0">
<div @click="search" class="absolute inset-y-0 right-3 flex items-center gap-1 cursor-pointer">
<div class="relative py-1 z-0">
<div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
<GoogleIcon
:title="$t('search')"
class="text-lg text-gray-500 hover:text-gray-700"
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
name="search"
/>
<GoogleIcon
v-show="query"
:title="$t('clear')"
class="text-lg text-gray-500 hover:text-gray-700"
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
name="close"
@click="clear"
/>
</div>
<input
id="search"
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"
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"
autocomplete="off"
:placeholder="placeholder"
required
type="text"
v-model="query"
@keyup.enter="search"
/>
</div>
</div>
<div class="flex items-center gap-2" id="buttons">
<div class="flex items-center space-x-1 text-sm" id="buttons">
<slot />
<RouterLink :to="$view({name:'index'})">
<IconButton
:title="$t('home')"
class="text-white"
icon="home"
filled
/>
</RouterLink>
</div>
</div>
</template>

View File

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

View File

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

View File

@ -29,10 +29,11 @@ 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-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 border-r border-gray-200 dark:border-gray-700">
<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>
<div class="flex w-full px-2 pt-3 pb-1">
<div class="flex w-full px-2 mt-2">
<Logo
class="text-lg inline-flex"
/>
</div>
<ul class="flex h-full flex-col md:pb-4 space-y-1">
@ -40,10 +41,11 @@ const year = (new Date).getFullYear();
</ul>
</div>
<div class="mb-4 px-5 space-y-1">
<p class="block text-center text-xs text-gray-500 dark:text-gray-400">
<p class="block text-center text-xs">
&copy {{year}} {{ APP_COPYRIGHT }}
</p>
<p class="text-center text-xs text-indigo-600 dark:text-indigo-400 cursor-pointer">
<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>
</div>
</div>

View File

@ -18,10 +18,10 @@ const props = defineProps({
const classes = computed(() => {
let status = props.to === vroute.name
? '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';
? 'bg-secondary/30 dark:bg-secondary-d/30 border-secondary dark:border-secondary-d'
: 'border-transparent';
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`
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`
});
const closeSidebar = () => {

View File

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

View File

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

View File

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

View File

@ -12,7 +12,6 @@ const emit = defineEmits([
/** Propiedades */
const props = defineProps({
event: Object,
nameMap: { type: Object, default: () => ({}) },
});
const icons = {
@ -43,14 +42,6 @@ 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>
@ -88,7 +79,7 @@ const translatedDescription = computed(() => {
<div class="flex w-full flex-col justify-start space-y-2">
<div>
<h4 class="font-semibold">{{ $t('description') }}:</h4>
<p>{{ translatedDescription }}</p>
<p>{{ event.description }}.</p>
</div>
<div>
<h4 class="font-semibold">{{ $t('author') }}:</h4>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,300 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
import Modal from '@Holos/Modal.vue';
import serialService from '@Services/serialService';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
product: {
type: Object,
required: true
},
excludeSerials: {
type: Array,
default: () => []
},
warehouseId: {
type: Number,
default: null
},
mainWarehouse: {
type: Boolean,
default: false
},
preSelectedSerials: {
type: Array,
default: () => []
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Estado */
const loading = ref(false);
const availableSerials = ref([]);
const selectedSerials = ref([]);
const searchQuery = ref('');
/** Computados */
const filteredSerials = computed(() => {
if (!searchQuery.value) {
return availableSerials.value;
}
const query = searchQuery.value.toLowerCase();
return availableSerials.value.filter(serial =>
serial.serial_number.toLowerCase().includes(query)
);
});
const selectedCount = computed(() => selectedSerials.value.length);
const hasEnoughStock = computed(() => {
return availableSerials.value.length > 0;
});
const canConfirm = computed(() => {
return selectedSerials.value.length > 0;
});
/** Métodos */
const loadSerials = async () => {
loading.value = true;
try {
const response = await serialService.getAvailableSerials(props.product.id, props.warehouseId, { mainWarehouse: props.mainWarehouse });
const fetched = (response.serials?.data || []).filter(
serial => !props.excludeSerials.includes(serial.serial_number)
);
const fetchedIds = new Set(fetched.map(s => s.id));
const extras = props.preSelectedSerials.filter(s => !fetchedIds.has(s.id));
availableSerials.value = [...extras, ...fetched];
selectedSerials.value = props.preSelectedSerials.filter(s =>
availableSerials.value.some(a => a.id === s.id)
);
} catch (error) {
console.error('Error loading serials:', error);
availableSerials.value = [];
} finally {
loading.value = false;
}
};
const toggleSerial = (serial) => {
const index = selectedSerials.value.findIndex(s => s.id === serial.id);
if (index > -1) {
selectedSerials.value.splice(index, 1);
} else {
selectedSerials.value.push(serial);
}
};
const isSelected = (serial) => {
return selectedSerials.value.some(s => s.id === serial.id);
};
const selectAll = () => {
selectedSerials.value = [...availableSerials.value];
};
const clearSelection = () => {
selectedSerials.value = [];
};
const handleConfirm = () => {
emit('confirm', {
serials: selectedSerials.value,
serialNumbers: selectedSerials.value.map(s => s.serial_number),
quantity: selectedSerials.value.length
});
};
const handleClose = () => {
emit('close');
};
const resetState = () => {
selectedSerials.value = [];
searchQuery.value = '';
};
/** Watchers */
watch(() => props.show, (isShown) => {
if (isShown) {
resetState();
loadSerials();
}
},{ immediate: true });
</script>
<template>
<Modal :show="show" max-width="lg" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<GoogleIcon name="qr_code_2" class="text-2xl text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Seleccionar Números de Serie
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ product.name }}
</p>
</div>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<GoogleIcon name="close" class="text-xl" />
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-12">
<GoogleIcon name="hourglass_empty" class="text-4xl text-gray-400 animate-spin" />
</div>
<!-- Sin seriales disponibles -->
<div v-else-if="!hasEnoughStock" class="text-center py-8">
<GoogleIcon name="error" class="text-5xl text-red-400 mb-3" />
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Stock insuficiente
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">
Solo hay {{ availableSerials.length }} serial(es) disponible(s), pero necesitas {{ quantity }}.
</p>
<button
@click="handleClose"
class="mt-4 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Cerrar
</button>
</div>
<!-- Content -->
<div v-else class="space-y-6">
<!-- Selección manual -->
<div class="space-y-4">
<!-- Buscador y acciones -->
<div class="flex items-center gap-3">
<div class="flex-1 relative">
<GoogleIcon
name="search"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
v-model="searchQuery"
type="text"
placeholder="Buscar número de serie..."
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
/>
</div>
<button
v-if="selectedCount < availableSerials.length"
@click="selectAll"
class="px-3 py-2 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors"
>
Seleccionar todos
</button>
<button
v-if="selectedCount > 0"
@click="clearSelection"
class="px-3 py-2 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Limpiar
</button>
</div>
<!-- Contador -->
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">
{{ availableSerials.length }} serial(es) disponible(s)
</span>
<span
class="font-semibold"
:class="{
'text-green-600': selectedCount > 0,
'text-gray-500': selectedCount === 0
}"
>
{{ selectedCount }} seleccionado(s)
</span>
</div>
<!-- Lista de seriales -->
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-100 dark:divide-gray-700">
<button
v-for="serial in filteredSerials"
:key="serial.id"
type="button"
class="w-full flex items-center gap-3 p-3 text-left transition-colors"
:class="{
'bg-indigo-50 dark:bg-indigo-900/20': isSelected(serial),
'hover:bg-gray-50 dark:hover:bg-gray-800': !isSelected(serial)
}"
@click="toggleSerial(serial)"
>
<div
class="w-5 h-5 rounded border-2 flex items-center justify-center transition-colors"
:class="{
'border-indigo-500 bg-indigo-500': isSelected(serial),
'border-gray-300 dark:border-gray-600': !isSelected(serial)
}"
>
<GoogleIcon
v-if="isSelected(serial)"
name="check"
class="text-xs text-white"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
{{ serial.serial_number }}
</p>
<p v-if="serial.notes" class="text-xs text-gray-500 dark:text-gray-400 truncate">
{{ serial.notes }}
</p>
</div>
</button>
<!-- Sin resultados -->
<div v-if="filteredSerials.length === 0" class="p-8 text-center text-gray-500">
<GoogleIcon name="search_off" class="text-3xl mb-2" />
<p class="text-sm">No se encontraron seriales</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div v-if="hasEnoughStock && !loading" class="flex items-center justify-end gap-3 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="handleClose"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancelar
</button>
<button
type="button"
@click="handleConfirm"
:disabled="!canConfirm"
class="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<GoogleIcon name="check" class="text-lg" />
Confirmar
</button>
</div>
</div>
</Modal>
</template>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,35 +1,7 @@
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.',
@ -477,145 +449,4 @@ export default {
},
title: 'Puestos de trabajos'
},
pos: {
title: 'Punto de Venta',
subtitle: 'Gestión de ventas y caja',
category: 'Clasificaciones',
bundles: 'Paquetes',
inventory: 'Productos',
prices: 'Precios',
cashRegister: 'Caja',
point: 'Punto de Venta',
sales: 'Ventas',
returns: 'Devoluciones',
clients: 'Clientes',
suppliers: 'Proveedores',
unitMeasure: 'Unidades de medida',
clientTiers: 'Niveles de Clientes',
billingRequests: 'Solicitudes de Facturación',
warehouses: 'Almacenes',
movements: 'Movimientos',
bills: 'Facturas / Gastos'
},
cashRegister: {
title: 'Caja Registradora',
description: 'Gestión de apertura y cierre de caja',
open: 'Abrir Caja',
close: 'Cerrar Caja',
history: 'Historial de Cajas',
status: {
open: 'Abierta',
closed: 'Cerrada'
},
initialCash: 'Efectivo Inicial',
finalCash: 'Efectivo Final',
expectedCash: 'Efectivo Esperado',
cashSales: 'Ventas en Efectivo',
cardSales: 'Ventas con Tarjeta',
totalSales: 'Total de Ventas',
difference: 'Diferencia',
transactions: 'Transacciones',
openedBy: 'Abierta por',
openedAt: 'Fecha de Apertura',
closedAt: 'Fecha de Cierre',
notes: 'Notas / Observaciones'
},
inventory: {
title: 'Gestión De Inventario',
description: 'Administra los productos del inventario.',
create: {
title: 'Nuevo Producto',
},
edit: {
title: 'Editar Producto',
},
sku: 'SKU / Código',
product: 'Producto',
category: 'Clasificación',
stock: 'Stock',
state: 'Estado',
cost: 'Costo',
retailPrice: 'Precio Venta',
tax: 'Impuesto',
active: 'Activo',
inactive: 'Inactivo',
},
category: {
title: 'Gestión De Clasificaciones',
description: 'Administra las clasificaciones de productos.',
create: {
title: 'Nueva Clasificación',
},
edit: {
title: 'Editar Clasificación',
},
},
prices: {
title: 'Precios',
},
sales: {
title: 'Historial de Ventas',
invoice: 'Factura',
invoiceNumber: 'Número de Factura',
paymentMethod: 'Método de pago',
status: 'Estado',
total: 'Total',
subtotal: 'Subtotal',
tax: 'Impuesto (IVA)',
cancel: 'Cancelar venta',
cancelConfirm: '¿Estás seguro de cancelar esta venta? Se restaurará el stock.',
cancelled: 'Venta cancelada exitosamente',
detail: 'Detalle de venta',
date: 'Fecha de venta',
cashier: 'Cajero',
empty: 'No hay ventas registradas',
methods: {
cash: 'Efectivo',
credit_card: 'Tarjeta de Crédito',
debit_card: 'Tarjeta de Débito'
},
statuses: {
completed: 'Completada',
cancelled: 'Cancelada'
}
},
cart: {
title: 'Carrito de Compras',
empty: 'El carrito está vacío',
emptyMessage: 'Agrega productos para comenzar una venta',
addProduct: 'Agregar producto',
removeProduct: 'Eliminar producto',
quantity: 'Cantidad',
unitPrice: 'Precio unitario',
subtotal: 'Subtotal',
clear: 'Vaciar carrito',
clearConfirm: '¿Estás seguro de vaciar el carrito?',
checkout: 'Cobrar',
selectPayment: 'Selecciona método de pago',
processing: 'Procesando venta...',
success: 'Venta realizada exitosamente',
error: 'Error al procesar la venta',
noStock: 'No hay suficiente stock disponible',
total: 'Total a pagar'
},
clients: {
title: 'Clientes',
description: 'Gestión de clientes',
},
clientTiers: {
title: 'Niveles de Clientes',
description: 'Gestión de niveles de clientes',
},
warehouses: {
title: 'Almacenes',
description: 'Gestión de almacenes',
},
movements: {
title: 'Movimientos de Inventario',
description: 'Historial de entradas, salidas y traspasos de productos',
},
suppliers: {
title: 'Proveedores',
description: 'Gestión de proveedores',
},
}

View File

@ -6,8 +6,6 @@ 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()
@ -36,93 +34,10 @@ onMounted(() => {
name="dashboard"
to="dashboard.index"
/>
</Section>
<Section :name="$t('pos.title')">
<Link
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"
icon="person"
name="profile"
to="profile.show"
/>
</Section>
<Section

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,12 @@
<script setup>
import { onMounted, ref } from 'vue';
import { onMounted } 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 GoogleIcon from '@Shared/GoogleIcon.vue'
import Error from '@Holos/Form/Elements/Error.vue'
import Input from '@Holos/Form/InputWithIcon.vue'
/** Definidores */
const router = useRouter();
@ -23,32 +22,19 @@ 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()) {
@ -58,100 +44,33 @@ onMounted(() => {
</script>
<template>
<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
<form @submit.prevent="login">
<Input
icon="mail"
id="email"
type="email"
v-model="form.email"
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"
:onError="form.errors.email"
:placeholder="$t('email.title')"
/>
</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'"
<Input
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"
icon="password"
id="password"
type="password"
:onError="form.errors.password"
:placeholder="$t('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 class="!w-full">
{{ $t('auth.login') }}
</PrimaryButton>
</form>
<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>
</form>
</template>

View File

@ -1,270 +1,9 @@
<script setup>
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';
import PageHeader from '@Holos/PageHeader.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>
<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>
<PageHeader title="Dashboard" />
<p><b>{{ $t('welcome') }}</b>, {{ $page.user.name }}.</p>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,241 +0,0 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSearcher, apiURL } from '@Services/Api';
import SearcherHead from '@Holos/Searcher.vue';
import Table from '@Holos/Table.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
const route = useRoute();
const router = useRouter();
/** Estado */
const inventoryId = computed(() => route.params.id);
const warehouseId = computed(() => route.query.warehouse_id || null);
const isMainWarehouse = computed(() => !warehouseId.value || route.query.is_main === '1');
const inventory = ref(null);
const serials = ref({ data: [], total: 0 });
const activeTab = ref('disponible');
/** Buscador */
const searcher = useSearcher({
url: apiURL(`inventario/${route.params.id}/serials`),
filters: {},
onSuccess: (r) => {
serials.value = r.serials || { data: [], total: 0 };
inventory.value = r.inventory || null;
},
onError: () => {
serials.value = { data: [], total: 0 };
}
});
/** Métodos */
const warehouseFilters = () => {
if (!warehouseId.value) return {};
if (route.query.is_main === '1') return { main_warehouse: 1 };
return { warehouse_id: warehouseId.value };
};
const loadSerials = (filters = {}) => {
searcher.load({
url: apiURL(`inventario/${inventoryId.value}/serials`),
filters: {
...warehouseFilters(),
...filters,
status: activeTab.value
}
});
};
const onSearch = (query) => {
searcher.search(query, { ...warehouseFilters(), status: activeTab.value });
};
const switchTab = (tab) => {
activeTab.value = tab;
loadSerials({ q: searcher.query });
};
// Navegación
const goBack = () => {
router.go(-1);
};
// Badge de estado
const getStatusBadge = (status) => {
if (status === 'disponible') {
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300';
}
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300';
};
const getStatusLabel = (status) => {
return status === 'disponible' ? 'Disponible' : 'Vendido';
};
/** Ciclos */
onMounted(() => {
loadSerials();
});
</script>
<template>
<div>
<!-- Header con navegación -->
<div class="mb-4">
<button
@click="goBack"
class="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 transition-colors"
>
<GoogleIcon name="arrow_back" class="text-xl" />
Volver a Inventario
</button>
</div>
<!-- Info del producto -->
<div v-if="inventory" class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ inventory.name }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
SKU: {{ inventory.sku }}
<span v-if="inventory.category">
| Clasificación: {{ inventory.category.name }}
</span>
</p>
</div>
<div class="text-right">
<p class="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
{{ inventory.stock }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">
Disponibles
</p>
</div>
</div>
</div>
<!-- Buscador -->
<SearcherHead
title="Números de Serie"
placeholder="Buscar por número de serie..."
@search="onSearch"
/>
<!-- Pestañas -->
<div class="mt-4 border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
<button
@click="switchTab('disponible')"
:class="[
activeTab === 'disponible'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors'
]"
>
<GoogleIcon
name="check_circle"
:class="[
activeTab === 'disponible' ? 'text-indigo-500 dark:text-indigo-400' : 'text-gray-400 group-hover:text-gray-500',
'mr-2 text-xl'
]"
/>
Disponibles
</button>
<button
v-if="isMainWarehouse"
@click="switchTab('vendido')"
:class="[
activeTab === 'vendido'
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
'group inline-flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors'
]"
>
<GoogleIcon
name="shopping_cart"
:class="[
activeTab === 'vendido' ? 'text-indigo-500 dark:text-indigo-400' : 'text-gray-400 group-hover:text-gray-500',
'mr-2 text-xl'
]"
/>
Vendidos
</button>
</nav>
</div>
<!-- Tabla de seriales -->
<div class="pt-2 w-full">
<Table
:items="serials"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page, { status: activeTab })"
>
<template #head>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NÚMERO DE SERIE</th>
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">ESTADO</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">NOTAS</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">FECHA</th>
<th v-if="activeTab === 'vendido'" class="px-6 py-3 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">VENTA</th>
</template>
<template #body="{items}">
<tr
v-for="serial in items"
:key="serial.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td class="px-6 py-4 text-center whitespace-nowrap">
<span class="text-sm font-mono font-semibold text-gray-900 dark:text-gray-100">
{{ serial.serial_number }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="getStatusBadge(serial.status)"
>
{{ getStatusLabel(serial.status) }}
</span>
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ serial.notes || '-' }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ new Date(serial.created_at).toLocaleDateString('es-MX') }}
</span>
</td>
<td v-if="activeTab === 'vendido'" class="px-6 py-4 whitespace-nowrap text-center">
<span class="text-gray-500 dark:text-gray-400 text-xs">
{{ serial.sale_detail?.sale?.invoice_number || `#${serial.sale_detail_id}` }}
</span>
</td>
</tr>
</template>
<template #empty>
<td :colspan="activeTab === 'vendido' ? 5 : 4" class="table-cell text-center">
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
<GoogleIcon
:name="activeTab === 'disponible' ? 'qr_code_2' : 'shopping_cart'"
class="text-6xl mb-2 opacity-50"
/>
<p class="font-semibold">
{{ activeTab === 'disponible' ? 'No hay seriales disponibles' : 'No hay seriales vendidos' }}
</p>
<p class="text-sm mt-1">
{{ activeTab === 'disponible' ? 'Agrega seriales para comenzar' : 'Los seriales vendidos aparecerán aquí' }}
</p>
</div>
</td>
</template>
</Table>
</div>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,678 +0,0 @@
<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>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,190 +0,0 @@
<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