Cambios #2

Merged
juan.zapata merged 8 commits from develop into main 2025-11-19 19:08:31 +00:00
36 changed files with 2157 additions and 25 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_LANG=es-MX
VITE_APP_PORT=3000
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_MICROSERVICE_STOCK=http://localhost:3000/api

View File

@ -3,12 +3,12 @@
--color-page-t: #000;
--color-page-d: #292524;
--color-page-dt: #fff;
--color-primary: #374151;
--color-primary: #621132;
--color-primary-t: #fff;
--color-primary-d: #1c1917;
--color-primary-dt: #fff;
--color-secondary: #3b82f6;
--color-secondary-t: #fff;
--color-secondary: #fff;
--color-secondary-t: #621132;
--color-secondary-d: #312e81;
--color-secondary-dt: #fff;
--color-primary-info: #06b6d4;

View File

@ -1,16 +1,16 @@
services:
holos-frontend:
comal-pagos-frontend:
build:
context: .
dockerfile: dockerfile
ports:
- "${APP_PORT}:5173"
volumes:
- .:/var/www/holos.frontend
- /var/www/holos.frontend/node_modules
- .:/var/www/comal-pagos.frontend
- /var/www/comal-pagos.frontend/node_modules
networks:
- holos-network
- comal-pagos-network
networks:
holos-network:
comal-pagos-network:
driver: bridge

View File

@ -1,6 +1,6 @@
FROM node:22-alpine AS build
WORKDIR /var/www/holos.frontend
WORKDIR /var/www/comal-pagos.frontend
COPY package*.json ./
@ -11,7 +11,5 @@ COPY . .
COPY install.sh /usr/local/bin/install.sh
RUN chmod +x /usr/local/bin/install.sh
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/install.sh"]
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@ -2,7 +2,7 @@
<html id="main-page" lang="es">
<head>
<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" />
<title>Holos</title>
</head>

View File

@ -1,11 +1,11 @@
#! /bin/bash
#! /bin/sh
if [ ! -f .env ]; then
cp .env.example .env
fi
if [ ! -f colors.json ]; then
cp colors.json.example colors.json
if [ ! -f colors.css ]; then
cp colors.css.example colors.css
fi
exec "$@"

64
package-lock.json generated
View File

@ -23,6 +23,7 @@
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-multiselect": "^3.2.0",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.0",
"ziggy-js": "^2.5.2"
},
@ -1224,6 +1225,18 @@
"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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@ -1492,6 +1505,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/barcode-detector": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-2.2.2.tgz",
"integrity": "sha512-JcSekql+EV93evfzF9zBr+Y6aRfkR+QFvgyzbwQ0dbymZXoAI9+WgT7H1E429f+3RKNncHz2CW98VQtaaKpmfQ==",
"license": "MIT",
"dependencies": {
"@types/dom-webcodecs": "^0.1.11",
"zxing-wasm": "1.1.3"
}
},
"node_modules/birpc": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.3.0.tgz",
@ -3230,6 +3253,12 @@
"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": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@ -3641,6 +3670,19 @@
"npm": ">= 6.14.15"
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.7.3.tgz",
"integrity": "sha512-iSGko42FsEvdHyizBMBs/X+HMO9Z5ONDxjW+mQdoraOR5emRNedmjC5SEJdYzGz8ZP5ME3lwB4iHy3S7MOt5Qw==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
@ -3662,6 +3704,19 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/webrtc-adapter": {
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.3.tgz",
"integrity": "sha512-gnmRz++suzmvxtp3ehQts6s2JtAGPuDPjA1F3a9ckNpG1kYdYuHWYpazoAnL9FS5/B21tKlhkorbdCXat0+4xQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
@ -3711,6 +3766,15 @@
"@types/qs": "^6.9.17",
"qs": "~6.9.7"
}
},
"node_modules/zxing-wasm": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-1.1.3.tgz",
"integrity": "sha512-MYm9k/5YVs4ZOTIFwlRjfFKD0crhefgbnt1+6TEpmKUDFp3E2uwqGSKwQOd2hOIsta/7Usq4hnpNRYTLoljnfA==",
"license": "MIT",
"dependencies": {
"@types/emscripten": "^1.39.10"
}
}
}
}

View File

@ -1,6 +1,6 @@
{
"name": "notsoweb.frontend",
"copyright": "Notsoweb Software Inc.",
"copyright": "Golsystems",
"private": true,
"version": "0.9.10",
"type": "module",
@ -25,6 +25,7 @@
"vue": "^3.5.13",
"vue-i18n": "^11.1.1",
"vue-multiselect": "^3.2.0",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "^4.5.0",
"ziggy-js": "^2.5.2"
},

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

@ -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-xl p-6 m-3 max-w-auto shadow-lg mb-10">
<h3 class="text-xl font-semibold mb-4 text-gray-800">
{{ $t('address.create.title') }}
</h3>
<form @submit.prevent="handleSubmit">
<div class="mb-5">
<label
for="addressName"
class="block text-sm text-gray-600 font-medium mb-2"
>
{{ $t('address.name') }}:
</label>
<input
type="text"
id="addressName"
v-model="addressName"
:placeholder="$t('address.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>
<div class="mb-3 flex justify-center">
<button
type="submit"
class="w-1/4 bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors"
>
{{ $t('save') }}
</button>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,114 @@
<script setup>
import { ref } from "vue";
import Input from "@Holos/Form/Input.vue";
import QRscan from "./QRscan.vue";
/** Eventos */
const emit = defineEmits(["fine-searched"]);
/** Refs */
const numero_entrega = ref("");
const fineData = ref({
fecha: "",
numero_entrega: "",
nombre: "",
monto_entrega: "",
});
/** Métodos */
const handleSearch = () => {
if (!numero_entrega.value.trim()) {
Notify.warning("Por favor ingresa un número de entrega");
return;
}
fineData.value = {
fecha: "2025-11-11",
numero_entrega: numero_entrega.value,
nombre: "Juan Pérez García",
monto_entrega: "$1,500.00",
};
emit("fine-searched", fineData.value);
};
</script>
<template>
<div class="space-y-6 p-6">
<div class="bg-white rounded-xl p-6 shadow-lg">
<h3 class="text-xl font-semibold mb-4 text-gray-800">Buscar Multa</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<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-64 bg-gray-800 rounded-lg overflow-hidden"
>
<!-- QR -->
<QRscan
v-model:numero_entrega="numero_entrega"
@fine-searched="handleSearch"
/>
</div>
</div>
<div class="flex flex-col justify-center">
<div class="mb-5">
<Input
v-model="numero_entrega"
id="Número de entrega"
type="text"
placeholder="Ingrese el número de entrega"
@keyup.enter="handleSearch"
/>
</div>
<button
@click="handleSearch"
type="button"
class="w-full bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors"
>
Buscar
</button>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-6 shadow-lg">
<form @submit.prevent="handlePayment">
<!-- Fecha -->
<div class="grid grid-cols-2 gap-4 mb-5">
<Input
v-model="fineData.fecha"
id="Fecha de entrega"
type="date"
/>
</div>
<!-- Nombre -->
<div class="mb-5">
<Input v-model="fineData.nombre" id="Nombre del cajero" type="text"/>
</div>
<!-- Monto a Pagar -->
<div class="mb-5">
<Input
v-model="fineData.monto_entrega"
id="Monto a entregar"
type="text"
class="text-lg font-semibold"
/>
</div>
<!-- Botón Cobrar -->
<button
type="submit"
class="w-full bg-green-700 hover:bg-green-600 text-white font-medium py-3.5 rounded-lg transition-colors"
>
Entregar caja
</button>
</form>
</div>
</div>
</template>

View File

@ -0,0 +1,196 @@
<script setup>
import { onMounted, ref } 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: "",
min_amount_uma: "",
max_amount_uma: "",
min_amount_peso: "",
max_amount_peso: "",
unit_cost_uma: "",
unit_cost_peso: "",
});
const addresses = ref([]);
const units = ref([]);
/** 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 transformedData = {
direction_id: typeof form.direction === 'object' ? form.direction.id : Number(form.direction_id),
unit_id: form.unit ? (typeof form.unit === 'object' ? form.unit.id : Number(form.unit)) : null,
short_name: form.short_name,
name: form.name,
legal_instrument: form.legal_instrument,
article: form.article,
content: form.content,
min_amount_uma: form.min_amount_uma ? Number(form.min_amount_uma) : null,
max_amount_uma: form.max_amount_uma ? Number(form.max_amount_uma) : null,
min_amount_peso: form.min_amount_peso ? Number(form.min_amount_peso) : null,
max_amount_peso: form.max_amount_peso ? Number(form.max_amount_peso) : null,
unit_cost_uma: form.unit_cost_uma ? Number(form.unit_cost_uma) : null,
unit_cost_peso: form.unit_cost_peso ? Number(form.unit_cost_peso) : null,
};
emit('concept-created', transformedData);
};
</script>
<template>
<div class="bg-white rounded-xl p-6 m-3 max-w-auto shadow-lg mb-10">
<h3 class="text-xl font-semibold mb-4 text-gray-800">
{{ $t('concept.create.title') }}
</h3>
<form @submit.prevent="handleSubmit">
<div class="mb-5 grid grid-cols-2 gap-4">
<Selectable
v-model="form.direction"
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
/>
</div>
<Input
v-model="form.name"
class="col-span-2"
:id="$t('concept.name')"
type="text"
:onError="form.errors.name"
required
/>
<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"
required
/>
<Input
v-model="form.article"
class="col-span-2"
:id="$t('concept.article')"
type="text"
:onError="form.errors.article"
required
/>
</div>
<Textarea
v-model="form.content"
class="col-span-2"
:id="$t('concept.description')"
:onError="form.errors.content"
required
/>
<div class="mb-5 grid grid-cols-2 gap-4 py-2">
<Input
v-model="form.min_amount_uma"
:id="$t('concept.minimumAmountUma')"
type="number"
step="0.01"
:onError="form.errors.min_amount_uma"
/>
<Input
v-model="form.max_amount_uma"
:id="$t('concept.maximumAmountUma')"
type="number"
step="0.01"
:onError="form.errors.max_amount_uma"
/>
</div>
<div class="mb-5 grid grid-cols-2 gap-4 py-2">
<Input
v-model="form.min_amount_peso"
:id="$t('concept.minimumAmountPeso')"
type="number"
step="0.01"
:onError="form.errors.min_amount_peso"
/>
<Input
v-model="form.max_amount_peso"
:id="$t('concept.maximumAmountPeso')"
type="number"
step="0.01"
:onError="form.errors.max_amount_peso"
/>
</div>
<hr class="my-4 border-gray-300" />
<h4 class="text-lg font-semibold mb-4 text-gray-700">
{{ $t('concept.tabulator') }}
</h4>
<div class="mb-5 grid grid-cols-3 gap-4 py-2">
<Selectable
v-model="form.unit"
label="name"
value="id"
:title="$t('concept.sizeUnit')"
:options="units"
/>
<Input
v-model="form.unit_cost_uma"
:id="$t('concept.costUnitUma')"
type="number"
step="0.01"
:onError="form.errors.unit_cost_uma"
/>
<Input
v-model="form.unit_cost_peso"
:id="$t('concept.costUnitPeso')"
type="number"
step="0.01"
:onError="form.errors.unit_cost_peso"
/>
</div>
<div class="mb-3 p-7 flex justify-center">
<button
type="submit"
:disabled="form.processing"
class="w-1/4 bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors disabled:opacity-50"
>
{{ form.processing ? 'Guardando...' : $t('save') }}
</button>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,212 @@
<script setup>
import { ref } from "vue";
import Input from "@Holos/Form/Input.vue";
/** Eventos */
const emit = defineEmits(['discount-authorized']);
/** Refs */
const searchForm = ref({
fineNumber: "",
placa: "",
curp: ""
});
const discountData = ref({
fecha: "",
placa: "",
vin: "",
licencia: "",
tarjeta: "",
rfc: "",
nombre: "",
montoOriginal: "",
pagoMinimo: "",
nuevoMonto: ""
});
/** Métodos */
const handleSearch = () => {
if (!searchForm.value.fineNumber && !searchForm.value.placa && !searchForm.value.curp) {
alert("Por favor ingresa al menos un criterio de búsqueda");
return;
}
console.log("Buscando multa:", searchForm.value);
// Simular datos encontrados (temporal)
discountData.value = {
fecha: "2025-11-11",
placa: "ABC-123-DEF",
vin: "1HGBH41JXMN109186",
licencia: "LIC123456",
tarjeta: "TC987654",
rfc: "XAXX010101000",
nombre: "Juan Pérez García",
montoOriginal: "$1,500.00",
pagoMinimo: "$750.00",
nuevoMonto: ""
};
};
const handleAuthorize = () => {
if (!discountData.value.nuevoMonto) {
alert("Por favor ingresa el nuevo monto autorizado");
return;
}
console.log("Autorizando descuento:", discountData.value);
emit('discount-authorized', discountData.value);
};
</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">
<!-- Número de Multa -->
<Input
v-model="searchForm.fineNumber"
id="Número de Multa"
type="text"
placeholder=""
/>
<!-- Placa -->
<Input
v-model="searchForm.placa"
id="Placa"
type="text"
placeholder=""
/>
<!-- CURP -->
<Input
v-model="searchForm.curp"
id="CURP"
type="text"
placeholder=""
/>
<!-- Botón Buscar -->
<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">
<!-- Fecha y Placa -->
<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>
<!-- VIN y Licencia -->
<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>
<!-- Tarjeta de Circulación y RFC -->
<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>
<!-- Nombre -->
<div class="mb-5">
<Input
v-model="discountData.nombre"
id="Nombre"
type="text"
disabled
/>
</div>
<!-- Línea separadora -->
<hr class="my-6 border-gray-300" />
<!-- Monto Original y Pago Mínimo -->
<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>
<!-- Nuevo Monto a Cobrar (editable) -->
<div class="mb-6">
<Input
v-model="discountData.nuevoMonto"
id="Nuevo Monto a Cobrar (Autorizado)"
type="text"
placeholder="Ingrese el nuevo monto autorizado"
/>
</div>
<!-- Botón Autorizar -->
<button
type="submit"
class="w-full bg-green-700 hover:bg-green-600 text-white font-medium py-3.5 rounded-lg transition-colors"
>
Autorizar descuento
</button>
</form>
</div>
</div>
</template>

View File

@ -0,0 +1,277 @@
<script setup>
import { ref } from "vue";
import { useApi, apiURL } from "@/services/Api.js";
import Input from "@Holos/Form/Input.vue";
import QRscan from "./QRscan.vue";
/** Eventos */
const emit = defineEmits(["fine-searched"]);
const api = useApi();
/** Refs */
const fineNumber = ref("");
const fineData = ref({
fecha: "",
placa: "",
vin: "",
licencia: "",
tarjeta: "",
rfc: "",
nombre: "",
monto: "",
});
/** Métodos */
const handleSearch = async () => {
if (!fineNumber.value.trim()) {
window.Notify.warning("Por favor ingresa o escanea una multa");
return;
}
await api.get(apiURL(`fines/search`), {
params: {
id: fineNumber.value.trim(),
},
onSuccess: (data) => {
const model = data.model;
if (model.payments && model.payments.length > 0) {
const payment = model.payments[0];
if (payment.status === "paid") {
window.Notify.info("La multa ya ha sido pagada previamente");
fineData.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 || "",
monto: `$${data.total_to_pay.toFixed(2)}`,
isPaid: true,
};
return;
}
}
fineData.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 || "",
monto: `$${data.total_to_pay.toFixed(2)}`,
isPaid: false,
};
emit("fine-searched", {
...fineData.value,
rawData: data,
});
},
onFail: (error) => {
window.Notify.error(error.message || "Error al buscar la multa");
fineData.value = {
fecha: "",
placa: "",
vin: "",
licencia: "",
tarjeta: "",
rfc: "",
nombre: "",
monto: "",
};
},
onError: (error) => {
console.error("Error al buscar multa:", error);
window.Notify.error("Ocurrió un error al buscar la multa");
},
});
};
const handlePayment = async () => {
if (!fineData.value.monto) {
window.Notify.warning("No hay multa cargada para cobrar");
return;
}
await api.post(apiURL(`fines/${fineData.value.id}/mark-as-paid`), {
onSuccess: (data) => {
window.Notify.success("Multa cobrada exitosamente");
emit("payment-processed", { ...fineData.value, paymentData: data });
fineData.value = {
id: null,
fecha: "",
placa: "",
vin: "",
licencia: "",
tarjeta: "",
rfc: "",
nombre: "",
monto: "",
};
fineNumber.value = "";
},
onFail: (error) => {
window.Notify.error(
error.message || "Error al procesar el pago de la multa"
);
},
onError: (error) => {
window.Notify.error("Ocurrió un error al procesar el pago de la multa");
},
});
};
const clearForm = () => {
fineData.value = {
id: null,
fecha: "",
placa: "",
vin: "",
licencia: "",
tarjeta: "",
rfc: "",
nombre: "",
monto: "",
};
fineNumber.value = "";
};
</script>
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
<div class="bg-white rounded-xl p-6 shadow-lg">
<h3 class="text-xl font-semibold mb-4 text-gray-800">Buscar Multa</h3>
<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 flex flex-col items-center justify-center"
>
<!-- QR -->
<QRscan
v-model:fineNumber="fineNumber"
@fine-searched="handleSearch"
/>
</div>
</div>
<div class="mb-5">
<Input
v-model="fineNumber"
id="Número de Multa"
type="text"
placeholder="Ingrese el número de multa"
@keyup.enter="handleSearch"
/>
</div>
<button
@click="handleSearch"
type="button"
class="w-full bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors"
>
Buscar
</button>
</div>
<div class="bg-white rounded-xl p-6 shadow-lg">
<h3 class="text-xl font-semibold mb-4 text-gray-800">
Detalles de la Multa
</h3>
<form @submit.prevent="handlePayment">
<!-- Fecha y Placa -->
<div class="grid grid-cols-2 gap-4 mb-5">
<Input
v-model="fineData.fecha"
id="Fecha de Multa"
type="text"
disabled
/>
<Input v-model="fineData.placa" id="Placa" type="text" disabled />
</div>
<!-- VIN y Licencia -->
<div class="grid grid-cols-2 gap-4 mb-5">
<Input v-model="fineData.vin" id="VIN" type="text" disabled />
<Input
v-model="fineData.licencia"
id="Licencia"
type="text"
disabled
/>
</div>
<!-- Tarjeta de Circulación y RFC -->
<div class="grid grid-cols-2 gap-4 mb-5">
<Input
v-model="fineData.tarjeta"
id="Tarjeta de Circulación"
type="text"
disabled
/>
<Input v-model="fineData.rfc" id="RFC" type="text" disabled />
</div>
<!-- Nombre -->
<div class="mb-5">
<Input v-model="fineData.nombre" id="Nombre" type="text" disabled />
</div>
<!-- Línea separadora -->
<hr class="my-6 border-gray-300" />
<!-- Monto a Pagar -->
<div class="mb-5">
<Input
v-model="fineData.monto"
id="Monto a Pagar"
type="text"
disabled
class="text-lg font-semibold"
/>
</div>
<div class="space-y-6">
<div v-if="fineData.isPaid">
<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 otra multa
</button>
</div>
<!-- Botón Cobrar -->
<div>
<button
type="submit"
:disabled="fineData.isPaid"
:class="[
'w-full font-medium py-3.5 rounded-lg transition-colors',
fineData.isPaid
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-700 hover:bg-green-600 text-white',
]"
>
Cobrar
</button>
</div>
</div>
</form>
</div>
</div>
</template>

View File

@ -0,0 +1,202 @@
<script setup>
import { ref } from "vue";
import Input from "@Holos/Form/Input.vue";
import Selectable from "@Holos/Form/Selectable.vue";
/** Eventos */
const emit = defineEmits(["membership-paid"]);
/** Refs */
const searchForm = ref({
membershipNumber: "",
curp: "",
});
const memberData = ref({
image: null,
name: "",
curp: "",
tutorName: "",
});
const paymentData = ref({
service: [],
quantity: 1,
totalAmount: "",
});
const serviceOptions = ref([
{ id: 1, description: "Membresía Mensual" },
{ id: 2, description: "Membresía Trimestral" },
{ id: 3, description: "Membresía Semestral" },
{ id: 4, description: "Membresía Anual" },
]);
/** Métodos */
const handleSearch = () => {
if (!searchForm.value.membershipNumber && !searchForm.value.curp) {
alert("Por favor ingresa al menos un criterio de búsqueda");
return;
}
console.log("Buscando miembro:", searchForm.value);
// Simular datos encontrados
memberData.value = {
name: "Juan Pérez García",
curp: "PEGJ900101HDFRNN09",
tutorName: "María López Hernández",
image: null,
};
};
const handlePayment = () => {
if (!paymentData.value.service) {
alert("Por favor selecciona un servicio");
return;
}
console.log("Procesando pago de membresía:", {
member: memberData.value,
payment: paymentData.value,
});
emit("membership-paid", {
member: memberData.value,
payment: paymentData.value,
});
};
const calculateTotal = () => {
paymentData.value.totalAmount = "0.00";
};
</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 miembro</h3>
<form @submit.prevent="handleSearch">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<!-- Número de Membresía -->
<Input
v-model="searchForm.membershipNumber"
id="Número de Membresía"
type="text"
placeholder=""
/>
<!-- CURP -->
<Input
v-model="searchForm.curp"
id="CURP"
type="text"
placeholder=""
/>
<!-- Botón Buscar -->
<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>
<!-- Información del Miembro -->
<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">
Información del Miembro
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Fotografía -->
<div>
<label class="block text-sm text-gray-600 font-medium mb-2">
Fotografía
</label>
<div
class="w-full h-64 bg-gray-200 rounded-lg flex items-center justify-center"
>
<svg
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>
<!-- Datos del miembro -->
<div class="space-y-5">
<Input v-model="memberData.name" id="Nombre" type="text" disabled />
<Input v-model="memberData.curp" id="CURP" type="text" disabled />
<Input
v-model="memberData.tutorName"
id="Nombre Tutor"
type="text"
disabled
/>
</div>
</div>
</div>
<!-- Realizar Cobro de Membresía -->
<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">
Realizar Cobro de Membresía
</h3>
<form @submit.prevent="handlePayment">
<div class="grid grid-cols-3 gap-4 mb-6">
<!-- Servicio a Pagar -->
<Selectable
v-model="paymentData.service"
label="description"
title="Servicio a pagar"
:options="serviceOptions"
/>
<!-- Cantidad -->
<Input
v-model="paymentData.quantity"
id="Cantidad (días)"
type="number"
min="1"
@input="calculateTotal"
/>
<!-- Monto Total -->
<Input
v-model="paymentData.totalAmount"
id="Monto Total"
type="text"
disabled
/>
</div>
<!-- Botón Cobrar -->
<button
type="submit"
class="w-full bg-green-700 hover:bg-green-600 text-white font-medium py-3.5 rounded-lg transition-colors"
>
Pagar membresía
</button>
</form>
</div>
</div>
</template>

View File

@ -0,0 +1,130 @@
<script setup>
import { ref } from 'vue';
import { QrcodeStream } from 'vue-qrcode-reader';
/** Props y Emits */
defineProps({
fineNumber: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:fineNumber', 'fine-searched']);
/** 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 = 2;
ctx.strokeStyle = '#7a0b3a'; // Color del tema
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:fineNumber', code);
// Pausar el escaneo después de detectar
isScanning.value = false;
// Emitir evento para buscar la multa
emit('fine-searched');
// 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

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

View File

@ -80,6 +80,83 @@ export default {
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',
article: 'Articulado',
description: 'Contenido',
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 Unitario (UMAs)',
costUnitPeso: 'Costo Unitario (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',
create: {
title: 'Crear membresía',
description: 'Permite crear nuevas membresías.',
onSuccess: 'Membresía creada exitosamente',
onError: 'Error al crear la membresía'
}
},
app: {
theme: {
dark: 'Tema oscuro',

View File

@ -35,9 +35,34 @@ onMounted(() => {
to="dashboard.index"
/>
<Link
icon="person"
name="profile"
to="profile.show"
icon="add_home"
name="Gestionar Direcciones"
to="address.index"
/>
<Link
icon="how_to_vote"
name="Gestionar Conceptos"
to="concept.index"
/>
<Link
icon="receipt_long"
name="Gestionar Multas"
to="fine.index"
/>
<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"
/>
</Section>
<Section

View File

@ -21,11 +21,12 @@ const form = useForm({
email: '',
phone: '',
password: '',
roles: []
roles: [],
address: ''
});
const roles = ref([]);
const addresses = ref([]);
/** Métodos */
function submit() {
form.transform(data => ({
@ -80,5 +81,12 @@ onMounted(() => {
:options="roles"
multiple
/>
<Selectable
v-model="form.address"
label="description"
title="Direcciones"
:options="addresses"
multiple
/>
</Form>
</template>

View File

@ -0,0 +1,157 @@
<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="transl('title')" />
<AddressForm @address-created="handleAddressCreated" />
<div class="m-3">
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th v-text="$t('name')" />
<th v-text="$t('actions')" class="w-32 text-center" />
</template>
<template #body="{ items }">
<tr v-for="model in items" :key="model.id" class="table-row">
<td class="table-cell border">
{{ model.name }}
</td>
<td class="table-cell">
<div class="table-actions">
<IconButton
icon="edit"
:title="$t('crud.edit')"
@click="Modal.switchEditModal(model)"
outline
/>
<IconButton
icon="delete"
:title="$t('crud.destroy')"
@click="Modal.switchDestroyModal(model)"
outline
/>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="2" class="table-cell text-center py-4 text-gray-500">
{{ transl('list.empty') }}
</td>
</template>
</Table>
<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,9 @@
<script setup>
import PageHeader from "@Holos/PageHeader.vue";
import Checkout from '@App/CheckoutDelivery.vue';
</script>
<template>
<PageHeader title="Entrega de Caja" />
<Checkout />
</template>

View File

@ -0,0 +1,284 @@
<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 Editview from "@Holos/Modal/Edit.vue";
import Input from "@Holos/Form/Input.vue";
import Textarea from "@Holos/Form/Textarea.vue";
import Selectable from "@Holos/Form/Selectable.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: {},
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 = async () => {
const transformedData = {
direction_id: typeof modelModal.value.direction === 'object' ? modelModal.value.direction.id : Number(modelModal.value.direction_id),
unit_id: modelModal.value.unit ? (typeof modelModal.value.unit === 'object' ? modelModal.value.unit.id : Number(modelModal.value.unit)) : null,
short_name: modelModal.value.short_name,
name: modelModal.value.name,
legal_instrument: modelModal.value.legal_instrument,
article: modelModal.value.article,
content: modelModal.value.content,
min_amount_uma: modelModal.value.min_amount_uma ? Number(modelModal.value.min_amount_uma) : null,
max_amount_uma: modelModal.value.max_amount_uma ? Number(modelModal.value.max_amount_uma) : null,
min_amount_peso: modelModal.value.min_amount_peso ? Number(modelModal.value.min_amount_peso) : null,
max_amount_peso: modelModal.value.max_amount_peso ? Number(modelModal.value.max_amount_peso) : null,
unit_cost_uma: modelModal.value.unit_cost_uma ? Number(modelModal.value.unit_cost_uma) : null,
unit_cost_peso: modelModal.value.unit_cost_peso ? Number(modelModal.value.unit_cost_peso) : null,
};
const form = useForm(transformedData);
form.put(apiTo("update", { charge_concept: modelModal.value.id }), {
onSuccess: () => {
Notify.success(transl("edit.onSuccess"));
searcher.refresh();
Modal.switchEditModal();
},
onError: () => {
Notify.error(transl("edit.onError"));
},
});
};
</script>
<template>
<PageHeader :title="transl('title')" />
<ConceptForm @concept-created="handleConceptCreated" />
<div class="m-3">
<Table
:items="models"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th v-text="transl('direction')" />
<th v-text="transl('shortName')" />
<th v-text="transl('name')" />
<th v-text="transl('legal')" />
<th v-text="transl('article')" />
<th v-text="transl('description')" />
<th v-text="$t('actions')" class="w-32 text-center" />
</template>
<template #body="{ items }">
<tr v-for="model in items" :key="model.id" class="table-row">
<td class="table-cell border">
{{ model.direction?.name || "-" }}
</td>
<td class="table-cell border">
{{ model.short_name }}
</td>
<td class="table-cell border">
{{ model.name }}
</td>
<td class="table-cell border">
{{ model.legal_instrument }}
</td>
<td class="table-cell border">
{{ model.article }}
</td>
<td class="table-cell border">
{{ model.content }}
</td>
<td class="table-cell">
<div class="table-actions">
<IconButton
icon="edit"
:title="$t('crud.edit')"
@click="Modal.switchEditModal(model)"
outline
/>
<IconButton
icon="delete"
:title="$t('crud.destroy')"
@click="Modal.switchDestroyModal(model)"
outline
/>
</div>
</td>
</tr>
</template>
<template #empty>
<td colspan="7" class="table-cell text-center py-4 text-gray-500">
{{ transl("list.empty") }}
</td>
</template>
</Table>
<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--;
}
}
"
/>
<Editview
:show="editModal"
:title="transl('edit.title')"
@close="Modal.switchEditModal"
@update="handleConceptUpdated"
>
<div class="p-4 space-y-4">
<Selectable
v-model="modelModal.direction"
label="name"
value="id"
:title="$t('concept.direction')"
:options="addresses"
required
/>
<Input
v-model="modelModal.short_name"
:id="$t('concept.shortName')"
type="text"
required
/>
<Input
v-model="modelModal.name"
:id="$t('concept.name')"
type="text"
required
/>
<Input
v-model="modelModal.legal_instrument"
:id="$t('concept.legal')"
type="text"
required
/>
<Input
v-model="modelModal.article"
:id="$t('concept.article')"
type="text"
required
/>
<Textarea
v-model="modelModal.content"
:id="$t('concept.description')"
required
/>
<div class="grid grid-cols-2 gap-4">
<Input
v-model="modelModal.min_amount_uma"
:id="$t('concept.minimumAmountUma')"
type="number"
step="0.01"
/>
<Input
v-model="modelModal.max_amount_uma"
:id="$t('concept.maximumAmountUma')"
type="number"
step="0.01"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
v-model="modelModal.min_amount_peso"
:id="$t('concept.minimumAmountPeso')"
type="number"
step="0.01"
/>
<Input
v-model="modelModal.max_amount_peso"
:id="$t('concept.maximumAmountPeso')"
type="number"
step="0.01"
/>
</div>
<hr class="my-4 border-gray-300" />
<h4 class="text-lg font-semibold mb-2 text-gray-700">
{{ $t('concept.tabulator') }}
</h4>
<Selectable
v-model="modelModal.unit"
label="name"
value="id"
:title="$t('concept.sizeUnit')"
:options="units"
/>
<div class="grid grid-cols-2 gap-4">
<Input
v-model="modelModal.unit_cost_uma"
:id="$t('concept.costUnitUma')"
type="number"
step="0.01"
/>
<Input
v-model="modelModal.unit_cost_peso"
:id="$t('concept.costUnitPeso')"
type="number"
step="0.01"
/>
</div>
</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(`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,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,64 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api, useForm } from '@Services/Api';
import { apiTo, transl, viewTo } from './Module';
import IconButton from '@Holos/Button/Icon.vue'
import Input from '@Holos/Form/Input.vue';
import Selectable from '@Holos/Form/Selectable.vue';
import PageHeader from '@Holos/PageHeader.vue';
import Form from './Form.vue'
/** Definidores */
const router = useRouter();
/** Propiedades */
const form = useForm({
name: '',
paternal: '',
maternal: '',
curp: '',
membership_number: '',
});
/** Métodos */
function submit() {
form.transform(data => ({
...data,
})).post(apiTo('store'), {
onSuccess: () => {
Notify.success(Lang('register.create.onSuccess'))
router.push(viewTo({ name: 'index' }));
}
})
}
/** Ciclos */
onMounted(() => {
api.get(route('system.roles'), {
onSuccess: (r) => {
roles.value = r.roles;
}
});
})
</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,67 @@
<script setup>
import { transl } from './Module';
import PrimaryButton from '@Holos/Button/Primary.vue';
import Input from '@Holos/Form/Input.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.name"
id="name"
:onError="form.errors.name"
autofocus
required
/>
<Input
v-model="form.paternal"
id="paternal"
:onError="form.errors.paternal"
required
/>
<Input
v-model="form.maternal"
id="maternal"
:onError="form.errors.maternal"
/>
<Input
v-model="form.curp"
id="Curp"
:onError="form.errors.curp"
type="text"
/>
<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

@ -48,6 +48,71 @@ 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: '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')
},
]
}
],
},
{

View File

@ -16,6 +16,7 @@ export default defineConfig({
'@Components': fileURLToPath(new URL('./src/components', import.meta.url)),
'@Controllers': fileURLToPath(new URL('./src/controllers', 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)),
'@Lang': fileURLToPath(new URL('./src/lang', import.meta.url)),
'@Router': fileURLToPath(new URL('./src/router', import.meta.url)),