WIP
This commit is contained in:
parent
f8cc26a497
commit
173f5417b3
230
package-lock.json
generated
230
package-lock.json
generated
@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "notsoweb.frontend",
|
"name": "pdv.frontend",
|
||||||
"version": "0.9.10",
|
"version": "0.9.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "notsoweb.frontend",
|
"name": "pdv.frontend",
|
||||||
"version": "0.9.10",
|
"version": "0.9.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.9",
|
"@tailwindcss/postcss": "^4.0.9",
|
||||||
"@tailwindcss/vite": "^4.0.9",
|
"@tailwindcss/vite": "^4.0.9",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
|
"jspdf": "^3.0.4",
|
||||||
"laravel-echo": "^2.0.2",
|
"laravel-echo": "^2.0.2",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
@ -89,6 +90,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
|
||||||
@ -1230,12 +1240,32 @@
|
|||||||
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "5.2.4",
|
"version": "5.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||||
@ -1492,6 +1522,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/birpc": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz",
|
||||||
@ -1617,6 +1657,26 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@ -1744,6 +1804,28 @@
|
|||||||
"url": "https://github.com/sponsors/mesqueeb"
|
"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": {
|
"node_modules/css-select": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
|
||||||
@ -1860,6 +1942,16 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"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": {
|
"node_modules/domutils": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||||
@ -2111,6 +2203,17 @@
|
|||||||
"node": ">=8.6.0"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||||
@ -2135,6 +2238,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/filelist": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
||||||
@ -2423,6 +2532,26 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@ -2515,6 +2644,23 @@
|
|||||||
"graceful-fs": "^4.1.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": {
|
"node_modules/laravel-echo": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.1.4.tgz",
|
||||||
@ -2983,6 +3129,12 @@
|
|||||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/param-case": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||||
@ -3018,6 +3170,13 @@
|
|||||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@ -3140,6 +3299,23 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/relateurl": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
|
||||||
@ -3167,6 +3343,16 @@
|
|||||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.41.0",
|
"version": "4.41.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz",
|
||||||
@ -3299,6 +3485,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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/superjson": {
|
"node_modules/superjson": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
|
||||||
@ -3324,6 +3520,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.7",
|
"version": "4.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
|
||||||
@ -3382,6 +3588,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.13",
|
"version": "0.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||||
@ -3473,6 +3689,16 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/uuid": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "notsoweb.frontend",
|
"name": "pdv.frontend",
|
||||||
"copyright": "Notsoweb Software Inc.",
|
"copyright": "Golsystems",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.10",
|
"version": "0.9.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -14,6 +14,7 @@
|
|||||||
"@tailwindcss/vite": "^4.0.9",
|
"@tailwindcss/vite": "^4.0.9",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
|
"jspdf": "^3.0.4",
|
||||||
"laravel-echo": "^2.0.2",
|
"laravel-echo": "^2.0.2",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"pinia": "^3.0.1",
|
"pinia": "^3.0.1",
|
||||||
|
|||||||
BIN
public/Logo-hk.png
Normal file
BIN
public/Logo-hk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
@ -19,6 +19,6 @@ const home = () => {
|
|||||||
class="flex w-full justify-center items-center space-x-2 cursor-pointer"
|
class="flex w-full justify-center items-center space-x-2 cursor-pointer"
|
||||||
@click="home"
|
@click="home"
|
||||||
>
|
>
|
||||||
<img :src="$page.app.logo" class="h-20" />
|
<img :src="'/public/Logo-hk.png'" class="h-16" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -42,6 +42,9 @@ const maxWidthClass = computed(() => {
|
|||||||
'lg': 'sm:max-w-lg',
|
'lg': 'sm:max-w-lg',
|
||||||
'xl': 'sm:max-w-xl',
|
'xl': 'sm:max-w-xl',
|
||||||
'2xl': 'sm:max-w-2xl',
|
'2xl': 'sm:max-w-2xl',
|
||||||
|
'3xl': 'sm:max-w-3xl',
|
||||||
|
'4xl': 'sm:max-w-4xl',
|
||||||
|
'5xl': 'sm:max-w-5xl',
|
||||||
}[props.maxWidth];
|
}[props.maxWidth];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -38,45 +38,36 @@ const clear = () => {
|
|||||||
v-text="title"
|
v-text="title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full justify-between items-center border-y-2 border-page-t dark:border-page-dt">
|
<div class="flex w-full justify-between items-center py-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="relative py-1 z-0">
|
<div class="relative z-0">
|
||||||
<div @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
|
<div @click="search" class="absolute inset-y-0 right-3 flex items-center gap-1 cursor-pointer">
|
||||||
<GoogleIcon
|
<GoogleIcon
|
||||||
:title="$t('search')"
|
:title="$t('search')"
|
||||||
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
|
class="text-lg text-gray-500 hover:text-gray-700"
|
||||||
name="search"
|
name="search"
|
||||||
/>
|
/>
|
||||||
<GoogleIcon
|
<GoogleIcon
|
||||||
v-show="query"
|
v-show="query"
|
||||||
:title="$t('clear')"
|
:title="$t('clear')"
|
||||||
class="text-xl text-gray-700 hover:scale-110 hover:text-danger"
|
class="text-lg text-gray-500 hover:text-gray-700"
|
||||||
name="close"
|
name="close"
|
||||||
@click="clear"
|
@click="clear"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="search"
|
id="search"
|
||||||
class="bg-gray-100 border border-gray-300 text-gray-700 text-sm rounded-sm outline-0 focus:ring-primary focus:border-primary block sm:w-56 md:w-72 lg:w-80 pr-10 px-2.5 py-1"
|
class="bg-white border border-gray-300 text-gray-700 text-sm rounded-lg outline-0 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 block sm:w-56 md:w-72 lg:w-96 pr-16 px-4 py-2.5 shadow-sm"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
required
|
|
||||||
type="text"
|
type="text"
|
||||||
v-model="query"
|
v-model="query"
|
||||||
@keyup.enter="search"
|
@keyup.enter="search"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-1 text-sm" id="buttons">
|
<div class="flex items-center gap-2" id="buttons">
|
||||||
<slot />
|
<slot />
|
||||||
<RouterLink :to="$view({name:'index'})">
|
|
||||||
<IconButton
|
|
||||||
:title="$t('home')"
|
|
||||||
class="text-white"
|
|
||||||
icon="home"
|
|
||||||
filled
|
|
||||||
/>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -32,7 +32,7 @@ const loader = useLoader()
|
|||||||
class="fixed px-2 w-[calc(100vw)] bg-transparent transition-all duration-300 z-50"
|
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}"
|
:class="{'md:w-[calc(100vw-16rem)]':leftSidebar.isOpened,'md:w-[calc(100vw)]':!leftSidebar.isClosed}"
|
||||||
>
|
>
|
||||||
<div class="my-2 flex px-2 items-center justify-between h-[2.75rem] rounded-sm bg-primary dark:bg-primary-d text-white z-20 ">
|
<div class="my-2 flex px-2 items-center justify-between h-[2.75rem] rounded-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-700 shadow-sm z-20 ">
|
||||||
<GoogleIcon
|
<GoogleIcon
|
||||||
class="text-2xl mt-1 z-50"
|
class="text-2xl mt-1 z-50"
|
||||||
name="list"
|
name="list"
|
||||||
|
|||||||
@ -29,11 +29,10 @@ const year = (new Date).getFullYear();
|
|||||||
:class="{'w-64': leftSidebar.isClosed, 'w-screen': leftSidebar.isOpened}"
|
:class="{'w-64': leftSidebar.isClosed, 'w-screen': leftSidebar.isOpened}"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col h-full p-2 md:w-64">
|
<div class="flex flex-col h-full p-2 md:w-64">
|
||||||
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-primary dark:bg-primary-d text-white">
|
<div class="flex h-full flex-col w-[15.5rem] justify-between rounded-sm overflow-y-auto overflow-x-hidden bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 border-r border-gray-200 dark:border-gray-700">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex w-full px-2 mt-2">
|
<div class="flex w-full px-2 pt-3 pb-1">
|
||||||
<Logo
|
<Logo
|
||||||
class="text-lg inline-flex"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul class="flex h-full flex-col md:pb-4 space-y-1">
|
<ul class="flex h-full flex-col md:pb-4 space-y-1">
|
||||||
@ -41,11 +40,10 @@ const year = (new Date).getFullYear();
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4 px-5 space-y-1">
|
<div class="mb-4 px-5 space-y-1">
|
||||||
<p class="block text-center text-xs">
|
<p class="block text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
© {{year}} {{ APP_COPYRIGHT }}
|
© {{year}} {{ APP_COPYRIGHT }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-center text-xs text-yellow-500 cursor-pointer">
|
<p class="text-center text-xs text-indigo-600 dark:text-indigo-400 cursor-pointer">
|
||||||
<RouterLink :to="{name:'changelogs.app'}"> APP {{ APP_VERSION }} </RouterLink> <RouterLink :to="{name:'changelogs.core'}"> API {{ $page.app.version }} </RouterLink>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,10 +18,10 @@ const props = defineProps({
|
|||||||
|
|
||||||
const classes = computed(() => {
|
const classes = computed(() => {
|
||||||
let status = props.to === vroute.name
|
let status = props.to === vroute.name
|
||||||
? 'bg-secondary/30 dark:bg-secondary-d/30 border-secondary dark:border-secondary-d'
|
? 'bg-indigo-50 dark:bg-indigo-900/20 border-indigo-600 dark:border-indigo-400 text-indigo-700 dark:text-indigo-300'
|
||||||
: 'border-transparent';
|
: 'border-transparent text-gray-700 dark:text-gray-300';
|
||||||
|
|
||||||
return `flex items-center h-11 focus:outline-hidden hover:bg-secondary/30 dark:hover:bg-secondary-d/30 border-l-4 hover:border-secondary dark:hover:border-secondary-d pr-6 ${status} transition`
|
return `flex items-center h-11 focus:outline-hidden hover:bg-gray-100 dark:hover:bg-gray-800 border-l-4 hover:border-indigo-400 dark:hover:border-indigo-500 pr-6 ${status} transition`
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeSidebar = () => {
|
const closeSidebar = () => {
|
||||||
|
|||||||
@ -8,8 +8,8 @@ const props = defineProps({
|
|||||||
<template>
|
<template>
|
||||||
<ul v-if="$slots['default']">
|
<ul v-if="$slots['default']">
|
||||||
<li class="px-5 hidden md:block">
|
<li class="px-5 hidden md:block">
|
||||||
<div class="flex flex-row items-center h-8">
|
<div class="flex flex-row items-center h-8 mt-2">
|
||||||
<div class="text-sm font-light tracking-wide text-gray-400 uppercase">
|
<div class="text-xs font-semibold tracking-wide text-gray-500 dark:text-gray-400 uppercase">
|
||||||
{{name}}
|
{{name}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,15 +16,15 @@ const props = defineProps({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="pb-2">
|
<section class="pb-2">
|
||||||
<div class="w-full overflow-hidden rounded-sm shadow-lg dark:shadow-xs dark:shadow-white">
|
<div class="w-full overflow-hidden rounded-lg bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="w-full overflow-x-auto">
|
<div class="w-full overflow-x-auto">
|
||||||
<table v-if="!processing" class="w-full">
|
<table v-if="!processing" class="w-full">
|
||||||
<thead class="bg-primary text-primary-t dark:bg-primary-d dark:text-primary-dt">
|
<thead class="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<template v-if="items?.total > 0">
|
<template v-if="items?.total > 0">
|
||||||
<slot
|
<slot
|
||||||
name="body"
|
name="body"
|
||||||
|
|||||||
125
src/components/POS/CartItem.vue
Normal file
125
src/components/POS/CartItem.vue
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<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']);
|
||||||
|
|
||||||
|
/** 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(() => props.item.quantity < props.item.max_stock);
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const increment = () => {
|
||||||
|
if (canIncrement.value) {
|
||||||
|
emit('update-quantity', props.item.inventory_id, props.item.quantity + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrement = () => {
|
||||||
|
if (props.item.quantity > 1) {
|
||||||
|
emit('update-quantity', props.item.inventory_id, props.item.quantity - 1);
|
||||||
|
} else {
|
||||||
|
emit('remove', props.item.inventory_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
emit('remove', props.item.inventory_id);
|
||||||
|
};
|
||||||
|
</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">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 line-clamp-2 pr-2">
|
||||||
|
{{ item.product_name }}
|
||||||
|
</h4>
|
||||||
|
<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-3">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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 === 1 ? 'delete' : 'remove'"
|
||||||
|
class="text-base"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="w-10 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>
|
||||||
242
src/components/POS/CheckoutModal.vue
Normal file
242
src/components/POS/CheckoutModal.vue
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
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 */
|
||||||
|
const selectedMethod = ref('cash');
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedTotal = computed(() => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN'
|
||||||
|
}).format(props.cart.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 */
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!selectedMethod.value) {
|
||||||
|
window.Notify.error('Selecciona un método de pago');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('confirm', selectedMethod.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!props.processing) {
|
||||||
|
selectedMethod.value = 'cash';
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Total a pagar -->
|
||||||
|
<div class="pt-3 border-t-2 border-gray-300 dark:border-gray-600">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-base font-bold text-gray-800 dark:text-gray-200">Total a pagar</span>
|
||||||
|
<span class="text-3xl font-bold text-indigo-600 dark:text-indigo-400">{{ formattedTotal }}</span>
|
||||||
|
</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>
|
||||||
|
</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"
|
||||||
|
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>
|
||||||
103
src/components/POS/ProductCard.vue
Normal file
103
src/components/POS/ProductCard.vue
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<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 isLowStock = computed(() => props.product?.stock < 10);
|
||||||
|
const isOutOfStock = computed(() => props.product?.stock <= 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: ${product.stock}` }}
|
||||||
|
</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 categoría' }}
|
||||||
|
</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>
|
||||||
110
src/lang/es.js
110
src/lang/es.js
@ -449,4 +449,114 @@ export default {
|
|||||||
},
|
},
|
||||||
title: 'Puestos de trabajos'
|
title: 'Puestos de trabajos'
|
||||||
},
|
},
|
||||||
|
pos: {
|
||||||
|
title: 'Punto de Venta',
|
||||||
|
category: 'Categorías',
|
||||||
|
inventory: 'Inventario',
|
||||||
|
prices: 'Precios',
|
||||||
|
cashRegister: 'Caja',
|
||||||
|
point: 'Punto de Venta',
|
||||||
|
sales: 'Ventas',
|
||||||
|
},
|
||||||
|
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: 'Categoría',
|
||||||
|
stock: 'Stock',
|
||||||
|
state: 'Estado',
|
||||||
|
cost: 'Costo',
|
||||||
|
retailPrice: 'Precio Venta',
|
||||||
|
tax: 'Impuesto',
|
||||||
|
active: 'Activo',
|
||||||
|
inactive: 'Inactivo',
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
title: 'Gestión De Categorías',
|
||||||
|
description: 'Administra las categorías de productos.',
|
||||||
|
create: {
|
||||||
|
title: 'Nueva Categoría',
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
title: 'Editar Categoría',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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'
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@ -31,13 +31,30 @@ onMounted(() => {
|
|||||||
<Section name="Principal">
|
<Section name="Principal">
|
||||||
<Link
|
<Link
|
||||||
icon="monitoring"
|
icon="monitoring"
|
||||||
name="dashboard"
|
name="dashboard"
|
||||||
to="dashboard.index"
|
to="dashboard.index"
|
||||||
/>
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section :name="$t('pos.title')">
|
||||||
<Link
|
<Link
|
||||||
icon="person"
|
icon="category"
|
||||||
name="profile"
|
name="pos.category"
|
||||||
to="profile.show"
|
to="pos.category.index"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="inventory_2"
|
||||||
|
name="pos.inventory"
|
||||||
|
to="pos.inventory.index"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="point_of_sale"
|
||||||
|
name="pos.cashRegister"
|
||||||
|
to="pos.cashRegister.index"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="receipt_long"
|
||||||
|
name="pos.sales"
|
||||||
|
to="pos.sales.index"
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section
|
<Section
|
||||||
@ -47,19 +64,19 @@ onMounted(() => {
|
|||||||
<Link
|
<Link
|
||||||
v-if="hasPermission('users.index')"
|
v-if="hasPermission('users.index')"
|
||||||
icon="people"
|
icon="people"
|
||||||
name="users.title"
|
name="users.title"
|
||||||
to="admin.users.index"
|
to="admin.users.index"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
v-if="hasPermission('roles.index')"
|
v-if="hasPermission('roles.index')"
|
||||||
icon="license"
|
icon="license"
|
||||||
name="roles.title"
|
name="roles.title"
|
||||||
to="admin.roles.index"
|
to="admin.roles.index"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
v-if="hasPermission('activities.index')"
|
v-if="hasPermission('activities.index')"
|
||||||
icon="event"
|
icon="event"
|
||||||
name="history.title"
|
name="history.title"
|
||||||
to="admin.activities.index"
|
to="admin.activities.index"
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
267
src/pages/POS/CashRegister/CloseModal.vue
Normal file
267
src/pages/POS/CashRegister/CloseModal.vue
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } 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
|
||||||
|
},
|
||||||
|
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));
|
||||||
|
|
||||||
|
const expectedCash = computed(() => initialCash.value + cashSales.value);
|
||||||
|
const difference = computed(() => finalCash.value - expectedCash.value);
|
||||||
|
const hasDifference = computed(() => Math.abs(difference.value) > 0.01);
|
||||||
|
|
||||||
|
/** 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>
|
||||||
|
|
||||||
|
<!-- Cálculo de Efectivo -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-5 space-y-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h4 class="text-sm font-bold text-gray-900 dark:text-gray-100 uppercase tracking-wide">
|
||||||
|
Resumen de Efectivo
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<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>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t 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-3 flex items-center pointer-events-none">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 text-lg font-semibold">$</span>
|
||||||
|
</div>
|
||||||
|
<FormInput
|
||||||
|
v-model.number="finalCash"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
class="pl-8 text-lg font-semibold"
|
||||||
|
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>
|
||||||
389
src/pages/POS/CashRegister/Detail.vue
Normal file
389
src/pages/POS/CashRegister/Detail.vue
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
<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 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.payment_method === method)
|
||||||
|
.reduce((sum, sale) => sum + parseFloat(sale.total || 0), 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;
|
||||||
|
const finalCash = parseFloat(cashRegister.value.final_cash || 0);
|
||||||
|
const initialCash = parseFloat(cashRegister.value.initial_cash || 0);
|
||||||
|
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
|
||||||
|
const salesData = await salesService.getSales({
|
||||||
|
cash_register_id: route.params.id
|
||||||
|
});
|
||||||
|
sales.value = salesData.data || [];
|
||||||
|
} 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 formatCurrency = (amount) => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN'
|
||||||
|
}).format(amount || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
return new Date(dateString).toLocaleString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const methods = {
|
||||||
|
cash: 'Efectivo',
|
||||||
|
credit_card: 'Tarjeta de Crédito',
|
||||||
|
debit_card: 'Tarjeta de Débito'
|
||||||
|
};
|
||||||
|
return 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) }}
|
||||||
|
</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) }}
|
||||||
|
</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">
|
||||||
|
<GoogleIcon name="receipt" class="text-2xl text-indigo-600" />
|
||||||
|
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-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Folio</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Hora</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">Método</th>
|
||||||
|
<th class="px-6 py-3 text-right 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">
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
<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-right">
|
||||||
|
<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>
|
||||||
239
src/pages/POS/CashRegister/History.vue
Normal file
239
src/pages/POS/CashRegister/History.vue
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
<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>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ register.transaction_count || 0 }} ventas
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<p
|
||||||
|
v-if="register.status === 'closed'"
|
||||||
|
class="text-sm font-bold"
|
||||||
|
:class="getDifferenceColor(register.difference)"
|
||||||
|
>
|
||||||
|
{{ parseFloat(register.difference || 0) >= 0 ? '+' : '' }}${{ Math.abs(parseFloat(register.difference || 0)).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-gray-400 dark:text-gray-600">
|
||||||
|
-
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span
|
||||||
|
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full"
|
||||||
|
:class="getStatusBadge(register.status)"
|
||||||
|
>
|
||||||
|
{{ register.status === 'closed' ? 'Cerrada' : 'Abierta' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<button
|
||||||
|
v-if="register.status === 'closed'"
|
||||||
|
@click="viewDetail(register)"
|
||||||
|
class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||||
|
title="Ver detalle"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="visibility" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-gray-400 dark:text-gray-600">
|
||||||
|
<GoogleIcon name="lock_open" class="text-xl" />
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="9" class="table-cell text-center">
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<GoogleIcon
|
||||||
|
name="history"
|
||||||
|
class="text-6xl mb-2 opacity-50"
|
||||||
|
/>
|
||||||
|
<p class="font-semibold">
|
||||||
|
No hay registros de cajas
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">
|
||||||
|
Abre una caja para comenzar a registrar transacciones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
355
src/pages/POS/CashRegister/Index.vue
Normal file
355
src/pages/POS/CashRegister/Index.vue
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import useCashRegister from '@Stores/cashRegister';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import OpenModal from './OpenModal.vue';
|
||||||
|
import CloseModal from './CloseModal.vue';
|
||||||
|
|
||||||
|
/** Router */
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
/** Store */
|
||||||
|
const cashRegisterStore = useCashRegister();
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const showOpenModal = ref(false);
|
||||||
|
const showCloseModal = ref(false);
|
||||||
|
const refreshing = ref(false);
|
||||||
|
|
||||||
|
/** Computed */
|
||||||
|
const isOpen = computed(() => cashRegisterStore.hasOpenRegister);
|
||||||
|
const currentRegister = computed(() => cashRegisterStore.currentRegister);
|
||||||
|
const loading = computed(() => cashRegisterStore.loading);
|
||||||
|
|
||||||
|
/** M\u00e9todos */
|
||||||
|
const openCashRegister = async (initialCash) => {
|
||||||
|
const result = await cashRegisterStore.openRegister(initialCash);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
Notify.success('Caja abierta exitosamente');
|
||||||
|
showOpenModal.value = false;
|
||||||
|
} else {
|
||||||
|
Notify.error(result.error || 'Error al abrir la caja');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCashRegister = async (closeData) => {
|
||||||
|
const result = await cashRegisterStore.closeRegister(closeData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
Notify.success('Caja cerrada exitosamente - Corte generado');
|
||||||
|
showCloseModal.value = false;
|
||||||
|
|
||||||
|
// Opcional: Redirigir al historial o mostrar el resumen
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push({ name: 'pos.cashRegister.history' });
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
Notify.error(result.error || 'Error al cerrar la caja');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
refreshing.value = true;
|
||||||
|
await cashRegisterStore.refreshCurrentRegister();
|
||||||
|
refreshing.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToHistory = () => {
|
||||||
|
router.push({ name: 'pos.cashRegister.history' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPoint = () => {
|
||||||
|
router.push({ name: 'pos.point' });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ciclos */
|
||||||
|
onMounted(async () => {
|
||||||
|
await cashRegisterStore.loadCurrentRegister();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="max-w-7xl mx-auto mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-3">
|
||||||
|
<GoogleIcon name="point_of_sale" class="text-4xl" />
|
||||||
|
Caja Registradora
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Gestión de apertura y cierre de caja
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
@click="refreshData"
|
||||||
|
:disabled="refreshing"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
title="Actualizar"
|
||||||
|
>
|
||||||
|
<GoogleIcon :name="refreshing ? 'sync' : 'refresh'" :class="{ 'animate-spin': refreshing }" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="goToHistory"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="history" />
|
||||||
|
Historial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading && !currentRegister" class="max-w-7xl mx-auto">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-12">
|
||||||
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<GoogleIcon name="sync" class="text-6xl text-gray-400 animate-spin mb-4" />
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">Cargando información de caja...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caja Cerrada - Mostrar opción para abrir -->
|
||||||
|
<div v-else-if="!isOpen" class="max-w-4xl mx-auto">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<!-- Estado -->
|
||||||
|
<div class="bg-gradient-to-r from-red-500 to-red-600 p-6">
|
||||||
|
<div class="flex items-center gap-4 text-white">
|
||||||
|
<div class="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center">
|
||||||
|
<GoogleIcon name="lock" class="text-4xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">Caja Cerrada</h2>
|
||||||
|
<p class="text-red-100 mt-1">No hay ninguna caja abierta actualmente</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenido -->
|
||||||
|
<div class="p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<GoogleIcon name="info" class="text-6xl text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
Necesitas abrir la caja para comenzar
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
Al abrir la caja podrás registrar ventas y realizar transacciones.
|
||||||
|
Ingresa el monto inicial en efectivo para comenzar tu turno.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
|
<button
|
||||||
|
@click="goToHistory"
|
||||||
|
class="flex items-center gap-2 px-6 py-3 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="history" class="text-xl" />
|
||||||
|
Ver Historial
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showOpenModal = true"
|
||||||
|
class="flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg shadow-lg shadow-green-600/30 transition-all"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="lock_open" class="text-xl" />
|
||||||
|
Abrir Caja
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caja Abierta - Mostrar resumen -->
|
||||||
|
<div v-else class="max-w-7xl mx-auto space-y-6">
|
||||||
|
<!-- Estado Actual -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-gradient-to-r from-green-500 to-green-600 p-6">
|
||||||
|
<div class="flex items-center justify-between text-white">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center">
|
||||||
|
<GoogleIcon name="lock_open" class="text-4xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">Caja Abierta</h2>
|
||||||
|
<p class="text-green-100 mt-1">Operando normalmente</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-green-100 text-sm">Abierta por</p>
|
||||||
|
<p class="text-xl font-semibold">{{ cashRegisterStore.openedBy }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Información de la Caja -->
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Información General -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 dark:text-gray-100 uppercase tracking-wide border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||||
|
Información General
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">ID de Caja:</span>
|
||||||
|
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
#{{ cashRegisterStore.currentRegisterId }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Fecha de Apertura:</span>
|
||||||
|
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ new Date(cashRegisterStore.openedAt).toLocaleString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Efectivo Inicial:</span>
|
||||||
|
<span class="text-lg font-bold text-green-600 dark:text-green-400">
|
||||||
|
${{ cashRegisterStore.initialCash.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estadísticas de Ventas -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-sm font-bold text-gray-900 dark:text-gray-100 uppercase tracking-wide border-b border-gray-200 dark:border-gray-700 pb-2">
|
||||||
|
Resumen de Ventas
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Total de Transacciones:</span>
|
||||||
|
<span class="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ cashRegisterStore.transactionCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Total Vendido:</span>
|
||||||
|
<span class="text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
${{ cashRegisterStore.totalSales.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Balance Estimado:</span>
|
||||||
|
<span class="text-xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
${{ cashRegisterStore.currentBalance.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tarjetas de Resumen -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Ventas en Efectivo -->
|
||||||
|
<div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center text-white">
|
||||||
|
<GoogleIcon name="payments" class="text-2xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-green-700 dark:text-green-300 uppercase">Efectivo</p>
|
||||||
|
<p class="text-2xl font-bold text-green-900 dark:text-green-100">
|
||||||
|
${{ cashRegisterStore.expectedCash.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-green-600 dark:text-green-400">
|
||||||
|
Efectivo esperado en caja
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ventas con Tarjeta -->
|
||||||
|
<div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 border border-purple-200 dark:border-purple-800 rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-12 h-12 bg-purple-500 rounded-lg flex items-center justify-center text-white">
|
||||||
|
<GoogleIcon name="credit_card" class="text-2xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-purple-700 dark:text-purple-300 uppercase">Tarjeta</p>
|
||||||
|
<p class="text-2xl font-bold text-purple-900 dark:text-purple-100">
|
||||||
|
${{ cashRegisterStore.cardSales.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-purple-600 dark:text-purple-400">
|
||||||
|
Pagos con tarjeta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total General -->
|
||||||
|
<div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 border border-blue-200 dark:border-blue-800 rounded-xl p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white">
|
||||||
|
<GoogleIcon name="shopping_cart" class="text-2xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-blue-700 dark:text-blue-300 uppercase">Total</p>
|
||||||
|
<p class="text-2xl font-bold text-blue-900 dark:text-blue-100">
|
||||||
|
${{ cashRegisterStore.totalSales.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
{{ cashRegisterStore.transactionCount }} transacciones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Acciones -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">Acciones Rápidas</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<button
|
||||||
|
@click="goToPoint"
|
||||||
|
class="flex items-center justify-center gap-2 px-6 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg shadow-md transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="storefront" class="text-2xl" />
|
||||||
|
<span>Ir al Punto de Venta</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="goToHistory"
|
||||||
|
class="flex items-center justify-center gap-2 px-6 py-4 bg-gray-600 hover:bg-gray-700 text-white font-semibold rounded-lg shadow-md transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="history" class="text-2xl" />
|
||||||
|
<span>Ver Historial</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showCloseModal = true"
|
||||||
|
class="flex items-center justify-center gap-2 px-6 py-4 bg-orange-600 hover:bg-orange-700 text-white font-semibold rounded-lg shadow-md transition-colors"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="lock" class="text-2xl" />
|
||||||
|
<span>Cerrar Caja</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modales -->
|
||||||
|
<OpenModal
|
||||||
|
:show="showOpenModal"
|
||||||
|
@close="showOpenModal = false"
|
||||||
|
@confirm="openCashRegister"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CloseModal
|
||||||
|
:show="showCloseModal"
|
||||||
|
:cash-register="currentRegister"
|
||||||
|
@close="showCloseModal = false"
|
||||||
|
@confirm="closeCashRegister"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
161
src/pages/POS/CashRegister/OpenModal.vue
Normal file
161
src/pages/POS/CashRegister/OpenModal.vue
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emits */
|
||||||
|
const emit = defineEmits(['close', 'confirm']);
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const initialCash = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (initialCash.value < 0) {
|
||||||
|
Notify.error('El monto inicial no puede ser negativo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('confirm', initialCash.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
initialCash.value = 0;
|
||||||
|
loading.value = false;
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="lg" @close="handleClose">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center justify-center w-14 h-14 rounded-full bg-green-100 dark:bg-green-900/30">
|
||||||
|
<GoogleIcon name="point_of_sale" class="text-3xl text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Abrir Caja Registradora
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Inicio de turno</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<GoogleIcon name="info" class="text-blue-600 dark:text-blue-400 text-2xl shrink-0" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm text-blue-800 dark:text-blue-300 font-semibold mb-1">
|
||||||
|
Inicio de Turno
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||||
|
Ingresa el monto inicial en efectivo con el que comenzarás tu turno.
|
||||||
|
Este monto se utilizará como base para el corte de caja al finalizar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monto Inicial -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Monto Inicial en Efectivo *
|
||||||
|
</label>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-700 rounded-xl p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="text-4xl font-bold text-gray-900 dark:text-gray-100">$</span>
|
||||||
|
<input
|
||||||
|
v-model.number="initialCash"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
class="flex-1 text-4xl font-bold bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
Ejemplo: Si comienzas con $1,000.00 en efectivo para dar cambio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="bg-linear-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border border-green-200 dark:border-green-800 rounded-xl p-5">
|
||||||
|
<div class="grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-green-700 dark:text-green-400 uppercase font-semibold tracking-wide mb-2">
|
||||||
|
Efectivo Inicial
|
||||||
|
</p>
|
||||||
|
<p class="text-3xl font-bold text-green-900 dark:text-green-100">
|
||||||
|
${{ (initialCash || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-green-700 dark:text-green-400 uppercase font-semibold tracking-wide mb-2">
|
||||||
|
Fecha y Hora
|
||||||
|
</p>
|
||||||
|
<p class="text-base font-semibold text-green-800 dark:text-green-300">
|
||||||
|
{{ new Date().toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-green-700 dark:text-green-400">
|
||||||
|
{{ new Date().toLocaleTimeString('es-MX', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="handleClose"
|
||||||
|
class="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading || initialCash <= 0"
|
||||||
|
class="flex items-center gap-2 px-6 py-3 bg-green-600 hover:bg-green-700 text-white text-sm font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-600 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-green-600/30 transition-all"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="lock_open" class="text-xl" />
|
||||||
|
<span v-if="loading">Abriendo...</span>
|
||||||
|
<span v-else>Abrir Caja</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
127
src/pages/POS/Category/CreateModal.vue
Normal file
127
src/pages/POS/Category/CreateModal.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useForm, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
|
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['close', 'created']);
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Formulario */
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const createCategory = () => {
|
||||||
|
form.post(apiURL('categorias'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
Notify.success('Categoría creada exitosamente');
|
||||||
|
emit('created');
|
||||||
|
closeModal();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error('Error al crear la categoría');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Categoría
|
||||||
|
</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 categoría"
|
||||||
|
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 categoría"
|
||||||
|
></textarea>
|
||||||
|
<FormError :message="form.errors?.description" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estado -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
ESTADO
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="form.is_active"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option :value="true">Activo</option>
|
||||||
|
<option :value="false">Inactivo</option>
|
||||||
|
</select>
|
||||||
|
<FormError :message="form.errors?.is_active" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botones -->
|
||||||
|
<div class="flex items-center justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeModal"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="px-4 py-2 text-sm font-semibold text-white bg-gray-900 rounded-lg hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<span v-if="form.processing">Guardando...</span>
|
||||||
|
<span v-else>Guardar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
117
src/pages/POS/Category/DeleteModal.vue
Normal file
117
src/pages/POS/Category/DeleteModal.vue
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<script setup>
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emits */
|
||||||
|
const emit = defineEmits(['close', 'confirm']);
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleConfirm = () => {
|
||||||
|
emit('confirm', props.category.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="md" @close="handleClose">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30">
|
||||||
|
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Eliminar Categoría
|
||||||
|
</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 categoría?
|
||||||
|
</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 categoría perderán su categorizació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 Categoría
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
138
src/pages/POS/Category/EditModal.vue
Normal file
138
src/pages/POS/Category/EditModal.vue
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<script setup>
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useForm, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import FormInput from '@Holos/Form/Input.vue';
|
||||||
|
import FormError from '@Holos/Form/Elements/Error.vue';
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['close', 'updated']);
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
category: Object
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Formulario */
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const updateCategory = () => {
|
||||||
|
form.put(apiURL(`categorias/${props.category.id}`), {
|
||||||
|
onSuccess: () => {
|
||||||
|
Notify.success('Categoría actualizada exitosamente');
|
||||||
|
emit('updated');
|
||||||
|
closeModal();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error('Error al actualizar la categoría');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 Categoría
|
||||||
|
</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 categoría"
|
||||||
|
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 categoría"
|
||||||
|
></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>
|
||||||
200
src/pages/POS/Category/Index.vue
Normal file
200
src/pages/POS/Category/Index.vue
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
|
import Notify from '@Plugins/Notify';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** 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 || [];
|
||||||
|
},
|
||||||
|
onError: () => models.value = []
|
||||||
|
});
|
||||||
|
|
||||||
|
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('Categoría eliminada exitosamente');
|
||||||
|
closeDeleteModal();
|
||||||
|
searcher.search();
|
||||||
|
} else {
|
||||||
|
Notify.error('Error al eliminar la categoría');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
Notify.error('Error al eliminar la categoría');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 Categoría
|
||||||
|
</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="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 categoría"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
232
src/pages/POS/Inventory/CreateModal.vue
Normal file
232
src/pages/POS/Inventory/CreateModal.vue
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, 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', 'created']);
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const categories = ref([]);
|
||||||
|
|
||||||
|
/** Formulario */
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
sku: '',
|
||||||
|
category_id: '',
|
||||||
|
stock: 0,
|
||||||
|
cost: 0,
|
||||||
|
retail_price: 0,
|
||||||
|
tax: 16
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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 createProduct = () => {
|
||||||
|
form.post(apiURL('inventario'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
Notify.success('Producto creado exitosamente');
|
||||||
|
emit('created');
|
||||||
|
closeModal();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error('Error al crear el producto');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
form.reset();
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Observadores */
|
||||||
|
watch(() => props.show, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
loadCategories();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Categoría -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
CATEGORÍA
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="form.category_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="">Seleccionar categoría</option>
|
||||||
|
<option
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.id"
|
||||||
|
:value="category.id"
|
||||||
|
>
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<FormError :message="form.errors?.category_id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stock Inicial -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
STOCK INICIAL
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model.number="form.stock"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.stock" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Costo -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
COSTO
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model.number="form.cost"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.cost" />
|
||||||
|
</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-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>
|
||||||
115
src/pages/POS/Inventory/DeleteModal.vue
Normal file
115
src/pages/POS/Inventory/DeleteModal.vue
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<script setup>
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
product: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emits */
|
||||||
|
const emit = defineEmits(['close', 'confirm']);
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleConfirm = () => {
|
||||||
|
emit('confirm', props.product.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :show="show" max-width="md" @close="handleClose">
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30">
|
||||||
|
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Eliminar Producto
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleClose"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="space-y-5">
|
||||||
|
<p class="text-gray-700 dark:text-gray-300 text-base">
|
||||||
|
¿Estás seguro de que deseas eliminar este producto?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="product" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-3">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-base font-bold text-gray-900 dark:text-gray-100 mb-1">
|
||||||
|
{{ product.name }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="font-mono font-medium">SKU: {{ product.sku }}</span>
|
||||||
|
<span class="text-gray-400">•</span>
|
||||||
|
<span>{{ product.category?.name || 'Sin categoría' }}</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>
|
||||||
245
src/pages/POS/Inventory/EditModal.vue
Normal file
245
src/pages/POS/Inventory/EditModal.vue
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, 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,
|
||||||
|
product: Object
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const categories = ref([]);
|
||||||
|
|
||||||
|
/** Formulario */
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
sku: '',
|
||||||
|
category_id: '',
|
||||||
|
stock: 0,
|
||||||
|
cost: 0,
|
||||||
|
retail_price: 0,
|
||||||
|
tax: 16
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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 updateProduct = () => {
|
||||||
|
form.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();
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Observadores */
|
||||||
|
watch(() => props.product, (newProduct) => {
|
||||||
|
if (newProduct) {
|
||||||
|
form.name = newProduct.name || '';
|
||||||
|
form.sku = newProduct.sku || '';
|
||||||
|
form.category_id = newProduct.category_id || '';
|
||||||
|
form.stock = newProduct.stock || 0;
|
||||||
|
form.cost = parseFloat(newProduct.price?.cost || 0);
|
||||||
|
form.retail_price = parseFloat(newProduct.price?.retail_price || 0);
|
||||||
|
form.tax = parseFloat(newProduct.price?.tax || 16);
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(() => props.show, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
loadCategories();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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 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="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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Categoría -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
CATEGORÍA
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="form.category_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="">Seleccionar categoría</option>
|
||||||
|
<option
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.id"
|
||||||
|
:value="category.id"
|
||||||
|
>
|
||||||
|
{{ category.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<FormError :message="form.errors?.category_id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stock -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
STOCK
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model.number="form.stock"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.stock" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Costo -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase mb-1.5">
|
||||||
|
COSTO
|
||||||
|
</label>
|
||||||
|
<FormInput
|
||||||
|
v-model.number="form.cost"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError :message="form.errors?.cost" />
|
||||||
|
</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-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>
|
||||||
220
src/pages/POS/Inventory/Index.vue
Normal file
220
src/pages/POS/Inventory/Index.vue
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const models = ref([]);
|
||||||
|
const showCreateModal = ref(false);
|
||||||
|
const showEditModal = ref(false);
|
||||||
|
const showDeleteModal = ref(false);
|
||||||
|
const editingProduct = ref(null);
|
||||||
|
const deletingProduct = ref(null);
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiURL('inventario'),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
models.value = r.products || [];
|
||||||
|
},
|
||||||
|
onError: () => models.value = []
|
||||||
|
});
|
||||||
|
|
||||||
|
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 onProductSaved = () => {
|
||||||
|
searcher.search();
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
searcher.search();
|
||||||
|
} else {
|
||||||
|
Notify.error('Error al eliminar el producto');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
Notify.error('Error al eliminar el producto');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ciclos */
|
||||||
|
onMounted(() => {
|
||||||
|
searcher.search();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearcherHead
|
||||||
|
:title="$t('inventory.title')"
|
||||||
|
placeholder="Buscar por nombre o SKU..."
|
||||||
|
@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" />
|
||||||
|
Nuevo Producto
|
||||||
|
</button>
|
||||||
|
</SearcherHead>
|
||||||
|
|
||||||
|
<!-- Modal de Crear Producto -->
|
||||||
|
<CreateModal
|
||||||
|
:show="showCreateModal"
|
||||||
|
@close="closeCreateModal"
|
||||||
|
@created="onProductSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de Editar Producto -->
|
||||||
|
<EditModal
|
||||||
|
:show="showEditModal"
|
||||||
|
:product="editingProduct"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@updated="onProductSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de Eliminar Producto -->
|
||||||
|
<DeleteModal
|
||||||
|
:show="showDeleteModal"
|
||||||
|
:product="deletingProduct"
|
||||||
|
@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">SKU / CÓDIGO</th>
|
||||||
|
<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-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">CATEGORÍA</th>
|
||||||
|
<th class="px-6 py-3 text-right 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">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 text-gray-600 dark:text-gray-400">{{ model.sku }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ model.name }}</p>
|
||||||
|
<p v-if="model.description" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ model.description }}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ model.category?.name || '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
${{ parseFloat(model.price?.retail_price || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Costo: ${{ parseFloat(model.price?.cost || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }}
|
||||||
|
</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>
|
||||||
|
</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 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
|
||||||
|
title="Editar producto"
|
||||||
|
>
|
||||||
|
<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 producto"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="delete" class="text-xl" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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="inventory_2"
|
||||||
|
class="text-6xl mb-2 opacity-50"
|
||||||
|
/>
|
||||||
|
<p class="font-semibold">
|
||||||
|
{{ $t('registers.empty') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
310
src/pages/POS/Point.vue
Normal file
310
src/pages/POS/Point.vue
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
|
import { page } from '@Services/Page';
|
||||||
|
import useCart from '@Stores/cart';
|
||||||
|
import salesService from '@Services/salesService';
|
||||||
|
import ticketService from '@Services/ticketService';
|
||||||
|
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import ProductCard from '@Components/POS/ProductCard.vue';
|
||||||
|
import CartItem from '@Components/POS/CartItem.vue';
|
||||||
|
import CheckoutModal from '@Components/POS/CheckoutModal.vue';
|
||||||
|
|
||||||
|
/** i18n */
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
/** Stores */
|
||||||
|
const cart = useCart();
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const products = ref([]);
|
||||||
|
const showCheckoutModal = ref(false);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const processingPayment = ref(false);
|
||||||
|
|
||||||
|
/** Buscador de productos */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiURL('inventario'),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
products.value = r.products?.data || [];
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
products.value = [];
|
||||||
|
window.Notify.error(t('error.loading'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Computados */
|
||||||
|
const filteredProducts = computed(() => {
|
||||||
|
if (!searchQuery.value) return products.value;
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
return products.value.filter(product => {
|
||||||
|
return (
|
||||||
|
product.name?.toLowerCase().includes(query) ||
|
||||||
|
product.sku?.toLowerCase().includes(query) ||
|
||||||
|
product.description?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const addToCart = (product) => {
|
||||||
|
try {
|
||||||
|
cart.addProduct(product);
|
||||||
|
window.Notify.success(`${product.name} agregado al carrito`);
|
||||||
|
} catch (error) {
|
||||||
|
window.Notify.error(error.message || 'Error al agregar producto');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearCart = () => {
|
||||||
|
if (confirm(t('cart.clearConfirm'))) {
|
||||||
|
cart.clear();
|
||||||
|
window.Notify.success('Carrito vaciado');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCheckout = () => {
|
||||||
|
if (cart.isEmpty) {
|
||||||
|
window.Notify.error('El carrito está vacío');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showCheckoutModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCheckout = () => {
|
||||||
|
showCheckoutModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSale = async (paymentMethod) => {
|
||||||
|
processingPayment.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Establecer método de pago
|
||||||
|
cart.setPaymentMethod(paymentMethod);
|
||||||
|
|
||||||
|
// Preparar datos de la venta
|
||||||
|
const saleData = {
|
||||||
|
user_id: page.user.id,
|
||||||
|
subtotal: parseFloat(cart.subtotal.toFixed(2)),
|
||||||
|
tax: parseFloat(cart.tax.toFixed(2)),
|
||||||
|
total: parseFloat(cart.total.toFixed(2)),
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
items: cart.items.map(item => ({
|
||||||
|
inventory_id: item.inventory_id,
|
||||||
|
product_name: item.product_name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
subtotal: parseFloat((item.quantity * item.unit_price).toFixed(2))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crear venta
|
||||||
|
window.Notify.info('Procesando venta...');
|
||||||
|
const response = await salesService.createSale(saleData);
|
||||||
|
|
||||||
|
// Éxito
|
||||||
|
window.Notify.success('¡Venta realizada exitosamente!');
|
||||||
|
|
||||||
|
if (response && response.id) {
|
||||||
|
try {
|
||||||
|
// Mostrar notificación de que se está generando el ticket
|
||||||
|
window.Notify.info('Generando ticket...');
|
||||||
|
|
||||||
|
// Generar ticket PDF con descarga automática
|
||||||
|
await ticketService.generateSaleTicket(response, {
|
||||||
|
businessName: 'HIKVISION DISTRIBUIDOR',
|
||||||
|
autoDownload: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notificación de éxito
|
||||||
|
window.Notify.success('Ticket descargado correctamente');
|
||||||
|
} catch (ticketError) {
|
||||||
|
console.error('Error generando ticket:', ticketError);
|
||||||
|
window.Notify.warning('Venta registrada, pero hubo un error al generar el ticket');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpiar carrito
|
||||||
|
cart.clear();
|
||||||
|
|
||||||
|
// Cerrar modal
|
||||||
|
showCheckoutModal.value = false;
|
||||||
|
|
||||||
|
// Recargar productos para actualizar stock
|
||||||
|
searcher.search();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en venta:', error);
|
||||||
|
window.Notify.error(error.message || t('cart.error'));
|
||||||
|
} finally {
|
||||||
|
processingPayment.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ciclo de vida */
|
||||||
|
onMounted(() => {
|
||||||
|
searcher.search();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 mb-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<GoogleIcon name="point_of_sale" class="text-3xl text-indigo-600 dark:text-indigo-400" />
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $t('pos.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $t('pos.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenido Principal -->
|
||||||
|
<div class="flex gap-6 h-[calc(100%-120px)] px-6">
|
||||||
|
<!-- Columna de Productos -->
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<!-- Buscador -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar productos por nombre, SKU o código..."
|
||||||
|
class="w-full px-4 py-3 pl-12 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
<GoogleIcon
|
||||||
|
name="search"
|
||||||
|
class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid de Productos -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<div v-if="filteredProducts.length === 0" class="flex items-center justify-center h-full">
|
||||||
|
<div class="text-center">
|
||||||
|
<GoogleIcon name="inventory_2" class="text-6xl text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
|
{{ searchQuery ? 'No se encontraron productos' : 'No hay productos disponibles' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-4">
|
||||||
|
<ProductCard
|
||||||
|
v-for="product in filteredProducts"
|
||||||
|
:key="product.id"
|
||||||
|
:product="product"
|
||||||
|
@add-to-cart="addToCart"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel del Carrito -->
|
||||||
|
<div class="w-96 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 flex flex-col">
|
||||||
|
<!-- Header del Carrito -->
|
||||||
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<GoogleIcon name="shopping_cart" class="text-2xl text-indigo-600" />
|
||||||
|
{{ $t('cart.title') }}
|
||||||
|
</h2>
|
||||||
|
<span class="px-3 py-1 bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 text-sm font-semibold rounded-full">
|
||||||
|
{{ cart.itemCount }} {{ cart.itemCount === 1 ? 'item' : 'items' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items del Carrito -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
<div v-if="cart.isEmpty" class="flex flex-col items-center justify-center h-full text-center py-12">
|
||||||
|
<GoogleIcon name="shopping_cart" class="text-6xl text-gray-300 dark:text-gray-600 mb-4" />
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
{{ $t('cart.empty') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
||||||
|
{{ $t('cart.emptyMessage') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CartItem
|
||||||
|
v-for="item in cart.items"
|
||||||
|
:key="item.inventory_id"
|
||||||
|
:item="item"
|
||||||
|
@update-quantity="cart.updateQuantity"
|
||||||
|
@remove="cart.removeProduct"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resumen y Botones -->
|
||||||
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-4">
|
||||||
|
<!-- Totales -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span>{{ $t('cart.subtotal') }}</span>
|
||||||
|
<span class="font-semibold">${{ cart.subtotal.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span>IVA (16%)</span>
|
||||||
|
<span class="font-semibold">${{ cart.tax.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-base font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{{ $t('cart.total') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||||
|
${{ cart.total.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botones de Acción -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white text-base font-semibold rounded-lg transition-colors shadow-lg"
|
||||||
|
:disabled="cart.isEmpty"
|
||||||
|
@click="openCheckout"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="point_of_sale" class="text-xl" />
|
||||||
|
{{ $t('cart.checkout') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-4 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:text-gray-400 disabled:cursor-not-allowed text-sm font-semibold rounded-lg transition-colors"
|
||||||
|
:disabled="cart.isEmpty"
|
||||||
|
@click="handleClearCart"
|
||||||
|
>
|
||||||
|
<GoogleIcon name="delete_sweep" class="text-lg" />
|
||||||
|
{{ $t('cart.clear') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de Checkout -->
|
||||||
|
<CheckoutModal
|
||||||
|
:show="showCheckoutModal"
|
||||||
|
:cart="cart"
|
||||||
|
:processing="processingPayment"
|
||||||
|
@close="closeCheckout"
|
||||||
|
@confirm="handleConfirmSale"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
337
src/pages/POS/Sales/DetailModal.vue
Normal file
337
src/pages/POS/Sales/DetailModal.vue
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import ticketService from '@Services/ticketService';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import Modal from '@Holos/Modal.vue';
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
sale: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Emits */
|
||||||
|
const emit = defineEmits(['close', 'cancel-sale']);
|
||||||
|
|
||||||
|
/** Computados */
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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>
|
||||||
|
<p v-if="item.inventory?.sku" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
SKU: {{ item.inventory.sku }}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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.5 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="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>
|
||||||
|
</template>
|
||||||
240
src/pages/POS/Sales/Index.vue
Normal file
240
src/pages/POS/Sales/Index.vue
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { useSearcher, apiURL } from '@Services/Api';
|
||||||
|
import ticketService from '@Services/ticketService';
|
||||||
|
|
||||||
|
import SearcherHead from '@Holos/Searcher.vue';
|
||||||
|
import Table from '@Holos/Table.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import SaleDetailModal from './DetailModal.vue';
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const models = ref([]);
|
||||||
|
const showDetailModal = ref(false);
|
||||||
|
const selectedSale = ref(null);
|
||||||
|
|
||||||
|
/** Buscador de ventas */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiURL('sales'),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
models.value = r.sales || [];
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
models.value = [];
|
||||||
|
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 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 formatCurrency = (amount) => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN'
|
||||||
|
}).format(amount || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return new Intl.DateTimeFormat('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaymentMethodLabel = (method) => {
|
||||||
|
const methods = {
|
||||||
|
'cash': 'Efectivo',
|
||||||
|
'credit_card': 'Tarjeta de Crédito',
|
||||||
|
'debit_card': 'Tarjeta de Débito'
|
||||||
|
};
|
||||||
|
return 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, {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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)"
|
||||||
|
>
|
||||||
|
<!-- Se puede agregar filtros de fecha aquí si se desea -->
|
||||||
|
</SearcherHead>
|
||||||
|
|
||||||
|
<!-- Modal de Detalle -->
|
||||||
|
<SaleDetailModal
|
||||||
|
:show="showDetailModal"
|
||||||
|
:sale="selectedSale"
|
||||||
|
@close="closeDetailModal"
|
||||||
|
@cancel-sale="handleCancelSale"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
#{{ String(model.id).padStart(6, '0') }}
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
@ -28,6 +28,51 @@ const router = createRouter({
|
|||||||
name: 'dashboard.index',
|
name: 'dashboard.index',
|
||||||
component: () => import('@Pages/Dashboard/Index.vue')
|
component: () => import('@Pages/Dashboard/Index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'pos',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'category',
|
||||||
|
name: 'pos.category.index',
|
||||||
|
component: () => import('@Pages/POS/Category/Index.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'inventory',
|
||||||
|
name: 'pos.inventory.index',
|
||||||
|
component: () => import('@Pages/POS/Inventory/Index.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'point',
|
||||||
|
name: 'pos.point',
|
||||||
|
component: () => import('@Pages/POS/Point.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cash-register',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'pos.cashRegister.index',
|
||||||
|
component: () => import('@Pages/POS/CashRegister/Index.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'history',
|
||||||
|
name: 'pos.cashRegister.history',
|
||||||
|
component: () => import('@Pages/POS/CashRegister/History.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
name: 'pos.cashRegister.detail',
|
||||||
|
component: () => import('@Pages/POS/CashRegister/Detail.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sales',
|
||||||
|
name: 'pos.sales.index',
|
||||||
|
component: () => import('@Pages/POS/Sales/Index.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
126
src/services/cashRegisterService.js
Normal file
126
src/services/cashRegisterService.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { api, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio para gestionar cajas registradoras
|
||||||
|
*/
|
||||||
|
const cashRegisterService = {
|
||||||
|
/**
|
||||||
|
* Helper para extraer datos del registro de diferentes formatos de respuesta
|
||||||
|
*/
|
||||||
|
extractRegisterData(response) {
|
||||||
|
return response.register ||
|
||||||
|
response.cashRegister ||
|
||||||
|
response.cash_register ||
|
||||||
|
response.model ||
|
||||||
|
response;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abrir una nueva caja registradora
|
||||||
|
* @param {Number} initialCash - Monto inicial en efectivo
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async openCashRegister(initialCash) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.post(apiURL('cash-registers/open'), {
|
||||||
|
data: { initial_cash: initialCash },
|
||||||
|
onSuccess: (response) => resolve(this.extractRegisterData(response)),
|
||||||
|
onError: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener la caja registradora actualmente abierta
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getCurrentCashRegister() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL('cash-registers/current'), {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const register = this.extractRegisterData(response);
|
||||||
|
|
||||||
|
// Mapear campos del backend a nombres esperados por el frontend
|
||||||
|
if (response.total_sales !== undefined) {
|
||||||
|
register.total_sales = response.total_sales;
|
||||||
|
}
|
||||||
|
if (response.total_cash !== undefined) {
|
||||||
|
register.cash_sales = response.total_cash;
|
||||||
|
}
|
||||||
|
if (response.sales_count !== undefined) {
|
||||||
|
register.transaction_count = response.sales_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular card_sales sumando credit_card + debit_card
|
||||||
|
const creditCard = parseFloat(response.total_credit_card || 0);
|
||||||
|
const debitCard = parseFloat(response.total_debit_card || 0);
|
||||||
|
if (creditCard > 0 || debitCard > 0) {
|
||||||
|
register.card_sales = creditCard + debitCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mantener payment_summary si existe
|
||||||
|
if (response.payment_summary) {
|
||||||
|
register.payment_summary = response.payment_summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(register);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
// Si es 404, significa que no hay caja abierta (no es un error)
|
||||||
|
if (error.status === 404 || error.response?.status === 404) {
|
||||||
|
resolve(null);
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cerrar una caja registradora con el corte de caja
|
||||||
|
* @param {Number} id - ID de la caja registradora
|
||||||
|
* @param {Object} closeData - Datos del cierre (final_cash, notes, etc.)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async closeCashRegister(id, closeData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.put(apiURL(`cash-registers/${id}/close`), {
|
||||||
|
data: closeData,
|
||||||
|
onSuccess: (response) => resolve(this.extractRegisterData(response)),
|
||||||
|
onError: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener historial de cajas registradoras
|
||||||
|
* @param {Object} filters - Filtros de búsqueda (fecha, usuario, etc.)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getCashRegisterHistory(filters = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL('cash-registers'), {
|
||||||
|
params: filters,
|
||||||
|
onSuccess: (response) => resolve(response.registers || response.cashRegisters || response.cash_registers || response),
|
||||||
|
onError: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener detalle de una caja registradora específica
|
||||||
|
* @param {Number} id - ID de la caja registradora
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getCashRegisterDetail(id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL(`cash-registers/${id}`), {
|
||||||
|
onSuccess: (response) => resolve(this.extractRegisterData(response)),
|
||||||
|
onError: reject
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default cashRegisterService;
|
||||||
82
src/services/salesService.js
Normal file
82
src/services/salesService.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { api, apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio para gestionar ventas
|
||||||
|
*/
|
||||||
|
const salesService = {
|
||||||
|
/**
|
||||||
|
* Crear una nueva venta
|
||||||
|
* @param {Object} saleData - Datos de la venta
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async createSale(saleData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.post(apiURL('sales'), {
|
||||||
|
data: saleData,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response.model);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener lista de ventas con filtros
|
||||||
|
* @param {Object} filters - Filtros de búsqueda
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getSales(filters = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL('sales'), {
|
||||||
|
params: filters,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response.sales);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener detalle de una venta
|
||||||
|
* @param {Number} saleId - ID de la venta
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async getSaleDetails(saleId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.get(apiURL(`sales/${saleId}`), {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response.model);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelar una venta (restaura stock)
|
||||||
|
* @param {Number} saleId - ID de la venta
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async cancelSale(saleId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.put(apiURL(`sales/${saleId}/cancel`), {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
resolve(response.model);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default salesService;
|
||||||
397
src/services/ticketService.js
Normal file
397
src/services/ticketService.js
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
import jsPDF from 'jspdf';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Servicio para generar tickets de venta en formato PDF
|
||||||
|
*/
|
||||||
|
const ticketService = {
|
||||||
|
/**
|
||||||
|
* Helper para formatear moneda
|
||||||
|
*/
|
||||||
|
formatMoney(amount) {
|
||||||
|
return parseFloat(amount || 0).toFixed(2);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detectar ubicación del usuario (ciudad y estado)
|
||||||
|
*/
|
||||||
|
async getUserLocation() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://ipapi.co/json/');
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
city: data.city || 'Ciudad de México',
|
||||||
|
state: data.region || 'CDMX',
|
||||||
|
country: data.country_name || 'México'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
city: 'Ciudad de México',
|
||||||
|
state: 'CDMX',
|
||||||
|
country: 'México'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar ticket de venta
|
||||||
|
* @param {Object} saleData - Datos de la venta
|
||||||
|
* @param {Object} options - Opciones de configuración
|
||||||
|
*/
|
||||||
|
async generateSaleTicket(saleData, options = {}) {
|
||||||
|
const {
|
||||||
|
businessName = 'HIKVISION DISTRIBUIDOR',
|
||||||
|
autoDownload = true
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Detectar ubicación del usuario
|
||||||
|
const location = await this.getUserLocation();
|
||||||
|
const businessAddress = `${location.city}, ${location.state}`;
|
||||||
|
const businessPhone = 'Tel: (55) 1234-5678';
|
||||||
|
|
||||||
|
// Crear documento PDF - Ticket térmico 80mm de ancho
|
||||||
|
const doc = new jsPDF({
|
||||||
|
orientation: 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: [80, 297]
|
||||||
|
});
|
||||||
|
|
||||||
|
let yPosition = 10;
|
||||||
|
const leftMargin = 5;
|
||||||
|
const rightMargin = 75;
|
||||||
|
const centerX = 40;
|
||||||
|
const lineHeight = 5;
|
||||||
|
|
||||||
|
// Colores
|
||||||
|
const blackColor = [0, 0, 0];
|
||||||
|
const darkGrayColor = [80, 80, 80];
|
||||||
|
|
||||||
|
// ===== HEADER =====
|
||||||
|
// Nombre del negocio
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text(businessName, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Dirección y teléfono
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text(businessAddress, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 4;
|
||||||
|
doc.text(businessPhone, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Línea separadora
|
||||||
|
doc.setDrawColor(...darkGrayColor);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// ===== TÍTULO =====
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text('TICKET DE VENTA', centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 7;
|
||||||
|
|
||||||
|
// ===== INFORMACIÓN DE LA VENTA =====
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
|
||||||
|
// Folio
|
||||||
|
const folio = saleData.invoice_number || `INV-${String(saleData.id || '').padStart(12, '0')}`;
|
||||||
|
doc.text(`Folio: ${folio}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Fecha
|
||||||
|
const saleDate = new Date(saleData.created_at || Date.now());
|
||||||
|
const formattedDate = saleDate.toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
doc.text(`Fecha: ${formattedDate}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Hora
|
||||||
|
const formattedTime = saleDate.toLocaleTimeString('es-MX', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
doc.text(`Hora: ${formattedTime} p.m.`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Cajero
|
||||||
|
if (saleData.user?.name) {
|
||||||
|
doc.text(`Cajero: ${saleData.user.name}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Método de pago
|
||||||
|
const paymentMethods = {
|
||||||
|
cash: 'Efectivo',
|
||||||
|
credit_card: 'Tarjeta de Crédito',
|
||||||
|
debit_card: 'Tarjeta de Débito'
|
||||||
|
};
|
||||||
|
const paymentLabel = paymentMethods[saleData.payment_method] || saleData.payment_method;
|
||||||
|
doc.text(`Pago: ${paymentLabel}`, leftMargin, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Línea separadora
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// ===== PRODUCTOS =====
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text('PRODUCTOS', centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Header de tabla
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text('DESCRIPCIÓN', leftMargin, yPosition);
|
||||||
|
doc.text('CANT', 52, yPosition, { align: 'center' });
|
||||||
|
doc.text('PRECIO', rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Línea bajo header
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
|
||||||
|
// Iterar sobre los productos
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
const items = saleData.details || saleData.items || [];
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
// Nombre del producto
|
||||||
|
const productName = item.product_name || item.name || 'Producto';
|
||||||
|
const nameLines = doc.splitTextToSize(productName, 45);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(nameLines, leftMargin, yPosition);
|
||||||
|
yPosition += nameLines.length * 3.5;
|
||||||
|
|
||||||
|
// SKU (opcional)
|
||||||
|
if (item.inventory?.sku || item.sku) {
|
||||||
|
doc.setFontSize(7);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text(`SKU: ${item.inventory?.sku || item.sku}`, leftMargin, yPosition);
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cantidad y Precio unitario
|
||||||
|
const quantity = item.quantity || 1;
|
||||||
|
const unitPrice = this.formatMoney(item.unit_price);
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
|
||||||
|
doc.text(String(quantity), 52, yPosition, { align: 'center' });
|
||||||
|
doc.text(`$${unitPrice}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
|
||||||
|
yPosition += 5;
|
||||||
|
});
|
||||||
|
|
||||||
|
yPosition += 2;
|
||||||
|
|
||||||
|
// Línea separadora
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.setDrawColor(...blackColor);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// ===== TOTALES =====
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
|
||||||
|
// Subtotal
|
||||||
|
doc.text('Subtotal:', leftMargin, yPosition);
|
||||||
|
doc.text(`$${this.formatMoney(saleData.subtotal)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// IVA
|
||||||
|
doc.text('IVA (16%):', leftMargin, yPosition);
|
||||||
|
doc.text(`$${this.formatMoney(saleData.tax)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Línea antes del total
|
||||||
|
doc.setLineWidth(0.4);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Total
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text('TOTAL:', leftMargin, yPosition);
|
||||||
|
doc.text(`$${this.formatMoney(saleData.total)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 8;
|
||||||
|
|
||||||
|
// Línea decorativa doble
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 0.5;
|
||||||
|
doc.setLineWidth(0.2);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// ===== FOOTER =====
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text('¡Gracias por su compra!', centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text('Vuelva pronto', centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Información de impresión
|
||||||
|
doc.setFontSize(7);
|
||||||
|
const currentDate = new Date().toLocaleString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
doc.text(`Impreso: ${currentDate}`, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 3;
|
||||||
|
doc.text(`ID de venta: ${saleData.id || 'N/A'}`, centerX, yPosition, { align: 'center' });
|
||||||
|
|
||||||
|
// Guardar o retornar el PDF
|
||||||
|
if (autoDownload) {
|
||||||
|
const fileName = `ticket-${folio}.pdf`;
|
||||||
|
doc.save(fileName);
|
||||||
|
return doc;
|
||||||
|
} else {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar ticket de corte de caja
|
||||||
|
* @param {Object} cashRegisterData - Datos del corte de caja
|
||||||
|
* @param {Object} options - Opciones de configuración
|
||||||
|
*/
|
||||||
|
async generateCashRegisterTicket(cashRegisterData, options = {}) {
|
||||||
|
const {
|
||||||
|
businessName = 'HIKVISION DISTRIBUIDOR',
|
||||||
|
autoDownload = true
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Detectar ubicación del usuario
|
||||||
|
const location = await this.getUserLocation();
|
||||||
|
|
||||||
|
const doc = new jsPDF({
|
||||||
|
orientation: 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: [80, 297]
|
||||||
|
});
|
||||||
|
|
||||||
|
let yPosition = 10;
|
||||||
|
const leftMargin = 5;
|
||||||
|
const rightMargin = 75;
|
||||||
|
const centerX = 40;
|
||||||
|
const lineHeight = 5;
|
||||||
|
|
||||||
|
// Colores
|
||||||
|
const blackColor = [0, 0, 0];
|
||||||
|
const darkGrayColor = [80, 80, 80];
|
||||||
|
|
||||||
|
// Header
|
||||||
|
doc.setFontSize(14);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text(businessName, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
doc.text(`${location.city}, ${location.state}`, centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Línea
|
||||||
|
doc.setDrawColor(...darkGrayColor);
|
||||||
|
doc.setLineWidth(0.3);
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
doc.text('CORTE DE CAJA', centerX, yPosition, { align: 'center' });
|
||||||
|
yPosition += 7;
|
||||||
|
|
||||||
|
// Información
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...darkGrayColor);
|
||||||
|
|
||||||
|
const closeDate = new Date(cashRegisterData.closed_at || Date.now());
|
||||||
|
doc.text(`Fecha: ${closeDate.toLocaleDateString('es-MX')}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
doc.text(`Hora: ${closeDate.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`, leftMargin, yPosition);
|
||||||
|
yPosition += 4;
|
||||||
|
doc.text(`Cajero: ${cashRegisterData.user?.name || 'N/A'}`, leftMargin, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Línea
|
||||||
|
doc.line(leftMargin, yPosition, rightMargin, yPosition);
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Detalle
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(...blackColor);
|
||||||
|
|
||||||
|
doc.text('Efectivo Inicial:', leftMargin, yPosition);
|
||||||
|
doc.text(`$${this.formatMoney(cashRegisterData.initial_cash)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
doc.text('Ventas Totales:', leftMargin, yPosition);
|
||||||
|
doc.text(`$${this.formatMoney(cashRegisterData.total_sales)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
doc.text('Efectivo Final:', leftMargin, yPosition);
|
||||||
|
doc.text(`$${this.formatMoney(cashRegisterData.final_cash)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
// Total
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Diferencia:', leftMargin, yPosition);
|
||||||
|
const difference = parseFloat(cashRegisterData.final_cash || 0) -
|
||||||
|
(parseFloat(cashRegisterData.initial_cash || 0) + parseFloat(cashRegisterData.cash_sales || 0));
|
||||||
|
doc.text(`$${this.formatMoney(difference)}`, rightMargin, yPosition, { align: 'right' });
|
||||||
|
yPosition += 8;
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
doc.setFontSize(9);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Cierre de turno', centerX, yPosition, { align: 'center' });
|
||||||
|
|
||||||
|
if (autoDownload) {
|
||||||
|
doc.save(`corte-caja-${cashRegisterData.id}.pdf`);
|
||||||
|
return doc;
|
||||||
|
} else {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ticketService;
|
||||||
102
src/stores/cart.js
Normal file
102
src/stores/cart.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import Notify from '@Plugins/Notify';
|
||||||
|
|
||||||
|
const useCart = defineStore('cart', {
|
||||||
|
state: () => ({
|
||||||
|
items: [], // Productos en el carrito
|
||||||
|
paymentMethod: 'cash' // Método de pago por defecto
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
// Total de items
|
||||||
|
itemCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||||
|
|
||||||
|
// Subtotal (sin impuestos)
|
||||||
|
subtotal: (state) => state.items.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0),
|
||||||
|
|
||||||
|
// Total de impuestos
|
||||||
|
tax: (state) => {
|
||||||
|
return state.items.reduce((sum, item) => {
|
||||||
|
const itemSubtotal = item.unit_price * item.quantity;
|
||||||
|
const itemTax = (itemSubtotal * item.tax_rate) / 100;
|
||||||
|
return sum + itemTax;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Total final
|
||||||
|
total: (state) => {
|
||||||
|
const subtotal = state.items.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||||
|
const tax = state.items.reduce((sum, item) => {
|
||||||
|
const itemSubtotal = item.unit_price * item.quantity;
|
||||||
|
const itemTax = (itemSubtotal * item.tax_rate) / 100;
|
||||||
|
return sum + itemTax;
|
||||||
|
}, 0);
|
||||||
|
return subtotal + tax;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verificar si el carrito está vacío
|
||||||
|
isEmpty: (state) => state.items.length === 0
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// Agregar producto al carrito
|
||||||
|
addProduct(product) {
|
||||||
|
const existingItem = this.items.find(item => item.inventory_id === product.id);
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
// Si ya existe, incrementar cantidad
|
||||||
|
if (existingItem.quantity < product.stock) {
|
||||||
|
existingItem.quantity++;
|
||||||
|
} else {
|
||||||
|
Notify.warning('No hay suficiente stock disponible');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Agregar nuevo item
|
||||||
|
this.items.push({
|
||||||
|
inventory_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
sku: product.sku,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: parseFloat(product.price?.retail_price || 0),
|
||||||
|
tax_rate: parseFloat(product.price?.tax || 16),
|
||||||
|
max_stock: product.stock
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actualizar cantidad de un item
|
||||||
|
updateQuantity(inventoryId, quantity) {
|
||||||
|
const item = this.items.find(i => i.inventory_id === inventoryId);
|
||||||
|
if (item) {
|
||||||
|
if (quantity <= 0) {
|
||||||
|
this.removeProduct(inventoryId);
|
||||||
|
} else if (quantity <= item.max_stock) {
|
||||||
|
item.quantity = quantity;
|
||||||
|
} else {
|
||||||
|
Notify.warning('No hay suficiente stock disponible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remover producto
|
||||||
|
removeProduct(inventoryId) {
|
||||||
|
const index = this.items.findIndex(i => i.inventory_id === inventoryId);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.items.splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Limpiar carrito
|
||||||
|
clear() {
|
||||||
|
this.items = [];
|
||||||
|
this.paymentMethod = 'cash';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cambiar método de pago
|
||||||
|
setPaymentMethod(method) {
|
||||||
|
this.paymentMethod = method;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useCart;
|
||||||
184
src/stores/cashRegister.js
Normal file
184
src/stores/cashRegister.js
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import cashRegisterService from '@Services/cashRegisterService';
|
||||||
|
|
||||||
|
const useCashRegister = defineStore('cashRegister', {
|
||||||
|
state: () => ({
|
||||||
|
currentRegister: null, // Caja actualmente abierta
|
||||||
|
isOpen: false, // Estado de la caja
|
||||||
|
loading: false, // Indicador de carga
|
||||||
|
error: null // Error al cargar
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
// Verificar si hay caja abierta
|
||||||
|
hasOpenRegister: (state) => state.isOpen && state.currentRegister !== null,
|
||||||
|
|
||||||
|
// ID de la caja actual
|
||||||
|
currentRegisterId: (state) => state.currentRegister?.id || null,
|
||||||
|
|
||||||
|
// Usuario que abrió la caja
|
||||||
|
openedBy: (state) => state.currentRegister?.user?.name || 'N/A',
|
||||||
|
|
||||||
|
// Fecha y hora de apertura
|
||||||
|
openedAt: (state) => state.currentRegister?.opened_at || null,
|
||||||
|
|
||||||
|
// Efectivo inicial
|
||||||
|
initialCash: (state) => parseFloat(state.currentRegister?.initial_cash || 0),
|
||||||
|
|
||||||
|
// Total de ventas realizadas
|
||||||
|
totalSales: (state) => parseFloat(state.currentRegister?.total_sales || 0),
|
||||||
|
|
||||||
|
// Número de transacciones
|
||||||
|
transactionCount: (state) => parseInt(state.currentRegister?.transaction_count || 0),
|
||||||
|
|
||||||
|
// Efectivo esperado (inicial + ventas en efectivo)
|
||||||
|
expectedCash: (state) => {
|
||||||
|
const initial = parseFloat(state.currentRegister?.initial_cash || 0);
|
||||||
|
const cashSales = parseFloat(state.currentRegister?.cash_sales || 0);
|
||||||
|
return initial + cashSales;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ventas con tarjeta
|
||||||
|
cardSales: (state) => parseFloat(state.currentRegister?.card_sales || 0),
|
||||||
|
|
||||||
|
// Balance actual estimado
|
||||||
|
currentBalance: (state) => {
|
||||||
|
const initial = parseFloat(state.currentRegister?.initial_cash || 0);
|
||||||
|
const total = parseFloat(state.currentRegister?.total_sales || 0);
|
||||||
|
return initial + total;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/**
|
||||||
|
* Cargar la caja registradora actual desde el backend
|
||||||
|
*/
|
||||||
|
async loadCurrentRegister() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const register = await cashRegisterService.getCurrentCashRegister();
|
||||||
|
|
||||||
|
// El servicio ya maneja el 404 y devuelve null
|
||||||
|
// También verifica que el status sea 'open'
|
||||||
|
if (register === null) {
|
||||||
|
this.currentRegister = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
} else if (register && register.id) {
|
||||||
|
// El servicio ya devuelve el objeto register directamente
|
||||||
|
this.currentRegister = register;
|
||||||
|
// Verificar que el status sea 'open'
|
||||||
|
this.isOpen = register.status === 'open';
|
||||||
|
} else {
|
||||||
|
this.currentRegister = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Si no hay caja abierta (404), no es un error
|
||||||
|
if (error.status === 404 || error.response?.status === 404) {
|
||||||
|
this.currentRegister = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
} else {
|
||||||
|
this.error = error.message || 'Error al cargar la caja registradora';
|
||||||
|
// Aún así limpiamos el estado
|
||||||
|
this.currentRegister = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abrir una nueva caja registradora
|
||||||
|
* @param {Number} initialCash - Monto inicial en efectivo
|
||||||
|
*/
|
||||||
|
async openRegister(initialCash) {
|
||||||
|
// Validar que no haya caja ya abierta
|
||||||
|
if (this.isOpen) {
|
||||||
|
this.error = 'Ya hay una caja abierta';
|
||||||
|
return { success: false, error: this.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const register = await cashRegisterService.openCashRegister(initialCash);
|
||||||
|
|
||||||
|
if (register && register.id) {
|
||||||
|
// El servicio ya devuelve el objeto register directamente
|
||||||
|
this.currentRegister = register;
|
||||||
|
this.isOpen = register.status === 'open';
|
||||||
|
return { success: true, data: register };
|
||||||
|
} else {
|
||||||
|
this.error = 'Respuesta inválida del servidor';
|
||||||
|
return { success: false, error: this.error };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.error = extractErrorMessage(error, 'Error al abrir la caja');
|
||||||
|
return { success: false, error: this.error };
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cerrar la caja registradora actual
|
||||||
|
* @param {Object} closeData - Datos del cierre (final_cash, notes)
|
||||||
|
*/
|
||||||
|
async closeRegister(closeData) {
|
||||||
|
if (!this.currentRegister?.id) {
|
||||||
|
this.error = 'No hay caja abierta para cerrar';
|
||||||
|
return { success: false, error: this.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const register = await cashRegisterService.closeCashRegister(
|
||||||
|
this.currentRegister.id,
|
||||||
|
closeData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (register) {
|
||||||
|
// Limpiar estado
|
||||||
|
this.currentRegister = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
return { success: true, data: register };
|
||||||
|
} else {
|
||||||
|
this.error = 'Respuesta inválida del servidor';
|
||||||
|
return { success: false, error: this.error };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.error = extractErrorMessage(error, 'Error al cerrar la caja');
|
||||||
|
return { success: false, error: this.error };
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar datos de la caja actual (útil después de una venta)
|
||||||
|
*/
|
||||||
|
async refreshCurrentRegister() {
|
||||||
|
if (this.isOpen) {
|
||||||
|
await this.loadCurrentRegister();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpiar el estado
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.currentRegister = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.loading = false;
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useCashRegister;
|
||||||
Loading…
x
Reference in New Issue
Block a user