feat: add commercial classification management with CRUD operations and routing
This commit is contained in:
parent
aeea112abd
commit
498a15efd4
@ -18,6 +18,14 @@ const menuItems = ref<MenuItem[]>([
|
||||
icon: 'pi pi-chart-line',
|
||||
to: '/'
|
||||
},
|
||||
{
|
||||
label: 'Catálogo',
|
||||
icon: 'pi pi-book',
|
||||
items: [
|
||||
{ label: 'Unidades de Medida', icon: 'pi pi-calculator', to: '/catalog/units-of-measure' },
|
||||
{ label: 'Clasificaciones Comerciales', icon: 'pi pi-tags', to: '/catalog/classifications-comercial' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Almacén',
|
||||
icon: 'pi pi-box',
|
||||
@ -26,56 +34,6 @@ 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',
|
||||
items: [
|
||||
{ label: 'Nueva Venta', icon: 'pi pi-plus', to: '/ventas/nueva' },
|
||||
{ label: 'Lista de Ventas', icon: 'pi pi-list', to: '/ventas/lista' },
|
||||
{ label: 'Cotizaciones', icon: 'pi pi-file-edit', to: '/ventas/cotizaciones' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Clientes',
|
||||
icon: 'pi pi-users',
|
||||
to: '/clientes'
|
||||
},
|
||||
{
|
||||
label: 'Inventario',
|
||||
icon: 'pi pi-box',
|
||||
to: '/inventario'
|
||||
},
|
||||
{
|
||||
label: 'Finanzas',
|
||||
icon: 'pi pi-wallet',
|
||||
items: [
|
||||
{ label: 'Ingresos', icon: 'pi pi-arrow-up', to: '/finanzas/ingresos' },
|
||||
{ label: 'Gastos', icon: 'pi pi-credit-card', to: '/finanzas/gastos' },
|
||||
{ label: 'Cuentas por Cobrar', icon: 'pi pi-money-bill', to: '/finanzas/cobrar' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Reportes',
|
||||
icon: 'pi pi-chart-bar',
|
||||
to: '/reportes'
|
||||
},
|
||||
{
|
||||
label: 'Documentos',
|
||||
icon: 'pi pi-file',
|
||||
to: '/documentos'
|
||||
},
|
||||
{
|
||||
label: 'Módulo Personalizado',
|
||||
icon: 'pi pi-th-large',
|
||||
to: '/modulo-personalizado'
|
||||
},
|
||||
{
|
||||
label: 'Configuración',
|
||||
icon: 'pi pi-cog',
|
||||
@ -105,17 +63,17 @@ const isItemOpen = (label: string) => {
|
||||
|
||||
const isRouteActive = (to: string | undefined) => {
|
||||
if (!to) return false;
|
||||
|
||||
|
||||
// Coincidencia exacta
|
||||
if (route.path === to) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Para la ruta raíz, solo coincidencia exacta
|
||||
if (to === '/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Si la ruta actual es hija (ej: /warehouse/create)
|
||||
// y el item es el padre (ej: /warehouse)
|
||||
// SOLO marcar activo si la ruta hija NO está explícitamente en el menú
|
||||
@ -127,11 +85,11 @@ const isRouteActive = (to: string | undefined) => {
|
||||
}
|
||||
return item.to === route.path;
|
||||
});
|
||||
|
||||
|
||||
// Si NO está explícitamente en el menú, entonces es una ruta hija (create, edit, etc)
|
||||
return !isExplicitRoute;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@ -159,20 +117,16 @@ defineExpose({ toggleSidebar });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
:class="[
|
||||
'bg-surface-0 dark:bg-surface-900 border-r border-surface-200 dark:border-surface-700 transition-all duration-300',
|
||||
sidebarVisible ? 'w-64' : 'w-20'
|
||||
]"
|
||||
>
|
||||
<aside :class="[
|
||||
'bg-surface-0 dark:bg-surface-900 border-r border-surface-200 dark:border-surface-700 transition-all duration-300',
|
||||
sidebarVisible ? 'w-64' : 'w-20'
|
||||
]">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Logo / Brand -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary transition-all"
|
||||
:class="sidebarVisible ? 'bg-primary' : 'bg-primary/90'"
|
||||
>
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary transition-all"
|
||||
:class="sidebarVisible ? 'bg-primary' : 'bg-primary/90'">
|
||||
<i class="pi pi-chart-line text-xl text-white"></i>
|
||||
</div>
|
||||
<div v-if="sidebarVisible" class="flex flex-col">
|
||||
@ -191,66 +145,44 @@ defineExpose({ toggleSidebar });
|
||||
<ul class="space-y-1">
|
||||
<li v-for="item in menuItems" :key="item.label">
|
||||
<!-- Item sin subitems -->
|
||||
<a
|
||||
v-if="!item.items"
|
||||
:href="item.to"
|
||||
@click.prevent="item.to && navigateTo(item.to)"
|
||||
:class="[
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer',
|
||||
isRouteActive(item.to)
|
||||
? 'bg-primary text-white shadow-sm'
|
||||
: 'text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800'
|
||||
]"
|
||||
:title="!sidebarVisible ? item.label : ''"
|
||||
>
|
||||
<a v-if="!item.items" :href="item.to" @click.prevent="item.to && navigateTo(item.to)" :class="[
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer',
|
||||
isRouteActive(item.to)
|
||||
? 'bg-primary text-white shadow-sm'
|
||||
: 'text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800'
|
||||
]" :title="!sidebarVisible ? item.label : ''">
|
||||
<i :class="[item.icon, 'shrink-0']"></i>
|
||||
<span v-if="sidebarVisible">{{ item.label }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Item con subitems (Collapsible) -->
|
||||
<div v-else>
|
||||
<button
|
||||
@click="toggleItem(item.label)"
|
||||
:class="[
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
'text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800'
|
||||
]"
|
||||
:title="!sidebarVisible ? item.label : ''"
|
||||
>
|
||||
<button @click="toggleItem(item.label)" :class="[
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
'text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800'
|
||||
]" :title="!sidebarVisible ? item.label : ''">
|
||||
<div class="flex items-center gap-3">
|
||||
<i :class="[item.icon, 'shrink-0']"></i>
|
||||
<span v-if="sidebarVisible">{{ item.label }}</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="sidebarVisible"
|
||||
:class="[
|
||||
'pi pi-chevron-down text-xs transition-transform duration-200',
|
||||
isItemOpen(item.label) && 'rotate-180'
|
||||
]"
|
||||
></i>
|
||||
<i v-if="sidebarVisible" :class="[
|
||||
'pi pi-chevron-down text-xs transition-transform duration-200',
|
||||
isItemOpen(item.label) && 'rotate-180'
|
||||
]"></i>
|
||||
</button>
|
||||
|
||||
<!-- Subitems con animación -->
|
||||
<Transition
|
||||
name="submenu"
|
||||
@enter="onEnter"
|
||||
@leave="onLeave"
|
||||
>
|
||||
<ul
|
||||
v-if="sidebarVisible && isItemOpen(item.label)"
|
||||
class="ml-6 mt-1 space-y-1 overflow-hidden"
|
||||
>
|
||||
<Transition name="submenu" @enter="onEnter" @leave="onLeave">
|
||||
<ul v-if="sidebarVisible && isItemOpen(item.label)"
|
||||
class="ml-6 mt-1 space-y-1 overflow-hidden">
|
||||
<li v-for="subItem in item.items" :key="subItem.label">
|
||||
<a
|
||||
:href="subItem.to"
|
||||
@click.prevent="subItem.to && navigateTo(subItem.to)"
|
||||
<a :href="subItem.to" @click.prevent="subItem.to && navigateTo(subItem.to)"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2 pl-6 rounded-lg text-sm font-medium transition-all cursor-pointer',
|
||||
isRouteActive(subItem.to)
|
||||
? 'bg-primary text-white shadow-sm'
|
||||
: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800'
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<i :class="[subItem.icon, 'text-xs shrink-0']"></i>
|
||||
<span>{{ subItem.label }}</span>
|
||||
</a>
|
||||
@ -264,11 +196,9 @@ defineExpose({ toggleSidebar });
|
||||
|
||||
<!-- Toggle Button -->
|
||||
<div class="p-3 border-t border-surface-200 dark:border-surface-700">
|
||||
<button
|
||||
@click="toggleSidebar"
|
||||
<button @click="toggleSidebar"
|
||||
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-surface-700 dark:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors"
|
||||
:title="sidebarVisible ? 'Contraer sidebar' : 'Expandir sidebar'"
|
||||
>
|
||||
:title="sidebarVisible ? 'Contraer sidebar' : 'Expandir sidebar'">
|
||||
<i :class="sidebarVisible ? 'pi pi-angle-left' : 'pi pi-angle-right'"></i>
|
||||
<span v-if="sidebarVisible" class="text-sm font-medium">Contraer</span>
|
||||
</button>
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<p>
|
||||
Clasificaciones Comerciales
|
||||
</p>
|
||||
</template>
|
||||
624
src/modules/catalog/components/ComercialClassification.vue
Normal file
624
src/modules/catalog/components/ComercialClassification.vue
Normal file
@ -0,0 +1,624 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import Button from 'primevue/button';
|
||||
import Card from 'primevue/card';
|
||||
import Column from 'primevue/column';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useComercialClassificationStore } from '../stores/comercialClassificationStore';
|
||||
import type { ComercialClassification } from '../types/comercialClassification';
|
||||
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
const classificationStore = useComercialClassificationStore();
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
parent_id: number | null;
|
||||
created_at: string;
|
||||
subcategories: Category[];
|
||||
}
|
||||
|
||||
const selectedCategory = ref<Category | null>(null);
|
||||
const showCreateModal = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
|
||||
// Computed properties from store
|
||||
const loading = computed(() => classificationStore.loading);
|
||||
const categories = computed(() => transformClassifications(classificationStore.classifications));
|
||||
|
||||
// Form data
|
||||
const formData = ref({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
parent_id: null as number | null
|
||||
});
|
||||
|
||||
// Transform API data to component structure
|
||||
const transformClassifications = (classifications: ComercialClassification[]): Category[] => {
|
||||
return classifications.map(cls => ({
|
||||
id: cls.id,
|
||||
code: cls.code,
|
||||
name: cls.name,
|
||||
description: cls.description || '',
|
||||
parent_id: cls.parent_id,
|
||||
created_at: cls.created_at,
|
||||
subcategories: cls.children ? cls.children.map(child => ({
|
||||
id: child.id,
|
||||
code: child.code,
|
||||
name: child.name,
|
||||
description: child.description || '',
|
||||
parent_id: child.parent_id,
|
||||
created_at: child.created_at,
|
||||
subcategories: []
|
||||
})) : []
|
||||
}));
|
||||
};
|
||||
|
||||
const loadClassifications = async () => {
|
||||
try {
|
||||
await classificationStore.fetchClassifications();
|
||||
|
||||
// Select first category by default
|
||||
if (categories.value.length > 0 && categories.value[0]) {
|
||||
selectedCategory.value = categories.value[0];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading classifications:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'No se pudieron cargar las clasificaciones comerciales.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const selectCategory = (category: Category) => {
|
||||
selectedCategory.value = category;
|
||||
};
|
||||
|
||||
const addNewCategory = () => {
|
||||
// Reset form
|
||||
isEditMode.value = false;
|
||||
editingId.value = null;
|
||||
formData.value = {
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
parent_id: null
|
||||
};
|
||||
showCreateModal.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showCreateModal.value = false;
|
||||
isEditMode.value = false;
|
||||
editingId.value = null;
|
||||
};
|
||||
|
||||
const createClassification = async () => {
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
|
||||
if (isEditMode.value && editingId.value) {
|
||||
// Update existing classification
|
||||
await classificationStore.updateClassification(editingId.value, formData.value);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Clasificación Actualizada',
|
||||
detail: `La clasificación "${formData.value.name}" ha sido actualizada exitosamente.`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
// Actualizar la categoría seleccionada si fue la que se editó
|
||||
if (selectedCategory.value?.id === editingId.value) {
|
||||
const updatedCategory = categories.value.find(c => c.id === editingId.value);
|
||||
if (updatedCategory) {
|
||||
selectedCategory.value = updatedCategory;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new classification
|
||||
await classificationStore.createClassification(formData.value);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Clasificación Creada',
|
||||
detail: `La clasificación "${formData.value.name}" ha sido creada exitosamente.`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
// Si se creó una subclasificación, actualizar la vista de la categoría padre
|
||||
if (formData.value.parent_id && selectedCategory.value?.id === formData.value.parent_id) {
|
||||
const parentCategory = categories.value.find(c => c.id === formData.value.parent_id);
|
||||
if (parentCategory) {
|
||||
selectedCategory.value = parentCategory;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showCreateModal.value = false;
|
||||
isEditMode.value = false;
|
||||
editingId.value = null;
|
||||
// Reset form
|
||||
formData.value = {
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
parent_id: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error saving classification:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: isEditMode.value
|
||||
? 'No se pudo actualizar la clasificación. Por favor, intenta nuevamente.'
|
||||
: 'No se pudo crear la clasificación. Por favor, intenta nuevamente.',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addSubcategory = () => {
|
||||
if (selectedCategory.value) {
|
||||
isEditMode.value = false;
|
||||
editingId.value = null;
|
||||
formData.value = {
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
parent_id: selectedCategory.value.id
|
||||
};
|
||||
showCreateModal.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const editCategory = () => {
|
||||
if (!selectedCategory.value) return;
|
||||
|
||||
isEditMode.value = true;
|
||||
editingId.value = selectedCategory.value.id;
|
||||
formData.value = {
|
||||
code: selectedCategory.value.code,
|
||||
name: selectedCategory.value.name,
|
||||
description: selectedCategory.value.description,
|
||||
parent_id: selectedCategory.value.parent_id
|
||||
};
|
||||
showCreateModal.value = true;
|
||||
};
|
||||
|
||||
const deleteCategory = () => {
|
||||
if (!selectedCategory.value) return;
|
||||
|
||||
confirm.require({
|
||||
message: `¿Estás seguro de eliminar la clasificación comercial "${selectedCategory.value.name}"?`,
|
||||
header: 'Confirmar Eliminación',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptLabel: 'Eliminar',
|
||||
rejectClass: 'p-button-secondary p-button-outlined',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
const categoryName = selectedCategory.value!.name;
|
||||
const categoryId = selectedCategory.value!.id;
|
||||
await classificationStore.deleteClassification(categoryId);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Clasificación Eliminada',
|
||||
detail: `La clasificación "${categoryName}" ha sido eliminada exitosamente.`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
// Seleccionar otra categoría o limpiar la selección
|
||||
if (categories.value.length > 0) {
|
||||
selectedCategory.value = categories.value[0] || null;
|
||||
} else {
|
||||
selectedCategory.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting classification:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error al Eliminar',
|
||||
detail: 'No se pudo eliminar la clasificación. Puede estar en uso.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const editSubcategory = (subcategory: Category) => {
|
||||
isEditMode.value = true;
|
||||
editingId.value = subcategory.id;
|
||||
formData.value = {
|
||||
code: subcategory.code,
|
||||
name: subcategory.name,
|
||||
description: subcategory.description,
|
||||
parent_id: subcategory.parent_id
|
||||
};
|
||||
showCreateModal.value = true;
|
||||
};
|
||||
|
||||
const deleteSubcategory = (subcategory: Category) => {
|
||||
confirm.require({
|
||||
message: `¿Estás seguro de eliminar la subclasificación "${subcategory.name}"?`,
|
||||
header: 'Confirmar Eliminación',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptLabel: 'Eliminar',
|
||||
rejectClass: 'p-button-secondary p-button-outlined',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await classificationStore.deleteClassification(subcategory.id);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Subclasificación Eliminada',
|
||||
detail: `La subclasificación "${subcategory.name}" ha sido eliminada exitosamente.`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
// Actualizar la vista de la categoría padre para reflejar los cambios
|
||||
if (selectedCategory.value) {
|
||||
const updatedParent = categories.value.find(c => c.id === selectedCategory.value!.id);
|
||||
if (updatedParent) {
|
||||
selectedCategory.value = updatedParent;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting subcategory:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error al Eliminar',
|
||||
detail: 'No se pudo eliminar la subclasificación.',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadClassifications();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Toast Notifications -->
|
||||
<Toast position="bottom-right" />
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<ConfirmDialog></ConfirmDialog>
|
||||
|
||||
<!-- Page Heading -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold leading-tight tracking-tight text-surface-900 dark:text-white">
|
||||
Clasificaciones Comerciales
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Two-Panel Layout -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Panel (Category List) -->
|
||||
<Card class="lg:col-span-1">
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Add Category Button -->
|
||||
<Button
|
||||
label="Nueva Clasificación"
|
||||
icon="pi pi-plus"
|
||||
class="w-full"
|
||||
@click="addNewCategory"
|
||||
/>
|
||||
|
||||
<!-- Categories List -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<ProgressSpinner style="width: 50px; height: 50px" />
|
||||
</div>
|
||||
<div v-else-if="categories.length === 0" class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<i class="pi pi-inbox text-4xl text-surface-300 dark:text-surface-600 mb-4"></i>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400">
|
||||
No hay clasificaciones comerciales creadas
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:class="[
|
||||
'flex cursor-pointer items-center justify-between gap-4 rounded-lg px-4 py-3 transition-colors',
|
||||
selectedCategory?.id === category.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-surface-100 dark:hover:bg-surface-800'
|
||||
]"
|
||||
@click="selectCategory(category)"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
:class="[
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-lg',
|
||||
selectedCategory?.id === category.id
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400'
|
||||
]"
|
||||
>
|
||||
<i class="pi pi-shopping-bag"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
:class="[
|
||||
'truncate text-base',
|
||||
selectedCategory?.id === category.id
|
||||
? 'font-semibold text-primary'
|
||||
: 'font-normal text-surface-800 dark:text-surface-300'
|
||||
]"
|
||||
>
|
||||
{{ category.name }}
|
||||
</p>
|
||||
<p class="text-xs text-surface-500 dark:text-surface-400 truncate">
|
||||
{{ category.code }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="selectedCategory?.id === category.id ? 'text-primary' : 'text-surface-400'">
|
||||
<i class="pi pi-chevron-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Right Panel (Details View) -->
|
||||
<Card v-if="!selectedCategory" class="lg:col-span-2">
|
||||
<template #content>
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<i class="pi pi-shopping-bag text-6xl text-surface-300 dark:text-surface-600 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-surface-700 dark:text-surface-300 mb-2">
|
||||
Selecciona una clasificación
|
||||
</h3>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400">
|
||||
Elige una clasificación comercial de la lista para ver sus detalles
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card v-else class="lg:col-span-2">
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-surface-500 dark:text-surface-400">
|
||||
Detalles de la Clasificación Comercial
|
||||
</p>
|
||||
<h3 class="text-xl font-bold text-surface-900 dark:text-white mt-1">
|
||||
{{ selectedCategory.name }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm font-mono text-surface-600 dark:text-surface-400">
|
||||
Código: {{ selectedCategory.code }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
outlined
|
||||
rounded
|
||||
@click="editCategory"
|
||||
v-tooltip.top="'Editar'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
rounded
|
||||
@click="deleteCategory"
|
||||
v-tooltip.top="'Eliminar'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subcategory Section -->
|
||||
<div class="flex flex-col">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h4 class="font-semibold text-surface-800 dark:text-surface-200">
|
||||
Subclasificaciones ({{ selectedCategory.subcategories.length }})
|
||||
</h4>
|
||||
<Button
|
||||
label="Agregar Subclasificación"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
@click="addSubcategory"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Subcategory Table -->
|
||||
<DataTable
|
||||
:value="selectedCategory.subcategories"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<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="code" header="Código" sortable>
|
||||
<template #body="slotProps">
|
||||
<span class="text-surface-500 dark:text-surface-400 font-mono">
|
||||
{{ slotProps.data.code }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="created_at" header="Fecha de Creación" sortable>
|
||||
<template #body="slotProps">
|
||||
<span class="text-surface-500 dark:text-surface-400">
|
||||
{{ new Date(slotProps.data.created_at).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right">
|
||||
<template #body="slotProps">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="editSubcategory(slotProps.data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="danger"
|
||||
@click="deleteSubcategory(slotProps.data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center">
|
||||
<i class="pi pi-inbox text-4xl text-surface-300 dark:text-surface-600 mb-4"></i>
|
||||
<h3 class="text-lg font-semibold text-surface-900 dark:text-surface-0">
|
||||
No hay subclasificaciones
|
||||
</h3>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400 mt-2">
|
||||
Agrega subclasificaciones para organizar mejor esta categoría
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Classification Modal -->
|
||||
<Dialog
|
||||
v-model:visible="showCreateModal"
|
||||
modal
|
||||
:header="isEditMode ? 'Editar Clasificación Comercial' : 'Nueva Clasificación Comercial'"
|
||||
:style="{ width: '500px' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4 py-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: COM-001"
|
||||
/>
|
||||
</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: PRODUCTOS DE CONSUMO"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium mb-2">
|
||||
Descripción
|
||||
</label>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
rows="3"
|
||||
class="w-full"
|
||||
placeholder="Descripción de la clasificación comercial"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Parent Category -->
|
||||
<div>
|
||||
<label for="parent" class="block text-sm font-medium mb-2">
|
||||
Clasificación Padre (Opcional)
|
||||
</label>
|
||||
<Dropdown
|
||||
id="parent"
|
||||
v-model="formData.parent_id"
|
||||
:options="categories"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
placeholder="Seleccionar clasificación padre..."
|
||||
class="w-full"
|
||||
showClear
|
||||
/>
|
||||
<small class="text-surface-500 dark:text-surface-400">
|
||||
Deja vacío para crear una clasificación raíz
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="closeModal"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<Button
|
||||
:label="isEditMode ? 'Actualizar' : 'Crear'"
|
||||
@click="createClassification"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!formData.code || !formData.name"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
57
src/modules/catalog/services/clasificationsComercial.ts
Normal file
57
src/modules/catalog/services/clasificationsComercial.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import api from '../../../services/api';
|
||||
import type {
|
||||
ComercialClassificationResponse,
|
||||
CreateComercialClassificationData,
|
||||
UpdateComercialClassificationData,
|
||||
SingleComercialClassificationResponse
|
||||
} from '../types/comercialClassification';
|
||||
|
||||
export const comercialClassificationService = {
|
||||
/**
|
||||
* Get all comercial classifications with pagination
|
||||
* GET /api/comercial-classifications
|
||||
*/
|
||||
async getClassifications(page = 1, perPage = 10): Promise<ComercialClassificationResponse> {
|
||||
const response = await api.get(`/api/comercial-classifications`, {
|
||||
params: { page, per_page: perPage }
|
||||
});
|
||||
console.log('Comercial Classifications response:', response);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single comercial classification by ID
|
||||
* GET /api/comercial-classifications/:id
|
||||
*/
|
||||
async getClassificationById(id: number): Promise<SingleComercialClassificationResponse> {
|
||||
const response = await api.get(`/api/comercial-classifications/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new comercial classification
|
||||
* POST /api/comercial-classifications
|
||||
*/
|
||||
async createClassification(data: CreateComercialClassificationData): Promise<SingleComercialClassificationResponse> {
|
||||
const response = await api.post(`/api/comercial-classifications`, data);
|
||||
console.log('Create Comercial Classification response:', response);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing comercial classification
|
||||
* PUT /api/comercial-classifications/:id
|
||||
*/
|
||||
async updateClassification(id: number, data: UpdateComercialClassificationData): Promise<SingleComercialClassificationResponse> {
|
||||
const response = await api.put(`/api/comercial-classifications/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a comercial classification
|
||||
* DELETE /api/comercial-classifications/:id
|
||||
*/
|
||||
async deleteClassification(id: number): Promise<void> {
|
||||
await api.delete(`/api/comercial-classifications/${id}`);
|
||||
}
|
||||
};
|
||||
142
src/modules/catalog/stores/comercialClassificationStore.ts
Normal file
142
src/modules/catalog/stores/comercialClassificationStore.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { comercialClassificationService } from '../services/clasificationsComercial';
|
||||
import type { ComercialClassification, CreateComercialClassificationData, UpdateComercialClassificationData } from '../types/comercialClassification';
|
||||
|
||||
export const useComercialClassificationStore = defineStore('comercialClassification', () => {
|
||||
// State
|
||||
const classifications = ref<ComercialClassification[]>([]);
|
||||
const loading = ref(false);
|
||||
const loaded = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Getters
|
||||
const activeClassifications = computed(() =>
|
||||
classifications.value.filter(cls => cls.is_active === 1)
|
||||
);
|
||||
|
||||
const inactiveClassifications = computed(() =>
|
||||
classifications.value.filter(cls => cls.is_active === 0)
|
||||
);
|
||||
|
||||
const classificationCount = computed(() => classifications.value.length);
|
||||
|
||||
const getClassificationById = computed(() => {
|
||||
return (id: number) => classifications.value.find(cls => cls.id === id);
|
||||
});
|
||||
|
||||
// Actions
|
||||
const fetchClassifications = async (force = false) => {
|
||||
if (loaded.value && !force) {
|
||||
console.log('Comercial classifications already loaded from store');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await comercialClassificationService.getClassifications();
|
||||
classifications.value = response.data.comercial_classifications.data;
|
||||
loaded.value = true;
|
||||
|
||||
console.log('Comercial classifications loaded into store:', classifications.value.length);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Error loading comercial classifications';
|
||||
console.error('Error in comercial classification store:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshClassifications = () => {
|
||||
return fetchClassifications(true);
|
||||
};
|
||||
|
||||
const createClassification = async (data: CreateComercialClassificationData) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
await comercialClassificationService.createClassification(data);
|
||||
|
||||
// Refrescar la lista después de crear
|
||||
await refreshClassifications();
|
||||
|
||||
console.log('Comercial classification created successfully');
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Error creating comercial classification';
|
||||
console.error('Error creating comercial classification:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateClassification = async (id: number, data: UpdateComercialClassificationData) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
await comercialClassificationService.updateClassification(id, data);
|
||||
|
||||
// Refrescar la lista después de actualizar
|
||||
await refreshClassifications();
|
||||
|
||||
console.log('Comercial classification updated successfully');
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Error updating comercial classification';
|
||||
console.error('Error updating comercial classification:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteClassification = async (id: number) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
await comercialClassificationService.deleteClassification(id);
|
||||
|
||||
// Refrescar la lista después de eliminar
|
||||
await refreshClassifications();
|
||||
|
||||
console.log('Comercial classification deleted successfully');
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Error deleting comercial classification';
|
||||
console.error('Error deleting comercial classification:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearClassifications = () => {
|
||||
classifications.value = [];
|
||||
loaded.value = false;
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
classifications,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
// Getters
|
||||
activeClassifications,
|
||||
inactiveClassifications,
|
||||
classificationCount,
|
||||
getClassificationById,
|
||||
// Actions
|
||||
fetchClassifications,
|
||||
refreshClassifications,
|
||||
createClassification,
|
||||
updateClassification,
|
||||
deleteClassification,
|
||||
clearClassifications,
|
||||
};
|
||||
});
|
||||
61
src/modules/catalog/types/comercialClassification.d.ts
vendored
Normal file
61
src/modules/catalog/types/comercialClassification.d.ts
vendored
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Comercial Classification Type Definitions
|
||||
*/
|
||||
|
||||
export interface ComercialClassification {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parent_id: number | null;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
children?: ComercialClassification[];
|
||||
}
|
||||
|
||||
export interface CreateComercialClassificationData {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parent_id?: number | null;
|
||||
is_active?: number;
|
||||
}
|
||||
|
||||
export interface UpdateComercialClassificationData {
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
parent_id?: number | null;
|
||||
is_active?: number;
|
||||
}
|
||||
|
||||
export interface ComercialClassificationData {
|
||||
current_page: number;
|
||||
data: ComercialClassification[];
|
||||
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 ComercialClassificationResponse {
|
||||
status: string;
|
||||
data: {
|
||||
comercial_classifications: ComercialClassificationData;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SingleComercialClassificationResponse {
|
||||
status: string;
|
||||
data: {
|
||||
comercial_classification: ComercialClassification;
|
||||
};
|
||||
}
|
||||
@ -6,9 +6,10 @@ import Login from '../modules/auth/components/Login.vue';
|
||||
import MainLayout from '../MainLayout.vue';
|
||||
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';
|
||||
import ComercialClassification from '../modules/catalog/components/ComercialClassification.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@ -107,7 +108,16 @@ const routes: RouteRecordRaw[] = [
|
||||
title: 'Unidades de Medida',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'classifications-comercial',
|
||||
name: 'ClassificationsComercial',
|
||||
component: ComercialClassification,
|
||||
meta: {
|
||||
title: 'Clasificaciones Comerciales',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user