edgar.mendez 1cf8cc3aa5 feat: add unit equivalence management functionality
- 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.
2026-03-23 21:43:02 -06:00

395 lines
15 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import Card from 'primevue/card';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import InputText from 'primevue/inputtext';
import Dropdown from 'primevue/dropdown';
import Tag from 'primevue/tag';
import Paginator from 'primevue/paginator';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { supplierServices } from '../../services/supplier.services';
import type { Supplier, SupplierFormErrors, SupplierPaginatedResponse } from '../../types/suppliers.interfaces';
import { SupplierType } from '../../types/suppliers.interfaces';
import SupplierModal from './SupplierModal.vue';
import { useAuth } from '@/modules/auth/composables/useAuth';
const suppliers = ref<Supplier[]>([]);
const pagination = ref({
first: 0,
rows: 5,
total: 0,
page: 1,
lastPage: 1,
});
const loading = ref(false);
const loadingSupplier = ref(false);
// Modal state and form fields
const showModal = ref(false);
const isEditMode = ref(false);
const currentSupplier = ref<Supplier | null>(null);
const formErrors = ref<SupplierFormErrors>({});
const confirm = useConfirm();
const toast = useToast();
const { hasPermission } = useAuth();
const canViewSuppliers = computed(() =>
hasPermission([
'suppliers.index',
'suppliers.show',
'suppliers.store',
'suppliers.update',
'suppliers.destroy',
])
);
const canCreateSupplier = computed(() => hasPermission('suppliers.store'));
const canUpdateSupplier = computed(() => hasPermission('suppliers.update'));
const canDeleteSupplier = computed(() => hasPermission('suppliers.destroy'));
const handleDelete = (supplierId: number) => {
if (!canDeleteSupplier.value) {
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes eliminar proveedores.', life: 4000 });
return;
}
confirm.require({
message: '¿Seguro que deseas eliminar este proveedor?',
header: 'Confirmar eliminación',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Sí, eliminar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
await supplierServices.deleteSupplier(supplierId);
toast.add({ severity: 'success', summary: 'Eliminado', detail: 'Proveedor eliminado correctamente', life: 3000 });
fetchSuppliers(pagination.value.page);
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo eliminar el proveedor', life: 3000 });
}
}
});
};
const mapTypeToApi = (typeLabel: string | null) => {
if (!typeLabel) return undefined;
switch (typeLabel) {
case 'Cliente': return SupplierType.CLIENT;
case 'Proveedor': return SupplierType.PROVIDER;
case 'Ambos': return SupplierType.BOTH;
default: return undefined;
}
};
const fetchSuppliers = async (page = 1) => {
if (!canViewSuppliers.value) {
suppliers.value = [];
pagination.value = {
...pagination.value,
page: 1,
total: 0,
first: 0,
};
return;
}
pagination.value.page = page;
loading.value = true;
try {
const name = searchName.value ? searchName.value : undefined;
const type = selectedType.value ? mapTypeToApi(selectedType.value)?.toString() : undefined;
const response = await supplierServices.getSuppliers(true, name, type);
const paginated = response as SupplierPaginatedResponse;
suppliers.value = paginated.data;
pagination.value.total = paginated.total;
pagination.value.page = paginated.current_page;
pagination.value.lastPage = paginated.last_page;
pagination.value.first = (paginated.current_page - 1) * paginated.per_page;
pagination.value.rows = paginated.per_page;
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los proveedores.', life: 3000 });
} finally {
loading.value = false;
}
};
watch(
() => canViewSuppliers.value,
(allowed) => {
if (allowed) {
fetchSuppliers();
} else {
suppliers.value = [];
}
},
{ immediate: true }
);
const supplierTypes = [
{ label: 'Todos', value: null },
{ label: 'Cliente', value: 'Cliente' },
{ label: 'Proveedor', value: 'Proveedor' },
{ label: 'Ambos', value: 'Ambos' },
];
const selectedType = ref(null);
const searchName = ref('');
const clearFilters = () => {
searchName.value = '';
selectedType.value = null;
fetchSuppliers(1);
};
const onFilter = () => {
fetchSuppliers(1);
};
const onPageChange = (event: any) => {
const newPage = Math.floor(event.first / event.rows) + 1;
fetchSuppliers(newPage);
};
const openCreateModal = () => {
if (!canCreateSupplier.value) {
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes crear proveedores.', life: 4000 });
return;
}
isEditMode.value = false;
showModal.value = true;
currentSupplier.value = null;
formErrors.value = {};
};
const openEditModal = async (supplier: Supplier) => {
if (!canUpdateSupplier.value) {
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes actualizar proveedores.', life: 4000 });
return;
}
isEditMode.value = true;
formErrors.value = {};
loadingSupplier.value = true;
try {
// Obtener la información completa del proveedor incluyendo direcciones
const response = await supplierServices.getSupplierById(supplier.id);
currentSupplier.value = response.data;
showModal.value = true;
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudo cargar la información del proveedor',
life: 3000
});
console.error('Error loading supplier details:', e);
} finally {
loadingSupplier.value = false;
}
};
const closeModal = () => {
showModal.value = false;
isEditMode.value = false;
currentSupplier.value = null;
formErrors.value = {};
};
const handleModalSubmit = async (form: any) => {
formErrors.value = {};
try {
if (isEditMode.value && currentSupplier.value) {
if (!canUpdateSupplier.value) {
throw new Error('without-update-permission');
}
await supplierServices.updateSupplier(currentSupplier.value.id, form, 'patch');
toast.add({ severity: 'success', summary: 'Proveedor actualizado', detail: 'Proveedor actualizado correctamente', life: 3000 });
} else {
if (!canCreateSupplier.value) {
throw new Error('without-create-permission');
}
await supplierServices.createSupplier(form);
toast.add({ severity: 'success', summary: 'Proveedor creado', detail: 'Proveedor registrado correctamente', life: 3000 });
}
closeModal();
fetchSuppliers(pagination.value.page);
} catch (e: any) {
if (e?.response?.data?.errors) {
formErrors.value = e.response.data.errors;
}
toast.add({
severity: 'error',
summary: 'Error',
detail:
e?.response?.data?.message ||
(e?.message === 'without-create-permission'
? 'No tienes permisos para crear proveedores.'
: e?.message === 'without-update-permission'
? 'No tienes permisos para actualizar proveedores.'
: 'No se pudo guardar el proveedor'),
life: 3000,
});
}
};
// Mapeo para mostrar el tipo de proveedor con label legible
const typeLabel = (type: number) => {
switch (type) {
case SupplierType.CLIENT: return 'Cliente';
case SupplierType.PROVIDER: return 'Proveedor';
case SupplierType.BOTH: return 'Ambos';
default: return 'Desconocido';
}
};
const typeSeverity = (type: number) => {
switch (type) {
case SupplierType.CLIENT: return 'info';
case SupplierType.PROVIDER: return 'success';
case SupplierType.BOTH: return 'warning';
default: return 'secondary';
}
};
</script>
<template>
<div class="space-y-6">
<!-- Heading -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">Gestión de Proveedores
</h2>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">Administra la base de datos de proveedores y
sus contactos.</p>
</div>
<Button
v-if="canCreateSupplier"
label="Nuevo Proveedor"
icon="pi pi-plus"
@click="openCreateModal"
/>
<SupplierModal :visible="showModal" :isEditMode="isEditMode" :supplier="currentSupplier"
:formErrors="formErrors" :loading="loadingSupplier" @update:visible="val => { if (!val) closeModal(); }"
@submit="handleModalSubmit" @cancel="closeModal" />
</div>
<!-- Filters Toolbar -->
<Card v-if="canViewSuppliers">
<template #content>
<div class="flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-60">
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Buscar
Nombre</label>
<InputText v-model="searchName" placeholder="Ej. TechLogistics S.A." class="w-full" />
</div>
<div class="w-48">
<label
class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">Tipo</label>
<Dropdown v-model="selectedType" :options="supplierTypes" optionLabel="label"
optionValue="value" placeholder="Todos" class="w-full" @change="onFilter" />
</div>
<Button icon="pi pi-search" text rounded @click="onFilter" />
<Button icon="pi pi-times" text rounded severity="secondary" label="Limpiar" @click="clearFilters" />
</div>
</template>
</Card>
<!-- Table Content -->
<Card v-if="canViewSuppliers">
<template #content>
<DataTable :value="suppliers" :loading="loading" stripedRows responsiveLayout="scroll"
class="p-datatable-sm">
<Column field="name" header="Razón Social" style="min-width: 180px">
<template #body="{ data }">
<div>
<div class="font-bold text-surface-900 dark:text-white">{{ data.name }}</div>
<div class="text-xs text-gray-500">{{ data.comercial_name }}</div>
</div>
</template>
</Column>
<Column field="rfc" header="RFC" style="min-width: 130px">
<template #body="{ data }">
<span class="font-mono text-sm">{{ data.rfc }}</span>
</template>
</Column>
<Column field="curp" header="CURP" style="min-width: 180px">
<template #body="{ data }">
<span class="font-mono text-sm">{{ data.curp }}</span>
</template>
</Column>
<Column field="type" header="Tipo" style="min-width: 100px">
<template #body="{ data }">
<Tag :value="typeLabel(data.type)" :severity="typeSeverity(data.type)" />
</template>
</Column>
<Column field="credit_limit" header="Límite de Crédito" style="min-width: 140px">
<template #body="{ data }">
<span class="font-semibold text-green-600 dark:text-green-400">${{ parseFloat(data.credit_limit).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}</span>
</template>
</Column>
<Column field="payment_days" header="Días de Pago" style="min-width: 120px">
<template #body="{ data }">
<span class="font-semibold">{{ data.payment_days }} días</span>
</template>
</Column>
<Column field="created_at" header="Fecha de Registro" style="min-width: 120px">
<template #body="{ data }">
<span class="text-sm">{{ new Date(data.created_at).toLocaleDateString('es-MX') }}</span>
</template>
</Column>
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right"
style="min-width: 120px">
<template #body="{ data }">
<div class="flex items-center justify-end gap-2">
<Button
v-if="canUpdateSupplier"
icon="pi pi-pencil"
text
rounded
size="small"
@click="openEditModal(data)"
/>
<Button
v-if="canDeleteSupplier"
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
@click="handleDelete(data.id)"
/>
</div>
</template>
</Column>
</DataTable>
<div class="mt-4">
<Paginator :first="pagination.first" :rows="pagination.rows" :totalRecords="pagination.total"
:rowsPerPageOptions="[5, 10, 20, 50]" @page="onPageChange" />
</div>
</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>
</div>
<ConfirmDialog />
<Toast />
</template>