ADD: Mejora en la gestión de direcciones y conceptos, incluyendo validaciones y traducciones

This commit is contained in:
Juan Felipe Zapata Moreno 2025-11-18 21:14:29 -06:00
parent 740f7f5b78
commit 7f82f57588
8 changed files with 421 additions and 118 deletions

View File

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

@ -10,7 +10,7 @@ const addressName = ref("");
/** Métodos */
const handleSubmit = () => {
if (!addressName.value.trim()) {
alert("Por favor ingresa un nombre de dirección");
window.Notify.warning(window.Lang('address.validation.required'));
return;
}
@ -27,7 +27,7 @@ const handleSubmit = () => {
<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">
Crear nueva dirección
{{ $t('address.create.title') }}
</h3>
<form @submit.prevent="handleSubmit">
@ -36,12 +36,13 @@ const handleSubmit = () => {
for="addressName"
class="block text-sm text-gray-600 font-medium mb-2"
>
Nombre de la Dirección:
{{ $t('address.name') }}:
</label>
<input
type="text"
id="adddressName"
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>
@ -50,7 +51,7 @@ const handleSubmit = () => {
type="submit"
class="w-1/4 bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors"
>
Guardar
{{ $t('save') }}
</button>
</div>
</form>

View File

@ -1,136 +1,194 @@
<script setup>
import { ref } from "vue";
import { useForm } from "@Services/Api";
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({
address: [],
shortName: "",
direction_id: null,
unit_id: null,
short_name: "",
name: "",
legal: "",
legal_instrument: "",
article: "",
description: "",
minimumAmount: "",
maximumAmount: "",
sizeUnit: [],
content: "",
min_amount_uma: "",
max_amount_uma: "",
min_amount_peso: "",
max_amount_peso: "",
unit_cost_uma: "",
unit_cost_peso: "",
});
const addresses = ref([]);
const sizeUnit = 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 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: 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">
Crear nuevo concepto de cobro
{{ $t('concept.create.title') }}
</h3>
<form>
<form @submit.prevent="handleSubmit">
<div class="mb-5 grid grid-cols-2 gap-4">
<Selectable
v-model="form.address"
label="description"
title="Direcciones"
v-model="form.direction"
label="name"
value="id"
:title="$t('concept.direction')"
:options="addresses"
multiple
required
/>
<Input
v-model="form.shortName"
v-model="form.short_name"
class="col-span-2"
id="Nombre corto"
:id="$t('concept.shortName')"
type="text"
:onError="form.errors.shortName"
:onError="form.errors.short_name"
required
/>
</div>
<Input
v-model="form.name"
class="col-span-2"
id="Nombre"
: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"
v-model="form.legal_instrument"
class="col-span-2"
id="Instrumento Legal"
:id="$t('concept.legal')"
type="text"
:onError="form.errors.legal"
:onError="form.errors.legal_instrument"
required
/>
<Input
v-model="form.article"
class="col-span-2"
id="Articulado"
:id="$t('concept.article')"
type="text"
:onError="form.errors.article"
required
/>
</div>
<Textarea
v-model="form.description"
v-model="form.content"
class="col-span-2"
id="Descripción"
:onError="form.errors.description"
: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.minimumAmount"
class="col-span-2"
id="Monto mínimo (UMAs)"
type="text"
:onError="form.errors.minimumAmount"
required
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.maximumAmount"
class="col-span-2"
id="Monto máximo (UMAs)"
type="text"
:onError="form.errors.maximumAmount"
required
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.sizeUnit"
label="description"
title="Unidad de medida"
:options="sizeUnit"
multiple
required
v-model="form.unit"
label="name"
value="id"
:title="$t('concept.sizeUnit')"
:options="units"
/>
<Input
v-model="form.costMin"
class="col-span-2"
id="Costo mínimo (UMAs)"
type="text"
:onError="form.errors.costMin"
required
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.costMax"
class="col-span-2"
id="Costo máximo (UMAs)"
type="text"
:onError="form.errors.costMax"
required
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"
class="w-1/4 bg-[#7a0b3a] hover:bg-[#68082e] text-white font-medium py-3.5 rounded-lg transition-colors"
: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"
>
Guardar
{{ form.processing ? 'Guardando...' : $t('save') }}
</button>
</div>
</form>

View File

@ -82,8 +82,13 @@ export default {
},
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 dirección',
title: 'Crear nueva dirección',
description: 'Permite crear nuevas direcciones.',
onSuccess: 'Dirección creada exitosamente',
onError: 'Error al crear la dirección'
@ -94,11 +99,55 @@ export default {
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: {

View File

@ -1,7 +1,7 @@
<script setup>
import { onMounted, ref } from "vue";
import { useSearcher, useForm } from "@Services/Api";
import { can, apiTo, viewTo, transl } from "./Module";
import { apiTo, transl } from "./Module";
import Table from "@Holos/Table.vue";
import IconButton from "@Holos/Button/Icon.vue";
@ -45,8 +45,12 @@ const handleAddressCreated = async (address) => {
form.post(apiTo("store"), {
onSuccess: () => {
Notify.success(transl('create.onSuccess'));
searcher.refresh();
},
onError: () => {
Notify.error(transl('create.onError'));
}
});
};
@ -55,17 +59,21 @@ const handleAddressUpdated = async () => {
name: modelModal.value.name,
});
form.put(apiTo("update", { id: modelModal.value.id }), {
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="Creación de Dirección" />
<PageHeader :title="transl('title')" />
<AddressForm @address-created="handleAddressCreated" />
<div class="m-3">
<Table
@ -102,7 +110,7 @@ const handleAddressUpdated = async () => {
</template>
<template #empty>
<td colspan="2" class="table-cell text-center py-4 text-gray-500">
No hay direcciones registradas
{{ transl('list.empty') }}
</td>
</template>
</Table>
@ -111,7 +119,7 @@ const handleAddressUpdated = async () => {
subtitle=""
:model="modelModal"
:show="destroyModal"
:to="(address) => apiTo('destroy', { id: address.id })"
:to="(id) => apiTo('destroy', { direction: id })"
@close="Modal.switchDestroyModal"
@update="
() => {
@ -125,7 +133,7 @@ const handleAddressUpdated = async () => {
/>
<Editview
:show="editModal"
:title="$t('crud.edit')"
:title="transl('edit.title')"
@close="Modal.switchEditModal"
@update="handleAddressUpdated"
>
@ -134,12 +142,13 @@ const handleAddressUpdated = async () => {
for="editAddressName"
class="block text-sm text-gray-600 font-medium mb-2"
>
Nombre de la Dirección:
{{ 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>

View File

@ -1,8 +1,7 @@
<script setup>
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { useSearcher } from "@Services/Api";
import { can, apiTo, viewTo, transl } from "./Module";
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";
@ -10,6 +9,10 @@ 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();
@ -19,67 +22,133 @@ const destroyModal = ref(Modal.destroyModal);
const editModal = ref(Modal.editModal);
const modelModal = ref(Modal.modelModal);
/** Inicializar searcher */
//const models = ref(searcher.models);
const addresses = ref([]);
const units = ref([]);
const models = ref({
data: [],
total: 0,
});
const processing = ref(false);
//const searcher = useSearcher(apiTo("index"));
const searcher = {
models,
processing,
pagination: () => {},
search: () => {},
};
/** 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="Creación de Concepto" />
<ConceptForm />
<PageHeader :title="transl('title')" />
<ConceptForm @concept-created="handleConceptCreated" />
<div class="m-3">
<Table
:items="models"
:processing="searcher.processing.value"
:processing="searcher.processing"
@send-pagination="(page) => searcher.pagination(page)"
>
<template #head>
<th v-text="'Dirección'" />
<th v-text="'Nombre corto'" />
<th v-text="'Nombre'" />
<th v-text="'Instrumento Legal'" />
<th v-text="'Articulado'" />
<th v-text="'Descripción'" />
<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.description }}
{{ 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
v-if="can('edit') && ![1, 2].includes(model.id)"
icon="license"
:title="transl('permissions.title')"
icon="edit"
:title="$t('crud.edit')"
@click="Modal.switchEditModal(model)"
outline
/>
<RouterLink
v-if="can('edit')"
class="h-fit"
:to="viewTo({ name: 'edit', params: { id: model.id } })"
>
<IconButton icon="edit" :title="$t('crud.edit')" outline />
</RouterLink>
<IconButton
v-if="can('destroy') && ![1, 2].includes(model.id)"
icon="delete"
:title="$t('crud.destroy')"
@click="Modal.switchDestroyModal(model)"
@ -90,18 +159,17 @@ onMounted(() => {
</tr>
</template>
<template #empty>
<td colspan="2" class="table-cell text-center py-4 text-gray-500">
No hay direcciones registradas
<td colspan="7" class="table-cell text-center py-4 text-gray-500">
{{ transl("list.empty") }}
</td>
</template>
</Table>
<DestroyView
v-if="can('destroy')"
title="description"
subtitle=""
title="name"
subtitle="content"
:model="modelModal"
:show="destroyModal"
:to="(address) => apiTo('destroy', { address: address.id })"
:to="(id) => apiTo('destroy', { charge_concept: id })"
@close="Modal.switchDestroyModal"
@update="
() => {
@ -113,5 +181,104 @@ onMounted(() => {
}
"
/>
<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

@ -2,7 +2,7 @@ import { lang } from '@Lang/i18n';
import { hasPermission } from '@Plugins/RolePermission.js';
// Ruta API
const apiTo = (name, params = {}) => route(`concept.${name}`, params)
const apiTo = (name, params = {}) => route(`charge-concepts.${name}`, params)
// Ruta visual
const viewTo = ({ name = '', params = {}, query = {} }) => view({ name: `concept.${name}`, params, query })

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
}