Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8921833ed | |||
| 83bec44121 | |||
| 5ee73c309f | |||
| 0d1ccf9413 | |||
| 77e6e796d5 | |||
| 04fcb9df08 | |||
| f53d0ff457 | |||
| 2b7b884794 | |||
| f903f74fe4 | |||
| 104e2e327e | |||
| 76b1c6c02b | |||
| 1fc3e76027 | |||
|
|
dd61370db8 | ||
|
|
6a12a71f10 | ||
|
|
cdfd53e8ec | ||
|
|
523b0972d6 | ||
| 7a2b5f8400 | |||
|
|
b9d2f69390 | ||
|
|
8a518f477c | ||
|
|
d79552fafc | ||
| 8d513d215c | |||
|
|
4ac8799f88 | ||
|
|
5e46e55d73 | ||
|
|
bd6edc59d6 | ||
| 3029d425fe |
@ -1,10 +1,10 @@
|
|||||||
VITE_APP_NAME=GOLSCONTROL
|
VITE_APP_NAME=Ayuntamiento de Comalcalco
|
||||||
VITE_APP_URL=http://frontend.golscontrol.test
|
VITE_APP_URL=http://frontend.golscontrol.test
|
||||||
VITE_APP_LANG=es-MX
|
VITE_APP_LANG=es-MX
|
||||||
VITE_APP_PORT=3000
|
VITE_APP_PORT=3000
|
||||||
VITE_PAGINATION=25
|
VITE_PAGINATION=25
|
||||||
|
|
||||||
VITE_APP_API=http://localhost:8080 #Colocar http://localhost:8080 con el puerto del backend / http://backend.golscontrol.test
|
VITE_API_URL=http://localhost:8080
|
||||||
VITE_APP_API_SECURE=false
|
VITE_APP_API_SECURE=false
|
||||||
|
|
||||||
VITE_MICROSERVICE_STOCK=http://localhost:3000/api
|
VITE_MICROSERVICE_STOCK=http://localhost:3000/api
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@ dist-ssr
|
|||||||
.env
|
.env
|
||||||
colors.css
|
colors.css
|
||||||
notes.md
|
notes.md
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@ -3,12 +3,12 @@
|
|||||||
--color-page-t: #000;
|
--color-page-t: #000;
|
||||||
--color-page-d: #292524;
|
--color-page-d: #292524;
|
||||||
--color-page-dt: #fff;
|
--color-page-dt: #fff;
|
||||||
--color-primary: #374151;
|
--color-primary: #621132;
|
||||||
--color-primary-t: #fff;
|
--color-primary-t: #fff;
|
||||||
--color-primary-d: #1c1917;
|
--color-primary-d: #1c1917;
|
||||||
--color-primary-dt: #fff;
|
--color-primary-dt: #fff;
|
||||||
--color-secondary: #3b82f6;
|
--color-secondary: #fff;
|
||||||
--color-secondary-t: #fff;
|
--color-secondary-t: #621132;
|
||||||
--color-secondary-d: #312e81;
|
--color-secondary-d: #312e81;
|
||||||
--color-secondary-dt: #fff;
|
--color-secondary-dt: #fff;
|
||||||
--color-primary-info: #06b6d4;
|
--color-primary-info: #06b6d4;
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
services:
|
services:
|
||||||
holos-frontend:
|
comal-pagos-frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: dockerfile
|
dockerfile: dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT}:5173"
|
- "${APP_PORT}:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www/holos.frontend
|
- .:/var/www/comal-pagos.frontend
|
||||||
- /var/www/holos.frontend/node_modules
|
- /var/www/comal-pagos.frontend/node_modules
|
||||||
networks:
|
networks:
|
||||||
- holos-network
|
- comal-pagos-network
|
||||||
|
mem_limit: 512m
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
holos-network:
|
comal-pagos-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@ -1,6 +1,6 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
WORKDIR /var/www/holos.frontend
|
WORKDIR /var/www/comal-pagos.frontend
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
@ -11,7 +11,5 @@ COPY . .
|
|||||||
COPY install.sh /usr/local/bin/install.sh
|
COPY install.sh /usr/local/bin/install.sh
|
||||||
RUN chmod +x /usr/local/bin/install.sh
|
RUN chmod +x /usr/local/bin/install.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/install.sh"]
|
ENTRYPOINT ["/usr/local/bin/install.sh"]
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
@ -2,7 +2,7 @@
|
|||||||
<html id="main-page" lang="es">
|
<html id="main-page" lang="es">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Holos</title>
|
<title>Holos</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
#! /bin/bash
|
#! /bin/sh
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f colors.json ]; then
|
if [ ! -f colors.css ]; then
|
||||||
cp colors.json.example colors.json
|
cp colors.css.example colors.css
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
64
package-lock.json
generated
64
package-lock.json
generated
@ -23,6 +23,7 @@
|
|||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^11.1.1",
|
"vue-i18n": "^11.1.1",
|
||||||
"vue-multiselect": "^3.2.0",
|
"vue-multiselect": "^3.2.0",
|
||||||
|
"vue-qrcode-reader": "^5.7.3",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"ziggy-js": "^2.5.2"
|
"ziggy-js": "^2.5.2"
|
||||||
},
|
},
|
||||||
@ -1224,6 +1225,18 @@
|
|||||||
"vite": "^5.2.0 || ^6"
|
"vite": "^5.2.0 || ^6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dom-webcodecs": {
|
||||||
|
"version": "0.1.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.17.tgz",
|
||||||
|
"integrity": "sha512-IwKW5uKL0Zrv5ccUJpjIlqf7ppk2v29l/ZLQxLlwHxljBfnDD9Gxm+hzMkGM0AOAL/21H0pp7cTUYLiiVUGchA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/emscripten": {
|
||||||
|
"version": "1.41.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||||
|
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||||
@ -1492,6 +1505,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/barcode-detector": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/dom-webcodecs": "^0.1.11",
|
||||||
|
"zxing-wasm": "1.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/birpc": {
|
"node_modules/birpc": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz",
|
||||||
@ -3230,6 +3253,12 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sdp": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/socket.io-client": {
|
"node_modules/socket.io-client": {
|
||||||
"version": "4.8.1",
|
"version": "4.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||||
@ -3641,6 +3670,19 @@
|
|||||||
"npm": ">= 6.14.15"
|
"npm": ">= 6.14.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-qrcode-reader": {
|
||||||
|
"version": "5.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.7.3.tgz",
|
||||||
|
"integrity": "sha512-iSGko42FsEvdHyizBMBs/X+HMO9Z5ONDxjW+mQdoraOR5emRNedmjC5SEJdYzGz8ZP5ME3lwB4iHy3S7MOt5Qw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"barcode-detector": "2.2.2",
|
||||||
|
"webrtc-adapter": "8.2.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||||
@ -3662,6 +3704,19 @@
|
|||||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/webrtc-adapter": {
|
||||||
|
"version": "8.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.3.tgz",
|
||||||
|
"integrity": "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"sdp": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0",
|
||||||
|
"npm": ">=3.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.17.1",
|
"version": "8.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||||
@ -3711,6 +3766,15 @@
|
|||||||
"@types/qs": "^6.9.17",
|
"@types/qs": "^6.9.17",
|
||||||
"qs": "~6.9.7"
|
"qs": "~6.9.7"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zxing-wasm": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/emscripten": "^1.39.10"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "notsoweb.frontend",
|
"name": "notsoweb.frontend",
|
||||||
"copyright": "Notsoweb Software Inc.",
|
"copyright": "Golsystems",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.10",
|
"version": "0.9.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -25,6 +25,7 @@
|
|||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^11.1.1",
|
"vue-i18n": "^11.1.1",
|
||||||
"vue-multiselect": "^3.2.0",
|
"vue-multiselect": "^3.2.0",
|
||||||
|
"vue-qrcode-reader": "^5.7.3",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"ziggy-js": "^2.5.2"
|
"ziggy-js": "^2.5.2"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -4,12 +4,20 @@ import { useRouter } from 'vue-router';
|
|||||||
import useLoader from '@Stores/Loader';
|
import useLoader from '@Stores/Loader';
|
||||||
import { hasToken } from '@Services/Api';
|
import { hasToken } from '@Services/Api';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/factura/'];
|
||||||
|
|
||||||
/** Definidores */
|
/** Definidores */
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loader = useLoader();
|
const loader = useLoader();
|
||||||
|
|
||||||
/** Ciclos */
|
/** Ciclos */
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
await router.isReady();
|
||||||
|
|
||||||
|
const currentPath = router.currentRoute.value.path;
|
||||||
|
|
||||||
|
if (PUBLIC_PATHS.some(prefix => currentPath.startsWith(prefix))) return;
|
||||||
|
|
||||||
if(!hasToken()) {
|
if(!hasToken()) {
|
||||||
return router.push({ name: 'auth.index' })
|
return router.push({ name: 'auth.index' })
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/components/App/AddressSection.vue
Normal file
59
src/components/App/AddressSection.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['address-created']);
|
||||||
|
|
||||||
|
/** Refs */
|
||||||
|
const addressName = ref("");
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!addressName.value.trim()) {
|
||||||
|
window.Notify.warning(window.Lang('address.validation.required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emitir evento al padre con la nueva dirección
|
||||||
|
emit('address-created', {
|
||||||
|
name: addressName.value
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limpiar el formulario
|
||||||
|
addressName.value = "";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg p-8 mx-4 mt-4 mb-6 shadow-md">
|
||||||
|
<h2 class="text-2xl font-bold mb-6 text-gray-900">
|
||||||
|
Crear nueva dirección
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label
|
||||||
|
for="addressName"
|
||||||
|
class="block text-sm text-gray-700 font-medium mb-2"
|
||||||
|
>
|
||||||
|
Nombre de la Dirección:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="addressName"
|
||||||
|
v-model="addressName"
|
||||||
|
placeholder="Ingrese el nombre de la dirección"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-16 bg-primary hover:bg-primary/90 text-white font-semibold py-3 rounded-md transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
323
src/components/App/CheckoutDelivery.vue
Normal file
323
src/components/App/CheckoutDelivery.vue
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { apiURL, useApi } from "@/services/Api.js";
|
||||||
|
|
||||||
|
import Input from "@Holos/Form/Input.vue";
|
||||||
|
import QRscan from "./QRscan.vue";
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(["cash-cut-found", "cash-cut-delivered"]);
|
||||||
|
|
||||||
|
/** Instancias */
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
/** Control de visibilidad del formulario */
|
||||||
|
const isFormOpen = ref(false);
|
||||||
|
|
||||||
|
/** Refs */
|
||||||
|
const qr_token = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const cashCutData = ref({
|
||||||
|
id: "",
|
||||||
|
closed_by: "",
|
||||||
|
start_at: "",
|
||||||
|
end_at: "",
|
||||||
|
total_amount: 0,
|
||||||
|
status: "",
|
||||||
|
received_at: "",
|
||||||
|
received_by: "",
|
||||||
|
isReceived: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusTranslations = {
|
||||||
|
undelivered: "Sin entregar",
|
||||||
|
received: "Entregado",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusInSpanish = computed(() => {
|
||||||
|
return (
|
||||||
|
statusTranslations[cashCutData.value.status] || cashCutData.value.status
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedStartDate = computed(() => {
|
||||||
|
if (!cashCutData.value.start_at) return "";
|
||||||
|
return cashCutData.value.start_at.slice(0, 16);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedEndDate = computed(() => {
|
||||||
|
if (!cashCutData.value.end_at) return "";
|
||||||
|
return cashCutData.value.end_at.slice(0, 16);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cashierName = computed(() => {
|
||||||
|
return cashCutData.value.closed_by?.full_name || "N/A";
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedAmount = computed(() => {
|
||||||
|
if (!cashCutData.value.total_amount) return "0.00";
|
||||||
|
return `$${parseFloat(cashCutData.value.total_amount).toLocaleString(
|
||||||
|
"es-MX",
|
||||||
|
{
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}
|
||||||
|
)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleQRDetected = async (code) => {
|
||||||
|
qr_token.value = code;
|
||||||
|
await searchCashCut();
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchCashCut = async () => {
|
||||||
|
if (!qr_token.value.trim()) {
|
||||||
|
Notify.warning("Por favor escanea un código QR o ingresa el token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
await api.get(apiURL(`cash-cuts/find-by-qr-token`), {
|
||||||
|
params: {
|
||||||
|
qr_token: qr_token.value,
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const model = response.model;
|
||||||
|
|
||||||
|
if (model.status === "received") {
|
||||||
|
Notify.info("La caja ya ha sido entregada previamente");
|
||||||
|
|
||||||
|
cashCutData.value = {
|
||||||
|
id: model.id,
|
||||||
|
closed_by: model.closed_by,
|
||||||
|
start_at: model.start_at,
|
||||||
|
end_at: model.end_at,
|
||||||
|
total_amount: model.total_amount,
|
||||||
|
status: model.status,
|
||||||
|
received_at: model.received_at,
|
||||||
|
received_by: model.received_by,
|
||||||
|
isReceived: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cashCutData.value = {
|
||||||
|
id: model.id,
|
||||||
|
closed_by: model.closed_by,
|
||||||
|
start_at: model.start_at,
|
||||||
|
end_at: model.end_at,
|
||||||
|
total_amount: model.total_amount,
|
||||||
|
status: model.status,
|
||||||
|
received_at: model.received_at,
|
||||||
|
received_by: model.received_by,
|
||||||
|
isReceived: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Notify.success("Corte de caja encontrado");
|
||||||
|
emit("cash-cut-found", cashCutData.value);
|
||||||
|
},
|
||||||
|
onFail: (error) => {
|
||||||
|
Notify.warning(error.message || "No se encontró el corte de caja");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
Notify.error("Error al buscar el corte de caja");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelivery = async () => {
|
||||||
|
if (!cashCutData.value.id) {
|
||||||
|
Notify.warning("No hay corte de caja para entregar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cashCutData.value.isReceived) {
|
||||||
|
Notify.info("El corte de caja ya ha sido entregado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.put(apiURL(`cash-cuts/${cashCutData.value.id}/mark-as-received`), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Notify.success("Corte de caja entregado correctamente");
|
||||||
|
cashCutData.value.status = "received";
|
||||||
|
|
||||||
|
emit("cash-cut-delivered", cashCutData.value);
|
||||||
|
|
||||||
|
cashCutData.value.isReceived = true;
|
||||||
|
cashCutData.value.received_at = data.received_at;
|
||||||
|
cashCutData.value.received_by = data.received_by;
|
||||||
|
},
|
||||||
|
onFail: (error) => {
|
||||||
|
Notify.warning(error.message || "No se pudo entregar el corte de caja");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error("Error al entregar el corte de caja");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
searchCashCut();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearForm = () => {
|
||||||
|
cashCutData.value = {
|
||||||
|
id: null,
|
||||||
|
closed_by: null,
|
||||||
|
start_at: "",
|
||||||
|
end_at: "",
|
||||||
|
total_amount: 0,
|
||||||
|
status: "",
|
||||||
|
received_at: "",
|
||||||
|
received_by: "",
|
||||||
|
isReceived: false,
|
||||||
|
};
|
||||||
|
qr_token.value = "";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-4 mt-4 mb-6">
|
||||||
|
<!-- Botón para abrir el formulario -->
|
||||||
|
<button
|
||||||
|
v-if="!isFormOpen"
|
||||||
|
@click="isFormOpen = true"
|
||||||
|
class="w-full bg-primary hover:bg-primary/90 text-white font-semibold py-3 px-6 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg flex items-center justify-center space-x-2"
|
||||||
|
>
|
||||||
|
<span class="text-2xl">+</span>
|
||||||
|
<span>Buscar y Entregar Corte de Caja</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Formulario expandible -->
|
||||||
|
<div v-if="isFormOpen" class="space-y-6">
|
||||||
|
<div class="bg-white rounded-xl p-6 shadow-lg">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800">
|
||||||
|
Buscar Corte de Caja
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
@click="isFormOpen = false"
|
||||||
|
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
title="Cerrar"
|
||||||
|
>
|
||||||
|
<span class="text-2xl">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Scanner QR -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-sm text-gray-600 font-medium mb-2">
|
||||||
|
Escanear Código QR
|
||||||
|
</label>
|
||||||
|
<div class="w-full h-80 bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
<QRscan v-model="qr_token" @qr-detected="handleQRDetected" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input manual -->
|
||||||
|
<div class="flex flex-col justify-center">
|
||||||
|
<div class="mb-5">
|
||||||
|
<Input
|
||||||
|
v-model="qr_token"
|
||||||
|
id="Token QR"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ingrese el token del QR"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="handleSearch"
|
||||||
|
:disabled="loading"
|
||||||
|
type="button"
|
||||||
|
class="w-full bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? "Buscando..." : "Buscar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulario con datos del corte -->
|
||||||
|
<div v-if="cashCutData.id" class="bg-white rounded-xl p-6 shadow-lg">
|
||||||
|
<h3 class="text-xl font-semibold mb-4 text-gray-800">
|
||||||
|
Datos del Corte de Caja
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-5">
|
||||||
|
<Input
|
||||||
|
:model-value="formattedStartDate"
|
||||||
|
id="Fecha de inicio"
|
||||||
|
type="datetime-local"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
:model-value="formattedEndDate"
|
||||||
|
id="Fecha de cierre"
|
||||||
|
type="datetime-local"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<Input :model-value="cashierName" id="Cajero" type="text" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<Input
|
||||||
|
:model-value="formattedAmount"
|
||||||
|
id="Monto total"
|
||||||
|
type="text"
|
||||||
|
class="text-lg font-semibold"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<Input
|
||||||
|
:model-value="statusInSpanish"
|
||||||
|
id="Estado"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div v-if="cashCutData.isReceived">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="clearForm"
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3.5 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
Limpiar y buscar otro corte
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botón recibir -->
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="handleDelivery"
|
||||||
|
:disabled="cashCutData.isReceived"
|
||||||
|
:class="[
|
||||||
|
'w-full font-medium py-3.5 rounded-lg transition-colors',
|
||||||
|
cashCutData.isReceived
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-green-700 hover:bg-green-600 text-white',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Entregar Corte de Caja
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
371
src/components/App/ConceptSection.vue
Normal file
371
src/components/App/ConceptSection.vue
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from "vue";
|
||||||
|
import { useForm, api, apiURL } from "@Services/Api";
|
||||||
|
|
||||||
|
import Input from "@Holos/Form/Input.vue";
|
||||||
|
import Textarea from "@Holos/Form/Textarea.vue";
|
||||||
|
import Selectable from "@Holos/Form/Selectable.vue";
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(["concept-created"]);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
direction_id: null,
|
||||||
|
unit_id: null,
|
||||||
|
short_name: "",
|
||||||
|
name: "",
|
||||||
|
legal_instrument: "",
|
||||||
|
article: "",
|
||||||
|
content: "",
|
||||||
|
motivation: "",
|
||||||
|
min_amount_uma: "",
|
||||||
|
max_amount_uma: "",
|
||||||
|
min_amount_peso: "",
|
||||||
|
max_amount_peso: "",
|
||||||
|
unit_cost_uma: "",
|
||||||
|
unit_cost_peso: "",
|
||||||
|
charge_type: null,
|
||||||
|
concept_type: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.direction_id = null;
|
||||||
|
form.unit_id = null;
|
||||||
|
form.short_name = "";
|
||||||
|
form.name = "";
|
||||||
|
form.legal_instrument = "";
|
||||||
|
form.article = "";
|
||||||
|
form.content = "";
|
||||||
|
form.motivation = "";
|
||||||
|
form.min_amount_uma = "";
|
||||||
|
form.max_amount_uma = "";
|
||||||
|
form.min_amount_peso = "";
|
||||||
|
form.max_amount_peso = "";
|
||||||
|
form.unit_cost_uma = "";
|
||||||
|
form.unit_cost_peso = "";
|
||||||
|
form.charge_type = null;
|
||||||
|
form.concept_type = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFormOpen = ref(false);
|
||||||
|
const addresses = ref([]);
|
||||||
|
const units = ref([]);
|
||||||
|
|
||||||
|
const chargeTypeId = computed(() => {
|
||||||
|
return typeof form.charge_type === "object" && form.charge_type?.id
|
||||||
|
? form.charge_type.id
|
||||||
|
: form.charge_type;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chargeTypesOptions = ref([
|
||||||
|
{ id: "uma_range", name: "Rango por UMA" },
|
||||||
|
{ id: "peso_range", name: "Rango en pesos (MXN$)" },
|
||||||
|
{ id: "uma_fixed", name: "Tabulador en UMA" },
|
||||||
|
{ id: "peso_fixed", name: "Tabulador en pesos (MXN$)" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const conceptTypesOptions = ref([
|
||||||
|
{ id: "membership", name: "Membresía" },
|
||||||
|
{ id: "fine", name: "Multa" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const showUmaFields = computed(() => {
|
||||||
|
return (
|
||||||
|
chargeTypeId.value === "uma_range" || chargeTypeId.value === "uma_fixed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showPesoFields = computed(() => {
|
||||||
|
return (
|
||||||
|
chargeTypeId.value === "peso_range" || chargeTypeId.value === "peso_fixed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isRangeType = computed(() => {
|
||||||
|
return (
|
||||||
|
chargeTypeId.value === "uma_range" || chargeTypeId.value === "peso_range"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFixedType = computed(() => {
|
||||||
|
return (
|
||||||
|
chargeTypeId.value === "uma_fixed" || chargeTypeId.value === "peso_fixed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const conceptTypeId = computed(() => {
|
||||||
|
return typeof form.concept_type === "object" && form.concept_type?.id
|
||||||
|
? form.concept_type.id
|
||||||
|
: form.concept_type;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFineConcept = computed(() => conceptTypeId.value === "fine");
|
||||||
|
|
||||||
|
/** Cargar cosas */
|
||||||
|
onMounted(async () => {
|
||||||
|
api.get(apiURL("directions"), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
addresses.value = data.models?.data || data.data || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.get(apiURL("units"), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
units.value = data.models?.data || data.data || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const baseData = {
|
||||||
|
direction_id:
|
||||||
|
typeof form.direction_id === "object"
|
||||||
|
? form.direction_id.id
|
||||||
|
: Number(form.direction_id),
|
||||||
|
unit_id:
|
||||||
|
form.unit_id != null
|
||||||
|
? typeof form.unit_id === "object"
|
||||||
|
? form.unit_id.id
|
||||||
|
: Number(form.unit_id)
|
||||||
|
: null,
|
||||||
|
concept_type:
|
||||||
|
typeof form.concept_type === "object"
|
||||||
|
? form.concept_type.id
|
||||||
|
: String(form.concept_type),
|
||||||
|
short_name: form.short_name,
|
||||||
|
name: form.name,
|
||||||
|
...(isFineConcept.value && {
|
||||||
|
legal_instrument: form.legal_instrument,
|
||||||
|
article: form.article,
|
||||||
|
content: form.content,
|
||||||
|
motivation: form.motivation,
|
||||||
|
}),
|
||||||
|
charge_type: chargeTypeId.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agregar campos condicionales solo si tienen valor
|
||||||
|
if (showUmaFields.value && isRangeType.value) {
|
||||||
|
if (form.min_amount_uma)
|
||||||
|
baseData.min_amount_uma = Number(form.min_amount_uma);
|
||||||
|
if (form.max_amount_uma)
|
||||||
|
baseData.max_amount_uma = Number(form.max_amount_uma);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPesoFields.value && isRangeType.value) {
|
||||||
|
if (form.min_amount_peso)
|
||||||
|
baseData.min_amount_peso = Number(form.min_amount_peso);
|
||||||
|
if (form.max_amount_peso)
|
||||||
|
baseData.max_amount_peso = Number(form.max_amount_peso);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showUmaFields.value && isFixedType.value && form.unit_cost_uma) {
|
||||||
|
baseData.unit_cost_uma = Number(form.unit_cost_uma);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPesoFields.value && isFixedType.value && form.unit_cost_peso) {
|
||||||
|
baseData.unit_cost_peso = Number(form.unit_cost_peso);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("concept-created", baseData);
|
||||||
|
resetForm();
|
||||||
|
isFormOpen.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-4 mt-4 mb-6">
|
||||||
|
<button
|
||||||
|
v-if="!isFormOpen"
|
||||||
|
@click="isFormOpen = true"
|
||||||
|
class="w-full bg-primary hover:bg-primary/90 text-white font-semibold py-3 px-6 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg flex items-center justify-center space-x-2"
|
||||||
|
>
|
||||||
|
<span class="text-2xl">+</span>
|
||||||
|
<span>Nuevo Concepto</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="isFormOpen"
|
||||||
|
class="bg-white rounded-lg p-8 mx-4 mt-4 mb-6 shadow-md"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900">
|
||||||
|
{{ $t("concept.create.title") }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
@click="isFormOpen = false"
|
||||||
|
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
title="Cerrar"
|
||||||
|
>
|
||||||
|
<span class="text-2xl">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="mb-5 grid grid-cols-2 gap-4">
|
||||||
|
<Selectable
|
||||||
|
v-model="form.concept_type"
|
||||||
|
label="name"
|
||||||
|
value="id"
|
||||||
|
:title="$t('concept.conceptType')"
|
||||||
|
:options="conceptTypesOptions"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Selectable
|
||||||
|
v-model="form.direction_id"
|
||||||
|
label="name"
|
||||||
|
value="id"
|
||||||
|
:title="$t('concept.direction')"
|
||||||
|
:options="addresses"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="form.short_name"
|
||||||
|
class="col-span-2"
|
||||||
|
:id="$t('concept.shortName')"
|
||||||
|
type="text"
|
||||||
|
:onError="form.errors.short_name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="form.name"
|
||||||
|
class="col-span-2"
|
||||||
|
:id="$t('concept.name')"
|
||||||
|
type="text"
|
||||||
|
:onError="form.errors.name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template v-if="isFineConcept">
|
||||||
|
<div class="mb-5 grid grid-cols-2 gap-4 py-2">
|
||||||
|
<Input
|
||||||
|
v-model="form.legal_instrument"
|
||||||
|
class="col-span-2"
|
||||||
|
:id="$t('concept.legal')"
|
||||||
|
type="text"
|
||||||
|
:onError="form.errors.legal_instrument"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="form.article"
|
||||||
|
class="col-span-2"
|
||||||
|
:id="$t('concept.article')"
|
||||||
|
type="text"
|
||||||
|
:onError="form.errors.article"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5 gap-4">
|
||||||
|
<Textarea
|
||||||
|
v-model="form.content"
|
||||||
|
:id="$t('concept.description')"
|
||||||
|
:onError="form.errors.content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-5 gap-4 py-2">
|
||||||
|
<Input
|
||||||
|
v-model="form.motivation"
|
||||||
|
:id="$t('concept.motivation')"
|
||||||
|
type="text"
|
||||||
|
:onError="form.errors.motivation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="">
|
||||||
|
<hr class="my-6 border-gray-300" />
|
||||||
|
<h4 class="text-lg font-semibold mb-4 text-gray-700">
|
||||||
|
{{ $t("concept.defineChargeType") }}
|
||||||
|
</h4>
|
||||||
|
<div class="mb-5 grid grid-cols-3 gap-4 py-2">
|
||||||
|
<Selectable
|
||||||
|
v-model="form.charge_type"
|
||||||
|
label="name"
|
||||||
|
value="id"
|
||||||
|
:title="$t('concept.chargeType')"
|
||||||
|
:options="chargeTypesOptions"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Rango: solo monto mínimo y monto máximo (UMA o pesos) -->
|
||||||
|
<div
|
||||||
|
v-if="isRangeType"
|
||||||
|
class="mb-5 grid grid-cols-2 gap-4 py-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-if="showUmaFields"
|
||||||
|
v-model="form.min_amount_uma"
|
||||||
|
:id="$t('concept.minimumAmountUma')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
:onError="form.errors.min_amount_uma"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="showUmaFields"
|
||||||
|
v-model="form.max_amount_uma"
|
||||||
|
:id="$t('concept.maximumAmountUma')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
:onError="form.errors.max_amount_uma"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="showPesoFields"
|
||||||
|
v-model="form.min_amount_peso"
|
||||||
|
:id="$t('concept.minimumAmountPeso')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
:onError="form.errors.min_amount_peso"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="showPesoFields"
|
||||||
|
v-model="form.max_amount_peso"
|
||||||
|
:id="$t('concept.maximumAmountPeso')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
:onError="form.errors.max_amount_peso"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Tabulador: unidad de medida y costo por unidad (UMA o pesos) -->
|
||||||
|
<div v-if="isFixedType" class="mb-5 grid grid-cols-2 gap-4 py-2">
|
||||||
|
<Selectable
|
||||||
|
v-model="form.unit_id"
|
||||||
|
label="name"
|
||||||
|
value="id"
|
||||||
|
:title="$t('concept.sizeUnit')"
|
||||||
|
:options="units"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="showUmaFields"
|
||||||
|
v-model="form.unit_cost_uma"
|
||||||
|
:id="$t('concept.costUnitUma')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
:onError="form.errors.unit_cost_uma"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="showPesoFields"
|
||||||
|
v-model="form.unit_cost_peso"
|
||||||
|
:id="$t('concept.costUnitPeso')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
:onError="form.errors.unit_cost_peso"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="w-full max-w-xs bg-primary hover:bg-primary/90 text-white font-semibold py-3 rounded-md transition-all duration-200 shadow-sm hover:shadow-md disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ form.processing ? "Guardando..." : "Guardar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
406
src/components/App/DiscountSection.vue
Normal file
406
src/components/App/DiscountSection.vue
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { api, apiURL } from "@Services/Api";
|
||||||
|
|
||||||
|
import Input from "@Holos/Form/Input.vue";
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(["discount-authorized"]);
|
||||||
|
|
||||||
|
/** Refs */
|
||||||
|
const searchForm = ref({
|
||||||
|
fineNumber: "",
|
||||||
|
placa: "",
|
||||||
|
curp: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const discountData = ref({
|
||||||
|
id: null,
|
||||||
|
fecha: "",
|
||||||
|
placa: "",
|
||||||
|
vin: "",
|
||||||
|
licencia: "",
|
||||||
|
tarjeta: "",
|
||||||
|
rfc: "",
|
||||||
|
nombre: "",
|
||||||
|
montoOriginal: "",
|
||||||
|
pagoMinimo: "",
|
||||||
|
nuevoMonto: "",
|
||||||
|
descuentoAplicado: "",
|
||||||
|
// Solo los valores numéricos necesarios para cálculos
|
||||||
|
total_amount: 0,
|
||||||
|
min_total: 0,
|
||||||
|
discount: 0,
|
||||||
|
isPaid: false,
|
||||||
|
hasDiscount: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (
|
||||||
|
!searchForm.value.fineNumber &&
|
||||||
|
!searchForm.value.placa &&
|
||||||
|
!searchForm.value.curp
|
||||||
|
) {
|
||||||
|
window.Notify.warning("Por favor ingresa al menos un criterio de búsqueda");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.get(apiURL(`fines/search`), {
|
||||||
|
params: {
|
||||||
|
id: searchForm.value.fineNumber.trim() || undefined,
|
||||||
|
plate: searchForm.value.placa.trim() || undefined,
|
||||||
|
curp: searchForm.value.curp.trim() || undefined,
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const model = data.model;
|
||||||
|
const hasDiscount = model.discount && parseFloat(model.discount) > 0;
|
||||||
|
const isPaid =
|
||||||
|
model.payments &&
|
||||||
|
model.payments.length > 0 &&
|
||||||
|
model.payments[0].status === "paid";
|
||||||
|
|
||||||
|
// Verificar si ya está pagada
|
||||||
|
if (isPaid) {
|
||||||
|
window.Notify.info("La multa ya ha sido pagada previamente");
|
||||||
|
|
||||||
|
discountData.value = {
|
||||||
|
id: model.id,
|
||||||
|
fecha: new Date(model.created_at).toLocaleDateString("es-MX"),
|
||||||
|
placa: model.plate || "",
|
||||||
|
vin: model.vin || "",
|
||||||
|
licencia: model.license || "",
|
||||||
|
tarjeta: model.circulation || "",
|
||||||
|
rfc: model.rfc || "",
|
||||||
|
nombre: model.name || "",
|
||||||
|
montoOriginal: `$${data.total_amount.toFixed(2)}`,
|
||||||
|
pagoMinimo: `$${data.min_total.toFixed(2)}`,
|
||||||
|
nuevoMonto: "",
|
||||||
|
total_amount: data.total_amount,
|
||||||
|
min_total: data.min_total,
|
||||||
|
discount: parseFloat(model.discount) || 0,
|
||||||
|
isPaid: true,
|
||||||
|
hasDiscount: hasDiscount,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDiscount) {
|
||||||
|
window.Notify.info(
|
||||||
|
"La multa ya tiene un descuento aplicado previamente"
|
||||||
|
);
|
||||||
|
// Multa pendiente
|
||||||
|
discountData.value = {
|
||||||
|
id: model.id,
|
||||||
|
fecha: new Date(model.created_at).toLocaleDateString("es-MX"),
|
||||||
|
placa: model.plate || "",
|
||||||
|
vin: model.vin || "",
|
||||||
|
licencia: model.license || "",
|
||||||
|
tarjeta: model.circulation || "",
|
||||||
|
rfc: model.rfc || "",
|
||||||
|
nombre: model.name || "",
|
||||||
|
montoOriginal: `$${data.total_amount.toFixed(2)}`,
|
||||||
|
pagoMinimo: `$${data.min_total.toFixed(2)}`,
|
||||||
|
descuentoAplicado: `$${parseFloat(model.discount).toFixed(2)}`,
|
||||||
|
nuevoMonto: data.total_to_pay.toFixed(2),
|
||||||
|
total_amount: data.total_amount,
|
||||||
|
min_total: data.min_total,
|
||||||
|
discount: parseFloat(model.discount),
|
||||||
|
isPaid: false,
|
||||||
|
hasDiscount: true,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
discountData.value = {
|
||||||
|
id: model.id,
|
||||||
|
fecha: new Date(model.created_at).toLocaleDateString("es-MX"),
|
||||||
|
placa: model.plate || "",
|
||||||
|
vin: model.vin || "",
|
||||||
|
licencia: model.license || "",
|
||||||
|
tarjeta: model.circulation || "",
|
||||||
|
rfc: model.rfc || "",
|
||||||
|
nombre: model.name || "",
|
||||||
|
montoOriginal: `$${data.total_amount.toFixed(2)}`,
|
||||||
|
pagoMinimo: `$${data.min_total.toFixed(2)}`,
|
||||||
|
nuevoMonto: data.min_total.toFixed(2),
|
||||||
|
total_amount: data.total_amount,
|
||||||
|
min_total: data.min_total,
|
||||||
|
discount: 0,
|
||||||
|
isPaid: false,
|
||||||
|
hasDiscount: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Notify.success("Multa encontrada");
|
||||||
|
},
|
||||||
|
onFail: (error) => {
|
||||||
|
window.Notify.error(error.message || "Multa no encontrada");
|
||||||
|
clearDiscountData();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Error al buscar multa:", error);
|
||||||
|
window.Notify.error("Ocurrió un error al buscar la multa");
|
||||||
|
clearDiscountData();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthorize = async () => {
|
||||||
|
if (!discountData.value.id) {
|
||||||
|
window.Notify.warning("No hay multa cargada para autorizar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discountData.value.hasDiscount) {
|
||||||
|
window.Notify.error("Esta multa ya tiene un descuento autorizado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!discountData.value.nuevoMonto) {
|
||||||
|
window.Notify.warning("Por favor ingresa el nuevo monto autorizado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nuevoMontoNumber = parseFloat(discountData.value.nuevoMonto);
|
||||||
|
|
||||||
|
if (nuevoMontoNumber < discountData.value.min_total) {
|
||||||
|
window.Notify.error(
|
||||||
|
`El monto no puede ser menor al pago mínimo de $${discountData.value.min_total.toFixed(
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nuevoMontoNumber > discountData.value.total_amount) {
|
||||||
|
window.Notify.error(
|
||||||
|
`El monto no puede ser mayor al original de $${discountData.value.total_amount.toFixed(
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descuento = discountData.value.total_amount - nuevoMontoNumber;
|
||||||
|
|
||||||
|
await api.put(apiURL(`fines/${discountData.value.id}/discount`), {
|
||||||
|
data: {
|
||||||
|
discount: descuento,
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
window.Notify.success("Descuento autorizado exitosamente");
|
||||||
|
|
||||||
|
emit("discount-authorized", {
|
||||||
|
fine_id: discountData.value.id,
|
||||||
|
authorized_amount: nuevoMontoNumber,
|
||||||
|
discount: descuento,
|
||||||
|
response: response,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearDiscountData();
|
||||||
|
clearSearchForm();
|
||||||
|
},
|
||||||
|
onFail: (error) => {
|
||||||
|
window.Notify.error(error.message || "Error al autorizar el descuento");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Error al autorizar descuento:", error);
|
||||||
|
window.Notify.error("Ocurrió un error al autorizar el descuento");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDiscountData = () => {
|
||||||
|
discountData.value = {
|
||||||
|
id: null,
|
||||||
|
fecha: "",
|
||||||
|
placa: "",
|
||||||
|
vin: "",
|
||||||
|
licencia: "",
|
||||||
|
tarjeta: "",
|
||||||
|
rfc: "",
|
||||||
|
nombre: "",
|
||||||
|
montoOriginal: "",
|
||||||
|
pagoMinimo: "",
|
||||||
|
descuentoAplicado: "",
|
||||||
|
nuevoMonto: "",
|
||||||
|
total_amount: 0,
|
||||||
|
min_total: 0,
|
||||||
|
discount: 0,
|
||||||
|
isPaid: false,
|
||||||
|
hasDiscount: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearchForm = () => {
|
||||||
|
searchForm.value = {
|
||||||
|
fineNumber: "",
|
||||||
|
placa: "",
|
||||||
|
curp: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Formulario de búsqueda -->
|
||||||
|
<div class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg mb-6">
|
||||||
|
<h3 class="text-xl font-semibold mb-6 text-gray-800">
|
||||||
|
Buscar Multa para Autorización
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSearch">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||||
|
<Input
|
||||||
|
v-model="searchForm.fineNumber"
|
||||||
|
id="Número de Multa"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input v-model="searchForm.placa" id="Placa" type="text" />
|
||||||
|
|
||||||
|
<Input v-model="searchForm.curp" id="CURP" type="text" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="py-3 px-8 rounded-lg transition-colors h-[42px] bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detalles para Autorización -->
|
||||||
|
<div class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg">
|
||||||
|
<h3 class="text-xl font-semibold mb-6 text-gray-800">
|
||||||
|
Detalles para Autorización
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleAuthorize">
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-5">
|
||||||
|
<Input
|
||||||
|
v-model="discountData.fecha"
|
||||||
|
id="Fecha de Multa"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input v-model="discountData.placa" id="Placa" type="text" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-5">
|
||||||
|
<Input v-model="discountData.vin" id="VIN" type="text" disabled />
|
||||||
|
<Input
|
||||||
|
v-model="discountData.licencia"
|
||||||
|
id="Licencia"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-5">
|
||||||
|
<Input
|
||||||
|
v-model="discountData.tarjeta"
|
||||||
|
id="Tarjeta de Circulación"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input v-model="discountData.rfc" id="RFC" type="text" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-5">
|
||||||
|
<Input
|
||||||
|
v-model="discountData.nombre"
|
||||||
|
id="Nombre"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-6 border-gray-300" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-5">
|
||||||
|
<Input
|
||||||
|
v-model="discountData.montoOriginal"
|
||||||
|
id="Monto a Pagar (Original)"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="discountData.pagoMinimo"
|
||||||
|
id="Pago Mínimo"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="discountData.hasDiscount" class="mb-5">
|
||||||
|
<Input
|
||||||
|
v-model="discountData.descuentoAplicado"
|
||||||
|
id="Descuento Ya Aplicado"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<Input
|
||||||
|
v-model="discountData.nuevoMonto"
|
||||||
|
id="Nuevo Monto a Cobrar (Autorizado)"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
:min="discountData.min_total"
|
||||||
|
:max="discountData.total_amount"
|
||||||
|
placeholder="Ingrese el nuevo monto autorizado"
|
||||||
|
:disabled="discountData.isPaid || discountData.hasDiscount"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas -->
|
||||||
|
<div
|
||||||
|
v-if="discountData.isPaid"
|
||||||
|
class="mb-5 p-4 bg-blue-50 border border-blue-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<p class="text-blue-800 font-semibold">
|
||||||
|
Esta multa ya ha sido pagada previamente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="discountData.hasDiscount && !discountData.isPaid"
|
||||||
|
class="mb-5 p-4 bg-yellow-50 border border-yellow-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<p class="text-yellow-800 font-semibold">
|
||||||
|
Esta multa ya tiene un descuento autorizado de
|
||||||
|
{{ discountData.descuentoAplicado }}
|
||||||
|
</p>
|
||||||
|
<p class="text-yellow-700 text-sm mt-1">
|
||||||
|
Monto con descuento: ${{
|
||||||
|
(discountData.total_amount - discountData.discount).toFixed(2)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="discountData.isPaid || !discountData.id"
|
||||||
|
:class="[
|
||||||
|
'w-full font-medium py-3.5 rounded-lg transition-colors',
|
||||||
|
discountData.isPaid || discountData.hasDiscount || !discountData.id
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-green-700 hover:bg-green-600 text-white',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
discountData.isPaid
|
||||||
|
? "Multa ya pagada"
|
||||||
|
: discountData.hasDiscount
|
||||||
|
? "Descuento ya aplicado"
|
||||||
|
: "Autorizar Descuento"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
211
src/components/App/FinePaymentSummary.vue
Normal file
211
src/components/App/FinePaymentSummary.vue
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
selectedFines: { type: Array, default: () => [] },
|
||||||
|
isProcessingPayment: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["pay"]);
|
||||||
|
|
||||||
|
const selectedPaymentMethod = ref(null);
|
||||||
|
|
||||||
|
const paymentMethods = [
|
||||||
|
{ value: "cash", label: "Efectivo" },
|
||||||
|
{ value: "debit_card", label: "Tarjeta de Débito" },
|
||||||
|
{ value: "credit_card", label: "Tarjeta de Crédito" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasSelection = computed(() => props.selectedFines.length > 0);
|
||||||
|
const canPay = computed(() => hasSelection.value && selectedPaymentMethod.value !== null);
|
||||||
|
|
||||||
|
const effectiveAmount = (f) =>
|
||||||
|
parseFloat(f._total_to_pay ?? f.discount ?? f.total_amount ?? 0);
|
||||||
|
|
||||||
|
const totalSelected = computed(() =>
|
||||||
|
props.selectedFines.reduce((sum, f) => sum + effectiveAmount(f), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalTotal = computed(() =>
|
||||||
|
props.selectedFines.reduce((sum, f) => sum + parseFloat(f.total_amount || 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAnyDiscount = computed(() =>
|
||||||
|
props.selectedFines.some((f) => f.discount != null || f._has_early_discount)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasEarlyDiscount = computed(() =>
|
||||||
|
props.selectedFines.some((f) => f._has_early_discount)
|
||||||
|
);
|
||||||
|
|
||||||
|
const earlyDiscountExpiresAt = computed(() => {
|
||||||
|
const fine = props.selectedFines.find((f) => f._early_discount_expires_at);
|
||||||
|
if (!fine) return null;
|
||||||
|
return new Date(fine._early_discount_expires_at).toLocaleDateString("es-MX", {
|
||||||
|
day: "numeric", month: "long", year: "numeric",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSavings = computed(() => originalTotal.value - totalSelected.value);
|
||||||
|
|
||||||
|
const getConcepts = (fine) => {
|
||||||
|
if (!fine.charge_concepts?.length) return [];
|
||||||
|
return fine.charge_concepts.map((c) => c.short_name || c.name || "").filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.selectedFines.length, (len) => {
|
||||||
|
if (len === 0) selectedPaymentMethod.value = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePay = () => {
|
||||||
|
if (!canPay.value) return;
|
||||||
|
emit("pay", selectedPaymentMethod.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-72 shrink-0 bg-primary rounded-xl p-5 flex flex-col text-white self-start sticky top-5">
|
||||||
|
|
||||||
|
<!-- Título -->
|
||||||
|
<div class="flex items-center gap-2 font-semibold text-base mb-5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-white/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
Resumen de Cobro
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estado vacío -->
|
||||||
|
<div
|
||||||
|
v-if="!hasSelection"
|
||||||
|
class="flex flex-col items-center justify-center py-10 gap-3 text-white/50"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-white/30" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-center text-xs text-white/50 leading-relaxed">
|
||||||
|
Seleccione al menos una infracción para procesar el cobro.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multas seleccionadas -->
|
||||||
|
<div v-else class="flex-1 mb-4">
|
||||||
|
<p class="text-xs text-white/60 mb-2">
|
||||||
|
Infracciones seleccionadas ({{ selectedFines.length }})
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="fine in selectedFines"
|
||||||
|
:key="fine.id"
|
||||||
|
class="bg-white/10 rounded-xl p-3"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<span class="text-sm font-medium text-white">Folio: {{ fine.id }}</span>
|
||||||
|
<div class="flex flex-col items-end shrink-0 ml-2">
|
||||||
|
<span
|
||||||
|
v-if="fine.discount != null || fine._has_early_discount"
|
||||||
|
class="text-xs text-white/40 line-through leading-none mb-0.5"
|
||||||
|
>
|
||||||
|
${{ parseFloat(fine.total_amount || 0).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold text-white leading-none">
|
||||||
|
${{ effectiveAmount(fine).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(concept, i) in getConcepts(fine)"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-1.5 text-xs text-white/60"
|
||||||
|
>
|
||||||
|
<span class="text-white/40 shrink-0 mt-0.5">•</span>
|
||||||
|
<span>{{ concept }}</span>
|
||||||
|
</li>
|
||||||
|
<li v-if="!getConcepts(fine).length" class="text-xs text-white/40 italic">
|
||||||
|
Sin concepto registrado
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Método de pago -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<p class="text-xs text-white/60 mb-2">Método de pago</p>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
v-for="method in paymentMethods"
|
||||||
|
:key="method.value"
|
||||||
|
@click="selectedPaymentMethod = method.value"
|
||||||
|
:class="[
|
||||||
|
'w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-all text-left',
|
||||||
|
selectedPaymentMethod === method.value
|
||||||
|
? 'bg-white text-primary shadow-md'
|
||||||
|
: 'bg-white/10 text-white/70 hover:bg-white/20',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ method.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Totales y botón -->
|
||||||
|
<div class="mt-auto pt-4 border-t border-white/20">
|
||||||
|
<template v-if="!hasAnyDiscount">
|
||||||
|
<div class="flex justify-between text-sm text-white/60 mb-2">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>${{ totalSelected.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between font-bold text-white text-base mb-4">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>${{ totalSelected.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Aviso de descuento por pago oportuno -->
|
||||||
|
<div
|
||||||
|
v-if="hasEarlyDiscount && earlyDiscountExpiresAt"
|
||||||
|
class="flex items-start gap-2 bg-success/20 border border-success/40 rounded-lg px-3 py-2 mb-3"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-success shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-xs text-success leading-snug">
|
||||||
|
Descuento por pago oportuno (50%) vigente hasta el <strong>{{ earlyDiscountExpiresAt }}</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm text-white/60 mb-1">
|
||||||
|
<span>Subtotal original</span>
|
||||||
|
<span>${{ originalTotal.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm text-success mb-2">
|
||||||
|
<span>{{ hasEarlyDiscount ? 'Descuento por pago oportuno' : 'Descuento' }}</span>
|
||||||
|
<span>-${{ totalSavings.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between font-bold text-white text-base mb-4">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>${{ totalSelected.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
@click="handlePay"
|
||||||
|
:disabled="!canPay || isProcessingPayment"
|
||||||
|
:class="[
|
||||||
|
'w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all',
|
||||||
|
canPay && !isProcessingPayment
|
||||||
|
? 'bg-success hover:bg-success/90 text-white shadow-lg shadow-black/20'
|
||||||
|
: 'bg-white/10 text-white/30 cursor-not-allowed',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
{{ isProcessingPayment ? "Procesando..." : "Cobrar Total" }}
|
||||||
|
</button>
|
||||||
|
<p v-if="hasSelection && !selectedPaymentMethod" class="text-xs text-white/40 text-center mt-2">
|
||||||
|
Seleccione un método de pago para continuar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
156
src/components/App/FineResultCard.vue
Normal file
156
src/components/App/FineResultCard.vue
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
<script setup>
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import { downloadFineTicket, downloadFineReceipt } from '@/services/App/FineService.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fine: { type: Object, required: true },
|
||||||
|
isSelected: { type: Boolean, default: false },
|
||||||
|
selectable: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["toggle"]);
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
return new Date(dateStr).toLocaleDateString("es-MX");
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentMethodLabels = {
|
||||||
|
cash: "Efectivo",
|
||||||
|
debit_card: "T. Débito",
|
||||||
|
credit_card: "T. Crédito",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaidPaymentMethod = (fine) => {
|
||||||
|
const payment = fine.payments?.find(p => p.status === "paid");
|
||||||
|
return payment?.payment_method ? (paymentMethodLabels[payment.payment_method] ?? null) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConcepts = (fine) => {
|
||||||
|
if (!fine.charge_concepts?.length) return [];
|
||||||
|
return fine.charge_concepts.map((c) => c.short_name || c.name || "").filter(Boolean);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
@click="selectable && fine.status !== 'paid' ? emit('toggle', fine) : null"
|
||||||
|
:class="[
|
||||||
|
'rounded-xl border-2 p-4 transition-all',
|
||||||
|
selectable && fine.status !== 'paid' ? 'cursor-pointer' : '',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-gray-200 hover:border-gray-300',
|
||||||
|
fine.status === 'paid' ? 'opacity-60' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- Encabezado -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Indicador de selección -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors',
|
||||||
|
isSelected ? 'border-primary bg-primary' : 'border-gray-300 bg-white',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div v-if="isSelected" class="w-2 h-2 bg-white rounded-full" />
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-800">Folio: {{ fine.id }}</span>
|
||||||
|
<span
|
||||||
|
v-if="fine.status === 'paid'"
|
||||||
|
class="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
Pagada
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="fine.status === 'paid' && getPaidPaymentMethod(fine)"
|
||||||
|
class="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full"
|
||||||
|
>
|
||||||
|
{{ getPaidPaymentMethod(fine) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<span
|
||||||
|
v-if="fine.discount != null"
|
||||||
|
class="text-xs text-gray-400 line-through leading-none mb-0.5"
|
||||||
|
>
|
||||||
|
${{ parseFloat(fine.total_amount || 0).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'font-semibold text-lg leading-none',
|
||||||
|
fine.discount != null ? 'text-success' : 'text-gray-800',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
${{ parseFloat(fine.discount ?? fine.total_amount ?? 0).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="fine.pdf_path"
|
||||||
|
@click.stop="downloadFineTicket(fine)"
|
||||||
|
class="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-primary transition-colors"
|
||||||
|
title="Descargar boleta de multa"
|
||||||
|
>
|
||||||
|
<GoogleIcon class="text-xl" name="download" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="fine.status === 'paid' && fine.payments?.[0]?.receipt_pdf_path"
|
||||||
|
@click.stop="downloadFineReceipt(fine)"
|
||||||
|
class="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-green-600 transition-colors"
|
||||||
|
title="Descargar recibo de pago"
|
||||||
|
>
|
||||||
|
<GoogleIcon class="text-xl" name="receipt_long" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid de datos -->
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-1.5 text-sm mb-2">
|
||||||
|
<!-- Nombre -->
|
||||||
|
<div class="flex items-center gap-2 text-gray-600 min-w-0">
|
||||||
|
<GoogleIcon class="w-3.5 h-3.5 text-gray-400 shrink-0" name="person" />
|
||||||
|
<span class="truncate">{{ fine.name || "-" }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Fecha -->
|
||||||
|
<div class="flex items-center gap-2 text-gray-600">
|
||||||
|
<GoogleIcon class="w-3.5 h-3.5 text-gray-400 shrink-0" name="calendar_month" />
|
||||||
|
<span>{{ formatDate(fine.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- CURP -->
|
||||||
|
<div class="flex items-center gap-2 text-gray-600 min-w-0 col-span-2">
|
||||||
|
<GoogleIcon class="w-3.5 h-3.5 text-gray-400 shrink-0" name="id_card" />
|
||||||
|
<span class="truncate">CURP: {{ fine.curp || "-" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vehículo -->
|
||||||
|
<div v-if="fine.plate || fine.vin" class="flex items-center gap-2 text-sm text-gray-600 mb-2">
|
||||||
|
<GoogleIcon class="w-3.5 h-3.5 text-gray-400 shrink-0" name="airport_shuttle" />
|
||||||
|
<span class="text-gray-500">
|
||||||
|
{{ [fine.plate ? `Placas: ${fine.plate}` : null, fine.vin ? `VIN: ${fine.vin}` : null].filter(Boolean).join(" · ") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conceptos de infracción -->
|
||||||
|
<div v-if="getConcepts(fine).length" class="pt-2 border-t border-gray-100">
|
||||||
|
<div class="flex items-start gap-2 mb-1">
|
||||||
|
<GoogleIcon class="w-3.5 h-3.5 text-red-400 shrink-0 mt-0.5" name="warning" />
|
||||||
|
<span class="text-xs font-medium text-red-500 uppercase tracking-wide">
|
||||||
|
{{ getConcepts(fine).length === 1 ? "Infracción" : "Infracciones" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-1 pl-5">
|
||||||
|
<li
|
||||||
|
v-for="(concept, i) in getConcepts(fine)"
|
||||||
|
:key="i"
|
||||||
|
class="text-sm text-red-500 flex items-start gap-1.5"
|
||||||
|
>
|
||||||
|
<span class="text-red-300 mt-1 shrink-0">•</span>
|
||||||
|
<span>{{ concept }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
136
src/components/App/FineSearchPanel.vue
Normal file
136
src/components/App/FineSearchPanel.vue
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<script setup>
|
||||||
|
import QRscan from "./QRscan.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
activeTab: { type: String, required: true },
|
||||||
|
isSearching: { type: Boolean, default: false },
|
||||||
|
folioQuery: { type: String, default: "" },
|
||||||
|
curpQuery: { type: String, default: "" },
|
||||||
|
hasSelectedFine: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"update:folioQuery",
|
||||||
|
"update:curpQuery",
|
||||||
|
"tab-change",
|
||||||
|
"search-folio",
|
||||||
|
"search-curp",
|
||||||
|
"qr-detected",
|
||||||
|
"condone-fine",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "qr", label: "Escanear Boleta" },
|
||||||
|
{ id: "folio", label: "Buscar por Folio" },
|
||||||
|
{ id: "curp", label: "Buscar por CURP" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-xl bg-white shadow-lg overflow-hidden ">
|
||||||
|
|
||||||
|
<!-- Pestañas -->
|
||||||
|
<div class="flex border-b border-gray-200">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
@click="emit('tab-change', tab.id)"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-primary text-primary bg-white'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- Ícono QR -->
|
||||||
|
<svg v-if="tab.id === 'qr'" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" />
|
||||||
|
<rect x="5" y="5" width="3" height="3" fill="currentColor" stroke="none" /><rect x="16" y="5" width="3" height="3" fill="currentColor" stroke="none" /><rect x="5" y="16" width="3" height="3" fill="currentColor" stroke="none" />
|
||||||
|
<path d="M14 14h3v3h-3zM17 17h3v3h-3zM14 20h3" />
|
||||||
|
</svg>
|
||||||
|
<!-- Ícono Documento -->
|
||||||
|
<svg v-else-if="tab.id === 'folio'" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14,2 14,8 20,8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10,9 9,9 8,9" />
|
||||||
|
</svg>
|
||||||
|
<!-- Ícono Persona -->
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
<div class="ml-auto p-3">
|
||||||
|
<button
|
||||||
|
@click="emit('condone-fine')"
|
||||||
|
:disabled="!hasSelectedFine"
|
||||||
|
class="px-6 py-2.5 bg-[#621132] hover:bg-[#621132]/90 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Condonar Multa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Escanear QR -->
|
||||||
|
<div v-if="activeTab === 'qr'" class="p-6">
|
||||||
|
<div class="w-full h-72 bg-gray-900 rounded-xl overflow-hidden mb-4">
|
||||||
|
<QRscan @qr-detected="emit('qr-detected', $event)" />
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 text-sm text-center">
|
||||||
|
Posicione el código QR de la boleta física de infracción frente al lector.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buscar por Folio -->
|
||||||
|
<div v-else-if="activeTab === 'folio'" class="p-6">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="flex-1 flex items-center gap-2 border border-gray-300 rounded-lg px-3 py-2.5 bg-white focus-within:ring-2 focus-within:ring-primary focus-within:border-primary transition-all">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
:value="folioQuery"
|
||||||
|
@input="emit('update:folioQuery', $event.target.value)"
|
||||||
|
@keyup.enter="emit('search-folio')"
|
||||||
|
type="text"
|
||||||
|
placeholder="Número de folio..."
|
||||||
|
class="flex-1 text-sm outline-none bg-transparent text-gray-800 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="emit('search-folio')"
|
||||||
|
:disabled="isSearching"
|
||||||
|
type="button"
|
||||||
|
class="px-6 py-2.5 bg-primary hover:bg-primary/90 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors min-w-[90px]"
|
||||||
|
>
|
||||||
|
{{ isSearching ? "Buscando..." : "Buscar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buscar por CURP / Nombre -->
|
||||||
|
<div v-else class="p-6">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="flex-1 flex items-center gap-2 border border-gray-300 rounded-lg px-3 py-2.5 bg-white focus-within:ring-2 focus-within:ring-primary focus-within:border-primary transition-all">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
:value="curpQuery"
|
||||||
|
@input="emit('update:curpQuery', $event.target.value)"
|
||||||
|
@keyup.enter="emit('search-curp')"
|
||||||
|
type="text"
|
||||||
|
placeholder="CURP o nombre del infractor..."
|
||||||
|
class="flex-1 text-sm outline-none bg-transparent text-gray-800 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="emit('search-curp')"
|
||||||
|
:disabled="isSearching"
|
||||||
|
type="button"
|
||||||
|
class="px-6 py-2.5 bg-primary hover:bg-primary/90 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors min-w-[90px]"
|
||||||
|
>
|
||||||
|
{{ isSearching ? "Buscando..." : "Buscar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
216
src/components/App/FineSection.vue
Normal file
216
src/components/App/FineSection.vue
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useApi, apiURL } from "@/services/Api.js";
|
||||||
|
import FineSearchPanel from "./FineSearchPanel.vue";
|
||||||
|
import FineResultCard from "./FineResultCard.vue";
|
||||||
|
import FinePaymentSummary from "./FinePaymentSummary.vue";
|
||||||
|
|
||||||
|
const emit = defineEmits(["fine-searched", "payment-processed"]);
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
// ─── Pestañas ────────────────────────────────────────────────────────────────
|
||||||
|
const activeTab = ref("qr");
|
||||||
|
|
||||||
|
// ─── Estado de búsqueda ───────────────────────────────────────────────────────
|
||||||
|
const folioQuery = ref("");
|
||||||
|
const curpQuery = ref("");
|
||||||
|
const isSearching = ref(false);
|
||||||
|
const searchResults = ref([]);
|
||||||
|
const selectedFines = ref([]);
|
||||||
|
const searchMessage = ref("");
|
||||||
|
|
||||||
|
// ─── Computed ─────────────────────────────────────────────────────────────────
|
||||||
|
const hasSelection = computed(() => selectedFines.value.length > 0);
|
||||||
|
|
||||||
|
// ─── Procesado de respuestas ──────────────────────────────────────────────────
|
||||||
|
const processSingleResult = (data) => {
|
||||||
|
const fine = {
|
||||||
|
...data.model,
|
||||||
|
_total_to_pay: data.total_to_pay,
|
||||||
|
_has_early_discount: data.has_early_discount,
|
||||||
|
_early_discount_expires_at: data.early_discount_expires_at,
|
||||||
|
_early_discount_amount: data.discount,
|
||||||
|
};
|
||||||
|
searchResults.value = [fine];
|
||||||
|
if (fine.status === "paid") {
|
||||||
|
searchMessage.value = "Se encontró 1 multa (ya pagada).";
|
||||||
|
selectedFines.value = [];
|
||||||
|
Notify.info("La multa ya ha sido pagada previamente");
|
||||||
|
} else {
|
||||||
|
searchMessage.value = "Se encontraron 1 infracciones pendientes.";
|
||||||
|
selectedFines.value = [fine];
|
||||||
|
emit("fine-searched", { rawData: data });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processMultipleResults = (data) => {
|
||||||
|
const fines = data.models || [];
|
||||||
|
searchResults.value = fines;
|
||||||
|
selectedFines.value = [];
|
||||||
|
const pending = fines.filter((f) => f.status !== "paid");
|
||||||
|
searchMessage.value =
|
||||||
|
pending.length > 0
|
||||||
|
? `Se encontraron ${pending.length} infracciones pendientes.`
|
||||||
|
: "No se encontraron infracciones pendientes.";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Búsquedas ────────────────────────────────────────────────────────────────
|
||||||
|
const searchByQR = async (qrToken) => {
|
||||||
|
if (!qrToken?.trim()) return;
|
||||||
|
isSearching.value = true;
|
||||||
|
searchResults.value = [];
|
||||||
|
selectedFines.value = [];
|
||||||
|
await api.get(apiURL("fines/search"), {
|
||||||
|
params: { qr_token: qrToken.trim() },
|
||||||
|
onSuccess: processSingleResult,
|
||||||
|
onFail: (e) => Notify.error(e.message || "Error al buscar la multa"),
|
||||||
|
onError: () => Notify.error("Ocurrió un error al buscar la multa"),
|
||||||
|
});
|
||||||
|
isSearching.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchByFolio = async () => {
|
||||||
|
if (!folioQuery.value.trim()) {
|
||||||
|
Notify.warning("Por favor ingresa un folio");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSearching.value = true;
|
||||||
|
searchResults.value = [];
|
||||||
|
selectedFines.value = [];
|
||||||
|
await api.get(apiURL("fines/search"), {
|
||||||
|
params: { id: folioQuery.value.trim() },
|
||||||
|
onSuccess: processSingleResult,
|
||||||
|
onFail: (e) => Notify.error(e.message || "Error al buscar la multa"),
|
||||||
|
onError: () => Notify.error("Ocurrió un error al buscar la multa"),
|
||||||
|
});
|
||||||
|
isSearching.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchByCurp = async () => {
|
||||||
|
if (!curpQuery.value.trim()) {
|
||||||
|
Notify.warning("Por favor ingresa un CURP o nombre");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSearching.value = true;
|
||||||
|
searchResults.value = [];
|
||||||
|
selectedFines.value = [];
|
||||||
|
await api.get(apiURL("fines/search"), {
|
||||||
|
params: { curp: curpQuery.value.trim() },
|
||||||
|
onSuccess: processMultipleResults,
|
||||||
|
onFail: (e) => Notify.error(e.message || "Error al buscar multas"),
|
||||||
|
onError: () => Notify.error("Ocurrió un error al buscar multas"),
|
||||||
|
});
|
||||||
|
isSearching.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Selección ────────────────────────────────────────────────────────────────
|
||||||
|
const toggleFineSelection = (fine) => {
|
||||||
|
if (fine.status === "paid") return;
|
||||||
|
const idx = selectedFines.value.findIndex((f) => f.id === fine.id);
|
||||||
|
if (idx >= 0) selectedFines.value.splice(idx, 1);
|
||||||
|
else selectedFines.value.push(fine);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFineSelected = (fine) => selectedFines.value.some((f) => f.id === fine.id);
|
||||||
|
|
||||||
|
// ─── Cobro ────────────────────────────────────────────────────────────────────
|
||||||
|
const isProcessingPayment = ref(false);
|
||||||
|
|
||||||
|
const handlePayment = (paymentMethod) => {
|
||||||
|
if (!hasSelection.value || !paymentMethod) return;
|
||||||
|
isProcessingPayment.value = true;
|
||||||
|
|
||||||
|
const finesToPay = [...selectedFines.value];
|
||||||
|
const total = finesToPay.length;
|
||||||
|
let paidCount = 0;
|
||||||
|
|
||||||
|
for (const fine of finesToPay) {
|
||||||
|
api.post(apiURL(`fines/${fine.id}/mark-as-paid`), {
|
||||||
|
data: { payment_method: paymentMethod },
|
||||||
|
onSuccess: (data) => {
|
||||||
|
paidCount++;
|
||||||
|
const index = searchResults.value.findIndex(f => f.id === fine.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
searchResults.value[index] = data.fine;
|
||||||
|
}
|
||||||
|
selectedFines.value = selectedFines.value.filter(f => f.id !== fine.id);
|
||||||
|
emit("payment-processed", { fine, paymentData: data });
|
||||||
|
if (paidCount === total) {
|
||||||
|
isProcessingPayment.value = false;
|
||||||
|
Notify.success(
|
||||||
|
total === 1
|
||||||
|
? "Multa cobrada exitosamente"
|
||||||
|
: `${total} multas cobradas exitosamente`
|
||||||
|
);
|
||||||
|
searchMessage.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFail: (e) => {
|
||||||
|
isProcessingPayment.value = false;
|
||||||
|
Notify.error(e.message || "Error al procesar el pago");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
isProcessingPayment.value = false;
|
||||||
|
Notify.error("Ocurrió un error al procesar el pago");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Cambio de pestaña ────────────────────────────────────────────────────────
|
||||||
|
const switchTab = (tab) => {
|
||||||
|
activeTab.value = tab;
|
||||||
|
searchResults.value = [];
|
||||||
|
selectedFines.value = [];
|
||||||
|
searchMessage.value = "";
|
||||||
|
folioQuery.value = "";
|
||||||
|
curpQuery.value = "";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-5 p-6 min-h-[calc(100vh-80px)]">
|
||||||
|
|
||||||
|
<!-- Panel izquierdo -->
|
||||||
|
<div class="flex-1 flex flex-col gap-4 min-w-0">
|
||||||
|
|
||||||
|
<FineSearchPanel
|
||||||
|
:active-tab="activeTab"
|
||||||
|
:is-searching="isSearching"
|
||||||
|
:has-selected-fine="hasSelection"
|
||||||
|
v-model:folioQuery="folioQuery"
|
||||||
|
v-model:curpQuery="curpQuery"
|
||||||
|
@tab-change="switchTab"
|
||||||
|
@search-folio="searchByFolio"
|
||||||
|
@search-curp="searchByCurp"
|
||||||
|
@qr-detected="searchByQR"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Resultados de búsqueda -->
|
||||||
|
<div v-if="searchResults.length > 0" class="rounded-xl bg-white p-6 shadow-lg">
|
||||||
|
<h3 class="font-semibold text-gray-800 mb-0.5">Resultados de la búsqueda</h3>
|
||||||
|
<p class="text-sm text-gray-400 mb-4">{{ searchMessage }}</p>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<FineResultCard
|
||||||
|
v-for="fine in searchResults"
|
||||||
|
:key="fine.id"
|
||||||
|
:fine="fine"
|
||||||
|
:is-selected="isFineSelected(fine)"
|
||||||
|
:selectable="activeTab === 'curp'"
|
||||||
|
@toggle="toggleFineSelection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel derecho: Resumen de Cobro -->
|
||||||
|
<FinePaymentSummary
|
||||||
|
:selected-fines="selectedFines"
|
||||||
|
:is-processing-payment="isProcessingPayment"
|
||||||
|
@pay="handlePayment"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
338
src/components/App/MembershipChargeModal.vue
Normal file
338
src/components/App/MembershipChargeModal.vue
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { apiURL, useApi } from "@/services/Api.js";
|
||||||
|
|
||||||
|
import ShowModal from "@Holos/Modal/Show.vue";
|
||||||
|
import Input from "@Holos/Form/Input.vue";
|
||||||
|
import Selectable from "@Holos/Form/Selectable.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
memberId: [Number, String],
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "charged"]);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const loadingConcepts = ref(false);
|
||||||
|
const processing = ref(false);
|
||||||
|
const conceptOptions = ref([]);
|
||||||
|
const selectedConcept = ref(null);
|
||||||
|
const quantity = ref(1);
|
||||||
|
const unitAmountInPeso = ref(null);
|
||||||
|
const totalAmountInPeso = ref(null);
|
||||||
|
const loadingUnitAmountInPeso = ref(false);
|
||||||
|
const loadingTotalAmountInPeso = ref(false);
|
||||||
|
const umaConversionCache = ref({});
|
||||||
|
|
||||||
|
const unitAmount = computed(() => {
|
||||||
|
if (!selectedConcept.value) return 0;
|
||||||
|
|
||||||
|
return parseFloat(
|
||||||
|
selectedConcept.value.unit_cost_peso ?? selectedConcept.value.unit_cost_uma ?? 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPesoCharge = computed(() =>
|
||||||
|
selectedConcept.value?.charge_type?.startsWith("peso_")
|
||||||
|
);
|
||||||
|
|
||||||
|
const unitAmountLabel = computed(() => {
|
||||||
|
if (!selectedConcept.value || !unitAmount.value) return "No definido";
|
||||||
|
|
||||||
|
if (isPesoCharge.value) {
|
||||||
|
return `$${unitAmount.value.toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${unitAmount.value.toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} UMA`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalLabel = computed(() => {
|
||||||
|
const total = unitAmount.value * (parseInt(quantity.value, 10) || 1);
|
||||||
|
|
||||||
|
if (!selectedConcept.value || !total) return "No definido";
|
||||||
|
|
||||||
|
if (isPesoCharge.value) {
|
||||||
|
return `$${total.toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${total.toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} UMA`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalUmaAmount = computed(
|
||||||
|
() => unitAmount.value * (parseInt(quantity.value, 10) || 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const unitAmountInPesoLabel = computed(() => {
|
||||||
|
if (unitAmountInPeso.value == null) return "";
|
||||||
|
|
||||||
|
return `$${Number(unitAmountInPeso.value).toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAmountInPesoLabel = computed(() => {
|
||||||
|
if (totalAmountInPeso.value == null) return "";
|
||||||
|
|
||||||
|
return `$${Number(totalAmountInPeso.value).toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
selectedConcept.value = null;
|
||||||
|
quantity.value = 1;
|
||||||
|
unitAmountInPeso.value = null;
|
||||||
|
totalAmountInPeso.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConceptOptions() {
|
||||||
|
loadingConcepts.value = true;
|
||||||
|
|
||||||
|
await api.get(apiURL("charge-concepts"), {
|
||||||
|
params: {
|
||||||
|
type: "membership",
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
conceptOptions.value =
|
||||||
|
data.models?.data || data.data || data.models || data || [];
|
||||||
|
},
|
||||||
|
onFail: (error) => {
|
||||||
|
Notify.warning(error.message || "No se pudieron cargar los conceptos");
|
||||||
|
conceptOptions.value = [];
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error("Error al cargar los conceptos");
|
||||||
|
conceptOptions.value = [];
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
loadingConcepts.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
async (show) => {
|
||||||
|
if (show) {
|
||||||
|
resetForm();
|
||||||
|
await loadConceptOptions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
async function calculateUmaToPeso(value, targetRef, loadingRef) {
|
||||||
|
const numericValue = Number(value);
|
||||||
|
|
||||||
|
if (!numericValue || Number.isNaN(numericValue)) {
|
||||||
|
targetRef.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = numericValue.toString();
|
||||||
|
|
||||||
|
if (umaConversionCache.value[cacheKey] != null) {
|
||||||
|
targetRef.value = umaConversionCache.value[cacheKey];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingRef.value = true;
|
||||||
|
|
||||||
|
await api.post(apiURL("charge-concepts/calculate-uma"), {
|
||||||
|
data: {
|
||||||
|
value: numericValue,
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
umaConversionCache.value[cacheKey] = data.result;
|
||||||
|
targetRef.value = data.result;
|
||||||
|
},
|
||||||
|
onFail: () => {
|
||||||
|
targetRef.value = null;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
targetRef.value = null;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
loadingRef.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, isPesoCharge.value, unitAmount.value],
|
||||||
|
async ([show, isPesoChargeValue]) => {
|
||||||
|
if (!show || isPesoChargeValue || !selectedConcept.value) {
|
||||||
|
unitAmountInPeso.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await calculateUmaToPeso(
|
||||||
|
unitAmount.value,
|
||||||
|
unitAmountInPeso,
|
||||||
|
loadingUnitAmountInPeso
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, isPesoCharge.value, totalUmaAmount.value],
|
||||||
|
async ([show, isPesoChargeValue]) => {
|
||||||
|
if (!show || isPesoChargeValue || !selectedConcept.value) {
|
||||||
|
totalAmountInPeso.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await calculateUmaToPeso(
|
||||||
|
totalUmaAmount.value,
|
||||||
|
totalAmountInPeso,
|
||||||
|
loadingTotalAmountInPeso
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
async function submitCharge() {
|
||||||
|
if (!props.memberId) {
|
||||||
|
Notify.warning("Primero debes buscar un miembro");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedConcept.value?.id) {
|
||||||
|
Notify.warning("Selecciona un concepto");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericQuantity = parseInt(quantity.value, 10) || 0;
|
||||||
|
|
||||||
|
if (numericQuantity <= 0) {
|
||||||
|
Notify.warning("La cantidad debe ser mayor a 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processing.value = true;
|
||||||
|
|
||||||
|
await api.put(apiURL(`members/${props.memberId}/charge-membership`), {
|
||||||
|
data: {
|
||||||
|
charge_concept_id: selectedConcept.value.id,
|
||||||
|
quantity: numericQuantity,
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
Notify.success("Cobro registrado correctamente");
|
||||||
|
emit("charged", response);
|
||||||
|
emit("close");
|
||||||
|
},
|
||||||
|
onFail: (error) => {
|
||||||
|
Notify.warning(error.message || "No se pudo registrar el cobro");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error("Error al registrar el cobro");
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ShowModal
|
||||||
|
:show="show"
|
||||||
|
title="Agregar cobro"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<Selectable
|
||||||
|
v-model="selectedConcept"
|
||||||
|
label="name"
|
||||||
|
:options="conceptOptions"
|
||||||
|
title="Concepto"
|
||||||
|
:disabled="loadingConcepts || processing"
|
||||||
|
:placeholder="
|
||||||
|
loadingConcepts ? 'Cargando conceptos...' : 'Selecciona un concepto'
|
||||||
|
"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="quantity"
|
||||||
|
id="Cantidad"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:disabled="processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
:model-value="unitAmountLabel"
|
||||||
|
id="Costo unitario"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="selectedConcept && !isPesoCharge && loadingUnitAmountInPeso"
|
||||||
|
class="mt-1 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
Calculando equivalente en pesos...
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="selectedConcept && !isPesoCharge && unitAmountInPesoLabel"
|
||||||
|
class="mt-1 text-xs font-medium text-emerald-700"
|
||||||
|
>
|
||||||
|
Equivalente en pesos: {{ unitAmountInPesoLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
:model-value="isPesoCharge ? totalLabel : totalAmountInPesoLabel || 'No definido'"
|
||||||
|
id="Monto estimado"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="selectedConcept && !isPesoCharge"
|
||||||
|
class="mt-1 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
<span v-if="loadingTotalAmountInPeso">
|
||||||
|
Calculando monto en pesos...
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
Referencia en UMA: {{ totalLabel }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #buttons>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="processing || loadingConcepts"
|
||||||
|
@click="submitCharge"
|
||||||
|
>
|
||||||
|
{{ processing ? "Registrando..." : "Agregar cobro" }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ShowModal>
|
||||||
|
</template>
|
||||||
388
src/components/App/MembershipDetailModal.vue
Normal file
388
src/components/App/MembershipDetailModal.vue
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { apiURL, useApi } from "@/services/Api.js";
|
||||||
|
|
||||||
|
import ShowModal from "@Holos/Modal/Show.vue";
|
||||||
|
import Input from "@Holos/Form/Input.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
membership: Object,
|
||||||
|
loading: Boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "pay"]);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const quantity = ref(1);
|
||||||
|
const unitAmountInPeso = ref(null);
|
||||||
|
const totalAmountInPeso = ref(null);
|
||||||
|
const loadingUnitAmountInPeso = ref(false);
|
||||||
|
const loadingTotalAmountInPeso = ref(false);
|
||||||
|
const umaConversionCache = ref({});
|
||||||
|
|
||||||
|
const hasExpiration = computed(() => Boolean(props.membership?.expires_at));
|
||||||
|
|
||||||
|
const isNoExpires = computed(() => props.membership?.status === "no_expires");
|
||||||
|
|
||||||
|
const isExpired = computed(() => {
|
||||||
|
if (!props.membership || isNoExpires.value || !props.membership.expires_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(props.membership.expires_at) <= new Date();
|
||||||
|
});
|
||||||
|
|
||||||
|
const daysUntilExpiration = computed(() => {
|
||||||
|
if (!props.membership || isNoExpires.value || !props.membership.expires_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationDate = new Date(props.membership.expires_at);
|
||||||
|
const now = new Date();
|
||||||
|
const diffTime = expirationDate - now;
|
||||||
|
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
if (isNoExpires.value) return "Sin vencimiento";
|
||||||
|
if (isExpired.value || props.membership?.status === "expired") return "Vencida";
|
||||||
|
if (props.membership?.status === "active") return "Activa";
|
||||||
|
return props.membership?.status || "Sin estatus";
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusClasses = computed(() => {
|
||||||
|
if (isNoExpires.value) return "bg-slate-100 text-slate-700";
|
||||||
|
if (isExpired.value || props.membership?.status === "expired") {
|
||||||
|
return "bg-red-100 text-red-700";
|
||||||
|
}
|
||||||
|
if (props.membership?.status === "active") {
|
||||||
|
return "bg-green-100 text-green-700";
|
||||||
|
}
|
||||||
|
return "bg-gray-100 text-gray-700";
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedExpiration = computed(() => {
|
||||||
|
if (!hasExpiration.value) return "No aplica";
|
||||||
|
|
||||||
|
return new Date(props.membership.expires_at).toLocaleDateString("es-MX", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const unitAmount = computed(() => {
|
||||||
|
const concept = props.membership?.charge_concept;
|
||||||
|
if (!concept) return 0;
|
||||||
|
|
||||||
|
return parseFloat(concept.unit_cost_peso ?? concept.unit_cost_uma ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPesoCharge = computed(() =>
|
||||||
|
props.membership?.charge_concept?.charge_type?.startsWith("peso_")
|
||||||
|
);
|
||||||
|
|
||||||
|
const unitAmountLabel = computed(() => {
|
||||||
|
if (!unitAmount.value) return "No definido";
|
||||||
|
|
||||||
|
if (isPesoCharge.value) {
|
||||||
|
return `$${unitAmount.value.toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${unitAmount.value.toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} UMA`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalLabel = computed(() => {
|
||||||
|
const total = unitAmount.value * (parseInt(quantity.value, 10) || 1);
|
||||||
|
|
||||||
|
if (isPesoCharge.value) {
|
||||||
|
return `$${total.toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${total.toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} UMA`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalUmaAmount = computed(
|
||||||
|
() => unitAmount.value * (parseInt(quantity.value, 10) || 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const paymentsCount = computed(() => props.membership?.payments?.length || 0);
|
||||||
|
|
||||||
|
const expirationHint = computed(() => {
|
||||||
|
if (!hasExpiration.value || isNoExpires.value || daysUntilExpiration.value == null) {
|
||||||
|
return "No requiere renovación";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysUntilExpiration.value < 0) {
|
||||||
|
return "Membresía vencida";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysUntilExpiration.value === 0) return "Vence hoy";
|
||||||
|
if (daysUntilExpiration.value === 1) return "Vence mañana";
|
||||||
|
|
||||||
|
return `Faltan ${daysUntilExpiration.value} días para que venza`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const unitAmountInPesoLabel = computed(() => {
|
||||||
|
if (unitAmountInPeso.value == null) return "";
|
||||||
|
|
||||||
|
return `$${Number(unitAmountInPeso.value).toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAmountInPesoLabel = computed(() => {
|
||||||
|
if (totalAmountInPeso.value == null) return "";
|
||||||
|
|
||||||
|
return `$${Number(totalAmountInPeso.value).toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function calculateUmaToPeso(value, targetRef, loadingRef) {
|
||||||
|
const numericValue = Number(value);
|
||||||
|
|
||||||
|
if (!numericValue || Number.isNaN(numericValue)) {
|
||||||
|
targetRef.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = numericValue.toString();
|
||||||
|
|
||||||
|
if (umaConversionCache.value[cacheKey] != null) {
|
||||||
|
targetRef.value = umaConversionCache.value[cacheKey];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingRef.value = true;
|
||||||
|
|
||||||
|
await api.post(apiURL("charge-concepts/calculate-uma"), {
|
||||||
|
data: {
|
||||||
|
value: numericValue,
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
umaConversionCache.value[cacheKey] = data.result;
|
||||||
|
targetRef.value = data.result;
|
||||||
|
},
|
||||||
|
onFail: () => {
|
||||||
|
targetRef.value = null;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
targetRef.value = null;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
loadingRef.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, props.membership?.id],
|
||||||
|
() => {
|
||||||
|
quantity.value = 1;
|
||||||
|
unitAmountInPeso.value = null;
|
||||||
|
totalAmountInPeso.value = null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, isPesoCharge.value, unitAmount.value],
|
||||||
|
async ([show, isPesoChargeValue]) => {
|
||||||
|
if (!show || isPesoChargeValue) {
|
||||||
|
unitAmountInPeso.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await calculateUmaToPeso(
|
||||||
|
unitAmount.value,
|
||||||
|
unitAmountInPeso,
|
||||||
|
loadingUnitAmountInPeso
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, isPesoCharge.value, totalUmaAmount.value],
|
||||||
|
async ([show, isPesoChargeValue]) => {
|
||||||
|
if (!show || isPesoChargeValue) {
|
||||||
|
totalAmountInPeso.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await calculateUmaToPeso(
|
||||||
|
totalUmaAmount.value,
|
||||||
|
totalAmountInPeso,
|
||||||
|
loadingTotalAmountInPeso
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function submitPayment() {
|
||||||
|
emit("pay", { quantity: parseInt(quantity.value, 10) || 1 });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ShowModal
|
||||||
|
:show="show"
|
||||||
|
:title="membership?.charge_concept?.name || 'Detalle de membresía'"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<div v-if="membership" class="space-y-4 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4 rounded-lg bg-gray-50 p-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-gray-500">Estatus</p>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-gray-900">
|
||||||
|
{{ membership.status || "Sin estatus" }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs" :class="isExpired ? 'text-red-600' : 'text-green-700'">
|
||||||
|
{{ expirationHint }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
:class="statusClasses"
|
||||||
|
>
|
||||||
|
{{ statusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
:model-value="membership.charge_concept?.name || ''"
|
||||||
|
id="Concepto"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
:model-value="membership.charge_concept?.short_name || ''"
|
||||||
|
id="Clave"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
:model-value="membership.charge_concept?.charge_type || ''"
|
||||||
|
id="Tipo de cobro"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
:model-value="formattedExpiration"
|
||||||
|
id="Vencimiento"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
:model-value="unitAmountLabel"
|
||||||
|
id="Costo unitario"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="!isPesoCharge && loadingUnitAmountInPeso"
|
||||||
|
class="mt-1 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
Calculando equivalente en pesos...
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="!isPesoCharge && unitAmountInPesoLabel"
|
||||||
|
class="mt-1 text-xs font-medium text-emerald-700"
|
||||||
|
>
|
||||||
|
Equivalente en pesos: {{ unitAmountInPesoLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
:model-value="String(paymentsCount)"
|
||||||
|
id="Pagos registrados"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isExpired"
|
||||||
|
class="space-y-4 rounded-xl border border-red-200 bg-red-50 p-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-red-700">
|
||||||
|
La membresía está vencida. Puedes registrar un nuevo pago para renovarla.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
v-model="quantity"
|
||||||
|
id="Cantidad"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
:model-value="isPesoCharge ? totalLabel : totalAmountInPesoLabel"
|
||||||
|
id="Monto estimado"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="!isPesoCharge"
|
||||||
|
class="mt-1 text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
<span v-if="loadingTotalAmountInPeso">
|
||||||
|
Calculando monto en pesos...
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
Referencia en UMA: {{ totalLabel }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="isNoExpires"
|
||||||
|
class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700"
|
||||||
|
>
|
||||||
|
Esta membresía no tiene fecha de vencimiento, por lo que no requiere renovación.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-700"
|
||||||
|
>
|
||||||
|
Esta membresía sigue activa y no requiere pago por el momento.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #buttons>
|
||||||
|
<button
|
||||||
|
v-if="membership && isExpired"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-green-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="submitPayment"
|
||||||
|
>
|
||||||
|
{{ loading ? "Procesando..." : "Renovar membresía" }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ShowModal>
|
||||||
|
</template>
|
||||||
160
src/components/App/MembershipListItem.vue
Normal file
160
src/components/App/MembershipListItem.vue
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
membership: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["select"]);
|
||||||
|
|
||||||
|
const hasExpiration = computed(() => Boolean(props.membership?.expires_at));
|
||||||
|
|
||||||
|
const isNoExpires = computed(() => props.membership?.status === "no_expires");
|
||||||
|
|
||||||
|
const isExpired = computed(() => {
|
||||||
|
if (!props.membership || isNoExpires.value || !props.membership.expires_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(props.membership.expires_at) <= new Date();
|
||||||
|
});
|
||||||
|
|
||||||
|
const daysUntilExpiration = computed(() => {
|
||||||
|
if (!props.membership || isNoExpires.value || !props.membership.expires_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationDate = new Date(props.membership.expires_at);
|
||||||
|
const now = new Date();
|
||||||
|
const diffTime = expirationDate - now;
|
||||||
|
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
if (isNoExpires.value) return "Sin vencimiento";
|
||||||
|
if (isExpired.value || props.membership?.status === "expired") return "Vencida";
|
||||||
|
if (props.membership?.status === "active") return "Activa";
|
||||||
|
return props.membership?.status || "Sin estatus";
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusClasses = computed(() => {
|
||||||
|
if (isNoExpires.value) return "bg-slate-100 text-slate-700";
|
||||||
|
if (isExpired.value || props.membership?.status === "expired") {
|
||||||
|
return "bg-red-100 text-red-700";
|
||||||
|
}
|
||||||
|
if (props.membership?.status === "active") {
|
||||||
|
return "bg-green-100 text-green-700";
|
||||||
|
}
|
||||||
|
return "bg-gray-100 text-gray-700";
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardClasses = computed(() => {
|
||||||
|
if (isNoExpires.value) {
|
||||||
|
return "border-slate-200 bg-slate-50 hover:border-slate-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpired.value || props.membership?.status === "expired") {
|
||||||
|
return "border-red-200 bg-red-50 hover:border-red-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.membership?.status === "active") {
|
||||||
|
return "border-green-300 bg-gradient-to-r from-green-50 to-emerald-50 hover:border-green-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "border-gray-200 bg-white hover:border-primary";
|
||||||
|
});
|
||||||
|
|
||||||
|
const expirationHint = computed(() => {
|
||||||
|
if (!hasExpiration.value || isNoExpires.value || daysUntilExpiration.value == null) {
|
||||||
|
return "No requiere renovación";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysUntilExpiration.value < 0) {
|
||||||
|
return "Membresía vencida";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysUntilExpiration.value === 0) {
|
||||||
|
return "Vence hoy";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysUntilExpiration.value === 1) {
|
||||||
|
return "Vence mañana";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Faltan ${daysUntilExpiration.value} días`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const expirationHintClasses = computed(() => {
|
||||||
|
if (isNoExpires.value) return "bg-slate-100 text-slate-600";
|
||||||
|
if (isExpired.value || props.membership?.status === "expired") {
|
||||||
|
return "bg-red-100 text-red-600";
|
||||||
|
}
|
||||||
|
if (props.membership?.status === "active") {
|
||||||
|
return daysUntilExpiration.value != null && daysUntilExpiration.value <= 7
|
||||||
|
? "bg-amber-100 text-amber-700"
|
||||||
|
: "bg-green-100 text-green-700";
|
||||||
|
}
|
||||||
|
return "bg-gray-100 text-gray-600";
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedExpiration = computed(() => {
|
||||||
|
if (!hasExpiration.value) return "No aplica";
|
||||||
|
|
||||||
|
return new Date(props.membership.expires_at).toLocaleDateString("es-MX", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-xl border p-4 text-left shadow-sm transition hover:shadow-md"
|
||||||
|
:class="cardClasses"
|
||||||
|
@click="emit('select', membership)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-base font-semibold text-gray-900">
|
||||||
|
{{ membership.charge_concept?.name || "Membresía sin nombre" }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{{ membership.charge_concept?.short_name || "Sin clave corta" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
:class="statusClasses"
|
||||||
|
>
|
||||||
|
{{ statusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 inline-flex rounded-full px-3 py-1 text-sm font-medium" :class="expirationHintClasses">
|
||||||
|
{{ expirationHint }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="rounded-lg bg-white/70 p-3">
|
||||||
|
<p class="text-xs uppercase tracking-wide text-gray-500">Estatus</p>
|
||||||
|
<p class="mt-1 text-sm font-medium text-gray-800">
|
||||||
|
{{ statusLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-white/70 p-3">
|
||||||
|
<p class="text-xs uppercase tracking-wide text-gray-500">Vencimiento</p>
|
||||||
|
<p class="mt-1 text-sm font-medium text-gray-800">
|
||||||
|
{{ formattedExpiration }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
312
src/components/App/MembershipSection.vue
Normal file
312
src/components/App/MembershipSection.vue
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { apiURL, useApi } from "@/services/Api.js";
|
||||||
|
|
||||||
|
import Input from "@Holos/Form/Input.vue";
|
||||||
|
import MembershipChargeModal from "@App/MembershipChargeModal.vue";
|
||||||
|
import MembershipDetailModal from "@App/MembershipDetailModal.vue";
|
||||||
|
import MembershipListItem from "@App/MembershipListItem.vue";
|
||||||
|
|
||||||
|
const emit = defineEmits(["membership-paid"]);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
|
const searching = ref(false);
|
||||||
|
const processingPayment = ref(false);
|
||||||
|
const curp = ref("");
|
||||||
|
const showAddChargeModal = ref(false);
|
||||||
|
const showMembershipModal = ref(false);
|
||||||
|
const selectedMembership = ref(null);
|
||||||
|
|
||||||
|
const emptyMemberData = () => ({
|
||||||
|
id: null,
|
||||||
|
curp: "",
|
||||||
|
name: "",
|
||||||
|
tutor: null,
|
||||||
|
photo: "",
|
||||||
|
photo_url: "",
|
||||||
|
memberships: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberData = ref(emptyMemberData());
|
||||||
|
|
||||||
|
const hasMemberships = computed(
|
||||||
|
() => memberData.value.memberships && memberData.value.memberships.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeMember(model) {
|
||||||
|
return {
|
||||||
|
id: model.id,
|
||||||
|
curp: model.curp,
|
||||||
|
name: model.name,
|
||||||
|
tutor: model.tutor,
|
||||||
|
photo: model.photo,
|
||||||
|
photo_url: model.photo_url,
|
||||||
|
memberships: model.memberships || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMemberData({ preserveQuery = true } = {}) {
|
||||||
|
memberData.value = emptyMemberData();
|
||||||
|
selectedMembership.value = null;
|
||||||
|
showAddChargeModal.value = false;
|
||||||
|
showMembershipModal.value = false;
|
||||||
|
|
||||||
|
if (!preserveQuery) {
|
||||||
|
curp.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMemberByCurp(searchCurp, { notify = true } = {}) {
|
||||||
|
searching.value = true;
|
||||||
|
|
||||||
|
await api.get(apiURL("members/search"), {
|
||||||
|
params: {
|
||||||
|
curp: searchCurp,
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const model = response.model;
|
||||||
|
memberData.value = normalizeMember(model);
|
||||||
|
|
||||||
|
if (notify) {
|
||||||
|
if (memberData.value.memberships.length === 0) {
|
||||||
|
Notify.warning("El miembro no tiene membresías registradas");
|
||||||
|
} else {
|
||||||
|
Notify.success("Miembro encontrado");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFail: (error) => {
|
||||||
|
Notify.warning(error.message || "No se encontró el miembro con ese CURP");
|
||||||
|
clearMemberData();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error("Error al buscar el miembro");
|
||||||
|
clearMemberData();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
searching.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
if (!curp.value.trim()) {
|
||||||
|
Notify.warning("Por favor ingresa un CURP");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await searchMemberByCurp(curp.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMembershipDetails(membership) {
|
||||||
|
selectedMembership.value = membership;
|
||||||
|
showMembershipModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMembershipDetails() {
|
||||||
|
showMembershipModal.value = false;
|
||||||
|
selectedMembership.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddChargeModal() {
|
||||||
|
showAddChargeModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddChargeModal() {
|
||||||
|
showAddChargeModal.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePayment({ quantity }) {
|
||||||
|
if (!memberData.value.id || !selectedMembership.value) {
|
||||||
|
Notify.warning("No hay una membresía seleccionada");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processingPayment.value = true;
|
||||||
|
|
||||||
|
await api.put(apiURL(`members/${memberData.value.id}/charge-membership`), {
|
||||||
|
data: {
|
||||||
|
charge_concept_id: selectedMembership.value.charge_concept.id,
|
||||||
|
quantity,
|
||||||
|
},
|
||||||
|
onSuccess: async (response) => {
|
||||||
|
Notify.success("Pago de membresía procesado correctamente");
|
||||||
|
|
||||||
|
emit("membership-paid", {
|
||||||
|
member: response.member,
|
||||||
|
membership: response.membership,
|
||||||
|
payment: response.payment,
|
||||||
|
});
|
||||||
|
|
||||||
|
closeMembershipDetails();
|
||||||
|
await searchMemberByCurp(curp.value.trim(), { notify: false });
|
||||||
|
},
|
||||||
|
onFail: (error) => {
|
||||||
|
Notify.warning(error.message || "No se pudo procesar el pago");
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error("Error al procesar el pago");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
processingPayment.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChargeCreated(response) {
|
||||||
|
emit("membership-paid", {
|
||||||
|
member: response.member,
|
||||||
|
membership: response.membership,
|
||||||
|
payment: response.payment,
|
||||||
|
});
|
||||||
|
|
||||||
|
await searchMemberByCurp(curp.value.trim(), { notify: false });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="mb-6 m-3 max-w-auto rounded-xl bg-white p-6 shadow-lg">
|
||||||
|
<h3 class="mb-6 text-xl font-semibold text-gray-800">Buscar miembro</h3>
|
||||||
|
<form @submit.prevent="handleSearch">
|
||||||
|
<div class="grid items-end gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
v-model="curp"
|
||||||
|
id="CURP"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej: AMWO0020923"
|
||||||
|
:disabled="searching"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="searching"
|
||||||
|
class="h-[42px] rounded-lg bg-[#7a0b3a] px-8 py-3 text-white font-medium transition-colors hover:bg-[#68082e] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ searching ? "Buscando..." : "Buscar" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="memberData.id"
|
||||||
|
class="mb-6 m-3 max-w-auto rounded-xl bg-white p-6 shadow-lg"
|
||||||
|
>
|
||||||
|
<h3 class="mb-6 text-xl font-semibold text-gray-800">
|
||||||
|
Información del Miembro
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-600">
|
||||||
|
Fotografía
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="flex h-80 w-full items-center justify-center overflow-hidden rounded-lg bg-gray-200"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="memberData.photo_url"
|
||||||
|
:src="memberData.photo_url"
|
||||||
|
:alt="memberData.name"
|
||||||
|
class="h-80 w-80 object-cover"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-24 w-24 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<Input
|
||||||
|
:model-value="memberData.name"
|
||||||
|
id="Nombre"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
:model-value="memberData.curp"
|
||||||
|
id="CURP"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
:model-value="memberData.tutor || 'Sin tutor'"
|
||||||
|
id="Nombre Tutor"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<div class="rounded-lg bg-primary/5 p-4">
|
||||||
|
<p class="text-sm text-gray-600">Membresías encontradas</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold text-primary">
|
||||||
|
{{ memberData.memberships.length }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="memberData.id"
|
||||||
|
class="m-3 max-w-auto rounded-xl bg-white p-6 shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800">Membresías</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Haz clic en una membresía para ver el detalle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary/90"
|
||||||
|
@click="openAddChargeModal"
|
||||||
|
>
|
||||||
|
Agregar cobro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasMemberships" class="space-y-4">
|
||||||
|
<MembershipListItem
|
||||||
|
v-for="membership in memberData.memberships"
|
||||||
|
:key="membership.id"
|
||||||
|
:membership="membership"
|
||||||
|
@select="openMembershipDetails"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-8 text-center text-gray-500"
|
||||||
|
>
|
||||||
|
El miembro no tiene membresías registradas.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MembershipDetailModal
|
||||||
|
:show="showMembershipModal"
|
||||||
|
:membership="selectedMembership"
|
||||||
|
:loading="processingPayment"
|
||||||
|
@close="closeMembershipDetails"
|
||||||
|
@pay="handlePayment"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MembershipChargeModal
|
||||||
|
:show="showAddChargeModal"
|
||||||
|
:member-id="memberData.id"
|
||||||
|
@close="closeAddChargeModal"
|
||||||
|
@charged="handleChargeCreated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
31
src/components/App/PhotoUpload.vue
Normal file
31
src/components/App/PhotoUpload.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<script setup>
|
||||||
|
import SingleFile from '@Holos/Form/SingleFile.vue';
|
||||||
|
import Error from '@Holos/Form/Elements/Error.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
modelValue: [Object, File, String],
|
||||||
|
required: Boolean,
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
onError: [String, Array],
|
||||||
|
accept: { type: String, default: 'image/png, image/jpeg' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<SingleFile
|
||||||
|
:model-value="modelValue"
|
||||||
|
:title="title"
|
||||||
|
:required="required"
|
||||||
|
:accept="accept"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #previous>
|
||||||
|
<slot name="previous" />
|
||||||
|
</template>
|
||||||
|
</SingleFile>
|
||||||
|
<Error :onError="onError" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
130
src/components/App/QRscan.vue
Normal file
130
src/components/App/QRscan.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { QrcodeStream } from 'vue-qrcode-reader';
|
||||||
|
|
||||||
|
/** Props y Emits */
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'qr-detected']);
|
||||||
|
|
||||||
|
/** Refs */
|
||||||
|
const error = ref('');
|
||||||
|
const isScanning = ref(true);
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
function paintBoundingBox(detectedCodes, ctx) {
|
||||||
|
for (const detectedCode of detectedCodes) {
|
||||||
|
const {
|
||||||
|
boundingBox: { x, y, width, height }
|
||||||
|
} = detectedCode;
|
||||||
|
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.strokeStyle = '#10b981';
|
||||||
|
ctx.strokeRect(x, y, width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDetect(detectedCodes) {
|
||||||
|
if (detectedCodes.length > 0) {
|
||||||
|
const code = detectedCodes[0].rawValue;
|
||||||
|
|
||||||
|
// Emitir el código escaneado al componente padre
|
||||||
|
emit('update:modelValue', code);
|
||||||
|
|
||||||
|
// Pausar el escaneo después de detectar
|
||||||
|
isScanning.value = false;
|
||||||
|
|
||||||
|
// Emitir evento con el código detectado
|
||||||
|
emit('qr-detected', code);
|
||||||
|
|
||||||
|
// Reactivar el escaneo después de 2 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
isScanning.value = true;
|
||||||
|
error.value = '';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onError(err) {
|
||||||
|
error.value = `[${err.name}]: `;
|
||||||
|
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
error.value += 'Necesitas otorgar permiso de acceso a la cámara';
|
||||||
|
} else if (err.name === 'NotFoundError') {
|
||||||
|
error.value += 'No se encontró cámara en este dispositivo';
|
||||||
|
} else if (err.name === 'NotSupportedError') {
|
||||||
|
error.value += 'Se requiere contexto seguro';
|
||||||
|
} else if (err.name === 'NotReadableError') {
|
||||||
|
error.value += '¿La cámara ya está en uso?';
|
||||||
|
} else if (err.name === 'OverconstrainedError') {
|
||||||
|
error.value += 'Las cámaras instaladas no son adecuadas';
|
||||||
|
} else if (err.name === 'StreamApiNotSupportedError') {
|
||||||
|
error.value += 'No es compatible con este navegador';
|
||||||
|
} else if (err.name === 'InsecureContextError') {
|
||||||
|
error.value += 'El acceso a la cámara solo se permite en contexto seguro.';
|
||||||
|
} else {
|
||||||
|
error.value += err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error de cámara:', err);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative w-full h-full">
|
||||||
|
<!-- Componente de escaneo QR -->
|
||||||
|
<QrcodeStream
|
||||||
|
v-if="isScanning"
|
||||||
|
@detect="onDetect"
|
||||||
|
@error="onError"
|
||||||
|
:track="paintBoundingBox"
|
||||||
|
class="w-full h-full rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
</QrcodeStream>
|
||||||
|
|
||||||
|
<!-- Mensaje de éxito -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-full h-full rounded-lg bg-green-600 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div class="text-center text-white">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-16 w-16 mx-auto mb-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-semibold">¡Código detectado!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensaje de error -->
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="absolute bottom-0 left-0 right-0 bg-red-500 text-white p-3 text-sm"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(video) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -76,6 +76,7 @@ defineExpose({
|
|||||||
:preserve-search="true"
|
:preserve-search="true"
|
||||||
:required="required && !value"
|
:required="required && !value"
|
||||||
:track-by="trackBy"
|
:track-by="trackBy"
|
||||||
|
:append-to-body="true"
|
||||||
@select="(x, y) => emit('select', x, y)"
|
@select="(x, y) => emit('select', x, y)"
|
||||||
>
|
>
|
||||||
<template #noOptions>
|
<template #noOptions>
|
||||||
|
|||||||
@ -49,7 +49,7 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<main class="flex h-full justify-center md:p-2">
|
<main class="flex h-full justify-center md:p-2">
|
||||||
<div class="mt-14 md:mt-0 w-full shadow-lg dark:shadow-xs md:dark:shadow-white h-[calc(100vh-4.5rem)] px-2 pb-4 md:rounded-sm overflow-y-auto overflow-x-auto transition-colors duration-300">
|
<div class="mt-14 md:mt-0 w-full h-[calc(100vh-4.5rem)] pb-4 md:rounded-sm overflow-y-auto overflow-x-auto bg-gray-50 transition-colors duration-300">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { APP_VERSION, APP_COPYRIGHT } from '@/config.js'
|
import { APP_COPYRIGHT } from '@/config.js'
|
||||||
import useDarkMode from '@Stores/DarkMode'
|
import useDarkMode from '@Stores/DarkMode'
|
||||||
|
|
||||||
import Logo from '@Holos/Logo.vue'
|
import Logo from '@Holos/Logo.vue'
|
||||||
@ -9,6 +9,8 @@ import IconButton from '@Holos/Button/Icon.vue'
|
|||||||
/** Definidores */
|
/** Definidores */
|
||||||
const darkMode = useDarkMode()
|
const darkMode = useDarkMode()
|
||||||
|
|
||||||
|
const year = (new Date).getFullYear();
|
||||||
|
|
||||||
/** Propiedades */
|
/** Propiedades */
|
||||||
defineProps({
|
defineProps({
|
||||||
title: String
|
title: String
|
||||||
@ -21,13 +23,20 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex bg-primary dark:bg-primary-d">
|
<div class="h-screen flex bg-white dark:bg-gray-900">
|
||||||
<div
|
<!-- Columna izquierda - Logo -->
|
||||||
class="relative flex w-full lg:w-full justify-around items-center with-transition"
|
<div class="hidden lg:flex lg:w-1/2 bg-[#621134] items-center justify-center relative">
|
||||||
:class="{'app-bg-light':darkMode.isLight,'app-bg-dark':darkMode.isDark}"
|
<div class="flex items-center justify-center">
|
||||||
>
|
<Logo size="xl" class="text-lg inline-flex" />
|
||||||
<header class="absolute top-0 flex w-full h-8 px-1 items-center justify-end text-white">
|
</div>
|
||||||
<div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Columna derecha - Formulario -->
|
||||||
|
<div class="flex w-full lg:w-1/2 items-center justify-center bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div class="w-full max-w-md px-8">
|
||||||
|
<!-- Mobile logo y dark mode toggle -->
|
||||||
|
<div class="lg:hidden mb-8 flex items-center justify-between">
|
||||||
|
<Logo size="md" class="text-lg inline-flex" />
|
||||||
<IconButton v-if="darkMode.isLight"
|
<IconButton v-if="darkMode.isLight"
|
||||||
icon="light_mode"
|
icon="light_mode"
|
||||||
:title="$t('app.theme.light')"
|
:title="$t('app.theme.light')"
|
||||||
@ -39,29 +48,15 @@ onMounted(() => {
|
|||||||
@click="darkMode.applyLight()"
|
@click="darkMode.applyLight()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
<div class="flex w-full flex-col items-center justify-center space-y-2">
|
|
||||||
<div class="flex space-x-2 items-center justify-start text-white">
|
|
||||||
<Logo
|
|
||||||
class="text-lg inline-flex"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="bg-white/10 w-full backdrop-blur-xs text-white px-4 py-4 rounded-sm max-w-80">
|
<main>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="absolute bottom-0 flex w-full h-8 px-4 items-center justify-between bg-primary dark:bg-primary-d backdrop-blur-md text-white transition-colors duration-global">
|
<footer class="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
<div>
|
<span>
|
||||||
<span>
|
© {{year}} {{ APP_COPYRIGHT }}
|
||||||
©2024 {{ APP_COPYRIGHT }}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>
|
|
||||||
APP {{ APP_VERSION }} API {{ $page.app.version }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -43,7 +43,7 @@ onMounted(() => {
|
|||||||
<div class="flex w-full flex-col space-y-2">
|
<div class="flex w-full flex-col space-y-2">
|
||||||
<div class="flex space-x-2 items-center justify-start text-white">
|
<div class="flex space-x-2 items-center justify-start text-white">
|
||||||
<Logo
|
<Logo
|
||||||
class="text-lg inline-flex"
|
size="md" class="text-lg inline-flex"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,24 +1,45 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { hasToken } from '@Services/Api';
|
import { hasToken } from '@Services/Api';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
/** Definidores */
|
/** Definidores */
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
/* Propiedades */
|
||||||
|
const props = defineProps({
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'lg',
|
||||||
|
validator: (value) => ['sm', 'md', 'lg', 'xl'].includes(value)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
const home = () => {
|
const home = () => {
|
||||||
if(hasToken()) {
|
if(hasToken()) {
|
||||||
router.push({ name: 'dashboard.index' });
|
router.push({ name: 'address.index' });
|
||||||
} else {
|
} else {
|
||||||
location.replace('/');
|
location.replace('/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Computed */
|
||||||
|
const heightClass = computed(() => {
|
||||||
|
const size = {
|
||||||
|
sm: 'h-12',
|
||||||
|
md: 'h-16',
|
||||||
|
lg: 'h-20',
|
||||||
|
xl: 'h-64'
|
||||||
|
}
|
||||||
|
return size[props.size];
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex w-full justify-center items-center space-x-2 cursor-pointer"
|
class="flex w-full justify-center items-center space-x-2 cursor-pointer"
|
||||||
@click="home"
|
@click="home"
|
||||||
>
|
>
|
||||||
<img :src="$page.app.logo" class="h-20" />
|
<img :src="'/logo.png'" :class="heightClass" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -76,7 +76,7 @@ onUnmounted(() => {
|
|||||||
<div v-show="show" class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 bg-primary/90 z-50 transition-all" scroll-region>
|
<div v-show="show" class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 bg-primary/90 z-50 transition-all" scroll-region>
|
||||||
<div
|
<div
|
||||||
v-show="show"
|
v-show="show"
|
||||||
class="mb-6 bg-page text-page-t dark:bg-page-d dark:text-page-dt rounded-sm overflow-hidden shadow-xl transform transition-all sm:w-full sm:mx-auto"
|
class="mb-6 bg-page text-page-t dark:bg-page-d dark:text-page-dt rounded-sm overflow-visible shadow-xl transform transition-all sm:w-full sm:mx-auto"
|
||||||
:class="maxWidthClass"
|
:class="maxWidthClass"
|
||||||
>
|
>
|
||||||
<slot v-if="show" />
|
<slot v-if="show" />
|
||||||
|
|||||||
@ -29,7 +29,7 @@ const props = defineProps({
|
|||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="w-full right-0">
|
<div class="w-full right-0">
|
||||||
<div class="overflow-hidden shadow-lg">
|
<div class="overflow-visible shadow-lg">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,32 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink } from 'vue-router';
|
|
||||||
|
|
||||||
import IconButton from '@Holos/Button/Icon.vue'
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: String
|
title: String
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="title" class="flex w-full justify-center">
|
<div v-if="title" class="flex w-full justify-center py-4">
|
||||||
<h2
|
<h1
|
||||||
class="font-bold text-xl uppercase"
|
class="font-bold text-2xl tracking-wide"
|
||||||
v-text="title"
|
v-text="title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full justify-end py-[0.31rem] mb-2 border-y-2 border-page-t dark:border-page-dt">
|
<div v-if="$slots.default" class="flex w-full justify-end py-2 mb-3">
|
||||||
<div id="buttons" class="flex items-center space-x-1 text-sm">
|
<div id="buttons" class="flex items-center space-x-1 text-sm">
|
||||||
<slot />
|
<slot />
|
||||||
<RouterLink :to="$view({ name: 'index' })">
|
|
||||||
<IconButton
|
|
||||||
:title="$t('home')"
|
|
||||||
class="text-white"
|
|
||||||
icon="home"
|
|
||||||
filled
|
|
||||||
/>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -38,7 +38,7 @@ const clear = () => {
|
|||||||
v-text="title"
|
v-text="title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full justify-between items-center border-y-2 border-page-t dark:border-page-dt">
|
<div class="flex w-full pl-10 pb-2 items-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="relative py-1 z-0">
|
<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 @click="search" class="absolute inset-y-0 right-2 flex items-center pl-3 cursor-pointer">
|
||||||
@ -57,7 +57,7 @@ const clear = () => {
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="search"
|
id="search"
|
||||||
class="bg-gray-100 border border-gray-300 text-gray-700 text-sm rounded-sm outline-0 focus:ring-primary focus:border-primary block sm:w-56 md:w-72 lg:w-80 pr-10 px-2.5 py-1"
|
class="bg-gray-100 border border-gray-900 text-gray-700 text-sm rounded-sm outline-0 focus:ring-primary focus:border-primary block sm:w-56 md:w-72 lg:w-80 pr-10 px-2.5 py-1"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
required
|
required
|
||||||
@ -69,14 +69,6 @@ const clear = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-1 text-sm" id="buttons">
|
<div class="flex items-center space-x-1 text-sm" id="buttons">
|
||||||
<slot />
|
<slot />
|
||||||
<RouterLink :to="$view({name:'index'})">
|
|
||||||
<IconButton
|
|
||||||
:title="$t('home')"
|
|
||||||
class="text-white"
|
|
||||||
icon="home"
|
|
||||||
filled
|
|
||||||
/>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -66,22 +66,6 @@ const loader = useLoader()
|
|||||||
/>
|
/>
|
||||||
<span class="text-xs">{{ notifier.counter }}</span>
|
<span class="text-xs">{{ notifier.counter }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="darkMode.isDark">
|
|
||||||
<GoogleIcon
|
|
||||||
class="text-xl mt-1"
|
|
||||||
name="light_mode"
|
|
||||||
:title="$t('notifications.title')"
|
|
||||||
@click="darkMode.applyLight()"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li v-else>
|
|
||||||
<GoogleIcon
|
|
||||||
class="text-xl mt-1"
|
|
||||||
name="dark_mode"
|
|
||||||
:title="$t('notifications.title')"
|
|
||||||
@click="darkMode.applyDark()"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Dropdown align="right" width="48">
|
<Dropdown align="right" width="48">
|
||||||
|
|||||||
@ -33,7 +33,7 @@ const year = (new Date).getFullYear();
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex w-full px-2 mt-2">
|
<div class="flex w-full px-2 mt-2">
|
||||||
<Logo
|
<Logo
|
||||||
class="text-lg inline-flex"
|
size="md" class="text-lg inline-flex"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul class="flex h-full flex-col md:pb-4 space-y-1">
|
<ul class="flex h-full flex-col md:pb-4 space-y-1">
|
||||||
@ -44,9 +44,9 @@ const year = (new Date).getFullYear();
|
|||||||
<p class="block text-center text-xs">
|
<p class="block text-center text-xs">
|
||||||
© {{year}} {{ APP_COPYRIGHT }}
|
© {{year}} {{ APP_COPYRIGHT }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-center text-xs text-yellow-500 cursor-pointer">
|
<!-- <p class="text-center text-xs text-yellow-500 cursor-pointer">
|
||||||
<RouterLink :to="{name:'changelogs.app'}"> APP {{ APP_VERSION }} </RouterLink> <RouterLink :to="{name:'changelogs.core'}"> API {{ $page.app.version }} </RouterLink>
|
<RouterLink :to="{name:'changelogs.app'}"> APP {{ APP_VERSION }} </RouterLink> <RouterLink :to="{name:'changelogs.core'}"> API {{ $page.app.version }} </RouterLink>
|
||||||
</p>
|
</p> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,10 +18,10 @@ const props = defineProps({
|
|||||||
|
|
||||||
const classes = computed(() => {
|
const classes = computed(() => {
|
||||||
let status = props.to === vroute.name
|
let status = props.to === vroute.name
|
||||||
? 'bg-secondary/30 dark:bg-secondary-d/30 border-secondary dark:border-secondary-d'
|
? 'bg-white/20 dark:bg-white/20 border-white dark:border-white font-semibold'
|
||||||
: 'border-transparent';
|
: 'border-transparent';
|
||||||
|
|
||||||
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-white/10 dark:hover:bg-white/10 border-l-4 hover:border-white/50 dark:hover:border-white/50 pr-6 ${status} transition-all duration-200`
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeSidebar = () => {
|
const closeSidebar = () => {
|
||||||
|
|||||||
@ -16,15 +16,15 @@ const props = defineProps({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="pb-2">
|
<section class="pb-2">
|
||||||
<div class="w-full overflow-hidden rounded-sm shadow-lg dark:shadow-xs dark:shadow-white">
|
<div class="w-full overflow-hidden">
|
||||||
<div class="w-full overflow-x-auto">
|
<div class="w-full overflow-x-auto">
|
||||||
<table v-if="!processing" class="w-full">
|
<table v-if="!processing" class="w-full bg-white">
|
||||||
<thead class="bg-primary text-primary-t dark:bg-primary-d dark:text-primary-dt">
|
<thead class="bg-primary text-primary-t dark:bg-primary-d dark:text-primary-dt">
|
||||||
<tr>
|
<tr>
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<template v-if="items?.total > 0">
|
<template v-if="items?.total > 0">
|
||||||
<slot
|
<slot
|
||||||
name="body"
|
name="body"
|
||||||
|
|||||||
@ -56,15 +56,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-row {
|
.table-row {
|
||||||
@apply hover:bg-secondary/10 dark:hover:bg-secondary-d/10 transition-colors duration-100
|
@apply hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-cell {
|
.table-cell {
|
||||||
@apply px-2 py-0.5 text-sm border border-primary/30 dark:border-primary-dt/30;
|
@apply px-6 py-4 text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-actions {
|
.table-actions {
|
||||||
@apply flex justify-center items-center space-x-1;
|
@apply flex justify-center items-center space-x-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a.router-link-active {
|
nav a.router-link-active {
|
||||||
|
|||||||
@ -80,6 +80,91 @@ export default {
|
|||||||
description: 'Historial de acciones realizadas por los usuarios en orden cronológico.'
|
description: 'Historial de acciones realizadas por los usuarios en orden cronológico.'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
address: {
|
||||||
|
title: 'Direcciones',
|
||||||
|
name: 'Nombre de la Dirección',
|
||||||
|
placeholder: 'Ingrese el nombre de la dirección',
|
||||||
|
validation: {
|
||||||
|
required: 'Por favor ingresa un nombre de dirección'
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
title: 'Crear nueva dirección',
|
||||||
|
description: 'Permite crear nuevas direcciones.',
|
||||||
|
onSuccess: 'Dirección creada exitosamente',
|
||||||
|
onError: 'Error al crear la dirección'
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
title: 'Editar dirección',
|
||||||
|
description: 'Permite editar direcciones existentes.',
|
||||||
|
onSuccess: 'Dirección actualizada exitosamente',
|
||||||
|
onError: 'Error al actualizar la dirección'
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
title: 'Listado de Direcciones',
|
||||||
|
empty: 'No hay direcciones registradas'
|
||||||
|
},
|
||||||
|
deleted: 'Dirección eliminada',
|
||||||
|
permissions: {
|
||||||
|
title: 'Permisos de dirección'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
concept: {
|
||||||
|
title: 'Conceptos de Cobro',
|
||||||
|
direction: 'Dirección',
|
||||||
|
shortName: 'Nombre Corto',
|
||||||
|
name: 'Nombre',
|
||||||
|
legal: 'Instrumento Legal',
|
||||||
|
motivation: 'Motivación',
|
||||||
|
article: 'Articulado',
|
||||||
|
description: 'Contenido',
|
||||||
|
chargeType: 'Tipo de Cobro',
|
||||||
|
conceptType: 'Tipo de Concepto',
|
||||||
|
defineChargeType: 'Definiciones del Concepto de Cobro',
|
||||||
|
minimumAmountUma: 'Monto Mínimo (UMAs)',
|
||||||
|
maximumAmountUma: 'Monto Máximo (UMAs)',
|
||||||
|
minimumAmountPeso: 'Monto Mínimo (Pesos)',
|
||||||
|
maximumAmountPeso: 'Monto Máximo (Pesos)',
|
||||||
|
tabulator: 'Tabulador',
|
||||||
|
sizeUnit: 'Unidad de Medida',
|
||||||
|
costUnitUma: 'Costo por unidad en UMA',
|
||||||
|
costUnitPeso: 'Costo por unidad en Pesos',
|
||||||
|
validation: {
|
||||||
|
required: 'Por favor complete todos los campos requeridos'
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
title: 'Nuevo Concepto de Cobro',
|
||||||
|
description: 'Permite crear nuevos conceptos de cobro.',
|
||||||
|
onSuccess: 'Concepto creado exitosamente',
|
||||||
|
onError: 'Error al crear el concepto'
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
title: 'Editar concepto',
|
||||||
|
description: 'Permite editar conceptos existentes.',
|
||||||
|
onSuccess: 'Concepto actualizado exitosamente',
|
||||||
|
onError: 'Error al actualizar el concepto'
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
title: 'Listado de Conceptos',
|
||||||
|
empty: 'No hay conceptos registrados'
|
||||||
|
},
|
||||||
|
deleted: 'Concepto eliminado',
|
||||||
|
permissions: {
|
||||||
|
title: 'Permisos de concepto'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
membership: {
|
||||||
|
title: 'Membresías',
|
||||||
|
curp: 'CURP',
|
||||||
|
name: 'Nombre',
|
||||||
|
tutor: 'Tutor',
|
||||||
|
photo: 'Foto',
|
||||||
|
create: {
|
||||||
|
title: 'Crear membresía',
|
||||||
|
description: 'Permite crear nuevas membresías.',
|
||||||
|
onSuccess: 'Membresía creada exitosamente',
|
||||||
|
onError: 'Error al crear la membresía'
|
||||||
|
}
|
||||||
|
},
|
||||||
app: {
|
app: {
|
||||||
theme: {
|
theme: {
|
||||||
dark: 'Tema oscuro',
|
dark: 'Tema oscuro',
|
||||||
@ -138,6 +223,15 @@ export default {
|
|||||||
confirm:'Confirmar',
|
confirm:'Confirmar',
|
||||||
copyright:'Todos los derechos reservados.',
|
copyright:'Todos los derechos reservados.',
|
||||||
contact:'Contacto',
|
contact:'Contacto',
|
||||||
|
checkout: {
|
||||||
|
concept: "Concepto",
|
||||||
|
amount: "Monto total",
|
||||||
|
date: "Fecha",
|
||||||
|
user: "Usuario",
|
||||||
|
list: {
|
||||||
|
empty: "No hay cortes de caja registrados"
|
||||||
|
}
|
||||||
|
},
|
||||||
create: 'Crear',
|
create: 'Crear',
|
||||||
created: 'Registro creado',
|
created: 'Registro creado',
|
||||||
created_at: 'Fecha creación',
|
created_at: 'Fecha creación',
|
||||||
|
|||||||
@ -30,14 +30,44 @@ onMounted(() => {
|
|||||||
<template #leftSidebar>
|
<template #leftSidebar>
|
||||||
<Section name="Principal">
|
<Section name="Principal">
|
||||||
<Link
|
<Link
|
||||||
icon="monitoring"
|
icon="add_home"
|
||||||
name="dashboard"
|
name="Gestionar Direcciones"
|
||||||
to="dashboard.index"
|
to="address.index"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
icon="person"
|
icon="how_to_vote"
|
||||||
name="profile"
|
name="Gestionar Conceptos"
|
||||||
to="profile.show"
|
to="concept.index"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="receipt_long"
|
||||||
|
name="Gestionar Multas"
|
||||||
|
to="fine.index"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="bar_chart"
|
||||||
|
name="Dashboard de Multas"
|
||||||
|
to="fine.dashboard"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="car_tag"
|
||||||
|
name="Autorizar Descuentos"
|
||||||
|
to="discount.index"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="card_membership"
|
||||||
|
name="Cobro de Membresía"
|
||||||
|
to="membership.index"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="point_of_sale"
|
||||||
|
name="Entrega de caja"
|
||||||
|
to="checkout.index"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
icon="request_quote"
|
||||||
|
name="Solicitudes de Factura"
|
||||||
|
to="invoice-request.index"
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section
|
<Section
|
||||||
|
|||||||
@ -56,29 +56,27 @@ onMounted(() => {
|
|||||||
@click="searcher.search()"
|
@click="searcher.search()"
|
||||||
/>
|
/>
|
||||||
</SearcherHead>
|
</SearcherHead>
|
||||||
<div class="pt-2 space-y-2 w-full">
|
<div class="pt-2 space-y-2 w-full px-4">
|
||||||
<p class="text-sm">
|
<p class="text-sm text-gray-600">
|
||||||
{{ transl('description') }}
|
{{ transl('description') }}
|
||||||
</p>
|
</p>
|
||||||
<Table
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
:items="models"
|
<Table
|
||||||
@send-pagination="(page) => searcher.pagination(page)"
|
:items="models"
|
||||||
:processing="searcher.processing"
|
@send-pagination="(page) => searcher.pagination(page)"
|
||||||
>
|
:processing="searcher.processing"
|
||||||
<template #head>
|
>
|
||||||
<th v-text="$t('name')" />
|
<template #head>
|
||||||
<th
|
<th class="px-6 py-3 text-left text-sm font-semibold" v-text="$t('name')" />
|
||||||
v-text="$t('actions')"
|
<th class="px-6 py-3 text-center text-sm font-semibold w-32" v-text="$t('actions')" />
|
||||||
class="w-32 text-center"
|
</template>
|
||||||
/>
|
<template #body="{items}">
|
||||||
</template>
|
<tr v-for="model in items" class="border-b border-gray-200 hover:bg-gray-50 transition-colors">
|
||||||
<template #body="{items}">
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
<tr v-for="model in items" class="table-row">
|
{{ model.description }}
|
||||||
<td class="table-cell border">
|
</td>
|
||||||
{{ model.description }}
|
<td class="px-6 py-4">
|
||||||
</td>
|
<div class="flex justify-center items-center space-x-2">
|
||||||
<td class="table-cell">
|
|
||||||
<div class="table-actions">
|
|
||||||
<IconButton
|
<IconButton
|
||||||
v-if="can('edit') && ![1,2].includes(model.id)"
|
v-if="can('edit') && ![1,2].includes(model.id)"
|
||||||
icon="license"
|
icon="license"
|
||||||
@ -108,7 +106,8 @@ onMounted(() => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Permissions
|
<Permissions
|
||||||
|
|||||||
@ -21,11 +21,12 @@ const form = useForm({
|
|||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
password: '',
|
password: '',
|
||||||
roles: []
|
roles: [],
|
||||||
|
address: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const roles = ref([]);
|
const roles = ref([]);
|
||||||
|
const addresses = ref([]);
|
||||||
/** Métodos */
|
/** Métodos */
|
||||||
function submit() {
|
function submit() {
|
||||||
form.transform(data => ({
|
form.transform(data => ({
|
||||||
@ -80,5 +81,12 @@ onMounted(() => {
|
|||||||
:options="roles"
|
:options="roles"
|
||||||
multiple
|
multiple
|
||||||
/>
|
/>
|
||||||
|
<Selectable
|
||||||
|
v-model="form.address"
|
||||||
|
label="description"
|
||||||
|
title="Direcciones"
|
||||||
|
:options="addresses"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -58,51 +58,49 @@ onMounted(() => {
|
|||||||
@click="searcher.search()"
|
@click="searcher.search()"
|
||||||
/>
|
/>
|
||||||
</SearcherHead>
|
</SearcherHead>
|
||||||
<div class="pt-2 w-full">
|
<div class="pt-2 w-full px-4">
|
||||||
<Table
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
:items="models"
|
<Table
|
||||||
:processing="searcher.processing"
|
:items="models"
|
||||||
@send-pagination="(page) => searcher.pagination(page)"
|
:processing="searcher.processing"
|
||||||
>
|
@send-pagination="(page) => searcher.pagination(page)"
|
||||||
<template #head>
|
>
|
||||||
<th v-text="$t('user')" />
|
<template #head>
|
||||||
<th v-text="$t('contact')" />
|
<th class="px-6 py-3 text-left text-sm font-semibold" v-text="$t('user')" />
|
||||||
<th
|
<th class="px-6 py-3 text-left text-sm font-semibold" v-text="$t('contact')" />
|
||||||
v-text="$t('actions')"
|
<th class="px-6 py-3 text-center text-sm font-semibold w-32" v-text="$t('actions')" />
|
||||||
class="w-32 text-center"
|
</template>
|
||||||
/>
|
<template #body="{items}">
|
||||||
</template>
|
<tr
|
||||||
<template #body="{items}">
|
v-for="model in items"
|
||||||
<tr
|
class="border-b border-gray-200 hover:bg-gray-50 transition-colors"
|
||||||
v-for="model in items"
|
>
|
||||||
class="table-row"
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
>
|
{{ `${model.name} ${model.paternal}` }}
|
||||||
<td class="table-cell">
|
</td>
|
||||||
{{ `${model.name} ${model.paternal}` }}
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
</td>
|
<p>
|
||||||
<td class="table-cell">
|
<a
|
||||||
<p>
|
class="hover:underline"
|
||||||
<a
|
target="_blank"
|
||||||
class="hover:underline"
|
:href="`mailto:${model.email}`"
|
||||||
target="_blank"
|
>
|
||||||
:href="`mailto:${model.email}`"
|
{{ model.email }}
|
||||||
>
|
</a>
|
||||||
{{ model.email }}
|
</p>
|
||||||
</a>
|
<p v-if="model.phone" class="font-semibold text-xs">
|
||||||
</p>
|
<b>Teléfono: </b>
|
||||||
<p v-if="model.phone" class="font-semibold text-xs">
|
<span
|
||||||
<b>Teléfono: </b>
|
class="hover:underline"
|
||||||
<span
|
target="_blank"
|
||||||
class="hover:underline"
|
:href="`tel:${model.phone}`"
|
||||||
target="_blank"
|
>
|
||||||
:href="`tel:${model.phone}`"
|
{{ model.phone }}
|
||||||
>
|
</span>
|
||||||
{{ model.phone }}
|
</p>
|
||||||
</span>
|
</td>
|
||||||
</p>
|
<td class="px-6 py-4">
|
||||||
</td>
|
<div class="flex justify-center items-center space-x-2">
|
||||||
<td class="table-cell">
|
|
||||||
<div class="table-actions">
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="visibility"
|
icon="visibility"
|
||||||
:title="$t('crud.show')"
|
:title="$t('crud.show')"
|
||||||
@ -152,17 +150,12 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<td class="table-cell">
|
<td colspan="3" class="px-6 py-8 text-center text-gray-500 text-sm">
|
||||||
<div class="flex items-center text-sm">
|
{{ $t('registers.empty') }}
|
||||||
<p class="font-semibold">
|
|
||||||
{{ $t('registers.empty') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="table-cell">-</td>
|
|
||||||
<td class="table-cell">-</td>
|
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShowView
|
<ShowView
|
||||||
|
|||||||
159
src/pages/App/Address/Index.vue
Normal file
159
src/pages/App/Address/Index.vue
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useSearcher, useForm } from "@Services/Api";
|
||||||
|
import { apiTo, transl } from "./Module";
|
||||||
|
|
||||||
|
import Table from "@Holos/Table.vue";
|
||||||
|
import IconButton from "@Holos/Button/Icon.vue";
|
||||||
|
import PageHeader from "@Holos/PageHeader.vue";
|
||||||
|
import AddressForm from "@App/AddressSection.vue";
|
||||||
|
import Editview from "@Holos/Modal/Edit.vue";
|
||||||
|
import DestroyView from "@Holos/Modal/Template/Destroy.vue";
|
||||||
|
import ModalController from "@Controllers/ModalController.js";
|
||||||
|
|
||||||
|
/** Controladores */
|
||||||
|
const Modal = new ModalController();
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
const destroyModal = ref(Modal.destroyModal);
|
||||||
|
const editModal = ref(Modal.editModal);
|
||||||
|
const modelModal = ref(Modal.modelModal);
|
||||||
|
|
||||||
|
const models = ref({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Inicializar searcher */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiTo("index"),
|
||||||
|
filters: {},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
models.value = data.models;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Cargar datos iniciales */
|
||||||
|
onMounted(() => {
|
||||||
|
searcher.search();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddressCreated = async (address) => {
|
||||||
|
const form = useForm({
|
||||||
|
name: address.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
form.post(apiTo("store"), {
|
||||||
|
onSuccess: () => {
|
||||||
|
Notify.success(transl('create.onSuccess'));
|
||||||
|
searcher.refresh();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error(transl('create.onError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddressUpdated = async () => {
|
||||||
|
const form = useForm({
|
||||||
|
name: modelModal.value.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
form.put(apiTo("update", { direction: modelModal.value.id }), {
|
||||||
|
onSuccess: () => {
|
||||||
|
Notify.success(transl('edit.onSuccess'));
|
||||||
|
searcher.refresh();
|
||||||
|
Modal.switchEditModal();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error(transl('edit.onError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="DIRECCIONES" />
|
||||||
|
<AddressForm @address-created="handleAddressCreated" />
|
||||||
|
<div class="mx-4 mb-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<Table
|
||||||
|
:items="models"
|
||||||
|
:processing="searcher.processing"
|
||||||
|
@send-pagination="(page) => searcher.pagination(page)"
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold">Nombre</th>
|
||||||
|
<th class="px-6 py-3 text-center text-sm font-semibold w-32">Acciones</th>
|
||||||
|
</template>
|
||||||
|
<template #body="{ items }">
|
||||||
|
<tr v-for="model in items" :key="model.id" class="border-b border-gray-200 hover:bg-gray-50 transition-colors">
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
{{ model.name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex justify-center items-center space-x-2">
|
||||||
|
<IconButton
|
||||||
|
icon="edit"
|
||||||
|
title="Editar"
|
||||||
|
@click="Modal.switchEditModal(model)"
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="delete"
|
||||||
|
title="Eliminar"
|
||||||
|
@click="Modal.switchDestroyModal(model)"
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="2" class="px-6 py-8 text-center text-gray-500 text-sm">
|
||||||
|
{{ transl('list.empty') }}
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<DestroyView
|
||||||
|
title="name"
|
||||||
|
subtitle=""
|
||||||
|
:model="modelModal"
|
||||||
|
:show="destroyModal"
|
||||||
|
:to="(id) => apiTo('destroy', { direction: id })"
|
||||||
|
@close="Modal.switchDestroyModal"
|
||||||
|
@update="
|
||||||
|
() => {
|
||||||
|
const index = models.data.findIndex((m) => m.id === modelModal.id);
|
||||||
|
if (index > -1) {
|
||||||
|
models.data.splice(index, 1);
|
||||||
|
models.total--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Editview
|
||||||
|
:show="editModal"
|
||||||
|
:title="transl('edit.title')"
|
||||||
|
@close="Modal.switchEditModal"
|
||||||
|
@update="handleAddressUpdated"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<label
|
||||||
|
for="editAddressName"
|
||||||
|
class="block text-sm text-gray-600 font-medium mb-2"
|
||||||
|
>
|
||||||
|
{{ transl('name') }}:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="editAddressName"
|
||||||
|
v-model="modelModal.name"
|
||||||
|
:placeholder="transl('placeholder')"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Editview>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
src/pages/App/Address/Module.js
Normal file
21
src/pages/App/Address/Module.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { lang } from '@Lang/i18n';
|
||||||
|
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||||
|
|
||||||
|
// Ruta API
|
||||||
|
const apiTo = (name, params = {}) => route(`directions.${name}`, params)
|
||||||
|
|
||||||
|
// Ruta visual
|
||||||
|
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `address.${name}`, params, query })
|
||||||
|
|
||||||
|
// Obtener traducción del componente
|
||||||
|
const transl = (str) => lang(`address.${str}`)
|
||||||
|
|
||||||
|
// Determina si un usuario puede hacer algo no en base a los permisos
|
||||||
|
const can = (permission) => hasPermission(`address.${permission}`)
|
||||||
|
|
||||||
|
export {
|
||||||
|
can,
|
||||||
|
viewTo,
|
||||||
|
apiTo,
|
||||||
|
transl
|
||||||
|
}
|
||||||
248
src/pages/App/Checkout/Index.vue
Normal file
248
src/pages/App/Checkout/Index.vue
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import { useSearcher, apiURL } from "@Services/Api";
|
||||||
|
import { transl } from "./Module";
|
||||||
|
|
||||||
|
import PageHeader from "@Holos/PageHeader.vue";
|
||||||
|
import Checkout from "@App/CheckoutDelivery.vue";
|
||||||
|
import Table from "@Holos/Table.vue";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
|
const models = ref({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
type: "",
|
||||||
|
start_date: "",
|
||||||
|
end_date: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const expandedRows = ref(new Set());
|
||||||
|
|
||||||
|
const toggleRow = (id) => {
|
||||||
|
if (expandedRows.value.has(id)) {
|
||||||
|
expandedRows.value.delete(id);
|
||||||
|
} else {
|
||||||
|
expandedRows.value.add(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRowExpanded = (id) => {
|
||||||
|
return expandedRows.value.has(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConceptsSummary = (model) => {
|
||||||
|
if (!model.details || model.details.length === 0) return "-";
|
||||||
|
|
||||||
|
const allConcepts = model.details
|
||||||
|
.flatMap(
|
||||||
|
(detail) =>
|
||||||
|
detail.payment?.details?.map((pd) => pd.charge_concept?.name) || []
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const uniqueConcepts = [...new Set(allConcepts)];
|
||||||
|
|
||||||
|
if (uniqueConcepts.length === 0) return "-";
|
||||||
|
if (uniqueConcepts.length === 1) return uniqueConcepts[0];
|
||||||
|
|
||||||
|
return `${uniqueConcepts[0]} y ${uniqueConcepts.length - 1} más...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiURL("cash-cuts"),
|
||||||
|
filters,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
models.value = data.models || { data: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
searcher.search();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Entrega de Caja" />
|
||||||
|
<Checkout />
|
||||||
|
<div class="mx-4 mb-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-4 mb-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="text-sm font-medium text-gray-700"
|
||||||
|
>Filtrar por tipo:</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="filters.type"
|
||||||
|
@change="searcher.search()"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="membership">Membresías</option>
|
||||||
|
<option value="fine">Multas</option>
|
||||||
|
</select>
|
||||||
|
<label class="text-sm font-medium text-gray-700"
|
||||||
|
>Fecha de inicio:</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
v-model="filters.start_date"
|
||||||
|
@change="searcher.search()"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<label class="text-sm font-medium text-gray-700"
|
||||||
|
>Fecha de fin:</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
v-model="filters.end_date"
|
||||||
|
@change="searcher.search()"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<Table
|
||||||
|
:items="models"
|
||||||
|
:processing="searcher.processing"
|
||||||
|
@send-pagination="(page) => searcher.pagination(page)"
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-sm font-semibold"
|
||||||
|
v-text="transl('concept')"
|
||||||
|
/>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-sm font-semibold"
|
||||||
|
v-text="transl('amount')"
|
||||||
|
/>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-sm font-semibold"
|
||||||
|
v-text="transl('user')"
|
||||||
|
/>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-sm font-semibold"
|
||||||
|
v-text="transl('date')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #body="{ items }">
|
||||||
|
<template v-for="model in items" :key="model.id">
|
||||||
|
<!-- Fila principal -->
|
||||||
|
<tr
|
||||||
|
class="border-b border-gray-200 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
@click="toggleRow(model.id)"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-90': isRowExpanded(model.id) }"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ getConceptsSummary(model) }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
${{ parseFloat(model.total_amount || 0).toFixed(2) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
{{ model.closed_by?.full_name || "-" }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
{{
|
||||||
|
model.end_at
|
||||||
|
? new Date(model.end_at).toLocaleDateString()
|
||||||
|
: "-"
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Fila expandida con detalles -->
|
||||||
|
<tr v-if="isRowExpanded(model.id)" class="bg-gray-50">
|
||||||
|
<td colspan="4" class="px-6 py-4">
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-sm p-4 border border-gray-200"
|
||||||
|
>
|
||||||
|
<h4 class="font-semibold text-gray-800 mb-3">
|
||||||
|
Detalle del corte de caja
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="model.details && model.details.length > 0"
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="detail in model.details"
|
||||||
|
:key="detail.id"
|
||||||
|
class="border-b border-gray-200 pb-3 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="paymentDetail in detail.payment?.details || []"
|
||||||
|
:key="paymentDetail.id"
|
||||||
|
class="flex justify-between items-center py-2"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-gray-700">
|
||||||
|
{{ paymentDetail.charge_concept?.name || "-" }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Cantidad: {{ paymentDetail.quantity || 1 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="font-semibold text-gray-800">
|
||||||
|
${{
|
||||||
|
parseFloat(paymentDetail.amount || 0).toFixed(2)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex justify-between items-center pt-3 border-t-2 border-gray-300"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold text-gray-800">Total del corte</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Fecha:
|
||||||
|
{{
|
||||||
|
model.end_at
|
||||||
|
? new Date(model.end_at).toLocaleDateString()
|
||||||
|
: "-"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold text-blue-600">
|
||||||
|
${{ parseFloat(model.total_amount || 0).toFixed(2) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-center text-gray-500 py-4">
|
||||||
|
No hay detalles disponibles
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="4" class="px-6 py-8 text-center text-gray-500 text-sm">
|
||||||
|
{{ transl("list.empty") }}
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
src/pages/App/Checkout/Module.js
Normal file
21
src/pages/App/Checkout/Module.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { lang } from '@Lang/i18n';
|
||||||
|
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||||
|
|
||||||
|
// Ruta API
|
||||||
|
const apiTo = (name, params = {}) => route(`checkout.${name}`, params)
|
||||||
|
|
||||||
|
// Ruta visual
|
||||||
|
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `checkout.${name}`, params, query })
|
||||||
|
|
||||||
|
// Obtener traducción del componente
|
||||||
|
const transl = (str) => lang(`checkout.${str}`)
|
||||||
|
|
||||||
|
// Determina si un usuario puede hacer algo no en base a los permisos
|
||||||
|
const can = (permission) => hasPermission(`checkout.${permission}`)
|
||||||
|
|
||||||
|
export {
|
||||||
|
can,
|
||||||
|
viewTo,
|
||||||
|
apiTo,
|
||||||
|
transl
|
||||||
|
}
|
||||||
168
src/pages/App/Concept/Index.vue
Normal file
168
src/pages/App/Concept/Index.vue
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useSearcher, useForm, api, apiURL } from "@Services/Api";
|
||||||
|
import { apiTo, transl } from "./Module";
|
||||||
|
|
||||||
|
import Table from "@Holos/Table.vue";
|
||||||
|
import IconButton from "@Holos/Button/Icon.vue";
|
||||||
|
import PageHeader from "@Holos/PageHeader.vue";
|
||||||
|
import ConceptForm from "@App/ConceptSection.vue";
|
||||||
|
import DestroyView from "@Holos/Modal/Template/Destroy.vue";
|
||||||
|
import ModalController from "@Controllers/ModalController.js";
|
||||||
|
import EditModal from "./Modal/Edit.vue";
|
||||||
|
import Searcher from "@Holos/Searcher.vue";
|
||||||
|
|
||||||
|
/** Controladores */
|
||||||
|
const Modal = new ModalController();
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
const destroyModal = ref(Modal.destroyModal);
|
||||||
|
const editModal = ref(Modal.editModal);
|
||||||
|
const modelModal = ref(Modal.modelModal);
|
||||||
|
|
||||||
|
const addresses = ref([]);
|
||||||
|
const units = ref([]);
|
||||||
|
|
||||||
|
const models = ref({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Inicializar searcher */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiTo("index"),
|
||||||
|
filters: { paginate: true },
|
||||||
|
onSuccess: (data) => {
|
||||||
|
models.value = data.models;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.get(apiURL("directions"), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
addresses.value = data.models?.data || data.data || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
api.get(apiURL("units"), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
units.value = data.models?.data || data.data || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Cargar datos iniciales */
|
||||||
|
onMounted(() => {
|
||||||
|
searcher.search();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** crear concepto */
|
||||||
|
const handleConceptCreated = async (conceptData) => {
|
||||||
|
const form = useForm(conceptData);
|
||||||
|
|
||||||
|
form.post(apiTo("store"), {
|
||||||
|
onSuccess: () => {
|
||||||
|
Notify.success(transl("create.onSuccess"));
|
||||||
|
searcher.refresh();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error(transl("create.onError"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConceptUpdated = () => {
|
||||||
|
searcher.refresh();
|
||||||
|
Modal.switchEditModal();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<PageHeader :title="transl('title')" />
|
||||||
|
<ConceptForm @concept-created="handleConceptCreated" />
|
||||||
|
<Searcher title="Cobro de Membresía" @search="(x) => searcher.search(x)"></Searcher>
|
||||||
|
<div class="mx-4 mb-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<Table
|
||||||
|
:items="models"
|
||||||
|
:processing="searcher.processing"
|
||||||
|
@send-pagination="(page) => searcher.pagination(page)"
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold" v-text="transl('direction')" />
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold" v-text="transl('shortName')" />
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold" v-text="transl('name')" />
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold" v-text="transl('legal')" />
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold" v-text="transl('article')" />
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold" v-text="transl('description')" />
|
||||||
|
<th class="px-6 py-3 text-center text-sm font-semibold w-32" v-text="$t('actions')" />
|
||||||
|
</template>
|
||||||
|
<template #body="{ items }">
|
||||||
|
<tr v-for="model in items" :key="model.id" class="border-b border-gray-200 hover:bg-gray-50 transition-colors">
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
{{ model.direction?.name || "-" }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
{{ model.short_name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
{{ model.name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
{{ model.legal_instrument }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
{{ model.article }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-700">
|
||||||
|
{{ model.content }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex justify-center items-center space-x-2">
|
||||||
|
<IconButton
|
||||||
|
icon="edit"
|
||||||
|
title="Editar"
|
||||||
|
@click="Modal.switchEditModal(model)"
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="delete"
|
||||||
|
title="Eliminar"
|
||||||
|
@click="Modal.switchDestroyModal(model)"
|
||||||
|
outline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="7" class="px-6 py-8 text-center text-gray-500 text-sm">
|
||||||
|
{{ transl("list.empty") }}
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<DestroyView
|
||||||
|
title="name"
|
||||||
|
subtitle="content"
|
||||||
|
:model="modelModal"
|
||||||
|
:show="destroyModal"
|
||||||
|
:to="(id) => apiTo('destroy', { charge_concept: id })"
|
||||||
|
@close="Modal.switchDestroyModal"
|
||||||
|
@update="
|
||||||
|
() => {
|
||||||
|
const index = models.data.findIndex((m) => m.id === modelModal.id);
|
||||||
|
if (index > -1) {
|
||||||
|
models.data.splice(index, 1);
|
||||||
|
models.total--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<EditModal
|
||||||
|
:show="editModal"
|
||||||
|
:model="modelModal"
|
||||||
|
:addresses="addresses"
|
||||||
|
:units="units"
|
||||||
|
@close="Modal.switchEditModal"
|
||||||
|
@updated="handleConceptUpdated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
322
src/pages/App/Concept/Modal/Edit.vue
Normal file
322
src/pages/App/Concept/Modal/Edit.vue
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useForm } from "@Services/Api";
|
||||||
|
import { apiTo, transl } from "../Module";
|
||||||
|
|
||||||
|
import DialogModal from "@Holos/DialogModal.vue";
|
||||||
|
import Input from "@Holos/Form/Input.vue";
|
||||||
|
import Textarea from "@Holos/Form/Textarea.vue";
|
||||||
|
import Selectable from "@Holos/Form/Selectable.vue";
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
model: Object,
|
||||||
|
addresses: Array,
|
||||||
|
units: Array,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['close', 'updated']);
|
||||||
|
|
||||||
|
const chargeTypesOptions = [
|
||||||
|
{ id: 'uma_range', name: 'UMA Range' },
|
||||||
|
{ id: 'peso_range', name: 'Peso Range' },
|
||||||
|
{ id: 'uma_fixed', name: 'UMA unitario' },
|
||||||
|
{ id: 'peso_fixed', name: 'Peso unitario' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const conceptTypesOptions = [
|
||||||
|
{ id: "membership", name: "Membresía" },
|
||||||
|
{ id: "fine", name: "Multa" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const chargeTypeObject = computed({
|
||||||
|
get() {
|
||||||
|
if (!props.model.charge_type) return null;
|
||||||
|
return typeof props.model.charge_type === 'object' && props.model.charge_type?.id
|
||||||
|
? props.model.charge_type.id
|
||||||
|
: chargeTypesOptions.find(opt => opt.id === props.model.charge_type) || null;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
props.model.charge_type = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const conceptTypeObject = computed({
|
||||||
|
get() {
|
||||||
|
if (!props.model.concept_type) return null;
|
||||||
|
const conceptTypeId = typeof props.model.concept_type === 'object' && props.model.concept_type?.id
|
||||||
|
? props.model.concept_type.id
|
||||||
|
: props.model.concept_type;
|
||||||
|
return conceptTypesOptions.find(opt => opt.id === conceptTypeId) || null;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
if (value && typeof value === 'object' && value.id) {
|
||||||
|
props.model.concept_type = value.id;
|
||||||
|
} else if (value) {
|
||||||
|
props.model.concept_type = value;
|
||||||
|
} else {
|
||||||
|
props.model.concept_type = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const editChargeTypeId = computed(() => {
|
||||||
|
if (!props.model.charge_type) return null;
|
||||||
|
return typeof props.model.charge_type === 'object' && props.model.charge_type?.id
|
||||||
|
? props.model.charge_type.id
|
||||||
|
: props.model.charge_type;
|
||||||
|
});
|
||||||
|
|
||||||
|
const editShowUmaFields = computed(() => {
|
||||||
|
return editChargeTypeId.value === 'uma_range' || editChargeTypeId.value === 'uma_fixed';
|
||||||
|
});
|
||||||
|
|
||||||
|
const editShowPesoFields = computed(() => {
|
||||||
|
return editChargeTypeId.value === 'peso_range' || editChargeTypeId.value === 'peso_fixed';
|
||||||
|
});
|
||||||
|
|
||||||
|
const editIsRangeType = computed(() => {
|
||||||
|
return editChargeTypeId.value === 'uma_range' || editChargeTypeId.value === 'peso_range';
|
||||||
|
});
|
||||||
|
|
||||||
|
const editIsFixedType = computed(() => {
|
||||||
|
return editChargeTypeId.value === 'uma_fixed' || editChargeTypeId.value === 'peso_fixed';
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Metodos */
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
const transformedData = {
|
||||||
|
direction_id: typeof props.model.direction === 'object' ? props.model.direction.id : Number(props.model.direction_id),
|
||||||
|
unit_id: editIsFixedType.value && props.model.unit
|
||||||
|
? (typeof props.model.unit === 'object' ? props.model.unit.id : Number(props.model.unit))
|
||||||
|
: null,
|
||||||
|
short_name: props.model.short_name,
|
||||||
|
concept_type: props.model.concept_type,
|
||||||
|
name: props.model.name,
|
||||||
|
legal_instrument: props.model.legal_instrument,
|
||||||
|
article: props.model.article,
|
||||||
|
content: props.model.content,
|
||||||
|
motivation: props.model.motivation,
|
||||||
|
charge_type: editChargeTypeId.value,
|
||||||
|
// Campos UMA para tipos de rango
|
||||||
|
min_amount_uma: (editShowUmaFields.value && editIsRangeType.value && props.model.min_amount_uma) ? Number(props.model.min_amount_uma) : null,
|
||||||
|
max_amount_uma: (editShowUmaFields.value && editIsRangeType.value && props.model.max_amount_uma) ? Number(props.model.max_amount_uma) : null,
|
||||||
|
// Campos Peso para tipos de rango
|
||||||
|
min_amount_peso: (editShowPesoFields.value && editIsRangeType.value && props.model.min_amount_peso) ? Number(props.model.min_amount_peso) : null,
|
||||||
|
max_amount_peso: (editShowPesoFields.value && editIsRangeType.value && props.model.max_amount_peso) ? Number(props.model.max_amount_peso) : null,
|
||||||
|
// Campos de costo unitario para tipos fijos
|
||||||
|
unit_cost_uma: (editShowUmaFields.value && editIsFixedType.value && props.model.unit_cost_uma) ? Number(props.model.unit_cost_uma) : null,
|
||||||
|
unit_cost_peso: (editShowPesoFields.value && editIsFixedType.value && props.model.unit_cost_peso) ? Number(props.model.unit_cost_peso) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm(transformedData);
|
||||||
|
|
||||||
|
form.put(apiTo("update", { charge_concept: props.model.id }), {
|
||||||
|
onSuccess: () => {
|
||||||
|
Notify.success(transl("edit.onSuccess"));
|
||||||
|
emit('updated');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error(transl("edit.onError"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal
|
||||||
|
:show="show"
|
||||||
|
max-width="2xl"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-start justify-between gap-4 py-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold leading-tight text-page-t dark:text-page-dt">
|
||||||
|
{{ transl("edit.title") }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-page-t/65 dark:text-page-dt/65">
|
||||||
|
{{ transl("edit.description") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-7 w-7 items-center justify-center rounded-sm text-page-t/65 transition-all duration-200 hover:bg-page-t/10 hover:text-page-t dark:text-page-dt/65 dark:hover:bg-page-dt/10 dark:hover:text-page-dt"
|
||||||
|
:aria-label="$t('close')"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="space-y-5 px-4 pb-2 pt-1 [&_label]:mb-1 [&_label]:text-[0.72rem] [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-[0.07em] [&_.input-primary]:min-h-10 [&_.input-primary]:border [&_.input-primary]:border-page-t/15 [&_.input-primary]:bg-page-t/5 dark:[&_.input-primary]:border-page-dt/15 dark:[&_.input-primary]:bg-page-dt/5 [&_.multiselect__tags]:min-h-10 [&_.multiselect__tags]:border [&_.multiselect__tags]:border-page-t/15 [&_.multiselect__tags]:bg-page-t/5 dark:[&_.multiselect__tags]:border-page-dt/15 dark:[&_.multiselect__tags]:bg-page-dt/5 [&_textarea.input-primary]:min-h-22 [&_textarea.input-primary]:resize-y [&_.multiselect__single]:mb-0 [&_.multiselect__single]:text-[0.9rem] [&_.multiselect__single]:leading-[1.45] [&_.multiselect__placeholder]:mb-0 [&_.multiselect__placeholder]:text-[0.9rem] [&_.multiselect__placeholder]:leading-[1.45]">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Selectable
|
||||||
|
v-model="conceptTypeObject"
|
||||||
|
label="name"
|
||||||
|
:title="$t('concept.conceptType')"
|
||||||
|
:options="conceptTypesOptions"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Selectable
|
||||||
|
v-model="model.direction"
|
||||||
|
label="name"
|
||||||
|
value="id"
|
||||||
|
:title="$t('concept.direction')"
|
||||||
|
:options="addresses"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Input
|
||||||
|
v-model="model.short_name"
|
||||||
|
:id="$t('concept.shortName')"
|
||||||
|
placeholder="Ej: MANT-01"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<Input
|
||||||
|
v-model="model.name"
|
||||||
|
:id="$t('concept.name')"
|
||||||
|
placeholder="Nombre completo del concepto"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
v-model="model.legal_instrument"
|
||||||
|
:id="$t('concept.legal')"
|
||||||
|
placeholder="Base legal"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="model.article"
|
||||||
|
:id="$t('concept.article')"
|
||||||
|
placeholder="Artículo"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
v-model="model.content"
|
||||||
|
:id="$t('concept.description')"
|
||||||
|
placeholder="Descripcion detallada..."
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
v-model="model.motivation"
|
||||||
|
:id="$t('concept.motivation')"
|
||||||
|
placeholder="Motivacion del concepto..."
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="rounded-sm border border-page-t/12 dark:border-page-dt/12 bg-primary/5 dark:bg-primary-d/30 p-4 space-y-4">
|
||||||
|
<h3 class="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-page-t dark:text-page-dt">
|
||||||
|
<span class="inline-flex h-5 w-5 items-center justify-center rounded-sm bg-primary text-primary-t text-[10px] font-bold">$</span>
|
||||||
|
{{ $t('concept.defineChargeType') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Selectable
|
||||||
|
v-model="chargeTypeObject"
|
||||||
|
label="name"
|
||||||
|
:title="$t('concept.chargeType')"
|
||||||
|
:options="chargeTypesOptions"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="editIsRangeType" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
v-if="editShowUmaFields"
|
||||||
|
v-model="model.min_amount_uma"
|
||||||
|
:id="$t('concept.minimumAmountUma')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="editShowUmaFields"
|
||||||
|
v-model="model.max_amount_uma"
|
||||||
|
:id="$t('concept.maximumAmountUma')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="editShowPesoFields"
|
||||||
|
v-model="model.min_amount_peso"
|
||||||
|
:id="$t('concept.minimumAmountPeso')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="editShowPesoFields"
|
||||||
|
v-model="model.max_amount_peso"
|
||||||
|
:id="$t('concept.maximumAmountPeso')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="editIsFixedType" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Selectable
|
||||||
|
v-model="model.unit"
|
||||||
|
label="name"
|
||||||
|
value="id"
|
||||||
|
:title="$t('concept.sizeUnit')"
|
||||||
|
:options="units"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="editShowUmaFields"
|
||||||
|
v-model="model.unit_cost_uma"
|
||||||
|
:id="$t('concept.costUnitUma')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="editShowPesoFields"
|
||||||
|
v-model="model.unit_cost_peso"
|
||||||
|
:id="$t('concept.costUnitPeso')"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex w-full items-center justify-end gap-3 px-2 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary bg-transparent text-page-t/80 dark:text-page-dt/80 hover:opacity-80"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
{{ $t("close") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary px-6 py-2"
|
||||||
|
@click="handleUpdate"
|
||||||
|
>
|
||||||
|
{{ $t("update") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
21
src/pages/App/Concept/Module.js
Normal file
21
src/pages/App/Concept/Module.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { lang } from '@Lang/i18n';
|
||||||
|
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||||
|
|
||||||
|
// Ruta API
|
||||||
|
const apiTo = (name, params = {}) => route(`charge-concepts.${name}`, params)
|
||||||
|
|
||||||
|
// Ruta visual
|
||||||
|
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `concept.${name}`, params, query })
|
||||||
|
|
||||||
|
// Obtener traducción del componente
|
||||||
|
const transl = (str) => lang(`concept.${str}`)
|
||||||
|
|
||||||
|
// Determina si un usuario puede hacer algo no en base a los permisos
|
||||||
|
const can = (permission) => hasPermission(`concept.${permission}`)
|
||||||
|
|
||||||
|
export {
|
||||||
|
can,
|
||||||
|
viewTo,
|
||||||
|
apiTo,
|
||||||
|
transl
|
||||||
|
}
|
||||||
9
src/pages/App/Discount/Index.vue
Normal file
9
src/pages/App/Discount/Index.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script setup>
|
||||||
|
import PageHeader from "@Holos/PageHeader.vue";
|
||||||
|
import Discount from '@App/DiscountSection.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Autorizar Descuentos" />
|
||||||
|
<Discount />
|
||||||
|
</template>
|
||||||
21
src/pages/App/Discount/Module.js
Normal file
21
src/pages/App/Discount/Module.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { lang } from '@Lang/i18n';
|
||||||
|
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||||
|
|
||||||
|
// Ruta API
|
||||||
|
const apiTo = (name, params = {}) => route(`discount.${name}`, params)
|
||||||
|
|
||||||
|
// Ruta visual
|
||||||
|
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `discount.${name}`, params, query })
|
||||||
|
|
||||||
|
// Obtener traducción del componente
|
||||||
|
const transl = (str) => lang(`discount.${str}`)
|
||||||
|
|
||||||
|
// Determina si un usuario puede hacer algo no en base a los permisos
|
||||||
|
const can = (permission) => hasPermission(`discount.${permission}`)
|
||||||
|
|
||||||
|
export {
|
||||||
|
can,
|
||||||
|
viewTo,
|
||||||
|
apiTo,
|
||||||
|
transl
|
||||||
|
}
|
||||||
231
src/pages/App/Fine/Dashboard.vue
Normal file
231
src/pages/App/Fine/Dashboard.vue
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import PageHeader from '@Holos/PageHeader.vue';
|
||||||
|
import IndicatorCard from '@Holos/Card/Indicator.vue';
|
||||||
|
import FineResultCard from '@App/FineResultCard.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
import { useApi, useSearcher } from '@Services/Api';
|
||||||
|
import { apiTo, viewTo } from './DashboardModule.js';
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const processing = ref(false);
|
||||||
|
const fines = ref([]);
|
||||||
|
const stats = ref({ total_fines: 0, total_paid: 0, total_unpaid: 0, top_concepts: [] });
|
||||||
|
const users = ref([]);
|
||||||
|
|
||||||
|
const today = DateTime.now().toISODate();
|
||||||
|
const filters = ref({
|
||||||
|
date_from: today,
|
||||||
|
date_to: today,
|
||||||
|
creator_id: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// useSearcher crea una instancia independiente, evitando conflicto con el singleton de useApi
|
||||||
|
const usersSearcher = useSearcher({
|
||||||
|
url: route('admin.users.index'),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const list = Array.isArray(data.models) ? data.models : (data.models?.data ?? []);
|
||||||
|
users.value = list.filter(u => u);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPresetToday = () => {
|
||||||
|
const d = DateTime.now().toISODate();
|
||||||
|
filters.value.date_from = d;
|
||||||
|
filters.value.date_to = d;
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPresetThisMonth = () => {
|
||||||
|
const now = DateTime.now();
|
||||||
|
filters.value.date_from = now.startOf('month').toISODate();
|
||||||
|
filters.value.date_to = now.endOf('month').toISODate();
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
processing.value = true;
|
||||||
|
const params = { date_from: filters.value.date_from, date_to: filters.value.date_to };
|
||||||
|
if (filters.value.creator_id) params.creator_id = filters.value.creator_id;
|
||||||
|
|
||||||
|
api.get(apiTo('dashboard'), {
|
||||||
|
params,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
fines.value = data.fines ?? [];
|
||||||
|
stats.value = data.stats ?? {};
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
onError: () => { processing.value = false; },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectedAmount = computed(() =>
|
||||||
|
fines.value
|
||||||
|
.filter(f => f.status === 'paid')
|
||||||
|
.reduce((s, f) => s + parseFloat(f.total_amount || 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingAmount = computed(() =>
|
||||||
|
fines.value
|
||||||
|
.filter(f => f.status !== 'paid')
|
||||||
|
.reduce((s, f) => s + parseFloat(f.total_amount || 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatCurrency = (n) =>
|
||||||
|
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(n);
|
||||||
|
|
||||||
|
const periodLabel = computed(() => {
|
||||||
|
const from = filters.value.date_from;
|
||||||
|
const to = filters.value.date_to;
|
||||||
|
const todayIso = DateTime.now().toISODate();
|
||||||
|
if (from === todayIso && to === todayIso) return 'hoy';
|
||||||
|
if (from === to) return DateTime.fromISO(from).toFormat('dd/MM/yyyy');
|
||||||
|
return `${DateTime.fromISO(from).toFormat('dd/MM/yyyy')} – ${DateTime.fromISO(to).toFormat('dd/MM/yyyy')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => { usersSearcher.search(); load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Dashboard de Multas" />
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="flex flex-wrap gap-4 mb-6 items-end">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Fecha inicio</label>
|
||||||
|
<input
|
||||||
|
v-model="filters.date_from"
|
||||||
|
type="date"
|
||||||
|
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Fecha fin</label>
|
||||||
|
<input
|
||||||
|
v-model="filters.date_to"
|
||||||
|
type="date"
|
||||||
|
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Agente</label>
|
||||||
|
<select
|
||||||
|
v-model="filters.creator_id"
|
||||||
|
class="px-6 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option v-for="user in users.filter(u => u)" :key="user.id" :value="user.id">
|
||||||
|
{{ user.full_name || `${user.name} ${user.paternal}` }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Periodo rápido</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="setPresetToday"
|
||||||
|
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Hoy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="setPresetThisMonth"
|
||||||
|
class="px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Este mes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="load"
|
||||||
|
class="px-4 py-2 rounded-md bg-primary text-white text-sm font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||||
|
>
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPIs de conteo -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
|
||||||
|
<IndicatorCard
|
||||||
|
icon="receipt_long"
|
||||||
|
title="Total Multas"
|
||||||
|
:value="stats.total_fines ?? 0"
|
||||||
|
/>
|
||||||
|
<IndicatorCard
|
||||||
|
icon="check_circle"
|
||||||
|
title="Pagadas"
|
||||||
|
:value="stats.total_paid ?? 0"
|
||||||
|
/>
|
||||||
|
<IndicatorCard
|
||||||
|
icon="pending_actions"
|
||||||
|
title="Pendientes"
|
||||||
|
:value="stats.total_unpaid ?? 0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPIs de montos -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||||
|
<div class="relative flex-1 flex flex-col gap-2 p-4 rounded-sm bg-gray-200 dark:bg-transparent dark:border">
|
||||||
|
<label class="text-base font-semibold tracking-wider">Monto Recaudado</label>
|
||||||
|
<label class="text-primary dark:text-primary-dt text-3xl font-bold">
|
||||||
|
{{ formatCurrency(collectedAmount) }}
|
||||||
|
</label>
|
||||||
|
<div class="absolute bg-primary dark:bg-primary-d rounded-md p-2 right-4 bottom-4">
|
||||||
|
<GoogleIcon class="text-3xl text-gray-100" name="payments" :fill="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex-1 flex flex-col gap-2 p-4 rounded-sm bg-gray-200 dark:bg-transparent dark:border">
|
||||||
|
<label class="text-base font-semibold tracking-wider">Monto Pendiente</label>
|
||||||
|
<label class="text-primary dark:text-primary-dt text-3xl font-bold">
|
||||||
|
{{ formatCurrency(pendingAmount) }}
|
||||||
|
</label>
|
||||||
|
<div class="absolute bg-primary dark:bg-primary-d rounded-md p-2 right-4 bottom-4">
|
||||||
|
<GoogleIcon class="text-3xl text-gray-100" name="money_off" :fill="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Infracciones frecuentes + lista de multas -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold mb-3 tracking-wide">Infracciones más frecuentes</h2>
|
||||||
|
<div v-if="!processing && stats.top_concepts?.length" class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="concept in stats.top_concepts"
|
||||||
|
:key="concept.id"
|
||||||
|
class="flex items-center justify-between p-3 rounded-lg bg-gray-100 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 truncate pr-4">
|
||||||
|
{{ concept.short_name || concept.name }}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-sm font-bold text-primary dark:text-primary-dt bg-primary/10 px-2 py-0.5 rounded-full">
|
||||||
|
{{ concept.total }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="!processing" class="text-sm text-gray-400">Sin datos para el periodo.</p>
|
||||||
|
<p v-else class="text-sm text-gray-400">Cargando...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-semibold mb-3 tracking-wide">
|
||||||
|
Multas de <span class="text-primary dark:text-primary-dt">{{ periodLabel }}</span>
|
||||||
|
<span class="text-gray-400 font-normal"> ({{ fines.length }})</span>
|
||||||
|
</h2>
|
||||||
|
<div v-if="!processing && fines.length" class="flex flex-col gap-3 max-h-[520px] overflow-y-auto pr-1">
|
||||||
|
<FineResultCard
|
||||||
|
v-for="fine in fines"
|
||||||
|
:key="fine.id"
|
||||||
|
:fine="fine"
|
||||||
|
:selectable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="!processing" class="text-sm text-gray-400">Sin multas en este periodo.</p>
|
||||||
|
<p v-else class="text-sm text-gray-400">Cargando...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
src/pages/App/Fine/DashboardModule.js
Normal file
21
src/pages/App/Fine/DashboardModule.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { lang } from '@Lang/i18n';
|
||||||
|
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||||
|
|
||||||
|
// Ruta API
|
||||||
|
const apiTo = (name, params = {}) => route(`fines.${name}`, params)
|
||||||
|
|
||||||
|
// Ruta visual
|
||||||
|
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `fine.${name}`, params, query })
|
||||||
|
|
||||||
|
// Obtener traducción del componente
|
||||||
|
const transl = (str) => lang(`fine.${str}`)
|
||||||
|
|
||||||
|
// Determina si un usuario puede hacer algo no en base a los permisos
|
||||||
|
const can = (permission) => hasPermission(`fine.${permission}`)
|
||||||
|
|
||||||
|
export {
|
||||||
|
can,
|
||||||
|
viewTo,
|
||||||
|
apiTo,
|
||||||
|
transl
|
||||||
|
}
|
||||||
9
src/pages/App/Fine/Index.vue
Normal file
9
src/pages/App/Fine/Index.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<script setup>
|
||||||
|
import PageHeader from "@Holos/PageHeader.vue";
|
||||||
|
import FineSection from '@App/FineSection.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Cobro de Multa" />
|
||||||
|
<FineSection />
|
||||||
|
</template>
|
||||||
21
src/pages/App/Fine/Module.js
Normal file
21
src/pages/App/Fine/Module.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { lang } from '@Lang/i18n';
|
||||||
|
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||||
|
|
||||||
|
// Ruta API
|
||||||
|
const apiTo = (name, params = {}) => route(`fines.${name}`, params)
|
||||||
|
|
||||||
|
// Ruta visual
|
||||||
|
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `fine.${name}`, params, query })
|
||||||
|
|
||||||
|
// Obtener traducción del componente
|
||||||
|
const transl = (str) => lang(`fine.${str}`)
|
||||||
|
|
||||||
|
// Determina si un usuario puede hacer algo no en base a los permisos
|
||||||
|
const can = (permission) => hasPermission(`fine.${permission}`)
|
||||||
|
|
||||||
|
export {
|
||||||
|
can,
|
||||||
|
viewTo,
|
||||||
|
apiTo,
|
||||||
|
transl
|
||||||
|
}
|
||||||
206
src/pages/App/InvoiceRequest/Index.vue
Normal file
206
src/pages/App/InvoiceRequest/Index.vue
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { useSearcher, useApi } from '@Services/Api';
|
||||||
|
import { apiTo } from './Module';
|
||||||
|
|
||||||
|
import Table from '@Holos/Table.vue';
|
||||||
|
import IconButton from '@Holos/Button/Icon.vue';
|
||||||
|
import PageHeader from '@Holos/PageHeader.vue';
|
||||||
|
import ShowModal from './Modal/Show.vue';
|
||||||
|
import ModalController from '@Controllers/ModalController.js';
|
||||||
|
|
||||||
|
/** Controladores */
|
||||||
|
const Modal = new ModalController();
|
||||||
|
const api = useApi();
|
||||||
|
const showModal = ref(Modal.showModal);
|
||||||
|
const modelModal = ref(Modal.modelModal);
|
||||||
|
|
||||||
|
/** Estado */
|
||||||
|
const models = ref({ data: [], total: 0, current_page: 1, last_page: 1 });
|
||||||
|
const statusFilter = ref('');
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: { label: 'Pendiente', cls: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
|
||||||
|
processing: { label: 'En proceso', cls: 'bg-blue-100 text-blue-800 border-blue-200' },
|
||||||
|
completed: { label: 'Completada', cls: 'bg-green-100 text-green-800 border-green-200' },
|
||||||
|
rejected: { label: 'Rechazada', cls: 'bg-red-100 text-red-800 border-red-200' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTabs = [
|
||||||
|
{ value: '', label: 'Todas' },
|
||||||
|
{ value: 'pending', label: 'Pendientes' },
|
||||||
|
{ value: 'processing', label: 'En proceso' },
|
||||||
|
{ value: 'completed', label: 'Completadas'},
|
||||||
|
{ value: 'rejected', label: 'Rechazadas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Buscador */
|
||||||
|
const searcher = useSearcher({
|
||||||
|
url: apiTo(),
|
||||||
|
filters: {},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
models.value = data.models;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const doSearch = (page = 1) => {
|
||||||
|
const filters = { page };
|
||||||
|
if (statusFilter.value) filters.status = statusFilter.value;
|
||||||
|
searcher.search('', filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => doSearch());
|
||||||
|
|
||||||
|
watch(statusFilter, () => doSearch(1));
|
||||||
|
|
||||||
|
/** Paginación */
|
||||||
|
const pages = () => {
|
||||||
|
const pages = [];
|
||||||
|
for (let i = 1; i <= models.value.last_page; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Abrir detalle — carga info completa */
|
||||||
|
const openDetail = (model) => {
|
||||||
|
Modal.switchShowModal(model);
|
||||||
|
api.get(apiTo(model.id), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Modal.modelModal.value = data.model;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Actualiza fila en la lista al recibir cambio del modal */
|
||||||
|
const handleUpdated = (updatedModel) => {
|
||||||
|
const idx = models.value.data.findIndex(m => m.id === updatedModel.id);
|
||||||
|
if (idx > -1) {
|
||||||
|
models.value.data[idx] = updatedModel;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount) => {
|
||||||
|
if (!amount) return '—';
|
||||||
|
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader title="Solicitudes de Factura" />
|
||||||
|
|
||||||
|
<div class="mx-4 mb-4 space-y-4">
|
||||||
|
|
||||||
|
<!-- Filtros de estado -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="tab in statusTabs"
|
||||||
|
:key="tab.value"
|
||||||
|
class="px-4 py-1.5 rounded-full text-sm font-medium border transition-colors"
|
||||||
|
:class="statusFilter === tab.value
|
||||||
|
? 'bg-primary text-white border-primary'
|
||||||
|
: 'bg-white text-gray-600 border-gray-300 hover:border-primary hover:text-primary'"
|
||||||
|
@click="statusFilter = tab.value"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabla -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<Table
|
||||||
|
:items="models"
|
||||||
|
:processing="searcher.processing"
|
||||||
|
>
|
||||||
|
<template #head>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">#</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">Ciudadano</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">RFC</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">Monto pagado</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">Estado</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-semibold">Fecha solicitud</th>
|
||||||
|
<th class="px-4 py-3 text-center text-sm font-semibold w-20">Acción</th>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body="{ items }">
|
||||||
|
<tr
|
||||||
|
v-for="model in items"
|
||||||
|
:key="model.id"
|
||||||
|
class="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
@click="openDetail(model)"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500 font-mono">{{ model.id }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-800 font-medium">
|
||||||
|
{{ model.name || model.payment?.model?.name || '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 font-mono">{{ model.rfc }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700">
|
||||||
|
{{ formatAmount(model.payment?.total_amount) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border"
|
||||||
|
:class="statusConfig[model.status]?.cls ?? 'bg-gray-100 text-gray-700'"
|
||||||
|
>
|
||||||
|
{{ statusConfig[model.status]?.label ?? model.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{{ formatDate(model.request_at) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 flex justify-center" @click.stop>
|
||||||
|
<IconButton
|
||||||
|
icon="visibility"
|
||||||
|
title="Ver detalle"
|
||||||
|
outline
|
||||||
|
@click="openDetail(model)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<td colspan="7" class="px-6 py-10 text-center text-gray-400 text-sm">
|
||||||
|
No hay solicitudes de factura con los filtros seleccionados.
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginación personalizada -->
|
||||||
|
<div
|
||||||
|
v-if="models.last_page > 1"
|
||||||
|
class="flex justify-end flex-wrap gap-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="p in pages()"
|
||||||
|
:key="p"
|
||||||
|
class="px-3 py-1 text-sm border rounded transition-colors"
|
||||||
|
:class="models.current_page === p
|
||||||
|
? 'bg-primary text-white border-primary'
|
||||||
|
: 'bg-white text-gray-600 border-gray-300 hover:border-primary'"
|
||||||
|
@click="doSearch(p)"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de detalle -->
|
||||||
|
<ShowModal
|
||||||
|
:show="showModal"
|
||||||
|
:model="modelModal"
|
||||||
|
:loading="api.processing"
|
||||||
|
@close="Modal.switchShowModal()"
|
||||||
|
@updated="handleUpdated"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
364
src/pages/App/InvoiceRequest/Modal/Show.vue
Normal file
364
src/pages/App/InvoiceRequest/Modal/Show.vue
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, computed } from 'vue';
|
||||||
|
import { useForm, useApi } from '@Services/Api';
|
||||||
|
import { apiTo } from '../Module';
|
||||||
|
|
||||||
|
import DialogModal from '@Holos/DialogModal.vue';
|
||||||
|
import SingleFile from '@Holos/Form/SingleFile.vue';
|
||||||
|
import Input from '@Holos/Form/Input.vue';
|
||||||
|
import Textarea from '@Holos/Form/Textarea.vue';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
|
||||||
|
/** Props */
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
model: Object,
|
||||||
|
loading: Boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['close', 'updated']);
|
||||||
|
|
||||||
|
/** API para cambio de estado */
|
||||||
|
const apiStatus = useApi();
|
||||||
|
|
||||||
|
/** Formulario de subida CFDI */
|
||||||
|
const invoiceForm = ref(useForm({
|
||||||
|
xml_file: null,
|
||||||
|
pdf_file: null,
|
||||||
|
folio: '',
|
||||||
|
uuid: '',
|
||||||
|
stamped_at: '',
|
||||||
|
notes: '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Resetear formulario cuando se abre el modal */
|
||||||
|
watch(() => props.show, (open) => {
|
||||||
|
if (open) {
|
||||||
|
invoiceForm.value = useForm({
|
||||||
|
xml_file: null,
|
||||||
|
pdf_file: null,
|
||||||
|
folio: '',
|
||||||
|
uuid: '',
|
||||||
|
stamped_at: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
pending: { label: 'Pendiente', cls: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
processing: { label: 'En proceso', cls: 'bg-blue-100 text-blue-800' },
|
||||||
|
completed: { label: 'Completada', cls: 'bg-green-100 text-green-800' },
|
||||||
|
rejected: { label: 'Rechazada', cls: 'bg-red-100 text-red-800' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const canChangeStatus = computed(() =>
|
||||||
|
props.model?.status === 'pending' || props.model?.status === 'processing'
|
||||||
|
);
|
||||||
|
|
||||||
|
const canUpload = computed(() =>
|
||||||
|
props.model?.status !== 'completed' && props.model?.status !== 'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount) => {
|
||||||
|
if (!amount) return '—';
|
||||||
|
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cambiar estado */
|
||||||
|
const changeStatus = (status) => {
|
||||||
|
apiStatus.put(apiTo(props.model.id, 'status'), {
|
||||||
|
data: { status },
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Notify.success(
|
||||||
|
status === 'processing'
|
||||||
|
? 'Solicitud marcada como "En proceso".'
|
||||||
|
: 'Solicitud rechazada.'
|
||||||
|
);
|
||||||
|
emit('updated', data.model);
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
onFail: (data) => {
|
||||||
|
Notify.warning(data?.message ?? 'No se pudo cambiar el estado.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subir CFDI */
|
||||||
|
const uploadInvoice = () => {
|
||||||
|
if (!invoiceForm.value.xml_file && !invoiceForm.value.pdf_file) {
|
||||||
|
Notify.warning('Debes adjuntar al menos el archivo XML o el PDF del CFDI.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
invoiceForm.value.post(apiTo(props.model.id, 'invoice'), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
Notify.success(data?.message ?? 'Factura subida y enviada al ciudadano.');
|
||||||
|
emit('updated', data.model);
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
Notify.error('Error al subir los archivos. Verifica el formulario.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogModal
|
||||||
|
:show="show"
|
||||||
|
max-width="2xl"
|
||||||
|
@close="emit('close')"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-start justify-between gap-4 py-1">
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 class="text-2xl font-bold leading-tight text-page-t dark:text-page-dt">
|
||||||
|
Solicitud de Factura #{{ model?.id }}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
v-if="model?.status"
|
||||||
|
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ring-inset"
|
||||||
|
:class="statusConfig[model.status]?.cls"
|
||||||
|
>
|
||||||
|
{{ statusConfig[model.status]?.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-page-t/65 dark:text-page-dt/65">
|
||||||
|
Revisa detalles fiscales, pago asociado y gestiona el flujo de facturacion.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-7 w-7 items-center justify-center rounded-sm text-page-t/65 transition-all duration-200 hover:bg-page-t/10 hover:text-page-t dark:text-page-dt/65 dark:hover:bg-page-dt/10 dark:hover:text-page-dt"
|
||||||
|
aria-label="Cerrar"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div v-if="loading" class="flex flex-col items-center justify-center gap-3 py-12 text-page-t/70 dark:text-page-dt/70">
|
||||||
|
<div class="h-9 w-9 animate-spin rounded-full border-2 border-page-t/15 border-t-primary dark:border-page-dt/15 dark:border-t-primary-d"></div>
|
||||||
|
<p class="text-sm">Cargando informacion de la solicitud...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="model?.id" class="space-y-4 px-4 pb-4 pt-2 [&_label]:mb-1 [&_label]:text-[0.72rem] [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-[0.07em] [&_.input-primary]:min-h-10 [&_.input-primary]:border [&_.input-primary]:border-page-t/15 [&_.input-primary]:bg-page-t/5 dark:[&_.input-primary]:border-page-dt/15 dark:[&_.input-primary]:bg-page-dt/5 [&_textarea.input-primary]:min-h-22 [&_textarea.input-primary]:resize-y">
|
||||||
|
|
||||||
|
<section class="rounded-sm border border-page-t/12 bg-page-t/5 p-4 dark:border-page-dt/12 dark:bg-page-dt/5">
|
||||||
|
<h3 class="mb-3 border-b border-page-t/12 pb-2 text-xs font-semibold uppercase tracking-[0.14em] text-page-t/75 dark:border-page-dt/12 dark:text-page-dt/75">
|
||||||
|
Datos del ciudadano
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-x-6 gap-y-3 text-sm md:grid-cols-2">
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">RFC</p><p class="font-mono font-medium text-page-t dark:text-page-dt">{{ model.rfc }}</p></div>
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Nombre</p><p class="text-page-t dark:text-page-dt">{{ model.name || '—' }}</p></div>
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Email</p><p class="break-all text-page-t dark:text-page-dt">{{ model.email }}</p></div>
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Telefono</p><p class="text-page-t dark:text-page-dt">{{ model.phone || '—' }}</p></div>
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Razon social</p><p class="text-page-t dark:text-page-dt">{{ model.business_name || '—' }}</p></div>
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">C.P. fiscal</p><p class="font-medium text-page-t dark:text-page-dt">{{ model.fiscal_postal_code }}</p></div>
|
||||||
|
<div class="space-y-0.5 md:col-span-2"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Direccion</p><p class="text-page-t dark:text-page-dt">{{ model.address || '—' }}</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-sm border border-page-t/12 bg-page-t/5 p-4 dark:border-page-dt/12 dark:bg-page-dt/5">
|
||||||
|
<h3 class="mb-3 border-b border-page-t/12 pb-2 text-xs font-semibold uppercase tracking-[0.14em] text-page-t/75 dark:border-page-dt/12 dark:text-page-dt/75">
|
||||||
|
Datos fiscales
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-y-3 text-sm">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Regimen fiscal</p>
|
||||||
|
<p v-if="model.fiscal_regime" class="text-page-t dark:text-page-dt">
|
||||||
|
<span class="font-mono font-medium">{{ model.fiscal_regime.code }}</span>
|
||||||
|
— {{ model.fiscal_regime.name }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-page-t dark:text-page-dt">—</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Uso del CFDI</p>
|
||||||
|
<p v-if="model.cfdi_use" class="text-page-t dark:text-page-dt">
|
||||||
|
<span class="font-mono font-medium">{{ model.cfdi_use.code }}</span>
|
||||||
|
— {{ model.cfdi_use.name }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-page-t dark:text-page-dt">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-sm border border-page-t/12 bg-page-t/5 p-4 dark:border-page-dt/12 dark:bg-page-dt/5">
|
||||||
|
<h3 class="mb-3 border-b border-page-t/12 pb-2 text-xs font-semibold uppercase tracking-[0.14em] text-page-t/75 dark:border-page-dt/12 dark:text-page-dt/75">
|
||||||
|
Pago relacionado
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-x-6 gap-y-3 text-sm md:grid-cols-2">
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Monto</p><p class="font-semibold text-emerald-700 dark:text-emerald-400">{{ formatAmount(model.payment?.total_amount) }}</p></div>
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Pagado el</p><p class="text-page-t dark:text-page-dt">{{ formatDate(model.payment?.paid_at) }}</p></div>
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Infractor</p><p class="text-page-t dark:text-page-dt">{{ model.payment?.model?.name || '—' }}</p></div>
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Placa</p><p class="font-mono text-page-t dark:text-page-dt">{{ model.payment?.model?.plate || '—' }}</p></div>
|
||||||
|
<div class="space-y-0.5 md:col-span-2"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">CURP</p><p class="break-all font-mono text-xs text-page-t dark:text-page-dt">{{ model.payment?.model?.curp || '—' }}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="model.payment?.model?.charge_concepts?.length" class="mt-4">
|
||||||
|
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Conceptos de cargo</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="cc in model.payment.model.charge_concepts"
|
||||||
|
:key="cc.id"
|
||||||
|
class="flex items-center justify-between rounded-sm border border-page-t/12 bg-page p-2.5 text-xs dark:border-page-dt/12 dark:bg-page-d"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-page-t dark:text-page-dt">{{ cc.name }}</span>
|
||||||
|
<span class="ml-1 text-page-t/55 dark:text-page-dt/55">({{ cc.article }})</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-page-t dark:text-page-dt">
|
||||||
|
{{ formatAmount(cc.pivot?.override_amount) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="model.invoice" class="rounded-sm border border-page-t/12 bg-page-t/5 p-4 dark:border-page-dt/12 dark:bg-page-dt/5">
|
||||||
|
<h3 class="mb-3 border-b border-page-t/12 pb-2 text-xs font-semibold uppercase tracking-[0.14em] text-page-t/75 dark:border-page-dt/12 dark:text-page-dt/75">
|
||||||
|
Factura generada
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-x-6 gap-y-3 text-sm md:grid-cols-2">
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Folio</p><p class="font-mono font-medium text-page-t dark:text-page-dt">{{ model.invoice.folio || '—' }}</p></div>
|
||||||
|
<div class="space-y-0.5"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Timbrada el</p><p class="text-page-t dark:text-page-dt">{{ formatDate(model.invoice.stamped_at) }}</p></div>
|
||||||
|
<div class="space-y-0.5 md:col-span-2"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">UUID SAT</p><p class="break-all font-mono text-xs text-page-t dark:text-page-dt">{{ model.invoice.uuid || '—' }}</p></div>
|
||||||
|
<div v-if="model.invoice.notes" class="space-y-0.5 md:col-span-2"><p class="text-xs font-semibold uppercase tracking-wide text-page-t/55 dark:text-page-dt/55">Notas</p><p class="text-page-t dark:text-page-dt">{{ model.invoice.notes }}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
<a
|
||||||
|
v-if="model.invoice.xml_url"
|
||||||
|
:href="model.invoice.xml_url"
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center gap-1 rounded-sm border border-sky-300 bg-sky-50 px-3 py-1.5 text-sm font-medium text-sky-700 transition-colors hover:bg-sky-100 dark:border-sky-500/30 dark:bg-sky-900/30 dark:text-sky-200 dark:hover:bg-sky-900/50"
|
||||||
|
>
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-base"
|
||||||
|
name="code"
|
||||||
|
/>
|
||||||
|
Descargar XML
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-if="model.invoice.pdf_url"
|
||||||
|
:href="model.invoice.pdf_url"
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center gap-1 rounded-sm border border-rose-300 bg-rose-50 px-3 py-1.5 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-100 dark:border-rose-500/30 dark:bg-rose-900/30 dark:text-rose-200 dark:hover:bg-rose-900/50"
|
||||||
|
>
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-base"
|
||||||
|
name="picture_as_pdf"
|
||||||
|
/>
|
||||||
|
Descargar PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="canChangeStatus" class="rounded-sm border border-page-t/12 bg-page-t/5 p-4 dark:border-page-dt/12 dark:bg-page-dt/5">
|
||||||
|
<h3 class="mb-3 border-b border-page-t/12 pb-2 text-xs font-semibold uppercase tracking-[0.14em] text-page-t/75 dark:border-page-dt/12 dark:text-page-dt/75">
|
||||||
|
Cambiar estado
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-2.5">
|
||||||
|
<button
|
||||||
|
v-if="model.status === 'pending'"
|
||||||
|
class="inline-flex items-center gap-1 rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="apiStatus.processing"
|
||||||
|
@click="changeStatus('processing')"
|
||||||
|
>
|
||||||
|
Marcar en proceso
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 rounded-sm bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="apiStatus.processing"
|
||||||
|
@click="changeStatus('rejected')"
|
||||||
|
>
|
||||||
|
Rechazar solicitud
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="canUpload" class="rounded-sm border border-page-t/12 bg-page-t/5 p-4 dark:border-page-dt/12 dark:bg-page-dt/5">
|
||||||
|
<h3 class="mb-3 border-b border-page-t/12 pb-2 text-xs font-semibold uppercase tracking-[0.14em] text-page-t/75 dark:border-page-dt/12 dark:text-page-dt/75">
|
||||||
|
Subir CFDI (XML / PDF)
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<SingleFile
|
||||||
|
v-model="invoiceForm.xml_file"
|
||||||
|
title="Archivo XML"
|
||||||
|
accept=".xml,text/xml,application/xml"
|
||||||
|
/>
|
||||||
|
<SingleFile
|
||||||
|
v-model="invoiceForm.pdf_file"
|
||||||
|
title="Archivo PDF"
|
||||||
|
accept=".pdf,application/pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
v-model="invoiceForm.folio"
|
||||||
|
id="Folio"
|
||||||
|
placeholder="Ej. A-0001"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="invoiceForm.uuid"
|
||||||
|
id="UUID del timbre"
|
||||||
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
v-model="invoiceForm.stamped_at"
|
||||||
|
id="Fecha de timbrado"
|
||||||
|
type="datetime-local"
|
||||||
|
/>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
v-model="invoiceForm.notes"
|
||||||
|
id="Notas internas"
|
||||||
|
placeholder="Ej. Generado con PAC Facturama"
|
||||||
|
/>
|
||||||
|
<div class="pt-1 text-right">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 rounded-sm bg-emerald-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="invoiceForm.processing"
|
||||||
|
@click="uploadInvoice"
|
||||||
|
>
|
||||||
|
<span v-if="invoiceForm.processing">Subiendo…</span>
|
||||||
|
<span v-else>Subir y enviar al ciudadano</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex w-full items-center justify-end gap-3 px-2 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary bg-transparent text-page-t/80 dark:text-page-dt/80 hover:opacity-80"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DialogModal>
|
||||||
|
</template>
|
||||||
10
src/pages/App/InvoiceRequest/Module.js
Normal file
10
src/pages/App/InvoiceRequest/Module.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { apiURL } from '@Services/Api';
|
||||||
|
|
||||||
|
const apiTo = (id = null, action = '') => {
|
||||||
|
let path = 'invoice-requests';
|
||||||
|
if (id !== null) path += `/${id}`;
|
||||||
|
if (action) path += `/${action}`;
|
||||||
|
return apiURL(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { apiTo };
|
||||||
48
src/pages/App/Membership/Create.vue
Normal file
48
src/pages/App/Membership/Create.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useForm } from '@Services/Api';
|
||||||
|
import { apiTo, transl, viewTo } from './Module';
|
||||||
|
|
||||||
|
import IconButton from '@Holos/Button/Icon.vue';
|
||||||
|
import PageHeader from '@Holos/PageHeader.vue';
|
||||||
|
import Form from './Form.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
curp: '',
|
||||||
|
name: '',
|
||||||
|
tutor: '',
|
||||||
|
photo: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
form
|
||||||
|
.transform((data) => ({ ...data }))
|
||||||
|
.post(route('members.store'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
Notify.success(transl('create.onSuccess'));
|
||||||
|
router.push(viewTo({ name: 'index' }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageHeader :title="transl('create.title')">
|
||||||
|
<RouterLink :to="viewTo({ name: 'index' })">
|
||||||
|
<IconButton
|
||||||
|
class="text-white"
|
||||||
|
icon="arrow_back"
|
||||||
|
:title="$t('return')"
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
</PageHeader>
|
||||||
|
<Form
|
||||||
|
action="create"
|
||||||
|
:form="form"
|
||||||
|
@submit="submit"
|
||||||
|
>
|
||||||
|
</Form>
|
||||||
|
</template>
|
||||||
74
src/pages/App/Membership/Form.vue
Normal file
74
src/pages/App/Membership/Form.vue
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<script setup>
|
||||||
|
import { transl } from './Module';
|
||||||
|
|
||||||
|
import PrimaryButton from '@Holos/Button/Primary.vue';
|
||||||
|
import Input from '@Holos/Form/Input.vue';
|
||||||
|
import PhotoUpload from '@App/PhotoUpload.vue';
|
||||||
|
|
||||||
|
/** Eventos */
|
||||||
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
|
/** Propiedades */
|
||||||
|
defineProps({
|
||||||
|
action: {
|
||||||
|
default: 'create',
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
form: Object,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Métodos */
|
||||||
|
function submit() {
|
||||||
|
emit('submit');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full pb-2">
|
||||||
|
<p class="text-justify text-sm" v-text="transl(`${action}.description`)" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<form
|
||||||
|
@submit.prevent="submit"
|
||||||
|
class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="form.curp"
|
||||||
|
:id="$t('membership.curp')"
|
||||||
|
type="text"
|
||||||
|
:onError="form.errors.curp"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="form.name"
|
||||||
|
:id="$t('membership.name')"
|
||||||
|
type="text"
|
||||||
|
:onError="form.errors.name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="form.tutor"
|
||||||
|
:id="$t('membership.tutor')"
|
||||||
|
type="text"
|
||||||
|
:onError="form.errors.tutor"
|
||||||
|
/>
|
||||||
|
<PhotoUpload
|
||||||
|
v-model="form.photo"
|
||||||
|
:title="$t('membership.photo')"
|
||||||
|
:onError="form.errors.photo"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<slot />
|
||||||
|
<div
|
||||||
|
class="col-span-1 md:col-span-2 lg:col-span-3 xl:col-span-4 flex flex-col items-center justify-end space-y-4 mt-4"
|
||||||
|
>
|
||||||
|
<PrimaryButton
|
||||||
|
v-text="$t(action)"
|
||||||
|
:class="{ 'opacity-25': form.processing }"
|
||||||
|
:disabled="form.processing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
src/pages/App/Membership/Index.vue
Normal file
21
src/pages/App/Membership/Index.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup>
|
||||||
|
import { can, viewTo, } from "./Module";
|
||||||
|
|
||||||
|
import SearcherHead from "@Holos/Searcher.vue";
|
||||||
|
import IconButton from '@Holos/Button/Icon.vue'
|
||||||
|
import MembershipSection from "@App/MembershipSection.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SearcherHead title="Cobro de Membresía" @search="(x) => searcher.search(x)">
|
||||||
|
<RouterLink v-if="can('create')" :to="viewTo({ name: 'create' })">
|
||||||
|
<IconButton
|
||||||
|
class="text-white"
|
||||||
|
icon="add"
|
||||||
|
:title="$t('crud.create')"
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
</SearcherHead>
|
||||||
|
<MembershipSection />
|
||||||
|
</template>
|
||||||
21
src/pages/App/Membership/Module.js
Normal file
21
src/pages/App/Membership/Module.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { lang } from '@Lang/i18n';
|
||||||
|
import { hasPermission } from '@Plugins/RolePermission.js';
|
||||||
|
|
||||||
|
// Ruta API
|
||||||
|
const apiTo = (name, params = {}) => route(`membership.${name}`, params)
|
||||||
|
|
||||||
|
// Ruta visual
|
||||||
|
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `membership.${name}`, params, query })
|
||||||
|
|
||||||
|
// Obtener traducción del componente
|
||||||
|
const transl = (str) => lang(`membership.${str}`)
|
||||||
|
|
||||||
|
// Determina si un usuario puede hacer algo no en base a los permisos
|
||||||
|
const can = (permission) => true//hasPermission(`membership.${permission}`)
|
||||||
|
|
||||||
|
export {
|
||||||
|
can,
|
||||||
|
viewTo,
|
||||||
|
apiTo,
|
||||||
|
transl
|
||||||
|
}
|
||||||
@ -44,33 +44,41 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="login">
|
<div>
|
||||||
<Input
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">
|
||||||
icon="mail"
|
Bienvenido de nuevo
|
||||||
id="email"
|
</h1>
|
||||||
type="email"
|
|
||||||
v-model="form.email"
|
<form @submit.prevent="login" class="space-y-6">
|
||||||
:onError="form.errors.email"
|
<Input
|
||||||
:placeholder="$t('email.title')"
|
icon="mail"
|
||||||
/>
|
id="email"
|
||||||
<Input
|
type="email"
|
||||||
v-model="form.password"
|
v-model="form.email"
|
||||||
icon="password"
|
:onError="form.errors.email"
|
||||||
id="password"
|
:placeholder="$t('email.title')"
|
||||||
type="password"
|
/>
|
||||||
:onError="form.errors.password"
|
<Input
|
||||||
:placeholder="$t('password')"
|
v-model="form.password"
|
||||||
/>
|
icon="lock"
|
||||||
<PrimaryButton class="!w-full">
|
id="password"
|
||||||
{{ $t('auth.login') }}
|
type="password"
|
||||||
</PrimaryButton>
|
:onError="form.errors.password"
|
||||||
<div class="flex justify-end mt-4">
|
:placeholder="$t('password')"
|
||||||
<RouterLink
|
/>
|
||||||
class="text-sm ml-2 hover:text-blue-200 cursor-pointer hover:-translate-y-1 duration-500 transition-all"
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<RouterLink
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-dt transition-colors"
|
||||||
:to="viewTo({ name: 'forgot-password' })"
|
:to="viewTo({ name: 'forgot-password' })"
|
||||||
>
|
>
|
||||||
{{ $t('auth.forgotPassword.ask') }}
|
{{ $t('auth.forgotPassword.ask') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<PrimaryButton class="w-full! py-3! text-base! font-semibold!">
|
||||||
|
{{$t('auth.login')}}
|
||||||
|
</PrimaryButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
499
src/pages/Public/Invoice/Index.vue
Normal file
499
src/pages/Public/Invoice/Index.vue
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { apiURL } from '@Services/Api';
|
||||||
|
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||||||
|
|
||||||
|
|
||||||
|
/** Ruta actual (para leer :qr_token) */
|
||||||
|
const route = useRoute();
|
||||||
|
const qrToken = route.params.qr_token;
|
||||||
|
|
||||||
|
/** Estado general */
|
||||||
|
const loading = ref(true);
|
||||||
|
const notFound = ref(false);
|
||||||
|
const paymentData = ref(null);
|
||||||
|
const invoiceRequest = ref(null);
|
||||||
|
const hasInvoiceReq = ref(false);
|
||||||
|
const submitted = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const serverErrors = ref({});
|
||||||
|
|
||||||
|
/** Catálogos SAT */
|
||||||
|
const fiscalRegimes = ref([]);
|
||||||
|
const cfdiUses = ref([]);
|
||||||
|
const loadingCfdi = ref(false);
|
||||||
|
|
||||||
|
/** Formulario ciudadano */
|
||||||
|
const form = ref({
|
||||||
|
rfc: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
name: '',
|
||||||
|
business_name: '',
|
||||||
|
fiscal_postal_code: '',
|
||||||
|
address: '',
|
||||||
|
fiscal_regimen_id: null,
|
||||||
|
cfdi_use_id: null,
|
||||||
|
selectedRegime: null,
|
||||||
|
selectedCfdiUse: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** HTTP sin autenticación */
|
||||||
|
const publicGet = (path, params = {}) =>
|
||||||
|
axios.get(apiURL(path), { params, headers: { Accept: 'application/json' } });
|
||||||
|
|
||||||
|
const publicPost = (path, data = {}) =>
|
||||||
|
axios.post(apiURL(path), data, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' } });
|
||||||
|
|
||||||
|
/** Al cargar — obtener datos del pago */
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await publicGet(`invoice-request/payment/${qrToken}`);
|
||||||
|
const res = data?.data ?? data;
|
||||||
|
paymentData.value = res.model;
|
||||||
|
hasInvoiceReq.value = res.has_invoice_request;
|
||||||
|
invoiceRequest.value = res.model?.invoice_request ?? null;
|
||||||
|
|
||||||
|
// Prellenar RFC si el pago tiene uno registrado
|
||||||
|
if (res.model?.model?.rfc) {
|
||||||
|
form.value.rfc = res.model.model.rfc;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
notFound.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar regímenes fiscales en paralelo
|
||||||
|
loadFiscalRegimes();
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadFiscalRegimes = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await publicGet('sat/fiscal-regimes');
|
||||||
|
const res = data?.data ?? data;
|
||||||
|
fiscalRegimes.value = res.models?.data ?? res.models ?? [];
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Al cambiar régimen — obtener usos de CFDI compatibles */
|
||||||
|
const onRegimeChange = async (regime) => {
|
||||||
|
form.value.fiscal_regimen_id = regime?.id ?? null;
|
||||||
|
form.value.cfdi_use_id = null;
|
||||||
|
form.value.selectedCfdiUse = null;
|
||||||
|
cfdiUses.value = [];
|
||||||
|
|
||||||
|
if (!regime?.code) return;
|
||||||
|
|
||||||
|
loadingCfdi.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await publicGet('sat/cfdi-uses', { fiscal_regime: regime.code });
|
||||||
|
const res = data?.data ?? data;
|
||||||
|
cfdiUses.value = res.models?.data ?? res.models ?? [];
|
||||||
|
} catch {
|
||||||
|
cfdiUses.value = [];
|
||||||
|
} finally {
|
||||||
|
loadingCfdi.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCfdiChange = (use) => {
|
||||||
|
form.value.cfdi_use_id = use?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Validaciones básicas del cliente */
|
||||||
|
const rfcRegex = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i;
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const errors = {};
|
||||||
|
const rfc = form.value.rfc.trim().toUpperCase();
|
||||||
|
if (!rfc) {
|
||||||
|
errors.rfc = 'El RFC es obligatorio.';
|
||||||
|
} else if (!rfcRegex.test(rfc)) {
|
||||||
|
errors.rfc = 'El RFC no tiene un formato válido (ej. XAXX010101000).';
|
||||||
|
}
|
||||||
|
if (!form.value.email.trim()) errors.email = 'El correo electrónico es obligatorio.';
|
||||||
|
if (!form.value.fiscal_regimen_id) errors.fiscal_regimen_id = 'Selecciona un régimen fiscal.';
|
||||||
|
if (!form.value.cfdi_use_id) errors.cfdi_use_id = 'Selecciona el uso del CFDI.';
|
||||||
|
if (!form.value.fiscal_postal_code.trim()) {
|
||||||
|
errors.fiscal_postal_code = 'El código postal fiscal es obligatorio.';
|
||||||
|
} else if (!/^\d{5}$/.test(form.value.fiscal_postal_code.trim())) {
|
||||||
|
errors.fiscal_postal_code = 'El código postal debe tener exactamente 5 dígitos.';
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Enviar solicitud */
|
||||||
|
const submitRequest = async () => {
|
||||||
|
serverErrors.value = {};
|
||||||
|
const clientErrors = validate();
|
||||||
|
if (Object.keys(clientErrors).length) {
|
||||||
|
serverErrors.value = clientErrors;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
qr_token: qrToken,
|
||||||
|
rfc: form.value.rfc.trim().toUpperCase(),
|
||||||
|
email: form.value.email.trim(),
|
||||||
|
fiscal_regimen_id: form.value.fiscal_regimen_id,
|
||||||
|
cfdi_use_id: form.value.cfdi_use_id,
|
||||||
|
fiscal_postal_code: form.value.fiscal_postal_code.trim(),
|
||||||
|
};
|
||||||
|
if (form.value.phone.trim()) payload.phone = form.value.phone.trim();
|
||||||
|
if (form.value.name.trim()) payload.name = form.value.name.trim();
|
||||||
|
if (form.value.business_name.trim()) payload.business_name = form.value.business_name.trim();
|
||||||
|
if (form.value.address.trim()) payload.address = form.value.address.trim();
|
||||||
|
|
||||||
|
await publicPost('invoice-request', payload);
|
||||||
|
submitted.value = true;
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.response?.status;
|
||||||
|
if (status === 422) {
|
||||||
|
serverErrors.value = err.response.data?.errors ?? {};
|
||||||
|
} else if (status === 409) {
|
||||||
|
hasInvoiceReq.value = true;
|
||||||
|
invoiceRequest.value = { status: 'pending' };
|
||||||
|
} else if (status === 404) {
|
||||||
|
notFound.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (a) =>
|
||||||
|
a ? new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(a) : '—';
|
||||||
|
|
||||||
|
const formatDate = (iso) =>
|
||||||
|
iso ? new Date(iso).toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' }) : '—';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-100 flex flex-col">
|
||||||
|
|
||||||
|
<!-- Encabezado -->
|
||||||
|
<header class="bg-[#621134] text-white py-4 px-6 shadow">
|
||||||
|
<div class="max-w-2xl mx-auto flex items-center gap-3">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-3xl text-white"
|
||||||
|
name="receipt_long"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-light opacity-80">H. Ayuntamiento de Comalcalco</p>
|
||||||
|
<h1 class="text-lg font-bold leading-tight">Solicitud de Factura Electrónica</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Contenido principal -->
|
||||||
|
<main class="flex-1 py-8 px-4">
|
||||||
|
<div class="max-w-2xl mx-auto space-y-5">
|
||||||
|
|
||||||
|
<!-- Cargando -->
|
||||||
|
<div v-if="loading" class="bg-white rounded-xl shadow p-10 flex justify-center">
|
||||||
|
<div class="animate-spin rounded-full h-10 w-10 border-4 border-[#621134] border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR inválido / no encontrado -->
|
||||||
|
<div v-else-if="notFound" class="bg-white rounded-xl shadow p-8 text-center space-y-3">
|
||||||
|
<span class="material-symbols-outlined text-5xl text-red-400">qr_code_scanner</span>
|
||||||
|
<h2 class="text-lg font-bold text-gray-700">Código QR no válido</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
El código QR no es válido o ya expiró. Si crees que es un error, comunícate con el
|
||||||
|
departamento de Tránsito Municipal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Solicitud enviada exitosamente -->
|
||||||
|
<div v-else-if="submitted" class="bg-white rounded-xl shadow p-8 text-center space-y-3">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-5xl text-green-500"
|
||||||
|
name="check_circle"
|
||||||
|
/>
|
||||||
|
<h2 class="text-lg font-bold text-gray-700">¡Solicitud enviada!</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu solicitud de factura fue recibida correctamente. Te notificaremos cuando esté lista.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estado de solicitud existente -->
|
||||||
|
<template v-else-if="hasInvoiceReq && invoiceRequest">
|
||||||
|
|
||||||
|
<!-- Datos del pago -->
|
||||||
|
<div v-if="paymentData" class="bg-white rounded-xl shadow p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
Pago de referencia
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Titular:</span> <span class="font-medium">{{ paymentData.model?.name }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Placa:</span> <span class="font-mono">{{ paymentData.model?.plate }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Monto:</span> <span class="font-semibold text-green-700">{{ formatAmount(paymentData.total_amount) }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Pagado el:</span> {{ formatDate(paymentData.paid_at) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensaje de estado -->
|
||||||
|
<div class="bg-white rounded-xl shadow p-6 text-center space-y-2">
|
||||||
|
<template v-if="invoiceRequest.status === 'pending'">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-4xl text-yellow-500"
|
||||||
|
name="hourglass_empty"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-bold text-gray-700">Solicitud en espera</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu solicitud fue recibida y está en espera de procesarse.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="invoiceRequest.status === 'processing'">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-4xl text-blue-500"
|
||||||
|
name="sync"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-bold text-gray-700">En proceso</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu factura está siendo generada, pronto te notificaremos.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="invoiceRequest.status === 'completed'">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-4xl text-green-500"
|
||||||
|
name="task_alt"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-bold text-gray-700">Factura enviada</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu factura electrónica fue generada y enviada a tu WhatsApp. Revisa tus mensajes.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="invoiceRequest.status === 'rejected'">
|
||||||
|
<GoogleIcon
|
||||||
|
class="text-4xl text-red-400"
|
||||||
|
name="cancel"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-bold text-gray-700">Solicitud no procesada</h2>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Tu solicitud no pudo procesarse. Comunícate con el departamento de Tránsito Municipal.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Formulario ciudadano -->
|
||||||
|
<template v-else-if="paymentData">
|
||||||
|
|
||||||
|
<!-- Datos del pago -->
|
||||||
|
<div class="bg-white rounded-xl shadow p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
Pago de referencia
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Titular:</span> <span class="font-medium">{{ paymentData.model?.name }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Placa:</span> <span class="font-mono">{{ paymentData.model?.plate }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Monto:</span> <span class="font-semibold text-green-700">{{ formatAmount(paymentData.total_amount) }}</span></div>
|
||||||
|
<div><span class="text-gray-500">Pagado el:</span> {{ formatDate(paymentData.paid_at) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conceptos -->
|
||||||
|
<div v-if="paymentData.model?.charge_concepts?.length" class="mt-3 pt-3 border-t">
|
||||||
|
<p class="text-xs text-gray-500 mb-1">Conceptos cobrados:</p>
|
||||||
|
<div
|
||||||
|
v-for="cc in paymentData.model.charge_concepts"
|
||||||
|
:key="cc.id"
|
||||||
|
class="flex justify-between text-xs text-gray-700 py-0.5"
|
||||||
|
>
|
||||||
|
<span>{{ cc.name }} <span class="text-gray-400">({{ cc.article }})</span></span>
|
||||||
|
<span class="font-medium">{{ formatAmount(cc.pivot?.override_amount) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulario -->
|
||||||
|
<div class="bg-white rounded-xl shadow p-6">
|
||||||
|
<h2 class="text-base font-bold text-gray-700 mb-4">Datos para tu factura</h2>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="submitRequest">
|
||||||
|
|
||||||
|
<!-- RFC -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
RFC <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.rfc"
|
||||||
|
type="text"
|
||||||
|
maxlength="13"
|
||||||
|
placeholder="Ej. XAXX010101000"
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm font-mono uppercase focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
:class="serverErrors.rfc ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
/>
|
||||||
|
<p v-if="serverErrors.rfc" class="text-xs text-red-500 mt-1">{{ serverErrors.rfc }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Correo electrónico <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
placeholder="tucorreo@ejemplo.com"
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
:class="serverErrors.email ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
/>
|
||||||
|
<p v-if="serverErrors.email" class="text-xs text-red-500 mt-1">{{ serverErrors.email }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Teléfono -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Teléfono celular
|
||||||
|
<span class="text-gray-400 font-normal text-xs">(opcional — para recibir la factura por WhatsApp)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.phone"
|
||||||
|
type="tel"
|
||||||
|
maxlength="10"
|
||||||
|
placeholder="10 dígitos"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nombre -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Nombre completo <span class="text-gray-400 font-normal text-xs">(opcional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Como aparece en tu constancia fiscal"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Razón social -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Razón social <span class="text-gray-400 font-normal text-xs">(opcional, solo para personas morales)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.business_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nombre de la empresa"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Régimen fiscal -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Régimen fiscal <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40 bg-white"
|
||||||
|
:class="serverErrors.fiscal_regimen_id ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
@change="onRegimeChange(fiscalRegimes.find(r => r.id == $event.target.value))"
|
||||||
|
>
|
||||||
|
<option value="">Selecciona tu régimen fiscal…</option>
|
||||||
|
<option
|
||||||
|
v-for="regime in fiscalRegimes"
|
||||||
|
:key="regime.id"
|
||||||
|
:value="regime.id"
|
||||||
|
>
|
||||||
|
{{ regime.code }} — {{ regime.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="serverErrors.fiscal_regimen_id" class="text-xs text-red-500 mt-1">{{ serverErrors.fiscal_regimen_id }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uso del CFDI -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Uso del CFDI <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="loadingCfdi" class="text-sm text-gray-400 py-2">Cargando usos disponibles…</div>
|
||||||
|
<select
|
||||||
|
v-else
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40 bg-white"
|
||||||
|
:class="serverErrors.cfdi_use_id ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
:disabled="!form.fiscal_regimen_id"
|
||||||
|
@change="onCfdiChange(cfdiUses.find(u => u.id == $event.target.value))"
|
||||||
|
>
|
||||||
|
<option value="">{{ form.fiscal_regimen_id ? 'Selecciona el uso…' : 'Primero selecciona el régimen' }}</option>
|
||||||
|
<option
|
||||||
|
v-for="use in cfdiUses"
|
||||||
|
:key="use.id"
|
||||||
|
:value="use.id"
|
||||||
|
>
|
||||||
|
{{ use.code }} — {{ use.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="serverErrors.cfdi_use_id" class="text-xs text-red-500 mt-1">{{ serverErrors.cfdi_use_id }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Código postal fiscal -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Código postal fiscal <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.fiscal_postal_code"
|
||||||
|
type="text"
|
||||||
|
maxlength="5"
|
||||||
|
placeholder="5 dígitos"
|
||||||
|
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
:class="serverErrors.fiscal_postal_code ? 'border-red-400' : 'border-gray-300'"
|
||||||
|
/>
|
||||||
|
<p v-if="serverErrors.fiscal_postal_code" class="text-xs text-red-500 mt-1">{{ serverErrors.fiscal_postal_code }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dirección -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Dirección <span class="text-gray-400 font-normal text-xs">(opcional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.address"
|
||||||
|
type="text"
|
||||||
|
placeholder="Calle, número, colonia"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#621134]/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botón enviar -->
|
||||||
|
<div class="pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full py-3 rounded-lg text-white font-semibold text-sm bg-[#621134] hover:bg-[#7a1540] disabled:opacity-50 transition-colors"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
<span v-if="submitting">Enviando solicitud…</span>
|
||||||
|
<span v-else>Solicitar mi factura</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-center text-gray-400 pb-4">
|
||||||
|
Al enviar aceptas que tus datos sean utilizados únicamente para la emisión de la factura electrónica.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Pie -->
|
||||||
|
<footer class="bg-white border-t py-3 px-4 text-center text-xs text-gray-400">
|
||||||
|
H. Ayuntamiento de Comalcalco — Dirección de Tránsito Municipal
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -21,7 +21,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'index',
|
name: 'index',
|
||||||
redirect: '/dashboard'
|
redirect: '/address'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
@ -48,6 +48,86 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'address',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'address.index',
|
||||||
|
component: () => import('@Pages/App/Address/Index.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'concept',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'concept.index',
|
||||||
|
component: () => import('@Pages/App/Concept/Index.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'fine',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'fine.index',
|
||||||
|
component: () => import('@Pages/App/Fine/Index.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'fine.dashboard',
|
||||||
|
component: () => import('@Pages/App/Fine/Dashboard.vue')
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'discount',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'discount.index',
|
||||||
|
component: () => import('@Pages/App/Discount/Index.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'membership',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'membership.index',
|
||||||
|
component: () => import('@Pages/App/Membership/Index.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'create',
|
||||||
|
name: 'membership.create',
|
||||||
|
component: () => import('@Pages/App/Membership/Create.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'checkout',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'checkout.index',
|
||||||
|
component: () => import('@Pages/App/Checkout/Index.vue')
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'invoice-requests',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'invoice-request.index',
|
||||||
|
component: () => import('@Pages/App/InvoiceRequest/Index.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -156,6 +236,11 @@ const router = createRouter({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/factura/:qr_token',
|
||||||
|
name: 'invoice.request',
|
||||||
|
component: () => import('@Pages/Public/Invoice/Index.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: '404',
|
name: '404',
|
||||||
|
|||||||
11
src/services/App/FineService.js
Normal file
11
src/services/App/FineService.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const downloadFineTicket = (fine) => {
|
||||||
|
if (!fine.pdf_path) return;
|
||||||
|
window.open(fine.pdf_path, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFineReceipt = (fine) => {
|
||||||
|
if (!fine.payments?.[0]?.receipt_pdf_path) return;
|
||||||
|
window.open(fine.payments[0].receipt_pdf_path, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
export { downloadFineTicket, downloadFineReceipt };
|
||||||
@ -16,6 +16,7 @@ export default defineConfig({
|
|||||||
'@Components': fileURLToPath(new URL('./src/components', import.meta.url)),
|
'@Components': fileURLToPath(new URL('./src/components', import.meta.url)),
|
||||||
'@Controllers': fileURLToPath(new URL('./src/controllers', import.meta.url)),
|
'@Controllers': fileURLToPath(new URL('./src/controllers', import.meta.url)),
|
||||||
'@Holos': fileURLToPath(new URL('./src/components/Holos', import.meta.url)),
|
'@Holos': fileURLToPath(new URL('./src/components/Holos', import.meta.url)),
|
||||||
|
'@App': fileURLToPath(new URL('./src/components/App', import.meta.url)),
|
||||||
'@Layouts': fileURLToPath(new URL('./src/layouts', import.meta.url)),
|
'@Layouts': fileURLToPath(new URL('./src/layouts', import.meta.url)),
|
||||||
'@Lang': fileURLToPath(new URL('./src/lang', import.meta.url)),
|
'@Lang': fileURLToPath(new URL('./src/lang', import.meta.url)),
|
||||||
'@Router': fileURLToPath(new URL('./src/router', import.meta.url)),
|
'@Router': fileURLToPath(new URL('./src/router', import.meta.url)),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user