Compare commits

...

25 Commits

Author SHA1 Message Date
a8921833ed Feat: modificaciones modal y agregar boton condonar 2026-03-30 18:00:54 -06:00
83bec44121 FIX:Descuentos del 50 2026-03-30 11:31:25 -06:00
5ee73c309f feat: agregar métodos de pago y mejorar la visualización de descuentos en multas 2026-03-29 16:06:43 -06:00
0d1ccf9413 feat: enhance fine management features
- Updated Searcher component styles for improved UI.
- Added a new Dashboard for fines with statistics and filters.
- Introduced FineResultCard and FinePaymentSummary components for displaying fine details and payment summaries.
- Implemented FineSearchPanel for searching fines by folio or CURP.
- Added download functionality for fine tickets and receipts.
- Updated routing to include a dashboard view for fines.
- Integrated user search functionality for filtering fines by agent.
- Improved overall layout and organization of fine-related components.
2026-03-28 14:05:21 -06:00
77e6e796d5 FIX:Busqueda por folio en descuento 2026-03-26 11:51:35 -06:00
04fcb9df08 FIX:Intefaz de cobro de multa 2026-03-26 11:34:34 -06:00
f53d0ff457 FEAT:Modulo de facturación 2026-03-24 11:18:28 -06:00
2b7b884794 FIX:Paginación en conceptos 2026-03-09 15:29:07 -06:00
f903f74fe4 FIX:Busqueda de membresías 2026-03-06 14:59:25 -06:00
104e2e327e FIX:Errores en creación de conceptos 2026-03-06 11:44:15 -06:00
76b1c6c02b Merge branch 'main' of git.golsystems.mx:juan.zapata/comal-pagos 2026-03-06 09:46:22 -06:00
1fc3e76027 FIX:Registro de concepto 2026-03-06 09:39:54 -06:00
Juan Felipe Zapata Moreno
dd61370db8 add: rango de fechas cortes de caja 2025-12-26 13:36:17 -06:00
Juan Felipe Zapata Moreno
6a12a71f10 fix: usuario y monto 2025-12-23 09:16:21 -06:00
Juan Felipe Zapata Moreno
cdfd53e8ec Merge branch 'main' of git.golsystems.mx:juan.zapata/comal-pagos 2025-12-22 16:47:18 -06:00
Juan Felipe Zapata Moreno
523b0972d6 MOD: agregar gestión de visibilidad en el formulario de entrega y mejorar la interfaz de usuario en la sección de checkout 2025-12-22 16:15:12 -06:00
7a2b5f8400 UPDATE:Creación de conceptos 2025-12-17 17:15:34 -06:00
Juan Felipe Zapata Moreno
b9d2f69390 MOD: mejorar visibilidad en Modal 2025-12-12 13:14:49 -06:00
Juan Felipe Zapata Moreno
8a518f477c Correcion de qr multa 2025-11-28 14:29:35 -06:00
Juan Felipe Zapata Moreno
d79552fafc MOD: actualizar rutas y mejorar la interfaz de usuario en secciones de dirección y concepto 2025-11-28 12:50:27 -06:00
8d513d215c MOD: mejorar la gestión del formulario en ConceptSection 2025-11-26 19:48:09 -06:00
Juan Felipe Zapata Moreno
4ac8799f88 MOD: componentes de autenticación y diseño, mejora de estilos y estructura 2025-11-26 12:59:30 -06:00
Juan Felipe Zapata Moreno
5e46e55d73 MOD: actualizar módulo de entrega de caja y escaneo de QR 2025-11-24 16:40:50 -06:00
Juan Felipe Zapata Moreno
bd6edc59d6 ADD: modificación modulo concepto, multa y descuento 2025-11-20 16:03:37 -06:00
3029d425fe Cambios (#2)
Co-authored-by: Juan Felipe Zapata Moreno <juan.zapata@golsystems.com.mx>
Co-committed-by: Juan Felipe Zapata Moreno <juan.zapata@golsystems.com.mx>
2025-11-19 19:08:30 +00:00
71 changed files with 6254 additions and 221 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

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

View File

@ -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' })
} }

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

View 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">&times;</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>

View 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">&times;</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>

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@ -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> &copy {{year}} {{ APP_COPYRIGHT }}
&copy;2024 {{ APP_COPYRIGHT }} </span>
</span>
</div>
<div>
<span>
APP {{ APP_VERSION }} API {{ $page.app.version }}
</span>
</div>
</footer> </footer>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">
&copy {{year}} {{ APP_COPYRIGHT }} &copy {{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>

View File

@ -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 = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

View File

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

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

View File

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