- Implemented UnitsEquivalenceModal for managing unit equivalences. - Added unit equivalence store and service for CRUD operations. - Integrated unit equivalence management into Units.vue component. - Created new HTML page for unit equivalence management layout. - Defined TypeScript interfaces for unit equivalences. - Enhanced user permissions for managing unit equivalences.
505 lines
18 KiB
Vue
505 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { useConfirm } from 'primevue/useconfirm';
|
|
import Breadcrumb from 'primevue/breadcrumb';
|
|
import Button from 'primevue/button';
|
|
import Card from 'primevue/card';
|
|
import DataTable from 'primevue/datatable';
|
|
import Column from 'primevue/column';
|
|
import Tag from 'primevue/tag';
|
|
import Toast from 'primevue/toast';
|
|
import ConfirmDialog from 'primevue/confirmdialog';
|
|
import ProgressSpinner from 'primevue/progressspinner';
|
|
import InputText from 'primevue/inputtext';
|
|
import Select from 'primevue/select';
|
|
import UnitsEquivalenceModal from './UnitsEquivalenceModal.vue';
|
|
import { useUnitOfMeasureStore } from '../../stores/unitOfMeasureStore';
|
|
import UnitsForm from './UnitsForm.vue';
|
|
import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../../types/unit-measure.interfaces';
|
|
import { useAuth } from '@/modules/auth/composables/useAuth';
|
|
import { useUnitEquivalenceStore } from '../../stores/unitEquivalenceStore';
|
|
|
|
const router = useRouter();
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const unitStore = useUnitOfMeasureStore();
|
|
const { hasPermission } = useAuth();
|
|
|
|
// Breadcrumb
|
|
const breadcrumbItems = ref([
|
|
{ label: 'Catálogo', route: '/catalog' },
|
|
{ label: 'Unidades de Medida' }
|
|
]);
|
|
|
|
const home = ref({
|
|
icon: 'pi pi-home',
|
|
route: '/'
|
|
});
|
|
|
|
// State
|
|
const showDialog = ref(false);
|
|
const isEditing = ref(false);
|
|
const selectedUnit = ref<UnitOfMeasure | null>(null);
|
|
const unitsFormRef = ref<InstanceType<typeof UnitsForm> | null>(null);
|
|
const showEquivalenceModal = ref(false);
|
|
const equivalenceUnit = ref<{ id?: number; name?: string } | undefined>(undefined);
|
|
const search = ref('');
|
|
const statusFilter = ref<'all' | 'active' | 'inactive'>('all');
|
|
|
|
// Computed
|
|
const units = computed(() => unitStore.units);
|
|
const loading = computed(() => unitStore.loading);
|
|
const statusFilterOptions = [
|
|
{ label: 'Todas', value: 'all' },
|
|
{ label: 'Activas', value: 'active' },
|
|
{ label: 'Inactivas', value: 'inactive' },
|
|
];
|
|
|
|
const getStatusConfig = (isActive: boolean) => {
|
|
return isActive
|
|
? { label: 'Activa', severity: 'success' }
|
|
: { label: 'Inactiva', severity: 'secondary' };
|
|
};
|
|
|
|
const canViewUnits = computed(() =>
|
|
hasPermission([
|
|
'units-of-measure.index',
|
|
'units-of-measure.show',
|
|
'units-of-measure.store',
|
|
'units-of-measure.update',
|
|
'units-of-measure.destroy',
|
|
])
|
|
);
|
|
const canCreateUnit = computed(() => hasPermission('units-of-measure.store'));
|
|
const canUpdateUnit = computed(() => hasPermission('units-of-measure.update'));
|
|
const canDeleteUnit = computed(() => hasPermission('units-of-measure.destroy'));
|
|
const equivalenceStore = useUnitEquivalenceStore();
|
|
const canManageEquivalences = computed(() =>
|
|
hasPermission([
|
|
'unit-equivalences.index',
|
|
'unit-equivalences.show',
|
|
'unit-equivalences.store',
|
|
'unit-equivalences.update',
|
|
'unit-equivalences.destroy',
|
|
])
|
|
);
|
|
|
|
// Methods
|
|
const buildFilters = () => ({
|
|
paginate: true,
|
|
per_page: 25,
|
|
search: search.value.trim(),
|
|
is_active: statusFilter.value === 'all' ? undefined : statusFilter.value === 'active',
|
|
});
|
|
|
|
const loadUnits = async (force = false) => {
|
|
if (!canViewUnits.value) {
|
|
unitStore.clearUnits();
|
|
return;
|
|
}
|
|
|
|
await unitStore.fetchUnits({
|
|
force,
|
|
...buildFilters(),
|
|
});
|
|
};
|
|
|
|
const openCreateDialog = () => {
|
|
if (!canCreateUnit.value) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Sin permisos',
|
|
detail: 'No tienes permisos para crear unidades de medida.',
|
|
life: 4000,
|
|
});
|
|
return;
|
|
}
|
|
isEditing.value = false;
|
|
selectedUnit.value = null;
|
|
showDialog.value = true;
|
|
};
|
|
|
|
const openEditDialog = (unit: UnitOfMeasure) => {
|
|
if (!canUpdateUnit.value) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Sin permisos',
|
|
detail: 'No tienes permisos para actualizar unidades de medida.',
|
|
life: 4000,
|
|
});
|
|
return;
|
|
}
|
|
isEditing.value = true;
|
|
selectedUnit.value = unit;
|
|
showDialog.value = true;
|
|
};
|
|
|
|
const openEquivalenceModal = (unit: UnitOfMeasure | null) => {
|
|
// For now only show the modal with static maquetado
|
|
equivalenceUnit.value = unit ? { id: unit.id, name: unit.name } : undefined;
|
|
showEquivalenceModal.value = true;
|
|
// fetch equivalences for the selected unit (non-blocking, safe if endpoint missing)
|
|
equivalenceStore.fetchEquivalences({ paginate: false, is_active: true, from_unit_id: unit?.id }).catch(()=>{});
|
|
};
|
|
|
|
const handleCreateUnit = async (data: CreateUnitOfMeasureData) => {
|
|
await unitStore.createUnit(data);
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'Creación Exitosa',
|
|
detail: 'La unidad de medida ha sido creada correctamente.',
|
|
life: 3000,
|
|
});
|
|
};
|
|
|
|
const handleUpdateUnit = async (id: number, data: CreateUnitOfMeasureData) => {
|
|
await unitStore.updateUnit(id, data);
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'Actualización Exitosa',
|
|
detail: 'La unidad de medida ha sido actualizada correctamente.',
|
|
life: 3000,
|
|
});
|
|
};
|
|
|
|
const handleSaveUnit = async (data: CreateUnitOfMeasureData) => {
|
|
try {
|
|
if (isEditing.value && selectedUnit.value) {
|
|
if (!canUpdateUnit.value) {
|
|
throw new Error('without-update-permission');
|
|
}
|
|
await handleUpdateUnit(selectedUnit.value.id, data);
|
|
} else {
|
|
if (!canCreateUnit.value) {
|
|
throw new Error('without-create-permission');
|
|
}
|
|
await handleCreateUnit(data);
|
|
}
|
|
|
|
showDialog.value = false;
|
|
selectedUnit.value = null;
|
|
unitsFormRef.value?.resetSubmitting();
|
|
} catch (error) {
|
|
console.error('Error saving unit:', error);
|
|
|
|
const requestError = error as {
|
|
message?: string;
|
|
response?: {
|
|
status?: number;
|
|
data?: {
|
|
errors?: Record<string, string[]>;
|
|
message?: string;
|
|
};
|
|
};
|
|
};
|
|
|
|
if (requestError.response?.status === 422 && requestError.response.data?.errors) {
|
|
unitsFormRef.value?.setValidationErrors(requestError.response.data.errors);
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Errores de validación',
|
|
detail: 'Corrige los campos marcados para continuar.',
|
|
life: 4000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
unitsFormRef.value?.resetSubmitting();
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Error',
|
|
detail:
|
|
requestError.response?.data?.message ||
|
|
(requestError.message === 'without-update-permission'
|
|
? 'No tienes permisos para actualizar unidades de medida.'
|
|
: requestError.message === 'without-create-permission'
|
|
? 'No tienes permisos para crear unidades de medida.'
|
|
: 'No se pudo guardar la unidad de medida. Por favor, intenta nuevamente.'),
|
|
life: 3000,
|
|
});
|
|
}
|
|
};
|
|
|
|
const confirmDelete = (unit: UnitOfMeasure) => {
|
|
if (!canDeleteUnit.value) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Sin permisos',
|
|
detail: 'No tienes permisos para eliminar unidades de medida.',
|
|
life: 4000,
|
|
});
|
|
return;
|
|
}
|
|
confirm.require({
|
|
message: `¿Estás seguro de eliminar la unidad de medida "${unit.name}"?`,
|
|
header: 'Confirmar Eliminación',
|
|
icon: 'pi pi-exclamation-triangle',
|
|
acceptLabel: 'Sí, eliminar',
|
|
rejectLabel: 'Cancelar',
|
|
acceptClass: 'p-button-danger',
|
|
accept: () => deleteUnit(unit.id)
|
|
});
|
|
};
|
|
|
|
const deleteUnit = async (id: number) => {
|
|
if (!canDeleteUnit.value) {
|
|
toast.add({
|
|
severity: 'warn',
|
|
summary: 'Sin permisos',
|
|
detail: 'No tienes permisos para eliminar unidades de medida.',
|
|
life: 4000,
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
await unitStore.deleteUnit(id);
|
|
await loadUnits(true);
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'Eliminación Exitosa',
|
|
detail: 'La unidad de medida ha sido eliminada correctamente.',
|
|
life: 3000,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error deleting unit:', error);
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Error',
|
|
detail: 'No se pudo eliminar la unidad de medida. Puede estar en uso.',
|
|
life: 3000,
|
|
});
|
|
}
|
|
};
|
|
|
|
const applyFilters = async () => {
|
|
await loadUnits(true);
|
|
};
|
|
|
|
const clearFilters = async () => {
|
|
search.value = '';
|
|
statusFilter.value = 'all';
|
|
await loadUnits(true);
|
|
};
|
|
|
|
watch(
|
|
() => canViewUnits.value,
|
|
async (allowed) => {
|
|
if (allowed) {
|
|
await loadUnits();
|
|
} else {
|
|
unitStore.clearUnits();
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6">
|
|
<!-- Toast & Confirm Dialog -->
|
|
<Toast position="bottom-right" />
|
|
<ConfirmDialog />
|
|
|
|
<!-- Breadcrumb -->
|
|
<div class="flex flex-col gap-2">
|
|
<Breadcrumb :home="home" :model="breadcrumbItems">
|
|
<template #item="{ item }">
|
|
<a
|
|
v-if="item.route"
|
|
:href="item.route"
|
|
@click.prevent="router.push(item.route)"
|
|
class="text-primary hover:underline"
|
|
>
|
|
{{ item.label }}
|
|
</a>
|
|
<span v-else class="text-surface-600 dark:text-surface-400">
|
|
{{ item.label }}
|
|
</span>
|
|
</template>
|
|
</Breadcrumb>
|
|
|
|
<!-- Title -->
|
|
<div class="flex justify-between items-center">
|
|
<h1 class="text-3xl font-black leading-tight tracking-tight text-surface-900 dark:text-white">
|
|
Unidades de Medida
|
|
</h1>
|
|
<Button
|
|
v-if="canCreateUnit"
|
|
label="Nueva Unidad"
|
|
icon="pi pi-plus"
|
|
@click="openCreateDialog"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Card -->
|
|
<Card v-if="canViewUnits">
|
|
<template #content>
|
|
<div class="flex flex-col md:flex-row md:items-end gap-3 mb-4">
|
|
<div class="flex-1">
|
|
<label for="unit-search" class="block text-sm font-medium mb-2">Buscar</label>
|
|
<InputText
|
|
id="unit-search"
|
|
v-model="search"
|
|
class="w-full"
|
|
placeholder="Buscar por nombre o abreviatura"
|
|
@keyup.enter="applyFilters"
|
|
/>
|
|
</div>
|
|
|
|
<div class="w-full md:w-64">
|
|
<label for="status-filter" class="block text-sm font-medium mb-2">Estado</label>
|
|
<Select
|
|
id="status-filter"
|
|
v-model="statusFilter"
|
|
class="w-full"
|
|
optionLabel="label"
|
|
optionValue="value"
|
|
:options="statusFilterOptions"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
<Button label="Filtrar" icon="pi pi-search" @click="applyFilters" />
|
|
<Button
|
|
label="Limpiar"
|
|
icon="pi pi-refresh"
|
|
severity="secondary"
|
|
outlined
|
|
@click="clearFilters"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loading && units.length === 0" class="flex justify-center items-center py-12">
|
|
<ProgressSpinner
|
|
style="width: 50px; height: 50px"
|
|
strokeWidth="4"
|
|
animationDuration="1s"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Data Table -->
|
|
<DataTable
|
|
v-else
|
|
:value="units"
|
|
:loading="loading"
|
|
stripedRows
|
|
responsiveLayout="scroll"
|
|
:paginator="true"
|
|
:rows="10"
|
|
:rowsPerPageOptions="[5, 10, 20, 50]"
|
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
|
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} unidades"
|
|
>
|
|
<Column field="abbreviation" header="Abreviatura" sortable style="width: 150px">
|
|
<template #body="slotProps">
|
|
<span class="font-mono font-semibold text-primary">
|
|
{{ slotProps.data.abbreviation }}
|
|
</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="name" header="Nombre" sortable>
|
|
<template #body="slotProps">
|
|
<span class="font-medium text-surface-900 dark:text-white">
|
|
{{ slotProps.data.name }}
|
|
</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="sat_unit.name" header="Unidad SAT" sortable style="width: 200px">
|
|
<template #body="slotProps">
|
|
<span class="text-sm text-surface-600 dark:text-surface-400">
|
|
{{ slotProps.data.sat_unit?.name || 'N/A' }}
|
|
</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="sat_unit.code" header="Código SAT" sortable style="width: 120px">
|
|
<template #body="slotProps">
|
|
<span class="font-mono text-sm text-surface-600 dark:text-surface-400">
|
|
{{ slotProps.data.sat_unit?.code || 'N/A' }}
|
|
</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="is_active" header="Estado" sortable style="width: 120px">
|
|
<template #body="slotProps">
|
|
<Tag
|
|
:value="getStatusConfig(slotProps.data.is_active).label"
|
|
:severity="getStatusConfig(slotProps.data.is_active).severity"
|
|
/>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column header="Acciones" style="width: 150px">
|
|
<template #body="slotProps">
|
|
<div class="flex gap-2">
|
|
<Button
|
|
v-if="canUpdateUnit"
|
|
icon="pi pi-pencil"
|
|
text
|
|
rounded
|
|
severity="secondary"
|
|
@click="openEditDialog(slotProps.data)"
|
|
v-tooltip.top="'Editar'"
|
|
/>
|
|
<Button
|
|
v-if="canDeleteUnit"
|
|
icon="pi pi-trash"
|
|
text
|
|
rounded
|
|
severity="danger"
|
|
@click="confirmDelete(slotProps.data)"
|
|
v-tooltip.top="'Eliminar'"
|
|
/>
|
|
<!-- Equivalence button -->
|
|
<Button
|
|
v-if="canManageEquivalences"
|
|
icon="pi pi-exchange"
|
|
text
|
|
rounded
|
|
severity="help"
|
|
@click="openEquivalenceModal(slotProps.data)"
|
|
v-tooltip.top="'Gestionar equivalencias'"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<template #empty>
|
|
<div class="text-center py-8 text-surface-500 dark:text-surface-400">
|
|
No hay unidades de medida registradas.
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
</template>
|
|
</Card>
|
|
|
|
<Card v-else>
|
|
<template #content>
|
|
<div class="text-center py-10 text-surface-500 dark:text-surface-400">
|
|
<i class="pi pi-lock text-4xl mb-3"></i>
|
|
<p>No tienes permisos para visualizar este módulo.</p>
|
|
</div>
|
|
</template>
|
|
</Card>
|
|
|
|
<!-- Create/Edit Form Dialog -->
|
|
<UnitsForm
|
|
ref="unitsFormRef"
|
|
v-model:visible="showDialog"
|
|
:unit="selectedUnit"
|
|
:is-editing="isEditing"
|
|
@save="handleSaveUnit"
|
|
/>
|
|
|
|
<!-- Equivalences Modal (maquetado, no funcional) -->
|
|
<UnitsEquivalenceModal v-model="showEquivalenceModal" :unit="equivalenceUnit" />
|
|
</div>
|
|
</template>
|