feat: add unit of measure management with CRUD operations and routing

This commit is contained in:
Edgar Mendez Mendoza 2025-11-08 09:39:19 -06:00
parent d134db42b6
commit aeea112abd
9 changed files with 777 additions and 0 deletions

1
components.d.ts vendored
View File

@ -45,5 +45,6 @@ declare module 'vue' {
}
export interface GlobalDirectives {
StyleClass: typeof import('primevue/styleclass')['default']
Tooltip: typeof import('primevue/tooltip')['default']
}
}

View File

@ -26,6 +26,13 @@ const menuItems = ref<MenuItem[]>([
{ label: 'Administrar Clasificaciones', icon: 'pi pi-sitemap', to: '/warehouse/classifications' }
]
},
{
label: 'Catálogo',
icon: 'pi pi-book',
items: [
{ label: 'Unidades de Medida', icon: 'pi pi-calculator', to: '/catalog/units-of-measure' }
]
},
{
label: 'Ventas',
icon: 'pi pi-shopping-cart',

View File

@ -0,0 +1,443 @@
<script setup lang="ts">
import { ref, computed, onMounted } 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 Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Dropdown from 'primevue/dropdown';
import InputSwitch from 'primevue/inputswitch';
import Tag from 'primevue/tag';
import Toast from 'primevue/toast';
import ConfirmDialog from 'primevue/confirmdialog';
import ProgressSpinner from 'primevue/progressspinner';
import { useUnitOfMeasureStore } from '../stores/unitOfMeasureStore';
import { unitTypesService } from '../services/unitsTypes';
import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../types/unitOfMeasure';
import type { UnitType } from '../types/unitTypes';
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const unitStore = useUnitOfMeasureStore();
// 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 formData = ref<CreateUnitOfMeasureData>({
code: '',
name: '',
type: 4, // Default: Unidad
abbreviation: '',
is_active: 1
});
const editingId = ref<number | null>(null);
// Type options - loaded from API
const typeOptions = ref<{ label: string; value: number }[]>([]);
const loadingTypes = ref(false);
// Computed
const units = computed(() => unitStore.units);
const loading = computed(() => unitStore.loading);
const dialogTitle = computed(() =>
isEditing.value ? 'Editar Unidad de Medida' : 'Nueva Unidad de Medida'
);
const getStatusConfig = (isActive: number) => {
return isActive === 1
? { label: 'Activa', severity: 'success' }
: { label: 'Inactiva', severity: 'secondary' };
};
// Methods
const openCreateDialog = () => {
isEditing.value = false;
editingId.value = null;
formData.value = {
code: '',
name: '',
type: 4,
abbreviation: '',
is_active: 1
};
showDialog.value = true;
};
const openEditDialog = (unit: UnitOfMeasure) => {
isEditing.value = true;
editingId.value = unit.id;
formData.value = {
code: unit.code,
name: unit.name,
type: unit.type,
abbreviation: unit.abbreviation,
is_active: unit.is_active
};
showDialog.value = true;
};
const closeDialog = () => {
showDialog.value = false;
formData.value = {
code: '',
name: '',
type: 4,
abbreviation: '',
is_active: 1
};
editingId.value = null;
isEditing.value = false;
};
const saveUnit = async () => {
try {
if (isEditing.value && editingId.value) {
await unitStore.updateUnit(editingId.value, formData.value);
toast.add({
severity: 'success',
summary: 'Actualización Exitosa',
detail: 'La unidad de medida ha sido actualizada correctamente.',
life: 3000
});
} else {
await unitStore.createUnit(formData.value);
toast.add({
severity: 'success',
summary: 'Creación Exitosa',
detail: 'La unidad de medida ha sido creada correctamente.',
life: 3000
});
}
closeDialog();
} catch (error) {
console.error('Error saving unit:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudo guardar la unidad de medida. Por favor, intenta nuevamente.',
life: 3000
});
}
};
const confirmDelete = (unit: UnitOfMeasure) => {
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) => {
try {
await unitStore.deleteUnit(id);
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
});
}
};
// Load unit types from API
const loadUnitTypes = async () => {
try {
loadingTypes.value = true;
const response = await unitTypesService.getUnitTypes();
typeOptions.value = response.data.unit_types.map(type => ({
label: type.name,
value: type.id
}));
console.log('Unit types loaded:', typeOptions.value);
} catch (error) {
console.error('Error loading unit types:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudieron cargar los tipos de unidades.',
life: 3000
});
} finally {
loadingTypes.value = false;
}
};
// Lifecycle
onMounted(async () => {
await Promise.all([
unitStore.fetchUnits(),
loadUnitTypes()
]);
});
</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
label="Nueva Unidad"
icon="pi pi-plus"
@click="openCreateDialog"
/>
</div>
</div>
<!-- Main Card -->
<Card>
<template #content>
<!-- 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="code" header="Código" sortable style="width: 120px">
<template #body="slotProps">
<span class="font-mono font-medium text-surface-700 dark:text-surface-300">
{{ slotProps.data.code }}
</span>
</template>
</Column>
<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="type_name" header="Tipo" sortable style="width: 150px">
<template #body="slotProps">
<span class="text-sm text-surface-600 dark:text-surface-400">
{{ slotProps.data.type_name }}
</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
icon="pi pi-pencil"
text
rounded
severity="secondary"
@click="openEditDialog(slotProps.data)"
v-tooltip.top="'Editar'"
/>
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
@click="confirmDelete(slotProps.data)"
v-tooltip.top="'Eliminar'"
/>
</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>
<!-- Create/Edit Dialog -->
<Dialog
v-model:visible="showDialog"
:header="dialogTitle"
:modal="true"
:closable="true"
:draggable="false"
class="w-full max-w-md"
>
<div class="space-y-4 pt-4">
<!-- Code -->
<div>
<label for="code" class="block text-sm font-medium mb-2">
Código <span class="text-red-500">*</span>
</label>
<InputText
id="code"
v-model="formData.code"
class="w-full"
placeholder="Ej: GS1, KG01"
:required="true"
/>
</div>
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium mb-2">
Nombre <span class="text-red-500">*</span>
</label>
<InputText
id="name"
v-model="formData.name"
class="w-full"
placeholder="Ej: PIEZA, KILOGRAMO, METRO"
:required="true"
/>
</div>
<!-- Abbreviation -->
<div>
<label for="abbreviation" class="block text-sm font-medium mb-2">
Abreviatura <span class="text-red-500">*</span>
</label>
<InputText
id="abbreviation"
v-model="formData.abbreviation"
class="w-full"
placeholder="Ej: PZA, kg, m"
:required="true"
/>
</div>
<!-- Type -->
<div>
<label for="type" class="block text-sm font-medium mb-2">
Tipo <span class="text-red-500">*</span>
</label>
<Dropdown
id="type"
v-model="formData.type"
:options="typeOptions"
optionLabel="label"
optionValue="value"
class="w-full"
placeholder="Selecciona un tipo"
/>
</div>
<!-- Status -->
<div>
<label for="is_active" class="block text-sm font-medium mb-2">
Estado
</label>
<div class="flex items-center gap-3">
<InputSwitch
id="is_active"
:model-value="formData.is_active === 1"
@update:model-value="formData.is_active = $event ? 1 : 0"
/>
<span class="text-sm font-medium">
{{ formData.is_active === 1 ? 'Activa' : 'Inactiva' }}
</span>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button
label="Cancelar"
severity="secondary"
outlined
@click="closeDialog"
/>
<Button
:label="isEditing ? 'Actualizar' : 'Crear'"
:disabled="!formData.code || !formData.name || !formData.abbreviation"
@click="saveUnit"
/>
</div>
</template>
</Dialog>
</div>
</template>

View File

@ -0,0 +1,53 @@
import api from '../../../services/api';
import type {
UnitOfMeasureResponse,
CreateUnitOfMeasureData,
UpdateUnitOfMeasureData,
SingleUnitOfMeasureResponse
} from '../types/unitOfMeasure';
export const unitOfMeasureService = {
/**
* Get all units of measure with pagination
*/
async getUnits(page = 1, perPage = 10): Promise<UnitOfMeasureResponse> {
const response = await api.get(`/api/catalogs/units-of-measure`, {
params: { page, per_page: perPage }
});
console.log('Units of Measure response:', response);
return response.data;
},
/**
* Get a single unit of measure by ID
*/
async getUnitById(id: number): Promise<SingleUnitOfMeasureResponse> {
const response = await api.get(`/api/catalogs/units-of-measure/${id}`);
return response.data;
},
/**
* Create a new unit of measure
*/
async createUnit(data: CreateUnitOfMeasureData): Promise<SingleUnitOfMeasureResponse> {
const response = await api.post(`/api/catalogs/units-of-measure`, data);
console.log('Create Unit response:', response);
return response.data;
},
/**
* Update an existing unit of measure
*/
async updateUnit(id: number, data: UpdateUnitOfMeasureData): Promise<SingleUnitOfMeasureResponse> {
const response = await api.put(`/api/catalogs/units-of-measure/${id}`, data);
return response.data;
},
/**
* Delete a unit of measure
*/
async deleteUnit(id: number): Promise<void> {
await api.delete(`/api/catalogs/units-of-measure/${id}`);
}
};

View File

@ -0,0 +1,14 @@
import api from '../../../services/api';
import type { UnitTypesResponse } from '../types/unitTypes';
export const unitTypesService = {
/**
* Get all unit types
* Returns the list of available unit types (Unidad, Peso, Volumen, Longitud, etc.)
*/
async getUnitTypes(): Promise<UnitTypesResponse> {
const response = await api.get<UnitTypesResponse>(`/api/catalogs/unit-types`);
console.log('Unit Types response:', response.data);
return response.data;
},
};

View File

@ -0,0 +1,149 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { unitOfMeasureService } from '../services/unitOfMeasureService';
import type { UnitOfMeasure, CreateUnitOfMeasureData, UpdateUnitOfMeasureData } from '../types/unitOfMeasure';
export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
// State
const units = ref<UnitOfMeasure[]>([]);
const loading = ref(false);
const loaded = ref(false);
const error = ref<string | null>(null);
// Getters
const activeUnits = computed(() =>
units.value.filter(unit => unit.is_active === 1)
);
const inactiveUnits = computed(() =>
units.value.filter(unit => unit.is_active === 0)
);
const unitCount = computed(() => units.value.length);
const getUnitById = computed(() => {
return (id: number) => units.value.find(unit => unit.id === id);
});
const getUnitByAbbreviation = computed(() => {
return (abbreviation: string) =>
units.value.find(unit => unit.abbreviation.toLowerCase() === abbreviation.toLowerCase());
});
// Actions
const fetchUnits = async (force = false) => {
// Si ya están cargados y no se fuerza la recarga, no hacer nada
if (loaded.value && !force) {
console.log('Units of measure already loaded from store');
return;
}
try {
loading.value = true;
error.value = null;
const response = await unitOfMeasureService.getUnits();
units.value = response.data.units_of_measure.data;
loaded.value = true;
console.log('Units of measure loaded into store:', units.value.length);
} catch (err) {
error.value = err instanceof Error ? err.message : 'Error loading units of measure';
console.error('Error in unit of measure store:', err);
throw err;
} finally {
loading.value = false;
}
};
const refreshUnits = () => {
return fetchUnits(true);
};
const createUnit = async (data: CreateUnitOfMeasureData) => {
try {
loading.value = true;
error.value = null;
await unitOfMeasureService.createUnit(data);
// Refrescar la lista después de crear
await refreshUnits();
console.log('Unit of measure created successfully');
} catch (err) {
error.value = err instanceof Error ? err.message : 'Error creating unit of measure';
console.error('Error creating unit of measure:', err);
throw err;
} finally {
loading.value = false;
}
};
const updateUnit = async (id: number, data: UpdateUnitOfMeasureData) => {
try {
loading.value = true;
error.value = null;
await unitOfMeasureService.updateUnit(id, data);
// Refrescar la lista después de actualizar
await refreshUnits();
console.log('Unit of measure updated successfully');
} catch (err) {
error.value = err instanceof Error ? err.message : 'Error updating unit of measure';
console.error('Error updating unit of measure:', err);
throw err;
} finally {
loading.value = false;
}
};
const deleteUnit = async (id: number) => {
try {
loading.value = true;
error.value = null;
await unitOfMeasureService.deleteUnit(id);
// Refrescar la lista después de eliminar
await refreshUnits();
console.log('Unit of measure deleted successfully');
} catch (err) {
error.value = err instanceof Error ? err.message : 'Error deleting unit of measure';
console.error('Error deleting unit of measure:', err);
throw err;
} finally {
loading.value = false;
}
};
const clearUnits = () => {
units.value = [];
loaded.value = false;
error.value = null;
};
return {
// State
units,
loading,
loaded,
error,
// Getters
activeUnits,
inactiveUnits,
unitCount,
getUnitById,
getUnitByAbbreviation,
// Actions
fetchUnits,
refreshUnits,
createUnit,
updateUnit,
deleteUnit,
clearUnits,
};
});

View File

@ -0,0 +1,75 @@
/**
* Unit of Measure Type Definitions
*/
export interface UnitOfMeasure {
id: number;
code: string;
name: string;
type: number;
type_name: string;
abbreviation: string;
is_active: number; // API returns 0 or 1
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface CreateUnitOfMeasureData {
code: string;
name: string;
type: number;
abbreviation: string;
is_active?: number;
}
export interface UpdateUnitOfMeasureData {
code?: string;
name?: string;
type?: number;
abbreviation?: string;
is_active?: number;
}
export interface UnitOfMeasurePagination {
current_page: number;
last_page: number;
per_page: number;
total: number;
from: number;
to: number;
first_page_url: string;
last_page_url: string;
next_page_url: string | null;
prev_page_url: string | null;
path: string;
}
export interface UnitOfMeasureData {
current_page: number;
data: UnitOfMeasure[];
first_page_url: string;
from: number;
last_page: number;
last_page_url: string;
next_page_url: string | null;
path: string;
per_page: number;
prev_page_url: string | null;
to: number;
total: number;
}
export interface UnitOfMeasureResponse {
status: string;
data: {
units_of_measure: UnitOfMeasureData;
};
}
export interface SingleUnitOfMeasureResponse {
status: string;
data: {
unit_of_measure: UnitOfMeasure;
};
}

View File

@ -0,0 +1,15 @@
/**
* Unit Types Definitions
*/
export interface UnitType {
id: number;
name: string;
}
export interface UnitTypesResponse {
status: string;
data: {
unit_types: UnitType[];
};
}

View File

@ -8,6 +8,7 @@ import WarehouseIndex from '../modules/warehouse/components/WarehouseIndex.vue';
import WarehouseForm from '../modules/warehouse/components/WarehouseForm.vue';
import WarehouseCategory from '../modules/warehouse/components/WarehouseCategory.vue';
import WarehouseClassification from '../modules/warehouse/components/WarehouseClassification.vue';
import UnitOfMeasure from '../modules/catalog/components/UnitOfMeasure.vue';
const routes: RouteRecordRaw[] = [
{
@ -89,6 +90,25 @@ const routes: RouteRecordRaw[] = [
}
}
]
},
{
path: 'catalog',
name: 'Catalog',
meta: {
title: 'Catálogo',
requiresAuth: true
},
children: [
{
path: 'units-of-measure',
name: 'UnitsOfMeasure',
component: UnitOfMeasure,
meta: {
title: 'Unidades de Medida',
requiresAuth: true
}
}
]
}
]
},