This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-01 21:59:45 -06:00
parent f8cc26a497
commit 173f5417b3
38 changed files with 5487 additions and 43 deletions

230
package-lock.json generated
View File

@ -1,17 +1,18 @@
{
"name": "notsoweb.frontend",
"name": "pdv.frontend",
"version": "0.9.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "notsoweb.frontend",
"name": "pdv.frontend",
"version": "0.9.10",
"dependencies": {
"@tailwindcss/postcss": "^4.0.9",
"@tailwindcss/vite": "^4.0.9",
"@vitejs/plugin-vue": "^5.2.1",
"axios": "^1.8.1",
"jspdf": "^3.0.4",
"laravel-echo": "^2.0.2",
"luxon": "^3.5.0",
"pinia": "^3.0.1",
@ -89,6 +90,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
@ -1230,12 +1240,32 @@
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"license": "MIT"
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@ -1492,6 +1522,16 @@
"dev": true,
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz",
@ -1617,6 +1657,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -1744,6 +1804,28 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-js": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/css-select": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
@ -1860,6 +1942,16 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@ -2111,6 +2203,17 @@
"node": ">=8.6.0"
}
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -2135,6 +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": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@ -2423,6 +2532,26 @@
"node": ">=12"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -2515,6 +2644,23 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jspdf": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/laravel-echo": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.1.4.tgz",
@ -2983,6 +3129,12 @@
"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": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@ -3018,6 +3170,13 @@
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3140,6 +3299,23 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/relateurl": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
@ -3167,6 +3343,16 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.0.tgz",
@ -3299,6 +3485,16 @@
"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": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
@ -3324,6 +3520,16 @@
"node": ">=8"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
@ -3382,6 +3588,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
@ -3473,6 +3689,16 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "notsoweb.frontend",
"copyright": "Notsoweb Software Inc.",
"name": "pdv.frontend",
"copyright": "Golsystems",
"private": true,
"version": "0.9.10",
"type": "module",
@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.0.9",
"@vitejs/plugin-vue": "^5.2.1",
"axios": "^1.8.1",
"jspdf": "^3.0.4",
"laravel-echo": "^2.0.2",
"luxon": "^3.5.0",
"pinia": "^3.0.1",

BIN
public/Logo-hk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

View File

@ -19,6 +19,6 @@ const home = () => {
class="flex w-full justify-center items-center space-x-2 cursor-pointer"
@click="home"
>
<img :src="$page.app.logo" class="h-20" />
<img :src="'/public/Logo-hk.png'" class="h-16" />
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>

View 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>

View 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>

View File

@ -449,4 +449,114 @@ export default {
},
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'
},
}

View File

@ -31,13 +31,30 @@ onMounted(() => {
<Section name="Principal">
<Link
icon="monitoring"
name="dashboard"
name="dashboard"
to="dashboard.index"
/>
</Section>
<Section :name="$t('pos.title')">
<Link
icon="person"
name="profile"
to="profile.show"
icon="category"
name="pos.category"
to="pos.category.index"
/>
<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
@ -47,19 +64,19 @@ onMounted(() => {
<Link
v-if="hasPermission('users.index')"
icon="people"
name="users.title"
name="users.title"
to="admin.users.index"
/>
<Link
v-if="hasPermission('roles.index')"
icon="license"
name="roles.title"
name="roles.title"
to="admin.roles.index"
/>
<Link
v-if="hasPermission('activities.index')"
icon="event"
name="history.title"
name="history.title"
to="admin.activities.index"
/>
</Section>

View 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>

View 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>

View 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>

View File

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

View File

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

View File

@ -0,0 +1,127 @@
<script setup>
import { useForm, apiURL } from '@Services/Api';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
/** Eventos */
const emit = defineEmits(['close', 'created']);
/** Propiedades */
const props = defineProps({
show: Boolean
});
/** Formulario */
const form = useForm({
name: '',
description: '',
is_active: true
});
/** Métodos */
const createCategory = () => {
form.post(apiURL('categorias'), {
onSuccess: () => {
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>

View File

@ -0,0 +1,117 @@
<script setup>
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
category: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Métodos */
const handleConfirm = () => {
emit('confirm', props.category.id);
};
const handleClose = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Eliminar 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>

View File

@ -0,0 +1,138 @@
<script setup>
import { watch } from 'vue';
import { useForm, apiURL } from '@Services/Api';
import Modal from '@Holos/Modal.vue';
import FormInput from '@Holos/Form/Input.vue';
import FormError from '@Holos/Form/Elements/Error.vue';
/** Eventos */
const emit = defineEmits(['close', 'updated']);
/** Propiedades */
const props = defineProps({
show: Boolean,
category: Object
});
/** Formulario */
const form = useForm({
name: '',
description: '',
is_active: true
});
/** Métodos */
const updateCategory = () => {
form.put(apiURL(`categorias/${props.category.id}`), {
onSuccess: () => {
Notify.success('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>

View 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>

View 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>

View File

@ -0,0 +1,115 @@
<script setup>
import Modal from '@Holos/Modal.vue';
import GoogleIcon from '@Shared/GoogleIcon.vue';
/** Props */
const props = defineProps({
show: {
type: Boolean,
default: false
},
product: {
type: Object,
default: null
}
});
/** Emits */
const emit = defineEmits(['close', 'confirm']);
/** Métodos */
const handleConfirm = () => {
emit('confirm', props.product.id);
};
const handleClose = () => {
emit('close');
};
</script>
<template>
<Modal :show="show" max-width="md" @close="handleClose">
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30">
<GoogleIcon name="delete_forever" class="text-2xl text-red-600 dark:text-red-400" />
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">
Eliminar Producto
</h3>
</div>
<button
@click="handleClose"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="space-y-5">
<p class="text-gray-700 dark:text-gray-300 text-base">
¿Estás seguro de que deseas eliminar este producto?
</p>
<div v-if="product" class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl p-5 space-y-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-base font-bold text-gray-900 dark:text-gray-100 mb-1">
{{ product.name }}
</p>
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span class="font-mono font-medium">SKU: {{ product.sku }}</span>
<span class="text-gray-400"></span>
<span>{{ product.category?.name || 'Sin 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>

View 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>

View 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
View 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>

View 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>

View 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>

View File

@ -28,6 +28,51 @@ const router = createRouter({
name: 'dashboard.index',
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',
children: [

View 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;

View 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;

View 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
View 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
View 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;