Merge pull request 'CTL-51-DEVELOP' (#20) from CTL-51-DEVELOP into qa

Reviewed-on: #20
This commit is contained in:
Edgar Méndez Mendoza 2026-03-24 06:25:09 +00:00
commit 2a9b751053
10 changed files with 727 additions and 49 deletions

View File

@ -38,7 +38,18 @@ const menuItems = ref<MenuItem[]>([
], ],
}, },
{ label: 'Clasificaciones Comerciales', icon: 'pi pi-tags', to: '/catalog/classifications-comercial' }, { label: 'Clasificaciones Comerciales', icon: 'pi pi-tags', to: '/catalog/classifications-comercial' },
{ label: 'Proveedores', icon: 'pi pi-briefcase', to: '/catalog/suppliers' }, {
label: 'Proveedores',
icon: 'pi pi-briefcase',
to: '/catalog/suppliers',
permission: [
'suppliers.index',
'suppliers.show',
'suppliers.store',
'suppliers.update',
'suppliers.destroy',
],
},
{ label: 'Documentos del Modelo', icon: 'pi pi-file', to: '/catalog/model-documents' }, { label: 'Documentos del Modelo', icon: 'pi pi-file', to: '/catalog/model-documents' },
{ {
label: 'Empresas', label: 'Empresas',
@ -57,7 +68,14 @@ const menuItems = ref<MenuItem[]>([
{ {
label: 'Productos', label: 'Productos',
icon: 'pi pi-shopping-cart', icon: 'pi pi-shopping-cart',
to: '/products' to: '/products',
permission: [
'products.index',
'products.show',
'products.store',
'products.update',
'products.destroy',
],
}, },
{ {
label: 'Requisiciones', label: 'Requisiciones',

View File

@ -166,6 +166,8 @@ class AuthService {
async getUserPermissions(): Promise<string[]> { async getUserPermissions(): Promise<string[]> {
try { try {
const response = await api.get('/api/user/permissions'); const response = await api.get('/api/user/permissions');
console.log('User permissions response:', response);
return this.normalizePermissions(response.data); return this.normalizePermissions(response.data);
} catch (error: any) { } catch (error: any) {
this.resolveAuthError(error, 'Error al obtener permisos del usuario'); this.resolveAuthError(error, 'Error al obtener permisos del usuario');

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { onMounted } from 'vue';
import Card from 'primevue/card'; import Card from 'primevue/card';
import Button from 'primevue/button'; import Button from 'primevue/button';
@ -16,9 +15,10 @@ import { useToast } from 'primevue/usetoast';
import { supplierServices } from '../../services/supplier.services'; import { supplierServices } from '../../services/supplier.services';
import type { Supplier, SupplierPaginatedResponse, SupplierFormErrors } from '../../types/suppliers.interfaces'; import type { Supplier, SupplierFormErrors, SupplierPaginatedResponse } from '../../types/suppliers.interfaces';
import { SupplierType } from '../../types/suppliers.interfaces'; import { SupplierType } from '../../types/suppliers.interfaces';
import SupplierModal from './SupplierModal.vue'; import SupplierModal from './SupplierModal.vue';
import { useAuth } from '@/modules/auth/composables/useAuth';
const suppliers = ref<Supplier[]>([]); const suppliers = ref<Supplier[]>([]);
@ -40,7 +40,26 @@ const formErrors = ref<SupplierFormErrors>({});
const confirm = useConfirm(); const confirm = useConfirm();
const toast = useToast(); 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) => { const handleDelete = (supplierId: number) => {
if (!canDeleteSupplier.value) {
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes eliminar proveedores.', life: 4000 });
return;
}
confirm.require({ confirm.require({
message: '¿Seguro que deseas eliminar este proveedor?', message: '¿Seguro que deseas eliminar este proveedor?',
header: 'Confirmar eliminación', header: 'Confirmar eliminación',
@ -71,11 +90,21 @@ const mapTypeToApi = (typeLabel: string | null) => {
}; };
const fetchSuppliers = async (page = 1) => { 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; loading.value = true;
try { try {
const name = searchName.value ? searchName.value : undefined; const name = searchName.value ? searchName.value : undefined;
const type = selectedType.value ? mapTypeToApi(selectedType.value)?.toString() : undefined; const type = selectedType.value ? mapTypeToApi(selectedType.value)?.toString() : undefined;
console.log('🔎 fetchSuppliers params:', { paginated: true, name, type, page });
const response = await supplierServices.getSuppliers(true, name, type); const response = await supplierServices.getSuppliers(true, name, type);
const paginated = response as SupplierPaginatedResponse; const paginated = response as SupplierPaginatedResponse;
suppliers.value = paginated.data; suppliers.value = paginated.data;
@ -85,15 +114,23 @@ const fetchSuppliers = async (page = 1) => {
pagination.value.first = (paginated.current_page - 1) * paginated.per_page; pagination.value.first = (paginated.current_page - 1) * paginated.per_page;
pagination.value.rows = paginated.per_page; pagination.value.rows = paginated.per_page;
} catch (e) { } catch (e) {
// Manejo de error opcional toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los proveedores.', life: 3000 });
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; };
onMounted(() => { watch(
fetchSuppliers(); () => canViewSuppliers.value,
}); (allowed) => {
if (allowed) {
fetchSuppliers();
} else {
suppliers.value = [];
}
},
{ immediate: true }
);
const supplierTypes = [ const supplierTypes = [
{ label: 'Todos', value: null }, { label: 'Todos', value: null },
@ -124,6 +161,10 @@ const onPageChange = (event: any) => {
const openCreateModal = () => { const openCreateModal = () => {
if (!canCreateSupplier.value) {
toast.add({ severity: 'warn', summary: 'Sin permisos', detail: 'No puedes crear proveedores.', life: 4000 });
return;
}
isEditMode.value = false; isEditMode.value = false;
showModal.value = true; showModal.value = true;
currentSupplier.value = null; currentSupplier.value = null;
@ -131,6 +172,10 @@ const openCreateModal = () => {
}; };
const openEditModal = async (supplier: Supplier) => { 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; isEditMode.value = true;
formErrors.value = {}; formErrors.value = {};
loadingSupplier.value = true; loadingSupplier.value = true;
@ -164,9 +209,15 @@ const handleModalSubmit = async (form: any) => {
formErrors.value = {}; formErrors.value = {};
try { try {
if (isEditMode.value && currentSupplier.value) { if (isEditMode.value && currentSupplier.value) {
if (!canUpdateSupplier.value) {
throw new Error('without-update-permission');
}
await supplierServices.updateSupplier(currentSupplier.value.id, form, 'patch'); await supplierServices.updateSupplier(currentSupplier.value.id, form, 'patch');
toast.add({ severity: 'success', summary: 'Proveedor actualizado', detail: 'Proveedor actualizado correctamente', life: 3000 }); toast.add({ severity: 'success', summary: 'Proveedor actualizado', detail: 'Proveedor actualizado correctamente', life: 3000 });
} else { } else {
if (!canCreateSupplier.value) {
throw new Error('without-create-permission');
}
await supplierServices.createSupplier(form); await supplierServices.createSupplier(form);
toast.add({ severity: 'success', summary: 'Proveedor creado', detail: 'Proveedor registrado correctamente', life: 3000 }); toast.add({ severity: 'success', summary: 'Proveedor creado', detail: 'Proveedor registrado correctamente', life: 3000 });
} }
@ -176,7 +227,18 @@ const handleModalSubmit = async (form: any) => {
if (e?.response?.data?.errors) { if (e?.response?.data?.errors) {
formErrors.value = e.response.data.errors; formErrors.value = e.response.data.errors;
} }
toast.add({ severity: 'error', summary: 'Error', detail: e?.response?.data?.message || 'No se pudo guardar el proveedor', life: 3000 }); 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,
});
} }
}; };
@ -211,14 +273,19 @@ const typeSeverity = (type: number) => {
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">Administra la base de datos de proveedores y <p class="text-gray-500 dark:text-gray-400 text-sm mt-1">Administra la base de datos de proveedores y
sus contactos.</p> sus contactos.</p>
</div> </div>
<Button label="Nuevo Proveedor" icon="pi pi-plus" @click="openCreateModal" /> <Button
v-if="canCreateSupplier"
label="Nuevo Proveedor"
icon="pi pi-plus"
@click="openCreateModal"
/>
<SupplierModal :visible="showModal" :isEditMode="isEditMode" :supplier="currentSupplier" <SupplierModal :visible="showModal" :isEditMode="isEditMode" :supplier="currentSupplier"
:formErrors="formErrors" :loading="loadingSupplier" @update:visible="val => { if (!val) closeModal(); }" :formErrors="formErrors" :loading="loadingSupplier" @update:visible="val => { if (!val) closeModal(); }"
@submit="handleModalSubmit" @cancel="closeModal" /> @submit="handleModalSubmit" @cancel="closeModal" />
</div> </div>
<!-- Filters Toolbar --> <!-- Filters Toolbar -->
<Card> <Card v-if="canViewSuppliers">
<template #content> <template #content>
<div class="flex flex-wrap gap-4 items-end"> <div class="flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-60"> <div class="flex-1 min-w-60">
@ -239,10 +306,10 @@ const typeSeverity = (type: number) => {
</Card> </Card>
<!-- Table Content --> <!-- Table Content -->
<Card> <Card v-if="canViewSuppliers">
<template #content> <template #content>
<DataTable :value="suppliers" :loading="loading" stripedRows responsiveLayout="scroll" <DataTable :value="suppliers" :loading="loading" stripedRows responsiveLayout="scroll"
class="p-datatable-sm">< class="p-datatable-sm">
<Column field="name" header="Razón Social" style="min-width: 180px"> <Column field="name" header="Razón Social" style="min-width: 180px">
<template #body="{ data }"> <template #body="{ data }">
<div> <div>
@ -285,9 +352,23 @@ const typeSeverity = (type: number) => {
style="min-width: 120px"> style="min-width: 120px">
<template #body="{ data }"> <template #body="{ data }">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<Button icon="pi pi-pencil" text rounded size="small" @click="openEditModal(data)" /> <Button
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-if="canUpdateSupplier"
@click="handleDelete(data.id)" /> 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> </div>
</template> </template>
</Column> </Column>
@ -298,6 +379,14 @@ const typeSeverity = (type: number) => {
</div> </div>
</template> </template>
</Card> </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> </div>
<ConfirmDialog /> <ConfirmDialog />

View File

@ -14,10 +14,12 @@ import ConfirmDialog from 'primevue/confirmdialog';
import ProgressSpinner from 'primevue/progressspinner'; import ProgressSpinner from 'primevue/progressspinner';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import Select from 'primevue/select'; import Select from 'primevue/select';
import UnitsEquivalenceModal from './UnitsEquivalenceModal.vue';
import { useUnitOfMeasureStore } from '../../stores/unitOfMeasureStore'; import { useUnitOfMeasureStore } from '../../stores/unitOfMeasureStore';
import UnitsForm from './UnitsForm.vue'; import UnitsForm from './UnitsForm.vue';
import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../../types/unit-measure.interfaces'; import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../../types/unit-measure.interfaces';
import { useAuth } from '@/modules/auth/composables/useAuth'; import { useAuth } from '@/modules/auth/composables/useAuth';
import { useUnitEquivalenceStore } from '../../stores/unitEquivalenceStore';
const router = useRouter(); const router = useRouter();
const toast = useToast(); const toast = useToast();
@ -41,6 +43,8 @@ const showDialog = ref(false);
const isEditing = ref(false); const isEditing = ref(false);
const selectedUnit = ref<UnitOfMeasure | null>(null); const selectedUnit = ref<UnitOfMeasure | null>(null);
const unitsFormRef = ref<InstanceType<typeof UnitsForm> | 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 search = ref('');
const statusFilter = ref<'all' | 'active' | 'inactive'>('all'); const statusFilter = ref<'all' | 'active' | 'inactive'>('all');
@ -71,6 +75,16 @@ const canViewUnits = computed(() =>
const canCreateUnit = computed(() => hasPermission('units-of-measure.store')); const canCreateUnit = computed(() => hasPermission('units-of-measure.store'));
const canUpdateUnit = computed(() => hasPermission('units-of-measure.update')); const canUpdateUnit = computed(() => hasPermission('units-of-measure.update'));
const canDeleteUnit = computed(() => hasPermission('units-of-measure.destroy')); 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 // Methods
const buildFilters = () => ({ const buildFilters = () => ({
@ -122,6 +136,14 @@ const openEditDialog = (unit: UnitOfMeasure) => {
showDialog.value = true; 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) => { const handleCreateUnit = async (data: CreateUnitOfMeasureData) => {
await unitStore.createUnit(data); await unitStore.createUnit(data);
toast.add({ toast.add({
@ -435,6 +457,16 @@ watch(
@click="confirmDelete(slotProps.data)" @click="confirmDelete(slotProps.data)"
v-tooltip.top="'Eliminar'" 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> </div>
</template> </template>
</Column> </Column>
@ -465,5 +497,8 @@ watch(
:is-editing="isEditing" :is-editing="isEditing"
@save="handleSaveUnit" @save="handleSaveUnit"
/> />
<!-- Equivalences Modal (maquetado, no funcional) -->
<UnitsEquivalenceModal v-model="showEquivalenceModal" :unit="equivalenceUnit" />
</div> </div>
</template> </template>

View File

@ -0,0 +1,300 @@
<script setup lang="ts">
import { computed, watch, ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Button from 'primevue/button';
import { useUnitEquivalenceStore } from '../../stores/unitEquivalenceStore';
import { useUnitOfMeasureStore } from '../../stores/unitOfMeasureStore' ;
import { useAuth } from '@/modules/auth/composables/useAuth';
const emit = defineEmits(['update:modelValue']);
const props = defineProps<{ modelValue: boolean; unit?: { id?: number; name?: string } }>();
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
});
const unitName = computed(() => props.unit?.name || 'Unidad seleccionada');
const equivalenceStore = useUnitEquivalenceStore();
const unitStore = useUnitOfMeasureStore();
const toast = useToast();
const confirm = useConfirm();
const { hasPermission } = useAuth();
const loading = computed(() => equivalenceStore.loading);
// Form state for create/edit
const isEditing = ref(false);
const editingId = ref<number | null>(null);
const form = ref<{
from_unit_id: number | null;
to_unit_id: number | null;
factor: number;
}>({
from_unit_id: props.unit?.id || null,
to_unit_id: null,
factor: 0,
});
watch(
() => props.unit,
async (u) => {
form.value.from_unit_id = u?.id || null;
// Refresca lista de equivalencias
if (visible.value) {
equivalenceStore.fetchEquivalences({ paginate: false, to_unit_id: u?.id }).catch(() => {});
// Si aún no hay unidades cargadas, las carga
if (!unitStore.units.length) {
await unitStore.fetchUnits({ paginate: false });
}
}
},
{ immediate: true }
);
// Si el modal se abre y no hay unidades, hace fetch
watch(
() => visible.value,
async (val) => {
if (val && !unitStore.units.length) {
await unitStore.fetchUnits({ paginate: false });
}
}
);
const canCreate = computed(() => hasPermission('unit-equivalences.store'));
const canUpdate = computed(() => hasPermission('unit-equivalences.update'));
const canDelete = computed(() => hasPermission('unit-equivalences.destroy'));
function resetForm() {
isEditing.value = false;
editingId.value = null;
form.value = {
from_unit_id: props.unit?.id || null,
to_unit_id: null,
factor: 0,
};
}
const validateForm = () => {
const errors: string[] = [];
if (!form.value.from_unit_id) errors.push('Unidad base es requerida.');
if (!form.value.to_unit_id) errors.push('Unidad destino es requerida.');
if (form.value.from_unit_id && form.value.to_unit_id && form.value.from_unit_id === form.value.to_unit_id) errors.push('La unidad base y destino deben ser diferentes.');
if (!(Number(form.value.factor) > 0)) errors.push('El factor debe ser mayor a 0.');
return errors;
};
const editEquivalence = (item: any) => {
isEditing.value = true;
editingId.value = item.id;
form.value.from_unit_id = item.from_unit_id;
form.value.to_unit_id = item.to_unit_id;
form.value.factor = item.factor;
};
const save = async () => {
const errors = validateForm();
if (errors.length) {
toast.add({ severity: 'warn', summary: 'Validación', detail: errors.join(' '), life: 5000 });
return;
}
try {
if (isEditing.value && editingId.value && canUpdate.value) {
const payload = {
from_unit_id: form.value.from_unit_id!,
to_unit_id: form.value.to_unit_id!,
factor: form.value.factor,
};
await equivalenceStore.updateEquivalence(editingId.value, payload);
toast.add({ severity: 'success', summary: 'Actualizado', detail: 'Equivalencia actualizada correctamente.' });
} else if (!isEditing.value && canCreate.value) {
const payload = {
from_unit_id: form.value.from_unit_id!,
to_unit_id: form.value.to_unit_id!,
factor: form.value.factor,
};
await equivalenceStore.createEquivalence(payload);
toast.add({ severity: 'success', summary: 'Creado', detail: 'Equivalencia creada correctamente.' });
} else {
toast.add({ severity: 'warn', summary: 'Permisos', detail: 'No tienes permisos para realizar esta acción.' });
}
// refresh list and reset
await equivalenceStore.fetchEquivalences({ paginate: false, to_unit_id: form.value.from_unit_id });
resetForm();
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || 'Error al guardar equivalencia';
toast.add({ severity: 'error', summary: 'Error', detail: msg });
}
};
const remove = (id: number) => {
if (!canDelete.value) {
toast.add({ severity: 'warn', summary: 'Permisos', detail: 'No tienes permisos para eliminar.' });
return;
}
confirm.require({
message: '¿Estás seguro de eliminar esta equivalencia?',
header: 'Confirmar eliminación',
icon: 'pi pi-exclamation-triangle',
accept: async () => {
try {
await equivalenceStore.deleteEquivalence(id);
toast.add({ severity: 'success', summary: 'Eliminado', detail: 'Equivalencia eliminada correctamente.' });
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || 'Error al eliminar equivalencia';
toast.add({ severity: 'error', summary: 'Error', detail: msg });
}
}
});
};
</script>
<template>
<Dialog v-model:visible="visible" :modal="true" :style="{ width: '900px' }" :closable="true">
<template #header>
<div class="flex items-center justify-between w-full">
<div>
<h3 class="text-lg font-bold">Gestión de Equivalencias</h3>
<p class="text-sm text-surface-600">Unidad: {{ unitName }}</p>
</div>
</div>
</template>
<!-- Maquetado estático: tabla de equivalencias y formulario lateral adaptado del HTML original -->
<div class="p-4">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="lg:col-span-2 bg-surface-container-lowest rounded-lg overflow-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-surface-container-high">
<th class="px-4 py-3 text-xs uppercase font-bold text-on-surface-variant">Unidad Origen</th>
<th class="px-4 py-3 text-xs uppercase font-bold text-on-surface-variant text-center">Factor</th>
<th class="px-4 py-3 text-xs uppercase font-bold text-on-surface-variant">Unidad Destino</th>
<th class="px-4 py-3 text-xs uppercase font-bold text-on-surface-variant text-right">Acciones</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-if="loading" class="text-center">
<td colspan="4" class="py-6">
<i class="pi pi-spin pi-spinner" />
</td>
</tr>
<tr v-else-if="equivalenceStore.equivalences.length === 0" class="text-center">
<td colspan="4" class="py-6">No hay equivalencias registradas para esta unidad.</td>
</tr>
<tr v-else v-for="item in equivalenceStore.equivalences" :key="item.id" class="hover:bg-surface-container-low transition-colors">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div class="h-8 w-8 bg-surface-container-low flex items-center justify-center rounded text-primary">
<i class="pi pi-box" />
</div>
<span class="font-semibold">{{ item.from_unit?.name || item.from_unit_id }}</span>
</div>
</td>
<td class="px-4 py-3 text-center font-mono">{{ item.factor }}</td>
<td class="px-4 py-3">
<span class="bg-secondary-container/30 text-secondary px-2 py-1 rounded text-xs font-bold">{{ item.to_unit?.name || item.to_unit_id }}</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-1">
<Button v-if="canUpdate" icon="pi pi-pencil" text rounded @click="editEquivalence(item)" />
<Button v-if="canDelete" icon="pi pi-trash" text rounded severity="danger" @click="remove(item.id)" />
</div>
</td>
</tr>
</tbody>
</table>
<div class="px-4 py-3 bg-surface-container-low flex items-center justify-between">
<span class="text-xs text-on-surface-variant">Mostrando {{ equivalenceStore.equivalences.length }} entradas</span>
<div class="flex gap-1">
<!-- pagination could go here -->
</div>
</div>
</div>
<!-- Lateral: Formulario estático -->
<div class="lg:col-span-1 space-y-4">
<div class="bg-surface-container-lowest p-4 rounded-lg border border-outline-variant">
<div class="flex items-center gap-3 mb-4">
<i class="pi pi-plus text-primary"></i>
<h4 class="text-sm font-bold uppercase">Agregar Nueva Equivalencia</h4>
</div>
<div class="space-y-3">
<div>
<label class="text-xs font-bold text-on-surface-variant uppercase">Unidad Base</label>
<div class="mt-1">
<input
type="text"
class="w-full border-b border-outline-variant py-2 text-sm bg-gray-100 cursor-not-allowed"
:value="unitName"
readonly
/>
</div>
</div>
<div>
<label class="text-xs font-bold text-on-surface-variant uppercase">Unidad Destino</label>
<div class="mt-1">
<select v-model="form.to_unit_id" class="w-full border-b border-outline-variant py-2 text-sm">
<option :value="null">Seleccionar unidad...</option>
<option v-for="unit in unitStore.units" :key="unit.id" :value="unit.id">
{{ unit.name }}
</option>
</select>
</div>
</div>
<div>
<label class="text-xs font-bold text-on-surface-variant uppercase">Factor</label>
<div class="mt-1">
<input v-model.number="form.factor" type="number" min="0.0001" step="0.0001" class="w-full border-b border-outline-variant py-2 text-sm" placeholder="1.00" />
</div>
</div>
<div class="flex gap-2 pt-2">
<Button label="Cancelar" text @click="visible = false" />
<Button v-if="canCreate || canUpdate" label="Guardar" class="p-button-success" @click="save" />
<Button v-else label="Guardar" disabled />
</div>
<div class="pt-2 text-xs text-on-surface-variant">
Solo es necesario definir la unidad de destino y el factor respecto a la unidad base.
</div>
</div>
</div>
<div class="bg-gradient-to-br from-secondary-container to-primary-container p-4 rounded-lg text-on-secondary-container">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-bold uppercase opacity-80">Info Rápida</span>
<i class="pi pi-info-circle"></i>
</div>
<p class="text-sm">Asegúrese de que las unidades base correspondan a las medidas de inventario físico.</p>
</div>
</div>
</div>
</div>
</Dialog>
</template>
<style scoped>
/* Scoped minimal adjustments so the maquetado se vea coherente dentro del Dialog */
.bg-surface-container-lowest { background-color: var(--surface-container-lowest, #fff); }
.bg-surface-container-high { background-color: var(--surface-container-high, #f3f3fe); }
.text-secondary { color: var(--secondary, #495c95); }
</style>

View File

@ -0,0 +1,74 @@
import api from '@/services/api';
import type {
UnitEquivalencesListResponse,
UnitEquivalenceResponse,
CreateUnitEquivalenceDTO,
UpdateUnitEquivalenceDTO,
ConvertDTO,
ConvertResponse,
} from '../types/unit-equivalence.interfaces';
export class UnitEquivalencesService {
public async list(params?: Record<string, any>): Promise<UnitEquivalencesListResponse> {
try {
const response = await api.get('/api/catalogs/unit-equivalences', { params });
console.log('Fetched unit equivalences:', response.data);
return response.data;
} catch (error) {
console.error('Error fetching unit equivalences:', error);
throw error;
}
}
public async show(id: number): Promise<UnitEquivalenceResponse> {
try {
const response = await api.get(`/api/catalogs/unit-equivalences/${id}`);
return response.data;
} catch (error) {
console.error('Error fetching unit equivalence:', error);
throw error;
}
}
public async create(data: CreateUnitEquivalenceDTO): Promise<UnitEquivalenceResponse> {
try {
const response = await api.post('/api/catalogs/unit-equivalences', data);
return response.data;
} catch (error) {
console.error('Error creating unit equivalence:', error);
throw error;
}
}
public async update(id: number, data: UpdateUnitEquivalenceDTO): Promise<UnitEquivalenceResponse> {
try {
const response = await api.put(`/api/catalogs/unit-equivalences/${id}`, data);
return response.data;
} catch (error) {
console.error('Error updating unit equivalence:', error);
throw error;
}
}
public async destroy(id: number): Promise<void> {
try {
await api.delete(`/api/catalogs/unit-equivalences/${id}`);
} catch (error) {
console.error('Error deleting unit equivalence:', error);
throw error;
}
}
public async convert(data: ConvertDTO): Promise<ConvertResponse> {
try {
const response = await api.post('/api/catalogs/unit-equivalences/convert', data);
return response.data;
} catch (error) {
console.error('Error converting units:', error);
throw error;
}
}
}
export const unitEquivalencesService = new UnitEquivalencesService();

View File

@ -0,0 +1,78 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { unitEquivalencesService } from '../services/unit-equivalences.services';
import type { UnitEquivalence, CreateUnitEquivalenceDTO, UpdateUnitEquivalenceDTO } from '../types/unit-equivalence.interfaces';
export const useUnitEquivalenceStore = defineStore('unitEquivalence', () => {
const equivalences = ref<UnitEquivalence[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const fetchEquivalences = async (params: Record<string, any> = {}) => {
try {
loading.value = true;
error.value = null;
const response = await unitEquivalencesService.list(params);
equivalences.value = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Error loading equivalences:', err);
error.value = err instanceof Error ? err.message : 'Error loading equivalences';
throw err;
} finally {
loading.value = false;
}
};
const createEquivalence = async (data: CreateUnitEquivalenceDTO) => {
try {
loading.value = true;
const response = await unitEquivalencesService.create(data);
// refresh list
await fetchEquivalences();
return response.unit_equivalence;
} catch (err) {
console.error('Error creating equivalence:', err);
throw err;
} finally {
loading.value = false;
}
};
const updateEquivalence = async (id: number, data: Partial<UpdateUnitEquivalenceDTO>) => {
try {
loading.value = true;
const response = await unitEquivalencesService.update(id, data);
await fetchEquivalences();
return response.unit_equivalence;
} catch (err) {
console.error('Error updating equivalence:', err);
throw err;
} finally {
loading.value = false;
}
};
const deleteEquivalence = async (id: number) => {
try {
loading.value = true;
await unitEquivalencesService.destroy(id);
await fetchEquivalences();
} catch (err) {
console.error('Error deleting equivalence:', err);
throw err;
} finally {
loading.value = false;
}
};
return {
equivalences,
loading,
error,
fetchEquivalences,
createEquivalence,
updateEquivalence,
deleteEquivalence,
};
});

View File

@ -0,0 +1,55 @@
export interface UnitEquivalence {
id: number;
from_unit_id: number;
to_unit_id: number;
product_id: number | null;
factor: number;
is_active: boolean;
from_unit?: any;
to_unit?: any;
product?: any | null;
operator?: string;
}
export interface PaginationMeta {
current_page: number;
per_page: number;
total: number;
}
export interface UnitEquivalencesListResponse {
data: UnitEquivalence[];
}
export interface UnitEquivalenceResponse {
unit_equivalence: UnitEquivalence;
}
export interface CreateUnitEquivalenceDTO {
from_unit_id: number;
to_unit_id: number;
factor: number;
is_active?: boolean;
product_id?: number | null;
operator?: string | null;
}
export type UpdateUnitEquivalenceDTO = Partial<CreateUnitEquivalenceDTO>;
export interface ConvertDTO {
value: number;
from_unit_id: number;
to_unit_id: number;
product_id?: number | null;
}
export interface ConvertResponse {
conversion: {
input_value: number;
result_value: number;
factor_used: number;
direction: string;
source: string;
unit_equivalence_id?: number;
};
}

View File

@ -10,6 +10,10 @@ import { useComercialClassificationStore } from '../../catalog/stores/comercialC
import { useUnitOfMeasureStore } from '../../catalog/stores/unitOfMeasureStore'; import { useUnitOfMeasureStore } from '../../catalog/stores/unitOfMeasureStore';
import { satCodeProductsService, type SatCodeProduct } from '../../catalog/services/sat-code-products.services'; import { satCodeProductsService, type SatCodeProduct } from '../../catalog/services/sat-code-products.services';
import type { Product, CreateProductData } from '../types/product'; import type { Product, CreateProductData } from '../types/product';
import { useAuth } from '@/modules/auth/composables/useAuth';
// Auth/Permissions
const { hasPermission } = useAuth();
// Props // Props
interface Props { interface Props {
@ -32,6 +36,11 @@ const emit = defineEmits<{
const clasificationsStore = useComercialClassificationStore(); const clasificationsStore = useComercialClassificationStore();
const unitOfMeasureStore = useUnitOfMeasureStore(); const unitOfMeasureStore = useUnitOfMeasureStore();
// Computed permission to store or update
const canSaveProduct = computed(() => {
return props.isEditing ? hasPermission('products.update') : hasPermission('products.store');
});
// Form data // Form data
const formData = ref<CreateProductData>({ const formData = ref<CreateProductData>({
code: '', code: '',
@ -700,11 +709,12 @@ const onUpload = (event: any) => {
outlined outlined
@click="handleCancel" @click="handleCancel"
/> />
<Button <Button
:label="isEditing ? 'Actualizar Producto' : 'Guardar Producto'" v-if="canSaveProduct"
:disabled="!isFormValid" :label="isEditing ? 'Actualizar Producto' : 'Guardar Producto'"
@click="handleSubmit" :disabled="!isFormValid"
/> @click="handleSubmit"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useAuth } from '@/modules/auth/composables/useAuth';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import Breadcrumb from 'primevue/breadcrumb'; import Breadcrumb from 'primevue/breadcrumb';
@ -38,6 +39,13 @@ const isEditing = ref(false);
const searchTerm = ref(''); const searchTerm = ref('');
const selectedProduct = ref<Product | null>(null); const selectedProduct = ref<Product | null>(null);
// Auth/Permissions
const { hasPermission } = useAuth();
const canCreateProduct = computed(() => hasPermission('products.store'));
const canUpdateProduct = computed(() => hasPermission('products.update'));
const canDeleteProduct = computed(() => hasPermission('products.destroy'));
const canViewProduct = computed(() => hasPermission('products.index'));
// Computed // Computed
const products = computed(() => productStore.products); const products = computed(() => productStore.products);
const loading = computed(() => productStore.loading); const loading = computed(() => productStore.loading);
@ -167,7 +175,12 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="space-y-6"> <div>
<div v-if="!canViewProduct" class="flex flex-col items-center py-16">
<i class="pi pi-ban text-red-400 text-5xl mb-4"></i>
<p class="text-lg text-red-600 font-semibold">No tienes permisos para visualizar este módulo.</p>
</div>
<div v-else class="space-y-6">
<!-- Toast Notifications --> <!-- Toast Notifications -->
<Toast position="bottom-right" /> <Toast position="bottom-right" />
@ -182,11 +195,12 @@ onMounted(() => {
<h1 class="text-2xl font-bold leading-tight tracking-tight text-surface-900 dark:text-white"> <h1 class="text-2xl font-bold leading-tight tracking-tight text-surface-900 dark:text-white">
Catálogo de Productos Catálogo de Productos
</h1> </h1>
<Button <Button
label="Nuevo Producto" v-if="canCreateProduct"
icon="pi pi-plus" label="Nuevo Producto"
@click="openCreateDialog" icon="pi pi-plus"
/> @click="openCreateDialog"
/>
</div> </div>
<!-- Filters & Search --> <!-- Filters & Search -->
@ -296,23 +310,25 @@ onMounted(() => {
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right" style="min-width: 120px"> <Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right" style="min-width: 120px">
<template #body="slotProps"> <template #body="slotProps">
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<Button <Button
icon="pi pi-pencil" v-if="canUpdateProduct"
text icon="pi pi-pencil"
rounded text
size="small" rounded
@click="editProduct(slotProps.data)" size="small"
v-tooltip.top="'Editar'" @click="editProduct(slotProps.data)"
/> v-tooltip.top="'Editar'"
<Button />
icon="pi pi-trash" <Button
text v-if="canDeleteProduct"
rounded icon="pi pi-trash"
size="small" text
severity="danger" rounded
@click="deleteProductConfirm(slotProps.data)" size="small"
v-tooltip.top="'Eliminar'" severity="danger"
/> @click="deleteProductConfirm(slotProps.data)"
v-tooltip.top="'Eliminar'"
/>
</div> </div>
</template> </template>
</Column> </Column>
@ -363,6 +379,7 @@ onMounted(() => {
@save="handleSaveProduct" @save="handleSaveProduct"
@cancel="handleCancelForm" @cancel="handleCancelForm"
/> />
</Dialog> </Dialog>
</div>
</div> </div>
</template> </template>