Merge pull request 'feature-comercial-module-ts' (#11) from feature-comercial-module-ts into qa
Reviewed-on: #11
This commit is contained in:
commit
3b682872e5
@ -1,9 +0,0 @@
|
|||||||
# API Configuration (Production)
|
|
||||||
VITE_API_URL=https://api.golscontrol.com/api
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
VITE_APP_ENV=production
|
|
||||||
|
|
||||||
# App Configuration
|
|
||||||
VITE_APP_NAME=GOLS Control
|
|
||||||
VITE_APP_VERSION=1.0.0
|
|
||||||
2
components.d.ts
vendored
2
components.d.ts
vendored
@ -31,6 +31,7 @@ declare module 'vue' {
|
|||||||
InputGroupAddon: typeof import('primevue/inputgroupaddon')['default']
|
InputGroupAddon: typeof import('primevue/inputgroupaddon')['default']
|
||||||
InputIcon: typeof import('primevue/inputicon')['default']
|
InputIcon: typeof import('primevue/inputicon')['default']
|
||||||
InputNumber: typeof import('primevue/inputnumber')['default']
|
InputNumber: typeof import('primevue/inputnumber')['default']
|
||||||
|
InputSwitch: typeof import('primevue/inputswitch')['default']
|
||||||
InputText: typeof import('primevue/inputtext')['default']
|
InputText: typeof import('primevue/inputtext')['default']
|
||||||
KpiCard: typeof import('./src/components/shared/KpiCard.vue')['default']
|
KpiCard: typeof import('./src/components/shared/KpiCard.vue')['default']
|
||||||
Menu: typeof import('primevue/menu')['default']
|
Menu: typeof import('primevue/menu')['default']
|
||||||
@ -39,6 +40,7 @@ declare module 'vue' {
|
|||||||
ProgressSpinner: typeof import('primevue/progressspinner')['default']
|
ProgressSpinner: typeof import('primevue/progressspinner')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
Select: typeof import('primevue/select')['default']
|
||||||
Sidebar: typeof import('./src/components/layout/Sidebar.vue')['default']
|
Sidebar: typeof import('./src/components/layout/Sidebar.vue')['default']
|
||||||
Tag: typeof import('primevue/tag')['default']
|
Tag: typeof import('primevue/tag')['default']
|
||||||
Textarea: typeof import('primevue/textarea')['default']
|
Textarea: typeof import('primevue/textarea')['default']
|
||||||
|
|||||||
@ -5,12 +5,13 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ${VITE_API_URL}
|
VITE_API_URL: ${VITE_API_URL}
|
||||||
container_name: controls-front-prod
|
container_name: front-controls
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT}:80"
|
- "${APP_PORT}:80"
|
||||||
networks:
|
networks:
|
||||||
- controls-network
|
- controls-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: 512mb
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
controls-network:
|
controls-network:
|
||||||
|
|||||||
@ -25,6 +25,20 @@ const menuItems = ref<MenuItem[]>([
|
|||||||
{ label: 'Unidades de Medida', icon: 'pi pi-calculator', to: '/catalog/units-of-measure' },
|
{ 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: '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' },
|
||||||
|
{ label: 'Documentos del Modelo', icon: 'pi pi-file', to: '/catalog/model-documents' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Productos',
|
||||||
|
icon: 'pi pi-shopping-cart',
|
||||||
|
to: '/products'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Requisiciones',
|
||||||
|
icon: 'pi pi-file-edit',
|
||||||
|
items: [
|
||||||
|
{ label: 'Requisiciones', icon: 'pi pi-file', to: '/requisitions/request' },
|
||||||
|
{ label: 'Crear Requisición', icon: 'pi pi-plus', to: '/requisitions/create' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -52,11 +66,6 @@ const menuItems = ref<MenuItem[]>([
|
|||||||
{ label: 'Departamentos', icon: 'pi pi-briefcase', to: '/rh/departments' }
|
{ label: 'Departamentos', icon: 'pi pi-briefcase', to: '/rh/departments' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Productos',
|
|
||||||
icon: 'pi pi-shopping-cart',
|
|
||||||
to: '/products'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Puntos de venta',
|
label: 'Puntos de venta',
|
||||||
icon: 'pi pi-cog',
|
icon: 'pi pi-cog',
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
<template>
|
|
||||||
<p>
|
|
||||||
Clasificaciones Comerciales
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
193
src/modules/catalog/components/ModelDocuments.vue
Normal file
193
src/modules/catalog/components/ModelDocuments.vue
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useModelDocumentStore } from '../stores/modelDocumentStore';
|
||||||
|
import { MODULE_NAMES } from '../types/modelDocument.interface';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const documentStore = useModelDocumentStore();
|
||||||
|
|
||||||
|
// Breadcrumb
|
||||||
|
const breadcrumbItems = ref([
|
||||||
|
{ label: 'Inicio', route: '/' },
|
||||||
|
{ label: 'Configuración', route: '/catalog' },
|
||||||
|
{ label: 'Tipos de Documento' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const home = ref({
|
||||||
|
icon: 'pi pi-home',
|
||||||
|
route: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
// State
|
||||||
|
const searchTerm = ref('');
|
||||||
|
const selectedModule = ref<number | null>(null);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const documents = computed(() => documentStore.documents);
|
||||||
|
const pagination = computed(() => documentStore.pagination);
|
||||||
|
const loading = computed(() => documentStore.loading);
|
||||||
|
|
||||||
|
const filteredDocuments = computed(() => {
|
||||||
|
let filtered = documents.value;
|
||||||
|
|
||||||
|
// Filter by search term
|
||||||
|
if (searchTerm.value) {
|
||||||
|
const search = searchTerm.value.toLowerCase();
|
||||||
|
filtered = filtered.filter(doc =>
|
||||||
|
doc.name.toLowerCase().includes(search) ||
|
||||||
|
getModuleName(doc.module).toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by module
|
||||||
|
if (selectedModule.value !== null) {
|
||||||
|
filtered = filtered.filter(doc => doc.module === selectedModule.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const getModuleName = (moduleId: number): string => {
|
||||||
|
return MODULE_NAMES[moduleId] || `Módulo ${moduleId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const getStatusIcon = (deletedAt: string | null) => {
|
||||||
|
return deletedAt === null ? 'pi pi-circle-fill' : 'pi pi-circle';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (deletedAt: string | null) => {
|
||||||
|
return deletedAt === null ? '#10b981' : '#ef4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageChange = (event: any) => {
|
||||||
|
documentStore.changePage(event.page + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(async () => {
|
||||||
|
await documentStore.fetchDocuments();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- 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>
|
||||||
|
<h1 class="text-3xl font-black leading-tight tracking-tight text-surface-900 dark:text-white">
|
||||||
|
Tipos de Documento
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Table Card -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading && documents.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="filteredDocuments"
|
||||||
|
:loading="loading"
|
||||||
|
stripedRows
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
>
|
||||||
|
<!-- ID Column -->
|
||||||
|
<Column field="id" header="ID" sortable style="width: 100px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-semibold text-surface-400 dark:text-surface-500">
|
||||||
|
#{{ slotProps.data.id }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Name Column -->
|
||||||
|
<Column field="name" header="Nombre" sortable>
|
||||||
|
<template #body="slotProps">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
:class="getStatusIcon(slotProps.data.deleted_at)"
|
||||||
|
class="text-xs"
|
||||||
|
:style="{ color: getStatusColor(slotProps.data.deleted_at) }"
|
||||||
|
/>
|
||||||
|
<span class="font-semibold text-surface-700 dark:text-surface-200">
|
||||||
|
{{ slotProps.data.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Folio Column -->
|
||||||
|
<Column field="num_folio" header="Folio Actual" sortable style="width: 150px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-mono text-sm text-surface-600 dark:text-surface-300">
|
||||||
|
{{ String(slotProps.data.num_folio).padStart(6, '0') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Created At Column -->
|
||||||
|
<Column field="created_at" header="Fecha Creación" sortable style="width: 150px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="text-sm text-surface-500 dark:text-surface-400">
|
||||||
|
{{ formatDate(slotProps.data.created_at) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<template #footer>
|
||||||
|
<Paginator
|
||||||
|
v-if="pagination.total > 0"
|
||||||
|
:rows="pagination.perPage"
|
||||||
|
:totalRecords="pagination.total"
|
||||||
|
:first="(pagination.currentPage - 1) * pagination.perPage"
|
||||||
|
@page="onPageChange"
|
||||||
|
template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||||
|
:rowsPerPageOptions="[10, 15, 20, 50]"
|
||||||
|
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} documentos"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,660 @@
|
|||||||
|
<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 InputSwitch from 'primevue/inputswitch';
|
||||||
|
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;
|
||||||
|
is_active: number;
|
||||||
|
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,
|
||||||
|
is_active: 1 as number
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed para el switch (convierte number a boolean y viceversa)
|
||||||
|
const isActiveSwitch = computed({
|
||||||
|
get: () => formData.value.is_active === 1,
|
||||||
|
set: (value: boolean) => {
|
||||||
|
formData.value.is_active = value ? 1 : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
is_active: cls.is_active,
|
||||||
|
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,
|
||||||
|
is_active: child.is_active,
|
||||||
|
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,
|
||||||
|
is_active: 1
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
is_active: 1
|
||||||
|
};
|
||||||
|
} 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,
|
||||||
|
is_active: 1
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
is_active: selectedCategory.value.is_active
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
is_active: subcategory.is_active
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Active Status -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label for="is_active" class="block text-sm font-medium mb-1">
|
||||||
|
Estado
|
||||||
|
</label>
|
||||||
|
<small class="text-surface-500 dark:text-surface-400">
|
||||||
|
Activa o desactiva esta clasificación comercial
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<InputSwitch
|
||||||
|
id="is_active"
|
||||||
|
v-model="isActiveSwitch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
@ -1,17 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import Dialog from 'primevue/dialog';
|
import Dialog from 'primevue/dialog';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
import Dropdown from 'primevue/dropdown';
|
import Dropdown from 'primevue/dropdown';
|
||||||
import Textarea from 'primevue/textarea';
|
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import type { Supplier, SupplierFormErrors } from '../../types/suppliers';
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
|
import type { Supplier, SupplierFormErrors, SupplierAddress } from '../../types/suppliers.interfaces';
|
||||||
|
import { SupplierType } from '../../types/suppliers.interfaces';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
supplier?: Supplier | null;
|
supplier?: Supplier | null;
|
||||||
formErrors: SupplierFormErrors;
|
formErrors: SupplierFormErrors;
|
||||||
|
loading?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -20,36 +23,66 @@ const emit = defineEmits<{
|
|||||||
(e: 'cancel'): void;
|
(e: 'cancel'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const defaultAddress = (): SupplierAddress => ({
|
||||||
|
country: 'México',
|
||||||
|
postal_code: '',
|
||||||
|
state: '',
|
||||||
|
municipality: '',
|
||||||
|
city: '',
|
||||||
|
street: '',
|
||||||
|
num_ext: '',
|
||||||
|
num_int: ''
|
||||||
|
});
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
comercial_name: '',
|
||||||
phone: '',
|
rfc: '',
|
||||||
type: '',
|
curp: '',
|
||||||
address: ''
|
type: SupplierType.PROVIDER as number,
|
||||||
|
credit_limit: 0,
|
||||||
|
payment_days: 30,
|
||||||
|
addresses: [defaultAddress()]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const address = computed(() => form.value.addresses[0] || defaultAddress());
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.supplier,
|
() => props.supplier,
|
||||||
(supplier) => {
|
(supplier) => {
|
||||||
if (props.isEditMode && supplier) {
|
if (props.isEditMode && supplier) {
|
||||||
form.value = {
|
form.value = {
|
||||||
name: supplier.name,
|
name: supplier.name,
|
||||||
email: supplier.contact_email,
|
comercial_name: supplier.comercial_name,
|
||||||
phone: supplier.phone_number,
|
rfc: supplier.rfc,
|
||||||
|
curp: supplier.curp,
|
||||||
type: supplier.type,
|
type: supplier.type,
|
||||||
address: supplier.address
|
credit_limit: parseFloat(supplier.credit_limit),
|
||||||
|
payment_days: supplier.payment_days,
|
||||||
|
addresses: supplier.addresses && supplier.addresses.length > 0
|
||||||
|
? [...supplier.addresses]
|
||||||
|
: [defaultAddress()]
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
form.value = { name: '', email: '', phone: '', type: '', address: '' };
|
form.value = {
|
||||||
|
name: '',
|
||||||
|
comercial_name: '',
|
||||||
|
rfc: '',
|
||||||
|
curp: '',
|
||||||
|
type: SupplierType.PROVIDER,
|
||||||
|
credit_limit: 0,
|
||||||
|
payment_days: 30,
|
||||||
|
addresses: [defaultAddress()]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const supplierTypeOptions = [
|
const supplierTypeOptions = [
|
||||||
{ label: 'General', value: 'general' },
|
{ label: 'Cliente', value: SupplierType.CLIENT },
|
||||||
{ label: 'Compra', value: 'purchases' },
|
{ label: 'Proveedor', value: SupplierType.PROVIDER },
|
||||||
{ label: 'Venta', value: 'sales' }
|
{ label: 'Cliente/Proveedor', value: SupplierType.BOTH }
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
@ -62,42 +95,125 @@ const handleCancel = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :visible="visible" modal :style="{ width: '600px' }" :header="isEditMode ? 'Editar Proveedor' : 'Crear Nuevo Proveedor'" :closable="true" @update:visible="val => emit('update:visible', val)">
|
<Dialog :visible="visible" modal :style="{ width: '800px' }" :header="isEditMode ? 'Editar Proveedor' : 'Crear Nuevo Proveedor'" :closable="true" @update:visible="val => emit('update:visible', val)">
|
||||||
<form class="flex flex-col gap-8" @submit.prevent="handleSubmit">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<!-- Loading Overlay -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||||
|
<ProgressSpinner style="width: 50px; height: 50px" strokeWidth="4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-else class="flex flex-col gap-6" @submit.prevent="handleSubmit">
|
||||||
|
|
||||||
|
<!-- Información General -->
|
||||||
|
<div class="border-b pb-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Información General</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label class="flex flex-col gap-2">
|
<label class="flex flex-col gap-2">
|
||||||
<p class="text-[#111418] dark:text-white text-sm font-semibold leading-normal">
|
<p class="text-sm font-semibold">Razón Social <span class="text-red-500">*</span></p>
|
||||||
Nombre del Proveedor <span class="text-red-500">*</span></p>
|
<InputText v-model="form.name" placeholder="Ej: Distribuidora Nacional S.A. de C.V." required />
|
||||||
<InputText v-model="form.name" placeholder="Ej: Suministros Industriales S.A." required class="w-full" />
|
<div v-if="formErrors.name" class="text-red-500 text-xs" v-for="err in formErrors.name" :key="err">{{ err }}</div>
|
||||||
<div v-if="formErrors.name" class="text-red-500 text-xs mt-1" v-for="err in formErrors.name" :key="err">{{ err }}</div>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex flex-col gap-2">
|
<label class="flex flex-col gap-2">
|
||||||
<p class="text-[#111418] dark:text-white text-sm font-semibold leading-normal">
|
<p class="text-sm font-semibold">Nombre Comercial <span class="text-red-500">*</span></p>
|
||||||
Correo de Contacto <span class="text-red-500">*</span></p>
|
<InputText v-model="form.comercial_name" placeholder="Ej: DistriNacional" required />
|
||||||
<InputText v-model="form.email" placeholder="contacto@proveedor.com" required class="w-full" type="email" />
|
<div v-if="formErrors.comercial_name" class="text-red-500 text-xs" v-for="err in formErrors.comercial_name" :key="err">{{ err }}</div>
|
||||||
<div v-if="formErrors.contact_email" class="text-red-500 text-xs mt-1" v-for="err in formErrors.contact_email" :key="err">{{ err }}</div>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
<label class="flex flex-col gap-2">
|
<label class="flex flex-col gap-2">
|
||||||
<p class="text-[#111418] dark:text-white text-sm font-semibold leading-normal">Teléfono</p>
|
<p class="text-sm font-semibold">RFC <span class="text-red-500">*</span></p>
|
||||||
<InputText v-model="form.phone" placeholder="+52 ..." class="w-full" type="tel" />
|
<InputText v-model="form.rfc" placeholder="XAXX010101000" required maxlength="13" class="font-mono uppercase" @input="form.rfc = form.rfc.toUpperCase()" />
|
||||||
<div v-if="formErrors.phone_number" class="text-red-500 text-xs mt-1" v-for="err in formErrors.phone_number" :key="err">{{ err }}</div>
|
<div v-if="formErrors.rfc" class="text-red-500 text-xs" v-for="err in formErrors.rfc" :key="err">{{ err }}</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex flex-col gap-2">
|
<label class="flex flex-col gap-2">
|
||||||
<p class="text-[#111418] dark:text-white text-sm font-semibold leading-normal">Tipo de Proveedor <span class="text-red-500">*</span></p>
|
<p class="text-sm font-semibold">CURP <span class="text-red-500">*</span></p>
|
||||||
<Dropdown v-model="form.type" :options="supplierTypeOptions" optionLabel="label" optionValue="value" placeholder="Seleccionar tipo" class="w-full" required />
|
<InputText v-model="form.curp" placeholder="XAXX010101HDFABC01" required maxlength="18" class="font-mono uppercase" @input="form.curp = form.curp.toUpperCase()" />
|
||||||
<div v-if="formErrors.type" class="text-red-500 text-xs mt-1" v-for="err in formErrors.type" :key="err">{{ err }}</div>
|
<div v-if="formErrors.curp" class="text-red-500 text-xs" v-for="err in formErrors.curp" :key="err">{{ err }}</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Información Comercial -->
|
||||||
|
<div class="border-b pb-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Información Comercial</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<label class="flex flex-col gap-2">
|
<label class="flex flex-col gap-2">
|
||||||
<p class="text-[#111418] dark:text-white text-sm font-semibold leading-normal">Dirección</p>
|
<p class="text-sm font-semibold">Tipo <span class="text-red-500">*</span></p>
|
||||||
<Textarea v-model="form.address" placeholder="Calle, Número, Colonia, Ciudad, Estado, CP" class="w-full" autoResize />
|
<Dropdown v-model="form.type" :options="supplierTypeOptions" optionLabel="label" optionValue="value" placeholder="Seleccionar tipo" required />
|
||||||
<div v-if="formErrors.address" class="text-red-500 text-xs mt-1" v-for="err in formErrors.address" :key="err">{{ err }}</div>
|
<div v-if="formErrors.type" class="text-red-500 text-xs" v-for="err in formErrors.type" :key="err">{{ err }}</div>
|
||||||
</label>
|
</label>
|
||||||
<div class="mt-4 pt-8 border-t border-[#dbe0e6] dark:border-[#2d3a4a] flex flex-col sm:flex-row items-center justify-end gap-4">
|
|
||||||
<Button label="Cancelar" text class="w-full sm:w-auto" @click="handleCancel" type="button" />
|
<label class="flex flex-col gap-2">
|
||||||
<Button :label="isEditMode ? 'Actualizar Proveedor' : 'Guardar Proveedor'" type="submit" class="w-full sm:w-auto" />
|
<p class="text-sm font-semibold">Límite de Crédito <span class="text-red-500">*</span></p>
|
||||||
|
<InputNumber v-model="form.credit_limit" mode="currency" currency="MXN" locale="es-MX" :minFractionDigits="2" required />
|
||||||
|
<div v-if="formErrors.credit_limit" class="text-red-500 text-xs" v-for="err in formErrors.credit_limit" :key="err">{{ err }}</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm font-semibold">Días de Pago <span class="text-red-500">*</span></p>
|
||||||
|
<InputNumber v-model="form.payment_days" :min="0" :max="365" suffix=" días" required />
|
||||||
|
<div v-if="formErrors.payment_days" class="text-red-500 text-xs" v-for="err in formErrors.payment_days" :key="err">{{ err }}</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dirección -->
|
||||||
|
<div class="border-b pb-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Dirección</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<label class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm font-semibold">País</p>
|
||||||
|
<InputText v-model="address.country" placeholder="México" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm font-semibold">Código Postal <span class="text-red-500">*</span></p>
|
||||||
|
<InputText v-model="address.postal_code" placeholder="06000" required maxlength="5" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm font-semibold">Estado <span class="text-red-500">*</span></p>
|
||||||
|
<InputText v-model="address.state" placeholder="CDMX" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
|
<label class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm font-semibold">Municipio/Alcaldía <span class="text-red-500">*</span></p>
|
||||||
|
<InputText v-model="address.municipality" placeholder="Cuauhtémoc" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm font-semibold">Ciudad <span class="text-red-500">*</span></p>
|
||||||
|
<InputText v-model="address.city" placeholder="Ciudad de México" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
|
<label class="flex flex-col gap-2 md:col-span-1">
|
||||||
|
<p class="text-sm font-semibold">Calle <span class="text-red-500">*</span></p>
|
||||||
|
<InputText v-model="address.street" placeholder="Av. Reforma" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm font-semibold">Número Ext. <span class="text-red-500">*</span></p>
|
||||||
|
<InputText v-model="address.num_ext" placeholder="123" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm font-semibold">Número Int.</p>
|
||||||
|
<InputText v-model="address.num_int" placeholder="4B" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botones -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-end gap-3 pt-2">
|
||||||
|
<Button label="Cancelar" text severity="secondary" @click="handleCancel" type="button" />
|
||||||
|
<Button :label="isEditMode ? 'Actualizar Proveedor' : 'Crear Proveedor'" type="submit" icon="pi pi-check" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -15,8 +15,9 @@ import { useToast } from 'primevue/usetoast';
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { supplierServices } from '../../services/supplierServices';
|
import { supplierServices } from '../../services/supplier.services';
|
||||||
import type { Supplier, SupplierPaginatedResponse, SupplierFormErrors } from '../../types/suppliers';
|
import type { Supplier, SupplierPaginatedResponse, SupplierFormErrors } from '../../types/suppliers.interfaces';
|
||||||
|
import { SupplierType } from '../../types/suppliers.interfaces';
|
||||||
import SupplierModal from './SupplierModal.vue';
|
import SupplierModal from './SupplierModal.vue';
|
||||||
|
|
||||||
|
|
||||||
@ -29,6 +30,7 @@ const pagination = ref({
|
|||||||
lastPage: 1,
|
lastPage: 1,
|
||||||
});
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadingSupplier = ref(false);
|
||||||
|
|
||||||
// Modal state and form fields
|
// Modal state and form fields
|
||||||
const showModal = ref(false);
|
const showModal = ref(false);
|
||||||
@ -58,11 +60,12 @@ const handleDelete = (supplierId: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const mapTypeToApi = (type: string) => {
|
const mapTypeToApi = (typeLabel: string | null) => {
|
||||||
switch (type) {
|
if (!typeLabel) return undefined;
|
||||||
case 'General': return 'general';
|
switch (typeLabel) {
|
||||||
case 'Compra': return 'purchases';
|
case 'Cliente': return SupplierType.CLIENT;
|
||||||
case 'Venta': return 'sales';
|
case 'Proveedor': return SupplierType.PROVIDER;
|
||||||
|
case 'Ambos': return SupplierType.BOTH;
|
||||||
default: return undefined;
|
default: return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -71,17 +74,11 @@ const fetchSuppliers = async (page = 1) => {
|
|||||||
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) : undefined;
|
const type = selectedType.value ? mapTypeToApi(selectedType.value)?.toString() : undefined;
|
||||||
console.log('🔎 fetchSuppliers params:', { paginated: true, name, type, page });
|
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.map(s => ({
|
suppliers.value = paginated.data;
|
||||||
...s,
|
|
||||||
email: s.contact_email,
|
|
||||||
phone: s.phone_number,
|
|
||||||
typeColor: s.type === 'general' ? 'info' : s.type === 'purchases' ? 'success' : 'warning',
|
|
||||||
date: s.created_at ? new Date(s.created_at).toLocaleDateString() : ''
|
|
||||||
}));
|
|
||||||
pagination.value.total = paginated.total;
|
pagination.value.total = paginated.total;
|
||||||
pagination.value.page = paginated.current_page;
|
pagination.value.page = paginated.current_page;
|
||||||
pagination.value.lastPage = paginated.last_page;
|
pagination.value.lastPage = paginated.last_page;
|
||||||
@ -100,9 +97,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
const supplierTypes = [
|
const supplierTypes = [
|
||||||
{ label: 'Todos', value: null },
|
{ label: 'Todos', value: null },
|
||||||
{ label: 'General', value: 'General' },
|
{ label: 'Cliente', value: 'Cliente' },
|
||||||
{ label: 'Compras', value: 'Compra' },
|
{ label: 'Proveedor', value: 'Proveedor' },
|
||||||
{ label: 'Ventas', value: 'Venta' },
|
{ label: 'Ambos', value: 'Ambos' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedType = ref(null);
|
const selectedType = ref(null);
|
||||||
@ -133,11 +130,27 @@ const openCreateModal = () => {
|
|||||||
formErrors.value = {};
|
formErrors.value = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditModal = (supplier: Supplier) => {
|
const openEditModal = async (supplier: Supplier) => {
|
||||||
isEditMode.value = true;
|
isEditMode.value = true;
|
||||||
showModal.value = true;
|
|
||||||
currentSupplier.value = supplier;
|
|
||||||
formErrors.value = {};
|
formErrors.value = {};
|
||||||
|
loadingSupplier.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Obtener la información completa del proveedor incluyendo direcciones
|
||||||
|
const response = await supplierServices.getSupplierById(supplier.id);
|
||||||
|
currentSupplier.value = response.data;
|
||||||
|
showModal.value = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'No se pudo cargar la información del proveedor',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
console.error('Error loading supplier details:', e);
|
||||||
|
} finally {
|
||||||
|
loadingSupplier.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
@ -151,24 +164,10 @@ const handleModalSubmit = async (form: any) => {
|
|||||||
formErrors.value = {};
|
formErrors.value = {};
|
||||||
try {
|
try {
|
||||||
if (isEditMode.value && currentSupplier.value) {
|
if (isEditMode.value && currentSupplier.value) {
|
||||||
const payload = {
|
await supplierServices.updateSupplier(currentSupplier.value.id, form, 'patch');
|
||||||
name: form.name,
|
|
||||||
contact_email: form.email,
|
|
||||||
phone_number: form.phone,
|
|
||||||
address: form.address,
|
|
||||||
type: form.type,
|
|
||||||
};
|
|
||||||
await supplierServices.updateSupplier(currentSupplier.value.id, payload, '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 {
|
||||||
const payload = {
|
await supplierServices.createSupplier(form);
|
||||||
name: form.name,
|
|
||||||
contact_email: form.email,
|
|
||||||
phone_number: form.phone,
|
|
||||||
address: form.address,
|
|
||||||
type: form.type,
|
|
||||||
};
|
|
||||||
await supplierServices.createSupplier(payload);
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
@ -182,14 +181,24 @@ const handleModalSubmit = async (form: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Mapeo para mostrar el tipo de proveedor con label legible
|
// Mapeo para mostrar el tipo de proveedor con label legible
|
||||||
const typeLabel = (type: string) => {
|
const typeLabel = (type: number) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'general': return 'General';
|
case SupplierType.CLIENT: return 'Cliente';
|
||||||
case 'purchases': return 'Compras';
|
case SupplierType.PROVIDER: return 'Proveedor';
|
||||||
case 'sales': return 'Ventas';
|
case SupplierType.BOTH: return 'Ambos';
|
||||||
default: return type;
|
default: return 'Desconocido';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const typeSeverity = (type: number) => {
|
||||||
|
switch (type) {
|
||||||
|
case SupplierType.CLIENT: return 'info';
|
||||||
|
case SupplierType.PROVIDER: return 'success';
|
||||||
|
case SupplierType.BOTH: return 'warning';
|
||||||
|
default: return 'secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -204,7 +213,7 @@ const typeLabel = (type: string) => {
|
|||||||
</div>
|
</div>
|
||||||
<Button label="Nuevo Proveedor" icon="pi pi-plus" @click="openCreateModal" />
|
<Button 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" @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>
|
||||||
|
|
||||||
@ -233,27 +242,45 @@ const typeLabel = (type: string) => {
|
|||||||
<Card>
|
<Card>
|
||||||
<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="id" header="ID" style="min-width: 80px" />
|
<Column field="name" header="Razón Social" style="min-width: 180px">
|
||||||
<Column field="name" header="Nombre" style="min-width: 200px">
|
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span class="font-bold text-surface-900 dark:text-white">{{ data.name }}</span>
|
<div>
|
||||||
|
<div class="font-bold text-surface-900 dark:text-white">{{ data.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ data.comercial_name }}</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="email" header="Correo de Contacto" style="min-width: 200px">
|
<Column field="rfc" header="RFC" style="min-width: 130px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span class="text-primary-600 dark:text-primary-400">{{ data.email }}</span>
|
<span class="font-mono text-sm">{{ data.rfc }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="curp" header="CURP" style="min-width: 180px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-mono text-sm">{{ data.curp }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="phone" header="Teléfono" style="min-width: 140px" />
|
|
||||||
<Column field="address" header="Dirección" style="min-width: 200px" />
|
|
||||||
<Column field="type" header="Tipo" style="min-width: 100px">
|
<Column field="type" header="Tipo" style="min-width: 100px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="typeLabel(data.type)" :severity="data.typeColor" />
|
<Tag :value="typeLabel(data.type)" :severity="typeSeverity(data.type)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="credit_limit" header="Límite de Crédito" style="min-width: 140px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold text-green-600 dark:text-green-400">${{ parseFloat(data.credit_limit).toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="payment_days" header="Días de Pago" style="min-width: 120px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold">{{ data.payment_days }} días</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="created_at" header="Fecha de Registro" style="min-width: 120px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-sm">{{ new Date(data.created_at).toLocaleDateString('es-MX') }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="date" header="Fecha de Registro" style="min-width: 120px" />
|
|
||||||
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right"
|
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right"
|
||||||
style="min-width: 120px">
|
style="min-width: 120px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
|
|||||||
@ -8,17 +8,13 @@ import Button from 'primevue/button';
|
|||||||
import Card from 'primevue/card';
|
import Card from 'primevue/card';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
import Column from 'primevue/column';
|
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 Tag from 'primevue/tag';
|
||||||
import Toast from 'primevue/toast';
|
import Toast from 'primevue/toast';
|
||||||
import ConfirmDialog from 'primevue/confirmdialog';
|
import ConfirmDialog from 'primevue/confirmdialog';
|
||||||
import ProgressSpinner from 'primevue/progressspinner';
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
import { useUnitOfMeasureStore } from '../stores/unitOfMeasureStore';
|
import { useUnitOfMeasureStore } from '../../stores/unitOfMeasureStore';
|
||||||
import { unitTypesService } from '../services/unitsTypes';
|
import UnitsForm from './UnitsForm.vue';
|
||||||
import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../types/unitOfMeasure';
|
import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../../types/unit-measure.interfaces';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@ -39,27 +35,12 @@ const home = ref({
|
|||||||
// State
|
// State
|
||||||
const showDialog = ref(false);
|
const showDialog = ref(false);
|
||||||
const isEditing = ref(false);
|
const isEditing = ref(false);
|
||||||
const formData = ref<CreateUnitOfMeasureData>({
|
const selectedUnit = ref<UnitOfMeasure | null>(null);
|
||||||
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
|
// Computed
|
||||||
const units = computed(() => unitStore.units);
|
const units = computed(() => unitStore.units);
|
||||||
const loading = computed(() => unitStore.loading);
|
const loading = computed(() => unitStore.loading);
|
||||||
|
|
||||||
const dialogTitle = computed(() =>
|
|
||||||
isEditing.value ? 'Editar Unidad de Medida' : 'Nueva Unidad de Medida'
|
|
||||||
);
|
|
||||||
|
|
||||||
const getStatusConfig = (isActive: number) => {
|
const getStatusConfig = (isActive: number) => {
|
||||||
return isActive === 1
|
return isActive === 1
|
||||||
? { label: 'Activa', severity: 'success' }
|
? { label: 'Activa', severity: 'success' }
|
||||||
@ -69,47 +50,20 @@ const getStatusConfig = (isActive: number) => {
|
|||||||
// Methods
|
// Methods
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
isEditing.value = false;
|
isEditing.value = false;
|
||||||
editingId.value = null;
|
selectedUnit.value = null;
|
||||||
formData.value = {
|
|
||||||
code: '',
|
|
||||||
name: '',
|
|
||||||
type: 4,
|
|
||||||
abbreviation: '',
|
|
||||||
is_active: 1
|
|
||||||
};
|
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditDialog = (unit: UnitOfMeasure) => {
|
const openEditDialog = (unit: UnitOfMeasure) => {
|
||||||
isEditing.value = true;
|
isEditing.value = true;
|
||||||
editingId.value = unit.id;
|
selectedUnit.value = unit;
|
||||||
formData.value = {
|
|
||||||
code: unit.code,
|
|
||||||
name: unit.name,
|
|
||||||
type: unit.type,
|
|
||||||
abbreviation: unit.abbreviation,
|
|
||||||
is_active: unit.is_active
|
|
||||||
};
|
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDialog = () => {
|
const handleSaveUnit = async (data: CreateUnitOfMeasureData) => {
|
||||||
showDialog.value = false;
|
|
||||||
formData.value = {
|
|
||||||
code: '',
|
|
||||||
name: '',
|
|
||||||
type: 4,
|
|
||||||
abbreviation: '',
|
|
||||||
is_active: 1
|
|
||||||
};
|
|
||||||
editingId.value = null;
|
|
||||||
isEditing.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveUnit = async () => {
|
|
||||||
try {
|
try {
|
||||||
if (isEditing.value && editingId.value) {
|
if (isEditing.value && selectedUnit.value) {
|
||||||
await unitStore.updateUnit(editingId.value, formData.value);
|
await unitStore.updateUnit(selectedUnit.value.id, data);
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Actualización Exitosa',
|
summary: 'Actualización Exitosa',
|
||||||
@ -117,7 +71,7 @@ const saveUnit = async () => {
|
|||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await unitStore.createUnit(formData.value);
|
await unitStore.createUnit(data);
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Creación Exitosa',
|
summary: 'Creación Exitosa',
|
||||||
@ -125,7 +79,7 @@ const saveUnit = async () => {
|
|||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
closeDialog();
|
showDialog.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving unit:', error);
|
console.error('Error saving unit:', error);
|
||||||
toast.add({
|
toast.add({
|
||||||
@ -169,35 +123,9 @@ const deleteUnit = async (id: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await unitStore.fetchUnits();
|
||||||
unitStore.fetchUnits(),
|
|
||||||
loadUnitTypes()
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -263,14 +191,6 @@ onMounted(async () => {
|
|||||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
||||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} unidades"
|
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">
|
<Column field="abbreviation" header="Abreviatura" sortable style="width: 150px">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
<span class="font-mono font-semibold text-primary">
|
<span class="font-mono font-semibold text-primary">
|
||||||
@ -287,10 +207,18 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="type_name" header="Tipo" sortable style="width: 150px">
|
<Column field="sat_unit.name" header="Unidad SAT" sortable style="width: 200px">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
<span class="text-sm text-surface-600 dark:text-surface-400">
|
<span class="text-sm text-surface-600 dark:text-surface-400">
|
||||||
{{ slotProps.data.type_name }}
|
{{ slotProps.data.sat_unit?.name || 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="sat_unit.code" header="Código SAT" sortable style="width: 120px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-mono text-sm text-surface-600 dark:text-surface-400">
|
||||||
|
{{ slotProps.data.sat_unit?.code || 'N/A' }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@ -336,107 +264,12 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Create/Edit Dialog -->
|
<!-- Create/Edit Form Dialog -->
|
||||||
<Dialog
|
<UnitsForm
|
||||||
v-model:visible="showDialog"
|
v-model:visible="showDialog"
|
||||||
:header="dialogTitle"
|
:unit="selectedUnit"
|
||||||
:modal="true"
|
:is-editing="isEditing"
|
||||||
:closable="true"
|
@save="handleSaveUnit"
|
||||||
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
261
src/modules/catalog/components/units/UnitsForm.vue
Normal file
261
src/modules/catalog/components/units/UnitsForm.vue
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
|
import InputSwitch from 'primevue/inputswitch';
|
||||||
|
import { satUnitsService } from '../../services/sat-units.services';
|
||||||
|
import type { UnitOfMeasure, CreateUnitOfMeasureData, SatUnit } from '../../types/unit-measure.interfaces';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
unit?: UnitOfMeasure | null;
|
||||||
|
isEditing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:visible', value: boolean): void;
|
||||||
|
(e: 'save', data: CreateUnitOfMeasureData): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
visible: false,
|
||||||
|
unit: null,
|
||||||
|
isEditing: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const formData = ref<CreateUnitOfMeasureData>({
|
||||||
|
name: '',
|
||||||
|
abbreviation: '',
|
||||||
|
code_sat: 1,
|
||||||
|
is_active: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Estado interno del switch (boolean para el UI)
|
||||||
|
const isActiveSwitch = ref(true);
|
||||||
|
|
||||||
|
// SAT Units
|
||||||
|
const satUnits = ref<SatUnit[]>([]);
|
||||||
|
const loadingSatUnits = ref(false);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Options para el dropdown
|
||||||
|
const satUnitOptions = computed(() =>
|
||||||
|
satUnits.value.map(unit => ({
|
||||||
|
label: `${unit.code} - ${unit.name}`,
|
||||||
|
value: unit.id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
props.isEditing ? 'Editar Unidad de Medida' : 'Nueva Unidad de Medida'
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return !!(formData.value.name && formData.value.abbreviation);
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyMessage = computed(() => {
|
||||||
|
if (props.isEditing && satUnits.value.length === 1) {
|
||||||
|
return 'Escribe para buscar otra unidad SAT...';
|
||||||
|
}
|
||||||
|
return 'No se encontraron unidades. Escribe para buscar.';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load SAT Units
|
||||||
|
const loadSatUnits = async (search: string) => {
|
||||||
|
// Solo buscar si hay texto
|
||||||
|
if (!search || search.trim().length === 0) {
|
||||||
|
// Si estamos editando y hay una unidad actual, mantenerla
|
||||||
|
if (props.unit?.sat_unit) {
|
||||||
|
satUnits.value = [props.unit.sat_unit];
|
||||||
|
} else {
|
||||||
|
satUnits.value = [];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingSatUnits.value = true;
|
||||||
|
const response = await satUnitsService.getSatUnits(search);
|
||||||
|
satUnits.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading SAT units:', error);
|
||||||
|
// Si hay error y estamos editando, mantener la unidad actual
|
||||||
|
if (props.unit?.sat_unit) {
|
||||||
|
satUnits.value = [props.unit.sat_unit];
|
||||||
|
} else {
|
||||||
|
satUnits.value = [];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingSatUnits.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const handleSearchChange = (event: any) => {
|
||||||
|
const query = event.value || '';
|
||||||
|
searchQuery.value = query;
|
||||||
|
|
||||||
|
// Limpiar timeout anterior
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar debounce de 500ms
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
loadSatUnits(query);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const resetForm = () => {
|
||||||
|
formData.value = {
|
||||||
|
name: '',
|
||||||
|
abbreviation: '',
|
||||||
|
code_sat: null,
|
||||||
|
is_active: 1
|
||||||
|
};
|
||||||
|
isActiveSwitch.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch para actualizar el formulario cuando cambie la unidad
|
||||||
|
watch(() => props.unit, (newUnit) => {
|
||||||
|
if (newUnit) {
|
||||||
|
formData.value = {
|
||||||
|
name: newUnit.name,
|
||||||
|
abbreviation: newUnit.abbreviation,
|
||||||
|
code_sat: newUnit.code_sat,
|
||||||
|
is_active: newUnit.is_active
|
||||||
|
};
|
||||||
|
isActiveSwitch.value = newUnit.is_active === 1;
|
||||||
|
|
||||||
|
// Precargar la unidad SAT actual en el dropdown
|
||||||
|
if (newUnit.sat_unit) {
|
||||||
|
satUnits.value = [newUnit.sat_unit];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
satUnits.value = []; // Limpiar el dropdown
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('update:visible', false);
|
||||||
|
satUnits.value = []; // Limpiar las opciones del dropdown
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// Convertir el switch boolean a number para el backend
|
||||||
|
formData.value.is_active = isActiveSwitch.value ? 1 : 0;
|
||||||
|
|
||||||
|
emit('save', { ...formData.value });
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
@update:visible="emit('update:visible', $event)"
|
||||||
|
:header="dialogTitle"
|
||||||
|
:modal="true"
|
||||||
|
:closable="true"
|
||||||
|
:draggable="false"
|
||||||
|
class="w-full max-w-md"
|
||||||
|
>
|
||||||
|
<div class="space-y-4 pt-4">
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Code SAT -->
|
||||||
|
<div>
|
||||||
|
<label for="code_sat" class="block text-sm font-medium mb-2">
|
||||||
|
Unidad SAT <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
id="code_sat"
|
||||||
|
v-model="formData.code_sat"
|
||||||
|
:options="satUnitOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
:loading="loadingSatUnits"
|
||||||
|
placeholder="Escribe para buscar una unidad SAT..."
|
||||||
|
class="w-full"
|
||||||
|
:filter="true"
|
||||||
|
filterPlaceholder="Buscar unidad SAT"
|
||||||
|
:showClear="false"
|
||||||
|
@filter="handleSearchChange"
|
||||||
|
:emptyFilterMessage="emptyMessage"
|
||||||
|
/>
|
||||||
|
<small class="text-surface-500 dark:text-surface-400">
|
||||||
|
Unidad del catálogo SAT
|
||||||
|
</small>
|
||||||
|
</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"
|
||||||
|
v-model="isActiveSwitch"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ isActiveSwitch ? 'Activa' : 'Inactiva' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
@click="handleClose"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="isEditing ? 'Actualizar' : 'Crear'"
|
||||||
|
:disabled="!isFormValid"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
44
src/modules/catalog/services/model-document.services.ts
Normal file
44
src/modules/catalog/services/model-document.services.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import api from '../../../services/api';
|
||||||
|
import type {
|
||||||
|
ModelDocument,
|
||||||
|
ModelDocumentPaginatedResponse,
|
||||||
|
CreateModelDocumentData,
|
||||||
|
UpdateModelDocumentData
|
||||||
|
} from '../types/modelDocument.interface';
|
||||||
|
|
||||||
|
export const modelDocumentService = {
|
||||||
|
// Obtener todos los documentos con paginación
|
||||||
|
getDocuments: async (page = 1, perPage = 15) => {
|
||||||
|
const response = await api.get<ModelDocumentPaginatedResponse>('/api/catalogs/document-models', {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
per_page: perPage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Obtener un documento por ID
|
||||||
|
getDocumentById: async (id: number) => {
|
||||||
|
const response = await api.get<{ data: ModelDocument }>(`/api/catalogs/document-models/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Crear nuevo documento
|
||||||
|
createDocument: async (data: CreateModelDocumentData) => {
|
||||||
|
const response = await api.post<{ data: ModelDocument }>('/api/catalogs/document-models', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actualizar documento
|
||||||
|
updateDocument: async (id: number, data: UpdateModelDocumentData) => {
|
||||||
|
const response = await api.put<{ data: ModelDocument }>(`/api/catalogs/document-models/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Eliminar documento (soft delete)
|
||||||
|
deleteDocument: async (id: number) => {
|
||||||
|
const response = await api.delete(`/api/catalogs/document-models/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
18
src/modules/catalog/services/sat-code-products.services.ts
Normal file
18
src/modules/catalog/services/sat-code-products.services.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import api from "../../../services/api";
|
||||||
|
|
||||||
|
export interface SatCodeProduct {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatCodeProductResponse {
|
||||||
|
data: SatCodeProduct[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const satCodeProductsService = {
|
||||||
|
async getSatCodeProducts(search: string = ''): Promise<SatCodeProductResponse> {
|
||||||
|
const response = await api.get(`/api/sat/products?search=${search}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
17
src/modules/catalog/services/sat-units.services.ts
Normal file
17
src/modules/catalog/services/sat-units.services.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import api from '../../../services/api';
|
||||||
|
import type { SatUnit } from '../types/unit-measure.interfaces';
|
||||||
|
|
||||||
|
// Respuesta del endpoint de unidades SAT
|
||||||
|
export interface SatUnitsResponse {
|
||||||
|
data: SatUnit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const satUnitsService = {
|
||||||
|
/**
|
||||||
|
* Get all SAT units with search filter
|
||||||
|
*/
|
||||||
|
async getSatUnits(search: string = ''): Promise<SatUnitsResponse> {
|
||||||
|
const response = await api.get(`/api/sat/units?search=${search}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import api from "../../../services/api";
|
import api from "../../../services/api";
|
||||||
import type { SupplierCreateRequest, SupplierCreateResponse, SupplierDeleteResponse, SupplierListResponse, SupplierPaginatedResponse, SupplierUpdateRequest, SupplierUpdateResponse } from "../types/suppliers";
|
import type { SupplierCreateRequest, SupplierCreateResponse, SupplierDeleteResponse, SupplierListResponse, SupplierPaginatedResponse, SupplierUpdateRequest, SupplierUpdateResponse } from "../types/suppliers.interfaces";
|
||||||
|
|
||||||
|
|
||||||
const supplierServices = {
|
const supplierServices = {
|
||||||
@ -27,6 +27,17 @@ const supplierServices = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getSupplierById(supplierId: number): Promise<SupplierCreateResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/suppliers/${supplierId}`);
|
||||||
|
console.log(`📦 Supplier with ID ${supplierId} response:`, response);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error fetching supplier with ID ${supplierId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async createSupplier(data: SupplierCreateRequest): Promise<SupplierCreateResponse> {
|
async createSupplier(data: SupplierCreateRequest): Promise<SupplierCreateResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/api/suppliers', data);
|
const response = await api.post('/api/suppliers', data);
|
||||||
@ -1,28 +1,27 @@
|
|||||||
import api from '../../../services/api';
|
import api from '../../../services/api';
|
||||||
import type {
|
import type {
|
||||||
UnitOfMeasureResponse,
|
UnitOfMeasurePaginatedResponse,
|
||||||
|
UnitOfMeasureUnpaginatedResponse,
|
||||||
CreateUnitOfMeasureData,
|
CreateUnitOfMeasureData,
|
||||||
UpdateUnitOfMeasureData,
|
UpdateUnitOfMeasureData,
|
||||||
SingleUnitOfMeasureResponse
|
SingleUnitOfMeasureResponse,
|
||||||
} from '../types/unitOfMeasure';
|
UnitOfMeasureResponseById
|
||||||
|
} from '../types/unit-measure.interfaces';
|
||||||
|
|
||||||
|
|
||||||
export const unitOfMeasureService = {
|
export const unitOfMeasureService = {
|
||||||
/**
|
/**
|
||||||
* Get all units of measure with pagination
|
* Get all units of measure with optional pagination and search
|
||||||
*/
|
*/
|
||||||
async getUnits(page = 1, perPage = 10): Promise<UnitOfMeasureResponse> {
|
async getUnits(paginate: boolean = true, search: string = ''): Promise<UnitOfMeasurePaginatedResponse | UnitOfMeasureUnpaginatedResponse> {
|
||||||
const response = await api.get(`/api/catalogs/units-of-measure`, {
|
const response = await api.get(`/api/catalogs/units-of-measure?paginate=${paginate}&search=${search}`);
|
||||||
params: { page, per_page: perPage }
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Units of Measure response:', response);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single unit of measure by ID
|
* Get a single unit of measure by ID
|
||||||
*/
|
*/
|
||||||
async getUnitById(id: number): Promise<SingleUnitOfMeasureResponse> {
|
async getUnitById(id: number): Promise<UnitOfMeasureResponseById> {
|
||||||
const response = await api.get(`/api/catalogs/units-of-measure/${id}`);
|
const response = await api.get(`/api/catalogs/units-of-measure/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
115
src/modules/catalog/stores/modelDocumentStore.ts
Normal file
115
src/modules/catalog/stores/modelDocumentStore.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { modelDocumentService } from '../services/model-document.services';
|
||||||
|
import type { ModelDocument, CreateModelDocumentData, UpdateModelDocumentData } from '../types/modelDocument.interface';
|
||||||
|
|
||||||
|
export const useModelDocumentStore = defineStore('modelDocument', () => {
|
||||||
|
// State
|
||||||
|
const documents = ref<ModelDocument[]>([]);
|
||||||
|
const pagination = ref({
|
||||||
|
currentPage: 1,
|
||||||
|
perPage: 15,
|
||||||
|
total: 0,
|
||||||
|
lastPage: 1
|
||||||
|
});
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const activeDocuments = computed(() =>
|
||||||
|
documents.value.filter(doc => doc.deleted_at === null)
|
||||||
|
);
|
||||||
|
|
||||||
|
const deletedDocuments = computed(() =>
|
||||||
|
documents.value.filter(doc => doc.deleted_at !== null)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const fetchDocuments = async (page = 1) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await modelDocumentService.getDocuments(page, pagination.value.perPage);
|
||||||
|
documents.value = response.data;
|
||||||
|
pagination.value = {
|
||||||
|
currentPage: response.current_page,
|
||||||
|
perPage: response.per_page,
|
||||||
|
total: response.total,
|
||||||
|
lastPage: response.last_page
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Error al cargar documentos';
|
||||||
|
console.error('Error fetching documents:', err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDocument = async (data: CreateModelDocumentData) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await modelDocumentService.createDocument(data);
|
||||||
|
// Recargar la lista después de crear
|
||||||
|
await fetchDocuments(pagination.value.currentPage);
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Error al crear documento';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDocument = async (id: number, data: UpdateModelDocumentData) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await modelDocumentService.updateDocument(id, data);
|
||||||
|
const index = documents.value.findIndex(doc => doc.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
documents.value[index] = response.data;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Error al actualizar documento';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDocument = async (id: number) => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await modelDocumentService.deleteDocument(id);
|
||||||
|
// Recargar la lista después de eliminar
|
||||||
|
await fetchDocuments(pagination.value.currentPage);
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Error al eliminar documento';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePage = async (page: number) => {
|
||||||
|
await fetchDocuments(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
documents,
|
||||||
|
pagination,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
// Getters
|
||||||
|
activeDocuments,
|
||||||
|
deletedDocuments,
|
||||||
|
// Actions
|
||||||
|
fetchDocuments,
|
||||||
|
createDocument,
|
||||||
|
updateDocument,
|
||||||
|
deleteDocument,
|
||||||
|
changePage
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { supplierServices } from '../services/supplierServices';
|
import { supplierServices } from '../services/supplier.services';
|
||||||
import type { Supplier } from '../types/suppliers';
|
import type { Supplier } from '../types/suppliers.interfaces';
|
||||||
|
|
||||||
export const useSupplierStore = defineStore('supplier', () => {
|
export const useSupplierStore = defineStore('supplier', () => {
|
||||||
const suppliers = ref<Supplier[]>([]);
|
const suppliers = ref<Supplier[]>([]);
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { unitOfMeasureService } from '../services/unitOfMeasureService';
|
import { unitOfMeasureService } from '../services/unit-measure.services';
|
||||||
import type { UnitOfMeasure, CreateUnitOfMeasureData, UpdateUnitOfMeasureData } from '../types/unitOfMeasure';
|
import type {
|
||||||
|
UnitOfMeasure,
|
||||||
|
CreateUnitOfMeasureData,
|
||||||
|
UpdateUnitOfMeasureData
|
||||||
|
} from '../types/unit-measure.interfaces';
|
||||||
|
|
||||||
export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
|
export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
|
||||||
// State
|
// State
|
||||||
@ -31,7 +35,7 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const fetchUnits = async (force = false) => {
|
const fetchUnits = async (force = false, paginate = true, search = '') => {
|
||||||
// Si ya están cargados y no se fuerza la recarga, no hacer nada
|
// Si ya están cargados y no se fuerza la recarga, no hacer nada
|
||||||
if (loaded.value && !force) {
|
if (loaded.value && !force) {
|
||||||
console.log('Units of measure already loaded from store');
|
console.log('Units of measure already loaded from store');
|
||||||
@ -42,8 +46,17 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await unitOfMeasureService.getUnits();
|
const response = await unitOfMeasureService.getUnits(paginate, search);
|
||||||
units.value = response.data.units_of_measure.data;
|
|
||||||
|
// Manejar respuesta paginada o no paginada
|
||||||
|
if ('current_page' in response) {
|
||||||
|
// Respuesta paginada
|
||||||
|
units.value = response.data;
|
||||||
|
} else {
|
||||||
|
// Respuesta no paginada
|
||||||
|
units.value = response.data;
|
||||||
|
}
|
||||||
|
|
||||||
loaded.value = true;
|
loaded.value = true;
|
||||||
|
|
||||||
console.log('Units of measure loaded into store:', units.value.length);
|
console.log('Units of measure loaded into store:', units.value.length);
|
||||||
|
|||||||
64
src/modules/catalog/types/modelDocument.interface.ts
Normal file
64
src/modules/catalog/types/modelDocument.interface.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// Tipos para Documentos del Sistema
|
||||||
|
export interface ModelDocument {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
module: number;
|
||||||
|
type: number;
|
||||||
|
num_folio: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationLink {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelDocumentPaginatedResponse {
|
||||||
|
current_page: number;
|
||||||
|
data: ModelDocument[];
|
||||||
|
first_page_url: string;
|
||||||
|
from: number;
|
||||||
|
last_page: number;
|
||||||
|
last_page_url: string;
|
||||||
|
links: PaginationLink[];
|
||||||
|
next_page_url: string | null;
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
prev_page_url: string | null;
|
||||||
|
to: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateModelDocumentData {
|
||||||
|
name: string;
|
||||||
|
module: number;
|
||||||
|
type: number;
|
||||||
|
num_folio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateModelDocumentData extends Partial<CreateModelDocumentData> {
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelDocumentStats {
|
||||||
|
total_active: number;
|
||||||
|
last_folio: string;
|
||||||
|
archived: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module mapping
|
||||||
|
export const MODULE_NAMES: Record<number, string> = {
|
||||||
|
1: 'Ventas',
|
||||||
|
2: 'Compras',
|
||||||
|
3: 'Clientes',
|
||||||
|
4: 'Proveedores',
|
||||||
|
5: 'Inventarios'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type mapping
|
||||||
|
export const TYPE_NAMES: Record<number, string> = {
|
||||||
|
0: 'Sin Folio',
|
||||||
|
1: 'Con Folio'
|
||||||
|
};
|
||||||
@ -1,11 +1,21 @@
|
|||||||
|
// Enum para tipos de proveedor
|
||||||
|
export const SupplierType = {
|
||||||
|
CLIENT: 0,
|
||||||
|
PROVIDER: 1,
|
||||||
|
BOTH: 2
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SupplierType = typeof SupplierType[keyof typeof SupplierType];
|
||||||
|
|
||||||
// Errores de validación del formulario de proveedor
|
// Errores de validación del formulario de proveedor
|
||||||
export interface SupplierFormErrors {
|
export interface SupplierFormErrors {
|
||||||
name?: string[];
|
name?: string[];
|
||||||
contact_email?: string[];
|
comercial_name?: string[];
|
||||||
phone_number?: string[];
|
rfc?: string[];
|
||||||
address?: string[];
|
curp?: string[];
|
||||||
type?: string[];
|
type?: string[];
|
||||||
|
credit_limit?: string[];
|
||||||
|
payment_days?: string[];
|
||||||
}
|
}
|
||||||
// Respuesta simple de proveedores (sin paginación)
|
// Respuesta simple de proveedores (sin paginación)
|
||||||
export interface SupplierListResponse {
|
export interface SupplierListResponse {
|
||||||
@ -15,10 +25,15 @@ export interface SupplierListResponse {
|
|||||||
export interface Supplier {
|
export interface Supplier {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
contact_email: string;
|
comercial_name: string;
|
||||||
phone_number: string;
|
rfc: string;
|
||||||
address: string;
|
curp: string;
|
||||||
type: string;
|
type: SupplierType;
|
||||||
|
credit_limit: string;
|
||||||
|
payment_days: number;
|
||||||
|
contact_email?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
addresses?: SupplierAddress[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
@ -51,12 +66,28 @@ export interface SupplierDeleteResponse {
|
|||||||
data: null;
|
data: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SupplierAddress {
|
||||||
|
country: string;
|
||||||
|
postal_code: string;
|
||||||
|
state: string;
|
||||||
|
municipality: string;
|
||||||
|
city: string;
|
||||||
|
street: string;
|
||||||
|
num_ext: string;
|
||||||
|
num_int: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SupplierCreateRequest {
|
export interface SupplierCreateRequest {
|
||||||
name: string;
|
name: string;
|
||||||
contact_email: string;
|
comercial_name: string;
|
||||||
phone_number: string;
|
rfc: string;
|
||||||
address: string;
|
curp: string;
|
||||||
type: string;
|
type: SupplierType;
|
||||||
|
credit_limit: number;
|
||||||
|
payment_days: number;
|
||||||
|
contact_email?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
addresses: SupplierAddress[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SupplierCreateResponse {
|
export interface SupplierCreateResponse {
|
||||||
81
src/modules/catalog/types/unit-measure.interfaces.ts
Normal file
81
src/modules/catalog/types/unit-measure.interfaces.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
// Interface para la unidad SAT
|
||||||
|
export interface SatUnit {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface para la Unidad de Medida
|
||||||
|
export interface UnitOfMeasure {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
abbreviation: string;
|
||||||
|
is_active: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
code_sat: number;
|
||||||
|
sat_unit: SatUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces para crear y actualizar unidades de medida
|
||||||
|
export interface CreateUnitOfMeasureData {
|
||||||
|
name: string;
|
||||||
|
abbreviation: string;
|
||||||
|
code_sat: number | null;
|
||||||
|
is_active: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUnitOfMeasureData {
|
||||||
|
name?: string;
|
||||||
|
abbreviation?: string;
|
||||||
|
code_sat?: number | null;
|
||||||
|
is_active?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface para los links de paginación
|
||||||
|
export interface PaginationLink {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface genérica para respuestas paginadas
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
current_page: number;
|
||||||
|
data: T[];
|
||||||
|
first_page_url: string;
|
||||||
|
from: number;
|
||||||
|
last_page: number;
|
||||||
|
last_page_url: string;
|
||||||
|
links: PaginationLink[];
|
||||||
|
next_page_url: string | null;
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
prev_page_url: string | null;
|
||||||
|
to: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface genérica para respuestas sin paginación
|
||||||
|
export interface UnpaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipo específico para la respuesta paginada de Unidades de Medida
|
||||||
|
export type UnitOfMeasurePaginatedResponse = PaginatedResponse<UnitOfMeasure>;
|
||||||
|
|
||||||
|
// Tipo específico para la respuesta no paginada de Unidades de Medida
|
||||||
|
export type UnitOfMeasureUnpaginatedResponse = UnpaginatedResponse<UnitOfMeasure>;
|
||||||
|
|
||||||
|
export type UnitOfMeasureResponseById = {
|
||||||
|
data: UnitOfMeasure;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SingleUnitOfMeasureResponse {
|
||||||
|
message: string;
|
||||||
|
data: UnitOfMeasure;
|
||||||
|
}
|
||||||
75
src/modules/catalog/types/unitOfMeasure.d.ts
vendored
75
src/modules/catalog/types/unitOfMeasure.d.ts
vendored
@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -5,8 +5,10 @@ import InputText from 'primevue/inputtext';
|
|||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import FileUpload from 'primevue/fileupload';
|
import FileUpload from 'primevue/fileupload';
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
import { useComercialClassificationStore } from '../../catalog/stores/comercialClassificationStore';
|
import { useComercialClassificationStore } from '../../catalog/stores/comercialClassificationStore';
|
||||||
import { useUnitOfMeasureStore } from '../../catalog/stores/unitOfMeasureStore';
|
import { useUnitOfMeasureStore } from '../../catalog/stores/unitOfMeasureStore';
|
||||||
|
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';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
@ -39,14 +41,22 @@ const formData = ref<CreateProductData>({
|
|||||||
description: '',
|
description: '',
|
||||||
unit_of_measure_id: 0,
|
unit_of_measure_id: 0,
|
||||||
suggested_sale_price: 0,
|
suggested_sale_price: 0,
|
||||||
attributes: {},
|
attributes: null,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
is_serial: false,
|
||||||
|
sat_code_product_id: null,
|
||||||
classifications: []
|
classifications: []
|
||||||
});
|
});
|
||||||
|
|
||||||
// Temporary string for price input
|
// Temporary string for price input
|
||||||
const priceInput = ref<string>('0');
|
const priceInput = ref<string>('0');
|
||||||
|
|
||||||
|
// SAT Code Products
|
||||||
|
const satCodeProducts = ref<SatCodeProduct[]>([]);
|
||||||
|
const loadingSatCodeProducts = ref(false);
|
||||||
|
const searchSatQuery = ref('');
|
||||||
|
let searchSatTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
// Attributes management
|
// Attributes management
|
||||||
interface AttributeValue {
|
interface AttributeValue {
|
||||||
id: string;
|
id: string;
|
||||||
@ -131,6 +141,67 @@ const toggleClassification = (classificationId: number) => {
|
|||||||
|
|
||||||
const loadingClassifications = computed(() => clasificationsStore.loading);
|
const loadingClassifications = computed(() => clasificationsStore.loading);
|
||||||
|
|
||||||
|
// Options para el dropdown de SAT
|
||||||
|
const satCodeProductOptions = computed(() =>
|
||||||
|
satCodeProducts.value.map(product => ({
|
||||||
|
label: `${product.code} - ${product.description}`,
|
||||||
|
value: product.id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const satEmptyMessage = computed(() => {
|
||||||
|
if (props.isEditing && satCodeProducts.value.length === 1) {
|
||||||
|
return 'Escribe para buscar otro código SAT...';
|
||||||
|
}
|
||||||
|
return 'No se encontraron códigos. Escribe para buscar.';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load SAT Code Products
|
||||||
|
const loadSatCodeProducts = async (search: string) => {
|
||||||
|
// Solo buscar si hay texto
|
||||||
|
if (!search || search.trim().length === 0) {
|
||||||
|
// Si estamos editando y hay un código actual, mantenerlo
|
||||||
|
if (props.product?.sat_code_product) {
|
||||||
|
satCodeProducts.value = [props.product.sat_code_product];
|
||||||
|
} else {
|
||||||
|
satCodeProducts.value = [];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loadingSatCodeProducts.value = true;
|
||||||
|
const response = await satCodeProductsService.getSatCodeProducts(search);
|
||||||
|
satCodeProducts.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading SAT code products:', error);
|
||||||
|
// Si hay error y estamos editando, mantener el código actual
|
||||||
|
if (props.product?.sat_code_product) {
|
||||||
|
satCodeProducts.value = [props.product.sat_code_product];
|
||||||
|
} else {
|
||||||
|
satCodeProducts.value = [];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingSatCodeProducts.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced search for SAT codes
|
||||||
|
const handleSatSearchChange = (event: any) => {
|
||||||
|
const query = event.value || '';
|
||||||
|
searchSatQuery.value = query;
|
||||||
|
|
||||||
|
// Limpiar timeout anterior
|
||||||
|
if (searchSatTimeout) {
|
||||||
|
clearTimeout(searchSatTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar debounce de 500ms
|
||||||
|
searchSatTimeout = setTimeout(() => {
|
||||||
|
loadSatCodeProducts(query);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
// Load classifications on mount
|
// Load classifications on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
console.log('🔄 ProductForm mounted, loading classifications and units of measure...');
|
console.log('🔄 ProductForm mounted, loading classifications and units of measure...');
|
||||||
@ -159,9 +230,16 @@ watch(() => props.product, (newProduct) => {
|
|||||||
suggested_sale_price: newProduct.suggested_sale_price,
|
suggested_sale_price: newProduct.suggested_sale_price,
|
||||||
attributes: newProduct.attributes || {},
|
attributes: newProduct.attributes || {},
|
||||||
is_active: newProduct.is_active,
|
is_active: newProduct.is_active,
|
||||||
|
is_serial: newProduct.is_serial || false,
|
||||||
|
sat_code_product_id: newProduct.sat_code_product_id || null,
|
||||||
classifications: newProduct.classifications?.map(c => c.id) || []
|
classifications: newProduct.classifications?.map(c => c.id) || []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Precargar el código SAT actual en el dropdown
|
||||||
|
if (newProduct.sat_code_product) {
|
||||||
|
satCodeProducts.value = [newProduct.sat_code_product];
|
||||||
|
}
|
||||||
|
|
||||||
priceInput.value = newProduct.suggested_sale_price.toString();
|
priceInput.value = newProduct.suggested_sale_price.toString();
|
||||||
|
|
||||||
// Convert attributes object to array format
|
// Convert attributes object to array format
|
||||||
@ -349,6 +427,48 @@ const onUpload = (event: any) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- SAT Code Product -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="sat-code-product" class="block text-sm font-medium mb-2">
|
||||||
|
Código SAT de Producto
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
id="sat-code-product"
|
||||||
|
v-model="formData.sat_code_product_id"
|
||||||
|
:options="satCodeProductOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
:loading="loadingSatCodeProducts"
|
||||||
|
placeholder="Escribe para buscar un código SAT..."
|
||||||
|
class="w-full"
|
||||||
|
:filter="true"
|
||||||
|
filterPlaceholder="Buscar código SAT"
|
||||||
|
:showClear="true"
|
||||||
|
@filter="handleSatSearchChange"
|
||||||
|
:emptyFilterMessage="satEmptyMessage"
|
||||||
|
/>
|
||||||
|
<small class="text-surface-500 dark:text-surface-400">
|
||||||
|
Código del catálogo SAT para facturación
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Is Serial -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="flex items-center space-x-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="formData.is_serial"
|
||||||
|
class="w-5 h-5 rounded border-surface-300 dark:border-surface-600 text-primary focus:ring-primary/50"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
Producto requiere número de serie
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-surface-500 mt-1 ml-8">
|
||||||
|
Marca esta opción si cada unidad del producto debe tener un número de serie único
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<label for="description" class="block text-sm font-medium mb-2">
|
<label for="description" class="block text-sm font-medium mb-2">
|
||||||
@ -369,7 +489,7 @@ const onUpload = (event: any) => {
|
|||||||
<!-- Product Attributes Card -->
|
<!-- Product Attributes Card -->
|
||||||
<Card>
|
<Card>
|
||||||
<template #title>
|
<template #title>
|
||||||
<h2 class="text-lg font-bold">Atributos del Producto</h2>
|
<h2 class="text-lg font-bold">Características del Producto</h2>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@ -381,7 +501,7 @@ const onUpload = (event: any) => {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<label class="block text-sm font-medium">
|
<label class="block text-sm font-medium">
|
||||||
Nombre del Atributo
|
Nombre de la característica
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
@ -399,7 +519,7 @@ const onUpload = (event: any) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<label class="block text-sm font-medium mb-2">
|
<label class="block text-sm font-medium mb-2">
|
||||||
Valores del Atributo
|
Valores de la característica
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Attribute values -->
|
<!-- Attribute values -->
|
||||||
@ -434,7 +554,7 @@ const onUpload = (event: any) => {
|
|||||||
|
|
||||||
<!-- Add attribute button -->
|
<!-- Add attribute button -->
|
||||||
<Button
|
<Button
|
||||||
label="Agregar Otro Atributo"
|
label="Agregar Otra Característica"
|
||||||
icon="pi pi-plus"
|
icon="pi pi-plus"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
|
|||||||
@ -282,6 +282,16 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
|
<!-- Is Serial -->
|
||||||
|
<Column field="is_serial" header="N° Serie" sortable style="min-width: 100px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<Tag
|
||||||
|
:value="slotProps.data.is_serial ? 'Sí' : 'No'"
|
||||||
|
:severity="slotProps.data.is_serial ? 'info' : 'secondary'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<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">
|
||||||
|
|||||||
13
src/modules/products/types/product.d.ts
vendored
13
src/modules/products/types/product.d.ts
vendored
@ -2,6 +2,8 @@
|
|||||||
* Product Type Definitions
|
* Product Type Definitions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { SatCodeProduct } from '../../catalog/services/sat-code-products.services';
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number;
|
id: number;
|
||||||
code: string;
|
code: string;
|
||||||
@ -13,6 +15,9 @@ export interface Product {
|
|||||||
suggested_sale_price: number;
|
suggested_sale_price: number;
|
||||||
attributes?: Record<string, string[]>;
|
attributes?: Record<string, string[]>;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
is_serial: boolean;
|
||||||
|
sat_code_product_id?: number | null;
|
||||||
|
sat_code_product?: SatCodeProduct;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
@ -28,8 +33,10 @@ export interface CreateProductData {
|
|||||||
description?: string;
|
description?: string;
|
||||||
unit_of_measure_id: number;
|
unit_of_measure_id: number;
|
||||||
suggested_sale_price: number;
|
suggested_sale_price: number;
|
||||||
attributes?: Record<string, string[]>;
|
attributes?: Record<string, string[]> | null;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
is_serial?: boolean;
|
||||||
|
sat_code_product_id?: number | null;
|
||||||
classifications?: number[];
|
classifications?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,8 +48,10 @@ export interface UpdateProductData {
|
|||||||
description?: string;
|
description?: string;
|
||||||
unit_of_measure_id?: number;
|
unit_of_measure_id?: number;
|
||||||
suggested_sale_price?: number;
|
suggested_sale_price?: number;
|
||||||
attributes?: Record<string, string[]>;
|
attributes?: Record<string, string[]> | null;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
is_serial?: boolean;
|
||||||
|
sat_code_product_id?: number | null;
|
||||||
classifications?: number[];
|
classifications?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
src/modules/purchases/types/purchases.d.ts
vendored
2
src/modules/purchases/types/purchases.d.ts
vendored
@ -1,4 +1,4 @@
|
|||||||
import type { Supplier } from '../../catalog/types/suppliers';
|
import type { Supplier } from '../../catalog/types/suppliers.interfaces';
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
760
src/modules/requisitions/CreateRequisition.vue
Normal file
760
src/modules/requisitions/CreateRequisition.vue
Normal file
@ -0,0 +1,760 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import Card from 'primevue/card';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Toast from 'primevue/toast';
|
||||||
|
import { useRequisitionStore } from './stores/requisitionStore';
|
||||||
|
import type { RequisitionItem } from './types/requisition.interfaces';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const toast = useToast();
|
||||||
|
const requisitionStore = useRequisitionStore();
|
||||||
|
|
||||||
|
const isEditMode = ref(false);
|
||||||
|
const requisitionId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
folio: '',
|
||||||
|
requester: 'Edgar Mendoza',
|
||||||
|
status: 'Borrador',
|
||||||
|
priority: 'medium',
|
||||||
|
department: '',
|
||||||
|
justification: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = ref<RequisitionItem[]>([]);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
|
||||||
|
const priorityOptions = [
|
||||||
|
{ label: 'Baja', value: 'low' },
|
||||||
|
{ label: 'Media', value: 'medium' },
|
||||||
|
{ label: 'Alta', value: 'high' },
|
||||||
|
{ label: 'Urgente', value: 'urgent' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const departmentOptions = [
|
||||||
|
{ label: 'Producción - Línea A', value: 'prod_a' },
|
||||||
|
{ label: 'Mantenimiento Industrial', value: 'maintenance' },
|
||||||
|
{ label: 'Logística y Almacén', value: 'logistics' },
|
||||||
|
{ label: 'Administración', value: 'admin' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const urlDialogVisible = ref(false);
|
||||||
|
const selectedItemIndex = ref<number | null>(null);
|
||||||
|
const tempUrl = ref('');
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
items.value.push({
|
||||||
|
id: items.value.length + 1,
|
||||||
|
product: '',
|
||||||
|
quantity: 0,
|
||||||
|
unit: '',
|
||||||
|
unitPrice: 0,
|
||||||
|
url: ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openUrlDialog = (index: number) => {
|
||||||
|
selectedItemIndex.value = index;
|
||||||
|
const item = items.value[index];
|
||||||
|
if (item) {
|
||||||
|
tempUrl.value = item.url || '';
|
||||||
|
}
|
||||||
|
urlDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveUrl = () => {
|
||||||
|
if (selectedItemIndex.value !== null) {
|
||||||
|
const item = items.value[selectedItemIndex.value];
|
||||||
|
if (item) {
|
||||||
|
item.url = tempUrl.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
urlDialogVisible.value = false;
|
||||||
|
tempUrl.value = '';
|
||||||
|
selectedItemIndex.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeUrlDialog = () => {
|
||||||
|
urlDialogVisible.value = false;
|
||||||
|
tempUrl.value = '';
|
||||||
|
selectedItemIndex.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
console.log('URL copiado al portapapeles');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error al copiar:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateSubtotal = (item: RequisitionItem): number => {
|
||||||
|
return item.quantity * item.unitPrice;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotal = (): number => {
|
||||||
|
return items.value.reduce((total: number, item: RequisitionItem) => total + calculateSubtotal(item), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN'
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (index: number) => {
|
||||||
|
items.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* const removeFile = (index: number) => {
|
||||||
|
uploadedFiles.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileUpload = (event: any) => {
|
||||||
|
const files = event.files;
|
||||||
|
files.forEach((file: File) => {
|
||||||
|
uploadedFiles.value.push({
|
||||||
|
name: file.name,
|
||||||
|
size: (file.size / 1024 / 1024).toFixed(2) + ' MB',
|
||||||
|
type: file.type.includes('image') ? 'image' : 'pdf'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}; */
|
||||||
|
|
||||||
|
const loadRequisition = async () => {
|
||||||
|
const id = route.params.id;
|
||||||
|
if (id && typeof id === 'string') {
|
||||||
|
isEditMode.value = true;
|
||||||
|
requisitionId.value = parseInt(id);
|
||||||
|
|
||||||
|
const requisition = requisitionStore.getRequisitionById(requisitionId.value);
|
||||||
|
if (requisition) {
|
||||||
|
form.value = {
|
||||||
|
folio: requisition.folio,
|
||||||
|
requester: requisition.requester,
|
||||||
|
status: requisition.status === 'draft' ? 'Borrador' : requisition.status,
|
||||||
|
priority: requisition.priority,
|
||||||
|
department: requisition.department,
|
||||||
|
justification: requisition.justification
|
||||||
|
};
|
||||||
|
items.value = [...requisition.items];
|
||||||
|
} else {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'Requisición no encontrada',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
router.push('/requisitions');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Modo creación - generar nuevo folio
|
||||||
|
const nextNumber = requisitionStore.requisitions.length + 1;
|
||||||
|
form.value.folio = `REQ-2024-${String(nextNumber).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
if (!form.value.priority) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Validación',
|
||||||
|
detail: 'La prioridad es requerida',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.department) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Validación',
|
||||||
|
detail: 'El departamento es requerido',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.value.length === 0) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Validación',
|
||||||
|
detail: 'Debe agregar al menos un item',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInvalidItems = items.value.some((item: RequisitionItem) =>
|
||||||
|
!item.product || item.quantity <= 0 || !item.unit || item.unitPrice <= 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasInvalidItems) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Validación',
|
||||||
|
detail: 'Todos los items deben tener producto, cantidad, unidad y precio válidos',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.push('/requisitions');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDraft = async () => {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
if (isEditMode.value && requisitionId.value) {
|
||||||
|
await requisitionStore.updateRequisition(requisitionId.value, form.value, items.value);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Guardado',
|
||||||
|
detail: 'Requisición actualizada correctamente',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await requisitionStore.createRequisition(form.value, items.value);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Guardado',
|
||||||
|
detail: 'Requisición guardada como borrador',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
router.push('/requisitions');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message || 'Error al guardar la requisición',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
let reqId: number;
|
||||||
|
|
||||||
|
if (isEditMode.value && requisitionId.value) {
|
||||||
|
await requisitionStore.updateRequisition(requisitionId.value, form.value, items.value);
|
||||||
|
reqId = requisitionId.value;
|
||||||
|
} else {
|
||||||
|
const newReq = await requisitionStore.createRequisition(form.value, items.value);
|
||||||
|
reqId = newReq.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enviar a aprobación
|
||||||
|
await requisitionStore.submitForApproval(reqId);
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Enviado',
|
||||||
|
detail: 'Requisición enviada a aprobación correctamente',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
router.push('/requisitions');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message || 'Error al enviar la requisición',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRequisition();
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalItems = () => {
|
||||||
|
return items.value.filter((item: RequisitionItem) => item.quantity > 0).length;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="space-y-6 py-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button icon="pi pi-arrow-left" text rounded @click="$router.back()" class="shrink-0" size="small" />
|
||||||
|
<h2 class="text-xl md:text-2xl lg:text-3xl font-black text-surface-900 dark:text-white tracking-tight">
|
||||||
|
Crear Requisición de Material
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<Button icon="pi pi-print" label="Imprimir" text size="small" />
|
||||||
|
<Button icon="pi pi-share-alt" label="Compartir" text size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Información General -->
|
||||||
|
<Card>
|
||||||
|
<template #header>
|
||||||
|
<div class="px-4 md:px-6 py-3 border-b border-surface-200 dark:border-surface-700 flex flex-col md:flex-row md:items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-info-circle text-primary text-lg"></i>
|
||||||
|
<h3 class="font-bold text-base">Información General</h3>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500">Campos obligatorios marcados con *</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Folio
|
||||||
|
</label>
|
||||||
|
<InputText v-model="form.folio" class="w-full bg-gray-50 dark:bg-gray-900" size="small" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Solicitante
|
||||||
|
</label>
|
||||||
|
<InputText v-model="form.requester" readonly class="w-full bg-gray-50 dark:bg-gray-900" size="small" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Estatus
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-blue-400"></span>
|
||||||
|
<InputText v-model="form.status" readonly class="w-full bg-gray-50 dark:bg-gray-900 pl-8" size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Prioridad <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.priority"
|
||||||
|
:options="priorityOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2 lg:col-span-4">
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Departamento / Centro de Costos
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.department"
|
||||||
|
:options="departmentOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="Seleccionar departamento"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2 lg:col-span-4">
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Justificación de la Requisición
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
v-model="form.justification"
|
||||||
|
placeholder="Describe el motivo y la necesidad de esta requisición..."
|
||||||
|
rows="3"
|
||||||
|
autoResize
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Items de la Requisición -->
|
||||||
|
<Card>
|
||||||
|
<template #header>
|
||||||
|
<div class="px-4 md:px-6 py-3 border-b border-surface-200 dark:border-surface-700 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-list text-primary text-lg"></i>
|
||||||
|
<h3 class="font-bold text-base">Items de la Requisición</h3>
|
||||||
|
</div>
|
||||||
|
<Button label="Agregar" icon="pi pi-plus-circle" @click="addItem" size="small" class="w-full sm:w-auto" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<!-- Mobile View - Cards -->
|
||||||
|
<div class="lg:hidden space-y-4">
|
||||||
|
<div v-for="(item, index) in items" :key="item.id"
|
||||||
|
class="border border-gray-200 dark:border-gray-800 rounded-lg p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between border-b pb-2 mb-2">
|
||||||
|
<span class="text-sm font-bold text-gray-500">
|
||||||
|
Item #{{ String(index + 1).padStart(2, '0') }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
severity="danger"
|
||||||
|
size="small"
|
||||||
|
@click="removeItem(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 mb-1">Producto / Servicio</label>
|
||||||
|
<InputText
|
||||||
|
v-model="item.product"
|
||||||
|
placeholder="Nombre del producto o servicio..."
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-900"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 mb-1">Cantidad</label>
|
||||||
|
<InputNumber v-model="item.quantity" :min="0" class="w-full" size="small" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 mb-1">Unidad</label>
|
||||||
|
<InputText
|
||||||
|
v-model="item.unit"
|
||||||
|
placeholder="Pz, Kg, Lt, etc."
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-900"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-bold text-gray-500 mb-1">Precio Unitario</label>
|
||||||
|
<InputNumber
|
||||||
|
v-model="item.unitPrice"
|
||||||
|
:min="0"
|
||||||
|
:minFractionDigits="2"
|
||||||
|
:maxFractionDigits="2"
|
||||||
|
mode="currency"
|
||||||
|
currency="MXN"
|
||||||
|
locale="es-MX"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<Button
|
||||||
|
:label="item.url ? 'Editar URL' : 'Agregar URL'"
|
||||||
|
:icon="item.url ? 'pi pi-pencil' : 'pi pi-link'"
|
||||||
|
size="small"
|
||||||
|
outlined
|
||||||
|
class="w-full"
|
||||||
|
@click="openUrlDialog(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-bold text-gray-500 uppercase">Subtotal</span>
|
||||||
|
<span class="text-lg font-bold text-primary">{{ formatCurrency(calculateSubtotal(item)) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop View - DataTable -->
|
||||||
|
<div class="hidden lg:block">
|
||||||
|
<DataTable :value="items" class="text-sm" stripedRows>
|
||||||
|
<Column field="id" header="#" class="text-center" style="width: 60px">
|
||||||
|
<template #body="{ index }">
|
||||||
|
<span class="text-gray-400 font-medium text-xs">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Cantidad" style="width: 130px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<InputNumber v-model="data.quantity" :min="0" class="w-full" size="small" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Unidad" style="width: 120px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<InputText
|
||||||
|
v-model="data.unit"
|
||||||
|
placeholder="Pz, Kg, Lt..."
|
||||||
|
class="w-full text-sm"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Producto / Servicio" style="min-width: 300px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<InputText
|
||||||
|
v-model="data.product"
|
||||||
|
placeholder="Nombre del producto o servicio..."
|
||||||
|
class="w-full border-none p-0 text-sm"
|
||||||
|
unstyled
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Precio Unitario" style="width: 180px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<InputNumber
|
||||||
|
v-model="data.unitPrice"
|
||||||
|
:min="0"
|
||||||
|
:minFractionDigits="2"
|
||||||
|
:maxFractionDigits="2"
|
||||||
|
mode="currency"
|
||||||
|
currency="MXN"
|
||||||
|
locale="es-MX"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Subtotal" headerClass="text-right" style="width: 180px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="font-semibold text-gray-700 dark:text-gray-300">{{ formatCurrency(calculateSubtotal(data)) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="URL" style="width: 120px" class="text-center">
|
||||||
|
<template #body="{ data, index }">
|
||||||
|
<Button
|
||||||
|
:icon="data.url ? 'pi pi-check-circle' : 'pi pi-link'"
|
||||||
|
:severity="data.url ? 'success' : 'secondary'"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
@click="openUrlDialog(index)"
|
||||||
|
v-tooltip.top="data.url ? 'Editar URL' : 'Agregar URL'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Acciones" class="text-center" style="width: 80px">
|
||||||
|
<template #body="{ index }">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
severity="danger"
|
||||||
|
size="small"
|
||||||
|
@click="removeItem(index)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 p-4 bg-gray-50 dark:bg-gray-800/30 flex flex-col sm:flex-row items-end justify-between gap-3">
|
||||||
|
<div class="text-left">
|
||||||
|
<p class="text-xs text-gray-500 uppercase font-semibold">Total Items</p>
|
||||||
|
<p class="text-lg font-bold">{{ totalItems() }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-gray-500 uppercase font-semibold mb-1">Total General</p>
|
||||||
|
<p class="text-2xl font-black text-primary">{{ formatCurrency(calculateTotal()) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Documentos y Fotos -->
|
||||||
|
<!-- <Card>
|
||||||
|
<template #header>
|
||||||
|
<div class="px-4 md:px-6 py-3 border-b border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-paperclip text-primary text-lg"></i>
|
||||||
|
<h3 class="font-bold text-base">Documentos y Fotos</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<FileUpload
|
||||||
|
mode="basic"
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
:maxFileSize="5000000"
|
||||||
|
chooseLabel="Seleccionar archivos"
|
||||||
|
@select="onFileUpload"
|
||||||
|
:auto="true"
|
||||||
|
customUpload
|
||||||
|
>
|
||||||
|
<template #empty>
|
||||||
|
<div class="border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-xl p-6 flex flex-col items-center justify-center text-center hover:border-primary hover:bg-primary/5 transition-all cursor-pointer">
|
||||||
|
<div class="bg-primary/10 w-12 h-12 rounded-full flex items-center justify-center mb-3">
|
||||||
|
<i class="pi pi-cloud-upload text-primary text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<p class="font-semibold text-gray-700 dark:text-gray-300 text-sm">Suelte archivos aquí</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
|
O haga clic para explorar. JPG, PNG, PDF (Máx. 5MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUpload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Archivos Subidos</p>
|
||||||
|
<div v-for="(file, index) in uploadedFiles" :key="index"
|
||||||
|
class="flex items-center justify-between p-2.5 border border-gray-200 dark:border-gray-800 rounded-lg bg-white dark:bg-gray-900 shadow-sm">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
|
<div class="p-1.5 rounded shrink-0" :class="file.type === 'image' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600' : 'bg-red-50 dark:bg-red-900/20 text-red-600'">
|
||||||
|
<i :class="file.type === 'image' ? 'pi pi-image' : 'pi pi-file-pdf'" class="text-base"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium truncate">{{ file.name }}</p>
|
||||||
|
<p class="text-[10px] text-gray-400 uppercase font-bold">{{ file.size }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
severity="danger"
|
||||||
|
size="small"
|
||||||
|
@click="removeFile(index)"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card> -->
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 pt-4 border-t border-gray-200 dark:border-gray-800 pb-8">
|
||||||
|
<Button
|
||||||
|
label="Cancelar Registro"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
@click="handleCancel"
|
||||||
|
class="w-full sm:w-auto order-3 sm:order-1"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2.5 order-1 sm:order-2">
|
||||||
|
<Button
|
||||||
|
label="Guardar como Borrador"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
:loading="isSaving"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="handleSaveDraft"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Enviar a Aprobación"
|
||||||
|
icon="pi pi-send"
|
||||||
|
size="small"
|
||||||
|
:loading="isSaving"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="handleSubmit"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="urlDialogVisible"
|
||||||
|
modal
|
||||||
|
:header="selectedItemIndex !== null && items[selectedItemIndex]?.url ? 'Editar URL del Producto' : 'Agregar URL del Producto'"
|
||||||
|
:style="{ width: '500px' }"
|
||||||
|
:dismissableMask="true"
|
||||||
|
>
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<i class="pi pi-link mr-2 text-primary"></i>
|
||||||
|
URL del producto
|
||||||
|
</label>
|
||||||
|
<InputText
|
||||||
|
v-model="tempUrl"
|
||||||
|
placeholder="https://ejemplo.com/producto"
|
||||||
|
class="w-full"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<small class="text-gray-500 mt-1 block">
|
||||||
|
Puede ser un enlace de Amazon, MercadoLibre, página del fabricante, etc.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tempUrl" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<i class="pi pi-info-circle text-blue-600 mt-0.5"></i>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs font-semibold text-blue-900 dark:text-blue-100 mb-1">Vista previa:</p>
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-300 break-all">{{ tempUrl }}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-copy"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
@click="copyToClipboard(tempUrl)"
|
||||||
|
v-tooltip.top="'Copiar'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="closeUrlDialog"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Guardar"
|
||||||
|
icon="pi pi-check"
|
||||||
|
@click="saveUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Toast />
|
||||||
|
</template>
|
||||||
|
|
||||||
526
src/modules/requisitions/Requisitions.vue
Normal file
526
src/modules/requisitions/Requisitions.vue
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import Card from 'primevue/card';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import Paginator from 'primevue/paginator';
|
||||||
|
import Toast from 'primevue/toast';
|
||||||
|
import ConfirmDialog from 'primevue/confirmdialog';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import { useRequisitionStore } from './stores/requisitionStore';
|
||||||
|
import type { Requisition } from './types/requisition.interfaces';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const toast = useToast();
|
||||||
|
const requisitionStore = useRequisitionStore();
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const selectedStatus = ref<string | null>(null);
|
||||||
|
const selectedPriority = ref<string | null>(null);
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
first: 0,
|
||||||
|
rows: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel dialog
|
||||||
|
const showCancelDialog = ref(false);
|
||||||
|
const cancelComment = ref('');
|
||||||
|
const requisitionToCancel = ref<Requisition | null>(null);
|
||||||
|
|
||||||
|
// Computed filtered requisitions
|
||||||
|
const filteredRequisitions = computed(() => {
|
||||||
|
let filtered = [...requisitionStore.requisitions];
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
filtered = filtered.filter(r =>
|
||||||
|
r.folio.toLowerCase().includes(query) ||
|
||||||
|
r.requester.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedStatus.value) {
|
||||||
|
filtered = filtered.filter(r => r.status === selectedStatus.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPriority.value) {
|
||||||
|
filtered = filtered.filter(r => r.priority === selectedPriority.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedRequisitions = computed(() => {
|
||||||
|
const start = pagination.value.first;
|
||||||
|
const end = start + pagination.value.rows;
|
||||||
|
return filteredRequisitions.value.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRecords = computed(() => filteredRequisitions.value.length);
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'Todos', value: null },
|
||||||
|
{ label: 'Borrador', value: 'draft' },
|
||||||
|
{ label: 'Pendiente', value: 'pending' },
|
||||||
|
{ label: 'Aprobado', value: 'approved' },
|
||||||
|
{ label: 'Rechazado', value: 'rejected' },
|
||||||
|
{ label: 'Cancelado', value: 'cancelled' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const priorityOptions = [
|
||||||
|
{ label: 'Todas', value: null },
|
||||||
|
{ label: 'Baja', value: 'low' },
|
||||||
|
{ label: 'Normal', value: 'normal' },
|
||||||
|
{ label: 'Alta', value: 'high' },
|
||||||
|
{ label: 'Urgente', value: 'urgent' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStatusSeverity = (status: string) => {
|
||||||
|
const severityMap: Record<string, string> = {
|
||||||
|
pending: 'warning',
|
||||||
|
approved: 'success',
|
||||||
|
draft: 'secondary',
|
||||||
|
rejected: 'danger',
|
||||||
|
cancelled: 'contrast'
|
||||||
|
};
|
||||||
|
return severityMap[status] || 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
pending: 'Pendiente',
|
||||||
|
approved: 'Aprobado',
|
||||||
|
draft: 'Borrador',
|
||||||
|
rejected: 'Rechazado',
|
||||||
|
cancelled: 'Cancelado'
|
||||||
|
};
|
||||||
|
return labelMap[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
low: 'bg-slate-300 dark:bg-slate-600',
|
||||||
|
normal: 'bg-blue-500',
|
||||||
|
high: 'bg-red-500',
|
||||||
|
urgent: 'bg-red-600'
|
||||||
|
};
|
||||||
|
return colorMap[priority] || 'bg-gray-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityLabel = (priority: string) => {
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
low: 'Baja',
|
||||||
|
normal: 'Normal',
|
||||||
|
high: 'Alta',
|
||||||
|
urgent: 'Urgente'
|
||||||
|
};
|
||||||
|
return labelMap[priority] || priority;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageChange = (event: any) => {
|
||||||
|
pagination.value.first = event.first;
|
||||||
|
pagination.value.rows = event.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (requisition: Requisition) => {
|
||||||
|
router.push(`/requisitions/${requisition.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (requisition: Requisition) => {
|
||||||
|
if (requisition.status === 'draft' || requisition.status === 'pending') {
|
||||||
|
router.push(`/requisitions/edit/${requisition.id}`);
|
||||||
|
} else {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'No permitido',
|
||||||
|
detail: 'Solo se pueden editar requisiciones en borrador o pendientes',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (requisition: Requisition) => {
|
||||||
|
requisitionToCancel.value = requisition;
|
||||||
|
cancelComment.value = '';
|
||||||
|
showCancelDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCancel = async () => {
|
||||||
|
if (!requisitionToCancel.value) return;
|
||||||
|
|
||||||
|
if (!cancelComment.value.trim() || cancelComment.value.trim().length < 10) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Campo requerido',
|
||||||
|
detail: 'Debe proporcionar un motivo válido (mínimo 10 caracteres)',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Usar el método cancelRequisition del store en lugar de deleteRequisition
|
||||||
|
await requisitionStore.cancelRequisition(
|
||||||
|
requisitionToCancel.value.id,
|
||||||
|
cancelComment.value.trim(),
|
||||||
|
requisitionToCancel.value.requester // O el usuario actual del sistema
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Cancelado',
|
||||||
|
detail: 'Requisición cancelada correctamente',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
showCancelDialog.value = false;
|
||||||
|
requisitionToCancel.value = null;
|
||||||
|
cancelComment.value = '';
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message || 'Error al cancelar la requisición',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCancelDialog = () => {
|
||||||
|
showCancelDialog.value = false;
|
||||||
|
requisitionToCancel.value = null;
|
||||||
|
cancelComment.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewRequisition = () => {
|
||||||
|
router.push('/requisitions/create');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await requisitionStore.fetchRequisitions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">
|
||||||
|
Gestión de Requisiciones
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||||
|
Revisa y administra las requisiciones de almacén y producción.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button label="Nueva Requisición" icon="pi pi-plus" @click="handleNewRequisition" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI Cards -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Pendientes</p>
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<h3 class="text-2xl font-bold">{{ requisitionStore.pendingCount }}</h3>
|
||||||
|
<Tag value="Acción requerida" severity="warning" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Aprobados Hoy</p>
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<h3 class="text-2xl font-bold">{{ requisitionStore.approvedTodayCount }}</h3>
|
||||||
|
<Tag value="Actualizado" severity="success" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Total Requisiciones</p>
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<h3 class="text-2xl font-bold">{{ requisitionStore.requisitions.length }}</h3>
|
||||||
|
<Tag value="Sistema" severity="info" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Presupuesto Mensual</p>
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<h3 class="text-2xl font-bold">${{ (requisitionStore.totalBudgetThisMonth / 1000).toFixed(1) }}k</h3>
|
||||||
|
<Tag value="Este mes" severity="secondary" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters and Table -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<!-- Filters Toolbar -->
|
||||||
|
<div class="flex flex-wrap gap-4 items-end mb-6">
|
||||||
|
<div class="flex-1 min-w-[300px]">
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Buscar
|
||||||
|
</label>
|
||||||
|
<span class="p-input-icon-left w-full">
|
||||||
|
<i class="pi pi-search" />
|
||||||
|
<InputText
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Buscar por número de folio (ej. REQ-2023...)"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-48">
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Estado
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedStatus"
|
||||||
|
:options="statusOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="Todos"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-48">
|
||||||
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
|
Prioridad
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedPriority"
|
||||||
|
:options="priorityOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="Todas"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button icon="pi pi-filter" label="Más Filtros" text />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DataTable -->
|
||||||
|
<DataTable
|
||||||
|
:value="paginatedRequisitions"
|
||||||
|
:loading="requisitionStore.loading"
|
||||||
|
stripedRows
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
class="p-datatable-sm"
|
||||||
|
>
|
||||||
|
<Column field="id" header="ID" style="min-width: 80px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-mono text-gray-500">{{ data.id }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="folio" header="Folio" style="min-width: 150px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-bold">{{ data.folio }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="requester" header="Solicitante" style="min-width: 150px" />
|
||||||
|
|
||||||
|
<Column field="status" header="Estado" style="min-width: 120px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="priority" header="Prioridad" style="min-width: 120px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span :class="['w-2 h-2 rounded-full', getPriorityColor(data.priority)]"></span>
|
||||||
|
{{ getPriorityLabel(data.priority) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="totalAmount" header="Monto Total" style="min-width: 140px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold">${{ data.totalAmount.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="createdAt" header="Fecha de Creación" style="min-width: 140px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-sm">{{ new Date(data.createdAt).toLocaleDateString('es-MX') }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Acciones" headerStyle="text-align: center" bodyStyle="text-align: center" style="min-width: 150px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-eye"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
@click="handleView(data)"
|
||||||
|
v-tooltip.top="'Ver Detalles'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
@click="handleEdit(data)"
|
||||||
|
v-tooltip.top="'Editar'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
@click="handleCancel(data)"
|
||||||
|
v-tooltip.top="'Cancelar'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<Paginator
|
||||||
|
:first="pagination.first"
|
||||||
|
:rows="pagination.rows"
|
||||||
|
:totalRecords="totalRecords"
|
||||||
|
:rowsPerPageOptions="[10, 20, 50]"
|
||||||
|
@page="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Help Section -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card class="bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/30">
|
||||||
|
<template #content>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<i class="pi pi-info-circle text-blue-500 text-xl"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-blue-900 dark:text-blue-300 text-sm mb-1">
|
||||||
|
¿Necesitas Ayuda?
|
||||||
|
</h4>
|
||||||
|
<p class="text-blue-700 dark:text-blue-400 text-xs leading-relaxed">
|
||||||
|
Los flujos de aprobación requieren al menos una firma de supervisor para requisiciones que excedan $500.
|
||||||
|
Consulta la sección "Ver Detalles" para la jerarquía de aprobación.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||||
|
<template #content>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<i class="pi pi-link text-slate-500 text-xl"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-slate-900 dark:text-slate-200 text-sm mb-2">
|
||||||
|
Enlaces Rápidos
|
||||||
|
</h4>
|
||||||
|
<div class="flex gap-3 text-xs">
|
||||||
|
<a href="#" class="text-primary hover:underline">Descargar Reporte</a>
|
||||||
|
<span class="text-slate-300 dark:text-slate-700">|</span>
|
||||||
|
<a href="#" class="text-primary hover:underline">Política de Aprobación</a>
|
||||||
|
<span class="text-slate-300 dark:text-slate-700">|</span>
|
||||||
|
<a href="#" class="text-primary hover:underline">Estado del Sistema</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancel Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="showCancelDialog"
|
||||||
|
modal
|
||||||
|
header="Cancelar Requisición"
|
||||||
|
:style="{ width: '500px' }"
|
||||||
|
:closable="true"
|
||||||
|
@hide="closeCancelDialog"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="requisitionToCancel" class="bg-orange-50 dark:bg-orange-900/10 border border-orange-200 dark:border-orange-900/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="pi pi-exclamation-triangle text-orange-500 text-xl mt-0.5"></i>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold text-orange-900 dark:text-orange-300 text-sm mb-1">
|
||||||
|
¿Está seguro de cancelar esta requisición?
|
||||||
|
</h4>
|
||||||
|
<p class="text-orange-700 dark:text-orange-400 text-xs">
|
||||||
|
Folio: <span class="font-bold">{{ requisitionToCancel.folio }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-orange-700 dark:text-orange-400 text-xs">
|
||||||
|
Solicitante: {{ requisitionToCancel.requester }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="cancelReason" class="block text-sm font-semibold text-surface-900 dark:text-white mb-2">
|
||||||
|
Motivo de cancelación <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="cancelReason"
|
||||||
|
v-model="cancelComment"
|
||||||
|
rows="4"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Describa el motivo por el cual se cancela esta requisición..."
|
||||||
|
:class="{ 'p-invalid': !cancelComment.trim() }"
|
||||||
|
/>
|
||||||
|
<small class="text-gray-500 dark:text-gray-400">Mínimo 10 caracteres requeridos</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
@click="closeCancelDialog"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Confirmar Cancelación"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="danger"
|
||||||
|
@click="confirmCancel"
|
||||||
|
:disabled="!cancelComment.trim() || cancelComment.trim().length < 10"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Toast />
|
||||||
|
<ConfirmDialog />
|
||||||
|
</template>
|
||||||
361
src/modules/requisitions/stores/requisitionStore.ts
Normal file
361
src/modules/requisitions/stores/requisitionStore.ts
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { Requisition, RequisitionForm, RequisitionItem } from '../types/requisition.interfaces';
|
||||||
|
|
||||||
|
export const useRequisitionStore = defineStore('requisition', () => {
|
||||||
|
const requisitions = ref<Requisition[]>([
|
||||||
|
|
||||||
|
]);
|
||||||
|
|
||||||
|
/* {
|
||||||
|
id: 1,
|
||||||
|
folio: 'REQ-2024-001',
|
||||||
|
requester: 'Edgar Mendoza',
|
||||||
|
department: 'prod_a',
|
||||||
|
status: 'draft',
|
||||||
|
priority: 'medium',
|
||||||
|
justification: 'Necesitamos equipos para la línea de producción A',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
product: 'Motor eléctrico 3HP',
|
||||||
|
quantity: 2,
|
||||||
|
unit: 'Pz',
|
||||||
|
unitPrice: 1250.00,
|
||||||
|
url: 'https://amazon.com/product/123'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
product: 'Cable calibre 12',
|
||||||
|
quantity: 50,
|
||||||
|
unit: 'M',
|
||||||
|
unitPrice: 15.50,
|
||||||
|
url: ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalAmount: 3275.00,
|
||||||
|
createdAt: '2024-02-20',
|
||||||
|
updatedAt: '2024-02-20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
folio: 'REQ-2024-002',
|
||||||
|
requester: 'María González',
|
||||||
|
department: 'maintenance',
|
||||||
|
status: 'pending',
|
||||||
|
priority: 'high',
|
||||||
|
justification: 'Mantenimiento preventivo de equipos críticos',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
product: 'Aceite hidráulico SAE 68',
|
||||||
|
quantity: 20,
|
||||||
|
unit: 'Lt',
|
||||||
|
unitPrice: 85.00,
|
||||||
|
url: 'https://mercadolibre.com/product/456'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalAmount: 1700.00,
|
||||||
|
createdAt: '2024-02-22',
|
||||||
|
updatedAt: '2024-02-22'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
folio: 'REQ-2024-003',
|
||||||
|
requester: 'Carlos Ruiz',
|
||||||
|
department: 'logistics',
|
||||||
|
status: 'approved',
|
||||||
|
priority: 'normal',
|
||||||
|
justification: 'Material de empaque para envíos',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
product: 'Caja de cartón corrugado 40x30x20',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'Pz',
|
||||||
|
unitPrice: 12.50,
|
||||||
|
url: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
product: 'Cinta adhesiva transparente',
|
||||||
|
quantity: 50,
|
||||||
|
unit: 'Pz',
|
||||||
|
unitPrice: 8.00,
|
||||||
|
url: ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalAmount: 1650.00,
|
||||||
|
createdAt: '2024-02-18',
|
||||||
|
updatedAt: '2024-02-23'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
folio: 'REQ-2024-004',
|
||||||
|
requester: 'Ana Martínez',
|
||||||
|
department: 'admin',
|
||||||
|
status: 'rejected',
|
||||||
|
priority: 'low',
|
||||||
|
justification: 'Suministros de oficina',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
product: 'Papel bond tamaño carta',
|
||||||
|
quantity: 10,
|
||||||
|
unit: 'Pq',
|
||||||
|
unitPrice: 120.00,
|
||||||
|
url: ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalAmount: 1200.00,
|
||||||
|
createdAt: '2024-02-15',
|
||||||
|
updatedAt: '2024-02-21'
|
||||||
|
} */
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
let nextId = ref(5);
|
||||||
|
let nextFolio = ref(5);
|
||||||
|
|
||||||
|
// Computed properties for statistics
|
||||||
|
const pendingCount = computed(() =>
|
||||||
|
requisitions.value.filter(r => r.status === 'pending').length
|
||||||
|
);
|
||||||
|
|
||||||
|
const approvedTodayCount = computed(() => {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
return requisitions.value.filter(
|
||||||
|
r => r.status === 'approved' && r.updatedAt === today
|
||||||
|
).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBudgetThisMonth = computed(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = now.getMonth();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
|
||||||
|
return requisitions.value
|
||||||
|
.filter(r => {
|
||||||
|
const reqDate = new Date(r.createdAt);
|
||||||
|
return reqDate.getMonth() === currentMonth &&
|
||||||
|
reqDate.getFullYear() === currentYear;
|
||||||
|
})
|
||||||
|
.reduce((sum, r) => sum + r.totalAmount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async function fetchRequisitions() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
// Simular delay de API
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
// Los datos ya están cargados en el estado inicial
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al cargar requisiciones';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequisitionById(id: number): Requisition | undefined {
|
||||||
|
return requisitions.value.find(r => r.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequisitionByFolio(folio: string): Requisition | undefined {
|
||||||
|
return requisitions.value.find(r => r.folio === folio);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRequisition(form: RequisitionForm, items: RequisitionItem[]): Promise<Requisition> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simular delay de API
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||||
|
const now = new Date().toISOString().split('T')[0] as string;
|
||||||
|
|
||||||
|
const newRequisition: Requisition = {
|
||||||
|
id: nextId.value++,
|
||||||
|
folio: `REQ-2024-${String(nextFolio.value++).padStart(3, '0')}`,
|
||||||
|
requester: form.requester,
|
||||||
|
department: form.department,
|
||||||
|
status: 'draft',
|
||||||
|
priority: form.priority,
|
||||||
|
justification: form.justification,
|
||||||
|
items: items.map((item, index) => ({ ...item, id: index + 1 })),
|
||||||
|
totalAmount,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
|
||||||
|
requisitions.value.unshift(newRequisition);
|
||||||
|
return newRequisition;
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al crear requisición';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRequisition(id: number, form: RequisitionForm, items: RequisitionItem[]): Promise<Requisition | null> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simular delay de API
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
const index = requisitions.value.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingReq = requisitions.value[index];
|
||||||
|
if (!existingReq) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||||
|
const now = new Date().toISOString().split('T')[0] as string;
|
||||||
|
|
||||||
|
const updatedRequisition: Requisition = {
|
||||||
|
...existingReq,
|
||||||
|
department: form.department,
|
||||||
|
priority: form.priority,
|
||||||
|
justification: form.justification,
|
||||||
|
items: items.map((item, idx) => ({ ...item, id: idx + 1 })),
|
||||||
|
totalAmount,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
|
||||||
|
requisitions.value[index] = updatedRequisition;
|
||||||
|
return updatedRequisition;
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al actualizar requisición';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForApproval(id: number): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const index = requisitions.value.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requisition = requisitions.value[index];
|
||||||
|
if (requisition) {
|
||||||
|
requisition.status = 'pending';
|
||||||
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al enviar requisición';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRequisition(id: number): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const index = requisitions.value.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
requisitions.value.splice(index, 1);
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al eliminar requisición';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeStatus(id: number, status: string): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const index = requisitions.value.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requisition = requisitions.value[index];
|
||||||
|
if (requisition) {
|
||||||
|
requisition.status = status;
|
||||||
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al cambiar estado';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelRequisition(id: number, reason: string, cancelledBy: string): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const index = requisitions.value.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requisition = requisitions.value[index];
|
||||||
|
if (requisition) {
|
||||||
|
requisition.status = 'cancelled';
|
||||||
|
requisition.cancellationReason = reason;
|
||||||
|
requisition.cancelledAt = new Date().toISOString();
|
||||||
|
requisition.cancelledBy = cancelledBy;
|
||||||
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al cancelar requisición';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requisitions,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
pendingCount,
|
||||||
|
approvedTodayCount,
|
||||||
|
totalBudgetThisMonth,
|
||||||
|
fetchRequisitions,
|
||||||
|
getRequisitionById,
|
||||||
|
getRequisitionByFolio,
|
||||||
|
createRequisition,
|
||||||
|
updateRequisition,
|
||||||
|
submitForApproval,
|
||||||
|
deleteRequisition,
|
||||||
|
changeStatus,
|
||||||
|
cancelRequisition
|
||||||
|
};
|
||||||
|
});
|
||||||
34
src/modules/requisitions/types/requisition.interfaces.ts
Normal file
34
src/modules/requisitions/types/requisition.interfaces.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
export interface RequisitionItem {
|
||||||
|
id: number;
|
||||||
|
product: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
unitPrice: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequisitionForm {
|
||||||
|
folio: string;
|
||||||
|
requester: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
department: string;
|
||||||
|
justification: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Requisition extends RequisitionForm {
|
||||||
|
id: number;
|
||||||
|
items: RequisitionItem[];
|
||||||
|
totalAmount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
cancellationReason?: string;
|
||||||
|
cancelledAt?: string;
|
||||||
|
cancelledBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadedFile {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
113
src/modules/rh/components/departments/DepartmentForm.vue
Normal file
113
src/modules/rh/components/departments/DepartmentForm.vue
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
@update:visible="$emit('update:visible', $event)"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '800px' }"
|
||||||
|
:closable="true"
|
||||||
|
class="p-0"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-col gap-1 w-full">
|
||||||
|
<h2 class="text-gray-900 dark:text-white text-2xl font-bold tracking-tight">
|
||||||
|
{{ mode === 'create' ? 'Crear Nuevo Departamento' : 'Editar Departamento' }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-base font-normal leading-normal">
|
||||||
|
Configure los detalles básicos para habilitar el nuevo departamento en la estructura organizacional.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="p-6 pt-0 space-y-6">
|
||||||
|
<!-- Department Name Field -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
||||||
|
Nombre del Departamento
|
||||||
|
</label>
|
||||||
|
<InputText
|
||||||
|
v-model="localFormData.name"
|
||||||
|
placeholder="Ej. Logística, Producción o Calidad"
|
||||||
|
class="w-full h-14"
|
||||||
|
/>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-xs font-normal">
|
||||||
|
Este nombre será visible en todos los reportes de RH.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Department Description Field -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
||||||
|
Descripción del Departamento
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
v-model="localFormData.description"
|
||||||
|
rows="5"
|
||||||
|
placeholder="Describa las funciones, responsabilidades y objetivos principales de este departamento..."
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-end gap-4 pt-4 border-t border-gray-200 dark:border-gray-800">
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
@click="handleCancel"
|
||||||
|
class="px-6"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="mode === 'create' ? 'Guardar Departamento' : 'Actualizar Departamento'"
|
||||||
|
icon="pi pi-save"
|
||||||
|
@click="handleSave"
|
||||||
|
class="px-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import type { Department } from '../../types/departments.interface';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
formData: Partial<Department>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:visible', value: boolean): void;
|
||||||
|
(e: 'save', data: Partial<Department>): void;
|
||||||
|
(e: 'cancel'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const localFormData = ref<Partial<Department>>({ ...props.formData });
|
||||||
|
|
||||||
|
// Watch for external formData changes
|
||||||
|
watch(() => props.formData, (newData) => {
|
||||||
|
localFormData.value = { ...newData };
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
emit('save', localFormData.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
emit('update:visible', false);
|
||||||
|
emit('cancel');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Estilos adicionales si son necesarios */
|
||||||
|
</style>
|
||||||
@ -113,15 +113,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="employeeCount" header="Número de Empleados" sortable class="text-center">
|
|
||||||
<template #body="slotProps">
|
|
||||||
<Tag
|
|
||||||
:value="`${slotProps.data.employeeCount} empleados`"
|
|
||||||
severity="secondary"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column header="Acciones" :exportable="false">
|
<Column header="Acciones" :exportable="false">
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@ -156,72 +147,13 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Create/Edit Dialog -->
|
<!-- Create/Edit Dialog -->
|
||||||
<Dialog
|
<DepartmentForm
|
||||||
v-model:visible="showDialog"
|
v-model:visible="showDialog"
|
||||||
:modal="true"
|
:mode="dialogMode"
|
||||||
:style="{ width: '800px' }"
|
:formData="formData"
|
||||||
:closable="true"
|
@save="saveDepartment"
|
||||||
class="p-0"
|
@cancel="showDialog = false"
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex flex-col gap-1 w-full">
|
|
||||||
<h2 class="text-gray-900 dark:text-white text-2xl font-bold tracking-tight">
|
|
||||||
{{ dialogMode === 'create' ? 'Crear Nuevo Departamento' : 'Editar Departamento' }}
|
|
||||||
</h2>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 text-base font-normal leading-normal">
|
|
||||||
Configure los detalles básicos para habilitar el nuevo departamento en la estructura organizacional.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="p-6 pt-0 space-y-6">
|
|
||||||
<!-- Department Name Field -->
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
|
||||||
Nombre del Departamento
|
|
||||||
</label>
|
|
||||||
<InputText
|
|
||||||
v-model="formData.name"
|
|
||||||
placeholder="Ej. Logística, Producción o Calidad"
|
|
||||||
class="w-full h-14"
|
|
||||||
/>
|
/>
|
||||||
<p class="text-gray-500 dark:text-gray-400 text-xs font-normal">
|
|
||||||
Este nombre será visible en todos los reportes de RH.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Department Description Field -->
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
|
||||||
Descripción del Departamento
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
v-model="formData.description"
|
|
||||||
rows="5"
|
|
||||||
placeholder="Describa las funciones, responsabilidades y objetivos principales de este departamento..."
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex items-center justify-end gap-4 pt-4 border-t border-gray-200 dark:border-gray-800">
|
|
||||||
<Button
|
|
||||||
label="Cancelar"
|
|
||||||
severity="secondary"
|
|
||||||
text
|
|
||||||
@click="showDialog = false"
|
|
||||||
class="px-6"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
:label="dialogMode === 'create' ? 'Guardar Departamento' : 'Actualizar Departamento'"
|
|
||||||
icon="pi pi-save"
|
|
||||||
@click="saveDepartment"
|
|
||||||
class="px-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -260,8 +192,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { DepartmentsService } from '../../services/departments.services';
|
||||||
|
|
||||||
// PrimeVue Components
|
// PrimeVue Components
|
||||||
import Toast from 'primevue/toast';
|
import Toast from 'primevue/toast';
|
||||||
@ -271,17 +204,19 @@ import Button from 'primevue/button';
|
|||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import Dialog from 'primevue/dialog';
|
import Dialog from 'primevue/dialog';
|
||||||
import InputText from 'primevue/inputtext';
|
|
||||||
import Textarea from 'primevue/textarea';
|
import type { Department, CreateDepartmentDTO, UpdateDepartmentDTO } from '../../types/departments.interface';
|
||||||
import Tag from 'primevue/tag';
|
import DepartmentForm from './DepartmentForm.vue';
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const departmentsService = new DepartmentsService();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showDialog = ref(false);
|
const showDialog = ref(false);
|
||||||
const showDeleteDialog = ref(false);
|
const showDeleteDialog = ref(false);
|
||||||
const dialogMode = ref<'create' | 'edit'>('create');
|
const dialogMode = ref<'create' | 'edit'>('create');
|
||||||
const departmentToDelete = ref<any>(null);
|
const departmentToDelete = ref<Department | null>(null);
|
||||||
|
|
||||||
// Breadcrumb
|
// Breadcrumb
|
||||||
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
|
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
|
||||||
@ -291,7 +226,7 @@ const breadcrumbItems = ref([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Form Data
|
// Form Data
|
||||||
const formData = ref<{id?: number; name: string; description: string; employeeCount: number; color: string; icon: string}>({
|
const formData = ref<Partial<Department>>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
employeeCount: 0,
|
employeeCount: 0,
|
||||||
@ -299,69 +234,45 @@ const formData = ref<{id?: number; name: string; description: string; employeeCo
|
|||||||
icon: 'pi pi-building'
|
icon: 'pi pi-building'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Color Options (for future use)
|
// Departments Data
|
||||||
// const colorOptions = [
|
const departments = ref<Department[]>([]);
|
||||||
// { label: 'Azul', value: '#3b82f6' },
|
|
||||||
// { label: 'Verde', value: '#10b981' },
|
|
||||||
// { label: 'Púrpura', value: '#8b5cf6' },
|
|
||||||
// { label: 'Naranja', value: '#f97316' },
|
|
||||||
// { label: 'Rosa', value: '#ec4899' },
|
|
||||||
// { label: 'Rojo', value: '#ef4444' },
|
|
||||||
// { label: 'Amarillo', value: '#f59e0b' },
|
|
||||||
// { label: 'Índigo', value: '#6366f1' },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
// Icon Options (for future use)
|
|
||||||
// const iconOptions = [
|
|
||||||
// { label: 'Edificio', value: 'pi pi-building' },
|
|
||||||
// { label: 'Usuarios', value: 'pi pi-users' },
|
|
||||||
// { label: 'Cog', value: 'pi pi-cog' },
|
|
||||||
// { label: 'Herramientas', value: 'pi pi-wrench' },
|
|
||||||
// { label: 'Camión', value: 'pi pi-truck' },
|
|
||||||
// { label: 'Gráfico', value: 'pi pi-chart-line' },
|
|
||||||
// { label: 'Escudo', value: 'pi pi-shield' },
|
|
||||||
// { label: 'Estrella', value: 'pi pi-star' },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
// Mock Data - Departments
|
|
||||||
const departments = ref([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Producción',
|
|
||||||
description: 'Línea de ensamblaje y manufactura de productos terminados.',
|
|
||||||
employeeCount: 45,
|
|
||||||
color: '#3b82f6',
|
|
||||||
icon: 'pi pi-building'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Logística',
|
|
||||||
description: 'Gestión de envíos nacionales, recepción y almacenamiento.',
|
|
||||||
employeeCount: 22,
|
|
||||||
color: '#10b981',
|
|
||||||
icon: 'pi pi-truck'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Control de Calidad',
|
|
||||||
description: 'Inspección de estándares de calidad y auditoría de procesos.',
|
|
||||||
employeeCount: 12,
|
|
||||||
color: '#8b5cf6',
|
|
||||||
icon: 'pi pi-shield'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Mantenimiento',
|
|
||||||
description: 'Soporte técnico, reparaciones y mantenimiento preventivo.',
|
|
||||||
employeeCount: 8,
|
|
||||||
color: '#f97316',
|
|
||||||
icon: 'pi pi-wrench'
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const totalEmployees = computed(() => {
|
const totalEmployees = computed(() => {
|
||||||
return departments.value.reduce((sum, dept) => sum + dept.employeeCount, 0);
|
return departments.value.reduce((sum, dept) => sum + (dept.employeeCount || 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch Departments from API
|
||||||
|
const fetchDepartments = async (showToast = true) => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await departmentsService.getDepartments();
|
||||||
|
departments.value = response.data;
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Departamentos Cargados',
|
||||||
|
detail: `Se cargaron ${response.data.length} departamentos`,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar departamentos:', error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'No se pudieron cargar los departamentos',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDepartments();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
@ -377,7 +288,7 @@ const openCreateDialog = () => {
|
|||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewDepartment = (department: any) => {
|
const viewDepartment = (department: Department) => {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
summary: 'Ver Departamento',
|
summary: 'Ver Departamento',
|
||||||
@ -386,7 +297,7 @@ const viewDepartment = (department: any) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const editDepartment = (department: any) => {
|
const editDepartment = (department: Department) => {
|
||||||
dialogMode.value = 'edit';
|
dialogMode.value = 'edit';
|
||||||
formData.value = { ...department };
|
formData.value = { ...department };
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
@ -397,25 +308,37 @@ const confirmDelete = (department: any) => {
|
|||||||
showDeleteDialog.value = true;
|
showDeleteDialog.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteDepartment = () => {
|
const deleteDepartment = async () => {
|
||||||
if (departmentToDelete.value) {
|
if (!departmentToDelete.value) return;
|
||||||
const index = departments.value.findIndex(d => d.id === departmentToDelete.value.id);
|
|
||||||
if (index > -1) {
|
try {
|
||||||
departments.value.splice(index, 1);
|
await departmentsService.deleteDepartment(departmentToDelete.value.id);
|
||||||
|
|
||||||
|
// Recargar la lista de departamentos
|
||||||
|
await fetchDepartments(false);
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Departamento Eliminado',
|
summary: 'Departamento Eliminado',
|
||||||
detail: `${departmentToDelete.value.name} ha sido eliminado`,
|
detail: `${departmentToDelete.value.name} ha sido eliminado exitosamente`,
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
}
|
} catch (error) {
|
||||||
}
|
console.error('Error al eliminar departamento:', error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'No se pudo eliminar el departamento',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
showDeleteDialog.value = false;
|
showDeleteDialog.value = false;
|
||||||
departmentToDelete.value = null;
|
departmentToDelete.value = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveDepartment = () => {
|
const saveDepartment = async (data: Partial<Department>) => {
|
||||||
if (!formData.value.name) {
|
if (!data.name) {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'warn',
|
severity: 'warn',
|
||||||
summary: 'Validación',
|
summary: 'Validación',
|
||||||
@ -425,47 +348,61 @@ const saveDepartment = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (dialogMode.value === 'create') {
|
if (dialogMode.value === 'create') {
|
||||||
// Create new department
|
// Create new department
|
||||||
const newDepartment = {
|
const createData: CreateDepartmentDTO = {
|
||||||
id: Math.max(...departments.value.map(d => d.id)) + 1,
|
name: data.name,
|
||||||
name: formData.value.name,
|
description: data.description || ''
|
||||||
description: formData.value.description,
|
|
||||||
employeeCount: formData.value.employeeCount,
|
|
||||||
color: formData.value.color,
|
|
||||||
icon: formData.value.icon
|
|
||||||
};
|
};
|
||||||
departments.value.push(newDepartment);
|
|
||||||
|
await departmentsService.createDepartment(createData);
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Departamento Creado',
|
summary: 'Departamento Creado',
|
||||||
detail: `${formData.value.name} ha sido creado exitosamente`,
|
detail: `${data.name} ha sido creado exitosamente`,
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Update existing department
|
// Update existing department
|
||||||
const index = departments.value.findIndex(d => d.id === formData.value.id);
|
if (!data.id) {
|
||||||
if (index > -1) {
|
toast.add({
|
||||||
const existingDept = departments.value[index]!;
|
severity: 'error',
|
||||||
departments.value[index] = {
|
summary: 'Error',
|
||||||
id: existingDept.id,
|
detail: 'ID del departamento no encontrado',
|
||||||
name: formData.value.name,
|
life: 3000
|
||||||
description: formData.value.description,
|
});
|
||||||
employeeCount: formData.value.employeeCount,
|
return;
|
||||||
color: formData.value.color,
|
}
|
||||||
icon: formData.value.icon
|
|
||||||
|
const updateData: UpdateDepartmentDTO = {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await departmentsService.updateDepartment(data.id, updateData);
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Departamento Actualizado',
|
summary: 'Departamento Actualizado',
|
||||||
detail: `${formData.value.name} ha sido actualizado`,
|
detail: `${data.name} ha sido actualizado exitosamente`,
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
// Recargar la lista de departamentos
|
||||||
|
await fetchDepartments(false);
|
||||||
showDialog.value = false;
|
showDialog.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al guardar departamento:', error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: `No se pudo ${dialogMode.value === 'create' ? 'crear' : 'actualizar'} el departamento`,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -1,323 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
|
|
||||||
<html class="light" lang="es"><head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
|
||||||
<title>Gestión de Puestos de Trabajo | RH Portal</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet"/>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
|
||||||
<script id="tailwind-config">
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: "class",
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
"primary": "#137fec",
|
|
||||||
"background-light": "#f6f7f8",
|
|
||||||
"background-dark": "#101922",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
"display": ["Inter"]
|
|
||||||
},
|
|
||||||
borderRadius: { "DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
.material-symbols-outlined {
|
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-background-light dark:bg-background-dark text-[#111418] dark:text-white font-display">
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
|
||||||
<!-- Sidebar Navigation -->
|
|
||||||
<aside class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
|
|
||||||
<div class="p-6 flex items-center gap-3">
|
|
||||||
<div class="size-10 rounded-lg bg-primary flex items-center justify-center text-white">
|
|
||||||
<span class="material-symbols-outlined">factory</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<h1 class="text-sm font-bold leading-none">Warehouse System</h1>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Admin Portal</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav class="flex-1 px-4 space-y-1 overflow-y-auto">
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" href="#">
|
|
||||||
<span class="material-symbols-outlined">dashboard</span>
|
|
||||||
<span class="text-sm font-medium">Dashboard</span>
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" href="#">
|
|
||||||
<span class="material-symbols-outlined">inventory_2</span>
|
|
||||||
<span class="text-sm font-medium">Inventario</span>
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" href="#">
|
|
||||||
<span class="material-symbols-outlined">precision_manufacturing</span>
|
|
||||||
<span class="text-sm font-medium">Producción</span>
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 bg-primary/10 text-primary rounded-lg transition-colors" href="#">
|
|
||||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">groups</span>
|
|
||||||
<span class="text-sm font-medium">Recursos Humanos</span>
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" href="#">
|
|
||||||
<span class="material-symbols-outlined">settings</span>
|
|
||||||
<span class="text-sm font-medium">Configuración</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
<div class="p-4 border-t border-gray-200 dark:border-gray-800">
|
|
||||||
<div class="flex items-center gap-3 p-2">
|
|
||||||
<div class="size-8 rounded-full bg-cover bg-center" data-alt="User profile avatar of admin" style="background-image: url('https://lh3.googleusercontent.com/aida-public/AB6AXuAZE4yg7YhxPnYbTfdwrgMaXbIwT17qinrNA2sczwJTMCecZuuRxWI9crz6h75iNsn4VKTmHSNkkwtpFOx2I72wWVgtbhDUeUX8ngUzF0A_22GqWF9F46TErldseaBQgTwjXDP5wWLyOU8S8Mf64JntceSLjq2U9exTu9Pkhgo6OPG1GkinNVD3rBNsVjwN-xNbtWLt6jYnwVcAkrCqawJsiK_Pf8R3chrYkUJNmzJgn9XSTOtBLUDT6A7K1V_G1jXX6k1jzSdWnFdg')"></div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-xs font-semibold truncate">Marcos Pérez</p>
|
|
||||||
<p class="text-[10px] text-gray-500 truncate">HR Manager</p>
|
|
||||||
</div>
|
|
||||||
<span class="material-symbols-outlined text-gray-400 text-sm">logout</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<main class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
||||||
<!-- Top Navigation Bar -->
|
|
||||||
<header class="h-16 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between px-8 shrink-0">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a class="text-xs font-medium text-gray-500 hover:text-primary transition-colors" href="#">INICIO</a>
|
|
||||||
<span class="text-xs text-gray-400">/</span>
|
|
||||||
<a class="text-xs font-medium text-gray-500 hover:text-primary transition-colors" href="#">RH</a>
|
|
||||||
<span class="text-xs text-gray-400">/</span>
|
|
||||||
<span class="text-xs font-bold text-gray-900 dark:text-white uppercase tracking-wider">Puestos</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="relative">
|
|
||||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">search</span>
|
|
||||||
<input class="h-9 w-64 bg-gray-100 dark:bg-gray-800 border-none rounded-lg pl-9 text-xs focus:ring-2 focus:ring-primary/50 transition-all" placeholder="Buscar..." type="text"/>
|
|
||||||
</div>
|
|
||||||
<button class="size-9 flex items-center justify-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 relative">
|
|
||||||
<span class="material-symbols-outlined text-gray-600 dark:text-gray-400">notifications</span>
|
|
||||||
<span class="absolute top-2 right-2 size-2 bg-red-500 rounded-full border-2 border-white dark:border-gray-900"></span>
|
|
||||||
</button>
|
|
||||||
<div class="h-8 w-px bg-gray-200 dark:bg-gray-800 mx-2"></div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="size-8 rounded-full bg-primary/20 text-primary flex items-center justify-center font-bold text-xs">AD</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<!-- Page Content -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-8 bg-background-light dark:bg-background-dark">
|
|
||||||
<div class="max-w-6xl mx-auto space-y-6">
|
|
||||||
<!-- Page Heading -->
|
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h2 class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">Puestos de Trabajo</h2>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 text-sm">Administre y organice los roles operativos y administrativos de la planta.</p>
|
|
||||||
</div>
|
|
||||||
<button class="bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-lg flex items-center gap-2 text-sm font-bold shadow-lg shadow-primary/20 transition-all">
|
|
||||||
<span class="material-symbols-outlined text-lg">add_circle</span>
|
|
||||||
Nuevo Puesto
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Search and Filter Bar -->
|
|
||||||
<div class="bg-white dark:bg-gray-900 p-4 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 flex gap-4">
|
|
||||||
<div class="flex-1 relative">
|
|
||||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">search</span>
|
|
||||||
<input class="w-full h-11 bg-gray-50 dark:bg-gray-800 border-none rounded-lg pl-10 text-sm focus:ring-2 focus:ring-primary/50" placeholder="Buscar por nombre o departamento..." type="text"/>
|
|
||||||
</div>
|
|
||||||
<button class="flex items-center gap-2 px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 transition-colors">
|
|
||||||
<span class="material-symbols-outlined">filter_list</span>
|
|
||||||
<span class="text-sm font-medium">Filtros</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Data Table Card -->
|
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-left border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-800">
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider w-1/4">Nombre del Puesto</th>
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider">Departamento</th>
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider w-1/3">Descripción / Actividades</th>
|
|
||||||
<th class="px-6 py-4 text-xs font-bold text-gray-500 uppercase tracking-wider text-right">Acciones</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
|
||||||
<!-- Row 1 -->
|
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors group">
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="size-8 rounded bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center text-blue-600">
|
|
||||||
<span class="material-symbols-outlined text-base">supervisor_account</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">Supervisor de Almacén</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm">
|
|
||||||
<span class="px-2.5 py-1 rounded-full text-[11px] font-bold bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">LOGÍSTICA</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">Coordinar la recepción de mercancías, gestión de inventarios y despacho...</p>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button class="p-2 text-gray-400 hover:text-primary transition-colors" title="Ver Detalle">
|
|
||||||
<span class="material-symbols-outlined text-lg">visibility</span>
|
|
||||||
</button>
|
|
||||||
<button class="p-2 text-gray-400 hover:text-primary transition-colors" title="Editar">
|
|
||||||
<span class="material-symbols-outlined text-lg">edit</span>
|
|
||||||
</button>
|
|
||||||
<button class="p-2 text-gray-400 hover:text-red-500 transition-colors" title="Eliminar">
|
|
||||||
<span class="material-symbols-outlined text-lg">delete</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Row 2 -->
|
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors group">
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="size-8 rounded bg-green-50 dark:bg-green-900/20 flex items-center justify-center text-green-600">
|
|
||||||
<span class="material-symbols-outlined text-base">precision_manufacturing</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">Operario de Línea A</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm">
|
|
||||||
<span class="px-2.5 py-1 rounded-full text-[11px] font-bold bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">PRODUCCIÓN</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">Operación de maquinaria de ensamblado y control de calidad primario...</p>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button class="p-2 text-gray-400 hover:text-primary transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">visibility</span>
|
|
||||||
</button>
|
|
||||||
<button class="p-2 text-gray-400 hover:text-primary transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">edit</span>
|
|
||||||
</button>
|
|
||||||
<button class="p-2 text-gray-400 hover:text-red-500 transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">delete</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Row 3 -->
|
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors group">
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="size-8 rounded bg-orange-50 dark:bg-orange-900/20 flex items-center justify-center text-orange-600">
|
|
||||||
<span class="material-symbols-outlined text-base">forklift</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">Montacarguista</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm">
|
|
||||||
<span class="px-2.5 py-1 rounded-full text-[11px] font-bold bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300">ALMACÉN</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">Traslado de pallets a zona de racks y carga de camiones de distribución...</p>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button class="p-2 text-gray-400 hover:text-primary transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">visibility</span>
|
|
||||||
</button>
|
|
||||||
<button class="p-2 text-gray-400 hover:text-primary transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">edit</span>
|
|
||||||
</button>
|
|
||||||
<button class="p-2 text-gray-400 hover:text-red-500 transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">delete</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Row 4 -->
|
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors group">
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="size-8 rounded bg-purple-50 dark:bg-purple-900/20 flex items-center justify-center text-purple-600">
|
|
||||||
<span class="material-symbols-outlined text-base">security</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">Analista de Seguridad</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm">
|
|
||||||
<span class="px-2.5 py-1 rounded-full text-[11px] font-bold bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">EHS</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">Monitoreo de protocolos de seguridad e higiene industrial en planta...</p>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button class="p-2 text-gray-400 hover:text-primary transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">visibility</span>
|
|
||||||
</button>
|
|
||||||
<button class="p-2 text-gray-400 hover:text-primary transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">edit</span>
|
|
||||||
</button>
|
|
||||||
<button class="p-2 text-gray-400 hover:text-red-500 transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">delete</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<!-- Pagination Footer -->
|
|
||||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-800 flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">Mostrando 1 a 4 de 12 puestos</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button class="size-8 flex items-center justify-center rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 disabled:opacity-50" disabled="">
|
|
||||||
<span class="material-symbols-outlined text-sm">chevron_left</span>
|
|
||||||
</button>
|
|
||||||
<button class="size-8 flex items-center justify-center rounded bg-primary text-white font-bold text-xs">1</button>
|
|
||||||
<button class="size-8 flex items-center justify-center rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 transition-colors text-xs font-bold">2</button>
|
|
||||||
<button class="size-8 flex items-center justify-center rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 transition-colors text-xs font-bold">3</button>
|
|
||||||
<button class="size-8 flex items-center justify-center rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-sm">chevron_right</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Summary Info Cards (Subtle Details) -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div class="bg-white dark:bg-gray-900 p-6 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm flex items-center gap-4">
|
|
||||||
<div class="size-12 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
|
||||||
<span class="material-symbols-outlined">work</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-500 font-medium">Total de Puestos</p>
|
|
||||||
<p class="text-xl font-black">12 Roles</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white dark:bg-gray-900 p-6 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm flex items-center gap-4">
|
|
||||||
<div class="size-12 rounded-full bg-green-500/10 flex items-center justify-center text-green-600">
|
|
||||||
<span class="material-symbols-outlined">domain</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-500 font-medium">Departamentos Activos</p>
|
|
||||||
<p class="text-xl font-black">5 Áreas</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white dark:bg-gray-900 p-6 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm flex items-center gap-4">
|
|
||||||
<div class="size-12 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-600">
|
|
||||||
<span class="material-symbols-outlined">history</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-gray-500 font-medium">Última Actualización</p>
|
|
||||||
<p class="text-xl font-black">Hoy, 09:12 AM</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body></html>
|
|
||||||
47
src/modules/rh/services/departments.services.ts
Normal file
47
src/modules/rh/services/departments.services.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
import type { CreateDepartmentDTO, DeleteDepartmentDTO, ResponseDepartmentsDTO, UpdateDepartmentDTO } from "../types/departments.interface";
|
||||||
|
import api from "@/services/api";
|
||||||
|
|
||||||
|
export class DepartmentsService {
|
||||||
|
|
||||||
|
public async getDepartments(): Promise<ResponseDepartmentsDTO> {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/rh/departments');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching departments:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createDepartment(data: CreateDepartmentDTO): Promise<ResponseDepartmentsDTO> {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/rh/departments', data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating department:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateDepartment(id: number, data: UpdateDepartmentDTO): Promise<ResponseDepartmentsDTO> {
|
||||||
|
try {
|
||||||
|
const response = await api.put(`/api/rh/departments/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating department:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteDepartment(id: number): Promise<DeleteDepartmentDTO> {
|
||||||
|
try {
|
||||||
|
const response = await api.delete(`/api/rh/departments/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting department:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
26
src/modules/rh/types/departments.interface.ts
Normal file
26
src/modules/rh/types/departments.interface.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export interface Department {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
employeeCount?: number;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
deleted_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseDepartmentsDTO {
|
||||||
|
data: Department[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteDepartmentDTO {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDepartmentDTO {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDepartmentDTO extends Partial<CreateDepartmentDTO> {}
|
||||||
@ -8,12 +8,19 @@ import Dropdown from 'primevue/dropdown';
|
|||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import Badge from 'primevue/badge';
|
import Badge from 'primevue/badge';
|
||||||
import Toast from 'primevue/toast';
|
import Toast from 'primevue/toast';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import IconField from 'primevue/iconfield';
|
||||||
|
import InputIcon from 'primevue/inputicon';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { purchaseServices } from '../../purchases/services/purchaseServices';
|
import { purchaseServices } from '../../purchases/services/purchaseServices';
|
||||||
import type { PurchaseDetailResponse } from '../../purchases/types/purchases';
|
import type { PurchaseDetailResponse } from '../../purchases/types/purchases';
|
||||||
import { useWarehouseStore } from '../../../stores/warehouseStore';
|
import { useWarehouseStore } from '../../../stores/warehouseStore';
|
||||||
|
import { useProductStore } from '../../products/stores/productStore';
|
||||||
import { inventoryWarehouseServices } from '../services/inventoryWarehouse.services';
|
import { inventoryWarehouseServices } from '../services/inventoryWarehouse.services';
|
||||||
import type { InventoryProductItem, CreateInventoryRequest } from '../types/warehouse.inventory';
|
import type { InventoryProductItem, CreateInventoryRequest } from '../types/warehouse.inventory';
|
||||||
|
import type { Product as ProductType } from '../../products/types/product';
|
||||||
|
|
||||||
interface SerialNumber {
|
interface SerialNumber {
|
||||||
serial: string;
|
serial: string;
|
||||||
@ -38,11 +45,22 @@ const toast = useToast();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const warehouseStore = useWarehouseStore();
|
const warehouseStore = useWarehouseStore();
|
||||||
|
const productStore = useProductStore();
|
||||||
|
|
||||||
// Data from API
|
// Data from API
|
||||||
const purchaseData = ref<PurchaseDetailResponse | null>(null);
|
const purchaseData = ref<PurchaseDetailResponse | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// Modo de operación: 'purchase' o 'manual'
|
||||||
|
const operationMode = computed(() => {
|
||||||
|
return route.query.purchaseId ? 'purchase' : 'manual';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Almacén de destino (para modo manual)
|
||||||
|
const targetWarehouseId = computed(() => {
|
||||||
|
return route.query.warehouse ? Number(route.query.warehouse) : null;
|
||||||
|
});
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
const purchaseOrderNumber = ref('ORD-2023-001');
|
const purchaseOrderNumber = ref('ORD-2023-001');
|
||||||
const totalItemsPO = ref(12);
|
const totalItemsPO = ref(12);
|
||||||
@ -92,6 +110,12 @@ const expandedRows = ref<any[]>([]);
|
|||||||
const newSerialNumber = ref('');
|
const newSerialNumber = ref('');
|
||||||
const newSerialWarehouse = ref<number>(1);
|
const newSerialWarehouse = ref<number>(1);
|
||||||
|
|
||||||
|
// Modal de productos
|
||||||
|
const showProductModal = ref(false);
|
||||||
|
const productSearch = ref('');
|
||||||
|
const selectedProducts = ref<ProductType[]>([]);
|
||||||
|
const loadingProducts = ref(false);
|
||||||
|
|
||||||
const totalReceived = computed(() => {
|
const totalReceived = computed(() => {
|
||||||
return products.value.reduce((sum, p) => sum + p.quantityReceived, 0);
|
return products.value.reduce((sum, p) => sum + p.quantityReceived, 0);
|
||||||
});
|
});
|
||||||
@ -123,11 +147,16 @@ const warehouseSummary = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isFormValid = computed(() => {
|
const isFormValid = computed(() => {
|
||||||
|
// En modo manual sin productos, no es válido
|
||||||
|
if (operationMode.value === 'manual' && products.value.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return products.value.every(product => {
|
return products.value.every(product => {
|
||||||
if (product.requiresSerial) {
|
if (product.requiresSerial) {
|
||||||
return product.serialNumbers.length === product.quantityOrdered;
|
return product.serialNumbers.length === product.quantityOrdered;
|
||||||
} else {
|
} else {
|
||||||
return product.warehouseId !== null;
|
return product.warehouseId !== null && product.quantityReceived > 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -183,12 +212,6 @@ async function confirmReceipt() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const purchaseId = route.query.purchaseId;
|
|
||||||
if (!purchaseId) {
|
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'ID de compra no válido', life: 3000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
@ -229,8 +252,10 @@ async function confirmReceipt() {
|
|||||||
// Enviar al API
|
// Enviar al API
|
||||||
const response = await inventoryWarehouseServices.addInventory(requestData);
|
const response = await inventoryWarehouseServices.addInventory(requestData);
|
||||||
|
|
||||||
// Actualizar estado de la compra a "Ingresada a Inventario" (4)
|
// Si es desde una compra, actualizar estado
|
||||||
await purchaseServices.updatePurchaseStatus(Number(purchaseId), '4');
|
if (operationMode.value === 'purchase' && route.query.purchaseId) {
|
||||||
|
await purchaseServices.updatePurchaseStatus(Number(route.query.purchaseId), '4');
|
||||||
|
}
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
@ -239,7 +264,7 @@ async function confirmReceipt() {
|
|||||||
life: 4000
|
life: 4000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Regresar a la vista de compras
|
// Regresar a la vista anterior
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.back();
|
router.back();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@ -261,13 +286,7 @@ async function fetchPurchaseDetails() {
|
|||||||
const purchaseId = route.query.purchaseId;
|
const purchaseId = route.query.purchaseId;
|
||||||
|
|
||||||
if (!purchaseId) {
|
if (!purchaseId) {
|
||||||
toast.add({
|
// Si no hay purchaseId, estamos en modo manual
|
||||||
severity: 'error',
|
|
||||||
summary: 'Error',
|
|
||||||
detail: 'No se proporcionó un ID de compra válido',
|
|
||||||
life: 3000
|
|
||||||
});
|
|
||||||
router.back();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -309,18 +328,136 @@ async function fetchPurchaseDetails() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeManualMode() {
|
||||||
|
// En modo manual, limpiar productos de ejemplo y establecer almacén por defecto
|
||||||
|
products.value = [];
|
||||||
|
purchaseOrderNumber.value = 'Entrada Manual';
|
||||||
|
totalItemsPO.value = 0;
|
||||||
|
|
||||||
|
// Obtener el nombre del almacén si se especificó
|
||||||
|
if (targetWarehouseId.value) {
|
||||||
|
const warehouse = warehouseStore.warehouses.find(w => w.id === targetWarehouseId.value);
|
||||||
|
if (warehouse) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Entrada Manual',
|
||||||
|
detail: `Agregando inventario al almacén: ${warehouse.name}`,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await warehouseStore.fetchWarehouses();
|
await warehouseStore.fetchWarehouses();
|
||||||
// Establecer el primer almacén activo como predeterminado
|
// Establecer el primer almacén activo como predeterminado
|
||||||
if (warehouseStore.activeWarehouses.length > 0) {
|
if (warehouseStore.activeWarehouses.length > 0) {
|
||||||
newSerialWarehouse.value = warehouseStore.activeWarehouses[0]?.id || 1;
|
newSerialWarehouse.value = warehouseStore.activeWarehouses[0]?.id || 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cargar datos según el modo
|
||||||
|
if (operationMode.value === 'purchase') {
|
||||||
await fetchPurchaseDetails();
|
await fetchPurchaseDetails();
|
||||||
|
} else {
|
||||||
|
initializeManualMode();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
// Lógica para cancelar y regresar
|
router.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Funciones para el modal de productos
|
||||||
|
const openProductModal = async () => {
|
||||||
|
showProductModal.value = true;
|
||||||
|
loadingProducts.value = true;
|
||||||
|
try {
|
||||||
|
await productStore.fetchProducts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar productos:', error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'No se pudieron cargar los productos',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingProducts.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeProductModal = () => {
|
||||||
|
showProductModal.value = false;
|
||||||
|
selectedProducts.value = [];
|
||||||
|
productSearch.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredProducts = computed(() => {
|
||||||
|
if (!productSearch.value) {
|
||||||
|
return productStore.activeProducts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = productSearch.value.toLowerCase();
|
||||||
|
return productStore.activeProducts.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(search) ||
|
||||||
|
p.sku.toLowerCase().includes(search) ||
|
||||||
|
(p.code && p.code.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addSelectedProducts = () => {
|
||||||
|
if (selectedProducts.value.length === 0) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Selección Requerida',
|
||||||
|
detail: 'Por favor seleccione al menos un producto',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedProducts.value.forEach(product => {
|
||||||
|
// Verificar si el producto ya está en la lista
|
||||||
|
const exists = products.value.find(p => p.id === product.id);
|
||||||
|
if (!exists) {
|
||||||
|
products.value.push({
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
sku: product.sku,
|
||||||
|
category: product.description || 'Sin categoría',
|
||||||
|
quantityOrdered: 1, // Cantidad inicial
|
||||||
|
quantityReceived: 0,
|
||||||
|
warehouseId: targetWarehouseId.value || null,
|
||||||
|
requiresSerial: product.is_serial,
|
||||||
|
serialNumbers: [],
|
||||||
|
purchaseCost: 0, // El usuario puede ajustar esto
|
||||||
|
attributes: product.attributes || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Productos Agregados',
|
||||||
|
detail: `Se agregaron ${selectedProducts.value.length} producto(s)`,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
closeProductModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeProduct = (productId: number) => {
|
||||||
|
const index = products.value.findIndex(p => p.id === productId);
|
||||||
|
if (index > -1) {
|
||||||
|
products.value.splice(index, 1);
|
||||||
|
toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Producto Eliminado',
|
||||||
|
detail: 'El producto ha sido eliminado de la lista',
|
||||||
|
life: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -343,13 +480,21 @@ function cancel() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-3xl font-black text-slate-900 tracking-tight">Registrar Entrada de Mercancía</h2>
|
<h2 class="text-3xl font-black text-slate-900 tracking-tight">Registrar Entrada de Mercancía</h2>
|
||||||
<p class="text-slate-500 mt-1 flex items-center gap-2">
|
<p class="text-slate-500 mt-1 flex items-center gap-2">
|
||||||
<i class="pi pi-receipt text-base"></i>
|
<i :class="operationMode === 'purchase' ? 'pi pi-receipt' : 'pi pi-warehouse'" class="text-base"></i>
|
||||||
|
<template v-if="operationMode === 'purchase'">
|
||||||
No. Orden #{{ purchaseOrderNumber }} |
|
No. Orden #{{ purchaseOrderNumber }} |
|
||||||
<span class="text-primary font-semibold">Distribución Multi-Almacén</span>
|
<span class="text-primary font-semibold">Distribución Multi-Almacén</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-primary font-semibold">Entrada Manual de Inventario</span>
|
||||||
|
<template v-if="targetWarehouseId">
|
||||||
|
| Almacén: {{ warehouseStore.warehouses.find(w => w.id === targetWarehouseId)?.name }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Card class="shadow-sm">
|
<Card class="shadow-sm" v-if="operationMode === 'purchase'">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="flex gap-4 px-2">
|
<div class="flex gap-4 px-2">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@ -364,7 +509,21 @@ function cancel() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
<Button icon="pi pi-eye" label="Ver Detalles" severity="secondary" outlined />
|
<Button
|
||||||
|
v-if="operationMode === 'manual'"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
label="Agregar Producto"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
@click="openProductModal"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="operationMode === 'purchase'"
|
||||||
|
icon="pi pi-eye"
|
||||||
|
label="Ver Detalles"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -372,7 +531,9 @@ function cancel() {
|
|||||||
<Card>
|
<Card>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="px-6 py-4 border-b border-slate-200 bg-slate-50/50 flex justify-between items-center">
|
<div class="px-6 py-4 border-b border-slate-200 bg-slate-50/50 flex justify-between items-center">
|
||||||
<h3 class="font-bold text-slate-900">Productos de la Orden</h3>
|
<h3 class="font-bold text-slate-900">
|
||||||
|
{{ operationMode === 'purchase' ? 'Productos de la Orden' : 'Productos a Ingresar' }}
|
||||||
|
</h3>
|
||||||
<span class="text-xs text-slate-500 flex items-center gap-1">
|
<span class="text-xs text-slate-500 flex items-center gap-1">
|
||||||
<i class="pi pi-info-circle text-sm"></i>
|
<i class="pi pi-info-circle text-sm"></i>
|
||||||
Seleccione el almacén de destino por cada producto
|
Seleccione el almacén de destino por cada producto
|
||||||
@ -380,7 +541,20 @@ function cancel() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="overflow-x-auto -m-6">
|
<!-- Empty State para modo manual -->
|
||||||
|
<div v-if="operationMode === 'manual' && products.length === 0" class="text-center py-12">
|
||||||
|
<i class="pi pi-inbox text-6xl text-slate-300 mb-4"></i>
|
||||||
|
<h3 class="text-lg font-semibold text-slate-700 mb-2">No hay productos agregados</h3>
|
||||||
|
<p class="text-sm text-slate-500 mb-6">Comienza agregando productos para crear la entrada de inventario</p>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-plus"
|
||||||
|
label="Agregar Producto"
|
||||||
|
severity="primary"
|
||||||
|
@click="openProductModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto -m-6">
|
||||||
<table class="w-full border-collapse">
|
<table class="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left text-xs font-bold text-slate-500 uppercase tracking-wider bg-slate-50">
|
<tr class="text-left text-xs font-bold text-slate-500 uppercase tracking-wider bg-slate-50">
|
||||||
@ -396,7 +570,7 @@ function cancel() {
|
|||||||
<template v-for="product in products" :key="product.id">
|
<template v-for="product in products" :key="product.id">
|
||||||
<tr :class="[
|
<tr :class="[
|
||||||
'hover:bg-slate-50 transition-colors',
|
'hover:bg-slate-50 transition-colors',
|
||||||
isRowExpanded(product) ? 'bg-primary/[0.02]' : ''
|
isRowExpanded(product) ? 'bg-primary/2' : ''
|
||||||
]">
|
]">
|
||||||
<td class="px-6 py-4" :class="{ 'border-l-4 border-primary': isRowExpanded(product) }">
|
<td class="px-6 py-4" :class="{ 'border-l-4 border-primary': isRowExpanded(product) }">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@ -414,7 +588,22 @@ function cancel() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-slate-600">{{ product.sku }}</td>
|
<td class="px-6 py-4 text-sm text-slate-600">{{ product.sku }}</td>
|
||||||
<td class="px-6 py-4 text-center text-sm font-bold text-slate-900">{{ product.quantityOrdered }}</td>
|
<td class="px-6 py-4 text-center text-sm font-bold text-slate-900">
|
||||||
|
<template v-if="operationMode === 'manual'">
|
||||||
|
<InputNumber
|
||||||
|
v-model="product.quantityOrdered"
|
||||||
|
:min="1"
|
||||||
|
showButtons
|
||||||
|
buttonLayout="horizontal"
|
||||||
|
:step="1"
|
||||||
|
class="w-full max-w-[120px] mx-auto"
|
||||||
|
:inputStyle="{ textAlign: 'center', fontWeight: 'bold', fontSize: '0.875rem', width: '60px' }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ product.quantityOrdered }}
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<InputNumber v-if="!product.requiresSerial" v-model="product.quantityReceived"
|
<InputNumber v-if="!product.requiresSerial" v-model="product.quantityReceived"
|
||||||
:max="product.quantityOrdered" :min="0"
|
:max="product.quantityOrdered" :min="0"
|
||||||
@ -440,19 +629,37 @@ function cancel() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-center">
|
<td class="px-6 py-4 text-center">
|
||||||
<Button v-if="product.requiresSerial"
|
<template v-if="product.requiresSerial">
|
||||||
|
<Button
|
||||||
:label="isRowExpanded(product) ? 'OCULTAR' : 'GESTIONAR SERIES'"
|
:label="isRowExpanded(product) ? 'OCULTAR' : 'GESTIONAR SERIES'"
|
||||||
:icon="isRowExpanded(product) ? 'pi pi-chevron-up' : 'pi pi-qrcode'"
|
:icon="isRowExpanded(product) ? 'pi pi-chevron-up' : 'pi pi-qrcode'"
|
||||||
:severity="isRowExpanded(product) ? 'secondary' : 'info'"
|
:severity="isRowExpanded(product) ? 'secondary' : 'info'"
|
||||||
:outlined="!isRowExpanded(product)"
|
:outlined="!isRowExpanded(product)"
|
||||||
size="small"
|
size="small"
|
||||||
@click="toggleRow(product)" />
|
@click="toggleRow(product)"
|
||||||
<Badge v-else value="Estándar" severity="secondary" />
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Badge value="Estándar" severity="secondary" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Botón eliminar en modo manual -->
|
||||||
|
<Button
|
||||||
|
v-if="operationMode === 'manual'"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
class="ml-2"
|
||||||
|
@click="removeProduct(product.id)"
|
||||||
|
v-tooltip.top="'Eliminar producto'"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Serial Numbers Expansion -->
|
<!-- Serial Numbers Expansion -->
|
||||||
<tr v-if="isRowExpanded(product)" class="bg-primary/[0.02]">
|
<tr v-if="isRowExpanded(product)" class="bg-primary/2">
|
||||||
<td colspan="6" class="px-10 pb-6 pt-2">
|
<td colspan="6" class="px-10 pb-6 pt-2">
|
||||||
<Card class="border border-primary/20 shadow-sm">
|
<Card class="border border-primary/20 shadow-sm">
|
||||||
<template #header>
|
<template #header>
|
||||||
@ -606,12 +813,148 @@ function cancel() {
|
|||||||
<!-- Footer Actions -->
|
<!-- Footer Actions -->
|
||||||
<div class="flex items-center justify-end gap-4 pt-4 border-t border-slate-200">
|
<div class="flex items-center justify-end gap-4 pt-4 border-t border-slate-200">
|
||||||
<Button label="Cancelar" severity="secondary" text @click="cancel" />
|
<Button label="Cancelar" severity="secondary" text @click="cancel" />
|
||||||
<Button label="Confirmar Recepción Multi-Almacén"
|
<Button
|
||||||
|
:label="operationMode === 'purchase' ? 'Confirmar Recepción Multi-Almacén' : 'Confirmar Entrada de Inventario'"
|
||||||
icon="pi pi-check"
|
icon="pi pi-check"
|
||||||
:disabled="!isFormValid"
|
:disabled="!isFormValid || products.length === 0"
|
||||||
@click="confirmReceipt" />
|
@click="confirmReceipt"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Modal de Selección de Productos -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="showProductModal"
|
||||||
|
modal
|
||||||
|
header="Seleccionar Productos"
|
||||||
|
:style="{ width: '90vw', maxWidth: '1200px' }"
|
||||||
|
:contentStyle="{ padding: '0' }"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-shopping-cart text-xl"></i>
|
||||||
|
<span class="font-bold text-lg">Seleccionar Productos del Catálogo</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<IconField iconPosition="left">
|
||||||
|
<InputIcon class="pi pi-search" />
|
||||||
|
<InputText
|
||||||
|
v-model="productSearch"
|
||||||
|
placeholder="Buscar por nombre, SKU o código..."
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Products Table -->
|
||||||
|
<DataTable
|
||||||
|
v-model:selection="selectedProducts"
|
||||||
|
:value="filteredProducts"
|
||||||
|
:loading="loadingProducts"
|
||||||
|
selectionMode="multiple"
|
||||||
|
dataKey="id"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="10"
|
||||||
|
:rowsPerPageOptions="[5, 10, 20, 50]"
|
||||||
|
stripedRows
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} productos"
|
||||||
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
||||||
|
>
|
||||||
|
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
||||||
|
|
||||||
|
<Column field="name" header="Producto" sortable style="min-width: 250px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-medium text-surface-900 dark:text-white">
|
||||||
|
{{ slotProps.data.name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-surface-500 dark:text-surface-400">
|
||||||
|
{{ slotProps.data.description }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="sku" header="SKU" sortable style="min-width: 120px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-mono text-sm text-surface-600 dark:text-surface-400">
|
||||||
|
{{ slotProps.data.sku }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="code" header="Código" sortable style="min-width: 100px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-mono text-sm text-primary-600 dark:text-primary-400">
|
||||||
|
{{ slotProps.data.code }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="is_serial" header="Tipo" style="min-width: 100px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<Badge
|
||||||
|
:value="slotProps.data.is_serial ? 'Serial' : 'Estándar'"
|
||||||
|
:severity="slotProps.data.is_serial ? 'info' : 'secondary'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="suggested_sale_price" header="Precio Sugerido" sortable style="min-width: 130px">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-semibold text-green-600 dark:text-green-400">
|
||||||
|
${{ slotProps.data.suggested_sale_price.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<i class="pi pi-inbox text-4xl text-surface-300 dark:text-surface-600 mb-4"></i>
|
||||||
|
<p class="text-surface-500 dark:text-surface-400">
|
||||||
|
No se encontraron productos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #loading>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<i class="pi pi-spin pi-spinner text-4xl text-primary mb-4"></i>
|
||||||
|
<p class="text-surface-500 dark:text-surface-400">
|
||||||
|
Cargando productos...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-surface-600 dark:text-surface-400">
|
||||||
|
{{ selectedProducts.length }} producto(s) seleccionado(s)
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
@click="closeProductModal"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Agregar Productos"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
:disabled="selectedProducts.length === 0"
|
||||||
|
@click="addSelectedProducts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -426,7 +426,11 @@ const getMovementTypeSeverity = (type: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openBatchAdd = () => {
|
const openBatchAdd = () => {
|
||||||
router.push({ name: 'BatchAddInventory' });
|
const warehouseId = route.params.id;
|
||||||
|
router.push({
|
||||||
|
name: 'WarehouseAddInventory',
|
||||||
|
query: { warehouse: warehouseId }
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewItem = (item: any) => {
|
const viewItem = (item: any) => {
|
||||||
|
|||||||
@ -1,448 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html class="light" lang="es">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
|
||||||
<title>Multi-Warehouse Goods Receipt Entry | Logistics Pro</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap"
|
|
||||||
rel="stylesheet" />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
|
||||||
rel="stylesheet" />
|
|
||||||
<script id="tailwind-config">
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: "class",
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
"primary": "#137fec",
|
|
||||||
"background-light": "#f6f7f8",
|
|
||||||
"background-dark": "#101922",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
"display": ["Inter"]
|
|
||||||
},
|
|
||||||
borderRadius: { "DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-symbols-outlined {
|
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="bg-background-light dark:bg-background-dark font-display min-h-screen">
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
|
||||||
<aside
|
|
||||||
class="w-64 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col shrink-0">
|
|
||||||
<div class="p-6 flex items-center gap-3">
|
|
||||||
<div class="w-10 h-10 bg-primary rounded-lg flex items-center justify-center text-white">
|
|
||||||
<span class="material-symbols-outlined">inventory_2</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-slate-900 dark:text-white text-base font-bold leading-none">Logistics Pro</h1>
|
|
||||||
<p class="text-slate-500 dark:text-slate-400 text-xs mt-1">Warehouse Admin</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav class="flex-1 px-4 py-4 space-y-1">
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
|
||||||
href="#">
|
|
||||||
<span class="material-symbols-outlined">dashboard</span>
|
|
||||||
<span class="text-sm font-medium">Dashboard</span>
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
|
||||||
href="#">
|
|
||||||
<span class="material-symbols-outlined">package_2</span>
|
|
||||||
<span class="text-sm font-medium">Inventario</span>
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 bg-primary/10 text-primary rounded-lg transition-colors"
|
|
||||||
href="#">
|
|
||||||
<span class="material-symbols-outlined"
|
|
||||||
style="font-variation-settings: 'FILL' 1">shopping_cart</span>
|
|
||||||
<span class="text-sm font-medium">Órdenes de Compra</span>
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
|
||||||
href="#">
|
|
||||||
<span class="material-symbols-outlined">warehouse</span>
|
|
||||||
<span class="text-sm font-medium">Almacenes</span>
|
|
||||||
</a>
|
|
||||||
<a class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
|
||||||
href="#">
|
|
||||||
<span class="material-symbols-outlined">bar_chart</span>
|
|
||||||
<span class="text-sm font-medium">Reportes</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
<div class="p-4 border-t border-slate-200 dark:border-slate-800">
|
|
||||||
<div class="flex items-center gap-3 px-3 py-2">
|
|
||||||
<div class="size-8 rounded-full bg-slate-200 dark:bg-slate-700 bg-cover bg-center"
|
|
||||||
style="background-image: url('https://lh3.googleusercontent.com/aida-public/AB6AXuDa90XPiU0x0vWPCwSkY-b0XPWxuaglsKdsGDuVxfyQKkMYEU5M9ZhbSQkCrmRlRjYEiSLJ6gAeZWIORr6MFvWrYq-WoJFzzEUf18zJjkqmJK9oU270B7r6BRVz-ynoNSS6rUNF4_PE2az4uQgysTtym0Akce2JSv5s077kdSfpUvEYPzeMM4Oi9SSMG9kAIjTiQbCrRVUcE81w8a1TwD-JHuzmXutnjRS3BaPKKcT57SiJhYhoSkL1waAxm7SWR_fWDpTzxbnDlHnl')">
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-medium text-slate-900 dark:text-white truncate">Carlos Ruiz</p>
|
|
||||||
<p class="text-xs text-slate-500 truncate">Supervisor</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<main class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
||||||
<header
|
|
||||||
class="h-16 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-8 shrink-0">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="flex items-center gap-2 text-slate-400 text-sm font-medium">
|
|
||||||
<a class="hover:text-primary" href="#">Inicio</a>
|
|
||||||
<span class="material-symbols-outlined text-xs">chevron_right</span>
|
|
||||||
<a class="hover:text-primary" href="#">Órdenes de Compra</a>
|
|
||||||
<span class="material-symbols-outlined text-xs">chevron_right</span>
|
|
||||||
<span class="text-slate-900 dark:text-white">Registrar Entrada (Multialmacén)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="relative w-64">
|
|
||||||
<span
|
|
||||||
class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-lg">search</span>
|
|
||||||
<input
|
|
||||||
class="w-full pl-10 pr-4 py-1.5 bg-slate-100 dark:bg-slate-800 border-none rounded-lg text-sm focus:ring-2 focus:ring-primary/20"
|
|
||||||
placeholder="Buscar PO, SKU..." type="text" />
|
|
||||||
</div>
|
|
||||||
<button class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white">
|
|
||||||
<span class="material-symbols-outlined">notifications</span>
|
|
||||||
</button>
|
|
||||||
<button class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-white">
|
|
||||||
<span class="material-symbols-outlined">settings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="flex-1 overflow-y-auto p-8">
|
|
||||||
<div class="max-w-7xl mx-auto space-y-6">
|
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-3xl font-black text-slate-900 dark:text-white tracking-tight">Registrar
|
|
||||||
Entrada de Mercancía</h2>
|
|
||||||
<p class="text-slate-500 dark:text-slate-400 mt-1 flex items-center gap-2">
|
|
||||||
<span class="material-symbols-outlined text-base">receipt_long</span>
|
|
||||||
Purchase Order #ORD-2023-001 | <span class="text-primary font-semibold">Distribución
|
|
||||||
Multi-Almacén</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="flex gap-4 bg-white dark:bg-slate-900 px-4 py-2 rounded-lg border border-slate-200 dark:border-slate-800 shadow-sm">
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-[10px] text-slate-500 uppercase tracking-wider font-bold">Total Items
|
|
||||||
PO</p>
|
|
||||||
<p class="text-lg font-black text-slate-900 dark:text-white leading-tight">12</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-px bg-slate-200 dark:bg-slate-800 h-8 self-center"></div>
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="text-[10px] text-slate-500 uppercase tracking-wider font-bold">Recibidos
|
|
||||||
</p>
|
|
||||||
<p class="text-lg font-black text-primary leading-tight">0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-200 font-bold text-sm rounded-lg hover:bg-slate-300 transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-lg">visibility</span>
|
|
||||||
Ver Detalles
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/50 flex justify-between items-center">
|
|
||||||
<h3 class="font-bold text-slate-900 dark:text-white">Productos de la Orden</h3>
|
|
||||||
<span class="text-xs text-slate-500 flex items-center gap-1">
|
|
||||||
<span class="material-symbols-outlined text-sm">info</span>
|
|
||||||
Seleccione el almacén de destino por cada producto
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr
|
|
||||||
class="text-left text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wider bg-slate-50 dark:bg-slate-800/30">
|
|
||||||
<th class="px-6 py-4">Producto</th>
|
|
||||||
<th class="px-6 py-4">SKU / Ref</th>
|
|
||||||
<th class="px-6 py-4 text-center">Cant. PO</th>
|
|
||||||
<th class="px-6 py-4 text-center">Recibida</th>
|
|
||||||
<th class="px-6 py-4">Almacén de Destino</th>
|
|
||||||
<th class="px-6 py-4">Acciones</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-800">
|
|
||||||
<tr class="group hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="size-10 bg-slate-100 dark:bg-slate-800 rounded-lg flex items-center justify-center text-slate-400">
|
|
||||||
<span class="material-symbols-outlined">inventory</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span
|
|
||||||
class="text-sm font-semibold text-slate-900 dark:text-white">Cables
|
|
||||||
de Red Cat6 2m</span>
|
|
||||||
<span class="text-xs text-slate-500">Accesorios Networking</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm text-slate-600 dark:text-slate-400">NET-C62M-WH
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="px-6 py-4 text-center text-sm font-bold text-slate-900 dark:text-white">
|
|
||||||
50</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<input
|
|
||||||
class="w-20 text-center px-2 py-1 bg-slate-100 dark:bg-slate-800 border-none rounded-lg text-sm font-bold focus:ring-2 focus:ring-primary"
|
|
||||||
max="50" min="0" type="number" value="0" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<select
|
|
||||||
class="w-full px-3 py-1.5 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg text-xs focus:ring-primary focus:border-primary">
|
|
||||||
<option value="">Seleccione Almacén...</option>
|
|
||||||
<option selected="" value="1">Almacén Principal (CDMX)</option>
|
|
||||||
<option value="2">Bodega Regional (MTY)</option>
|
|
||||||
<option value="3">CEDIS (GDL)</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400 uppercase">Estándar</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="bg-primary/[0.02] dark:bg-primary/[0.05]">
|
|
||||||
<td class="px-6 py-4 border-l-4 border-primary">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="size-10 bg-primary/10 rounded-lg flex items-center justify-center text-primary">
|
|
||||||
<span class="material-symbols-outlined">laptop_mac</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span
|
|
||||||
class="text-sm font-semibold text-slate-900 dark:text-white">MacBook
|
|
||||||
Pro M2 14"</span>
|
|
||||||
<span class="text-xs text-slate-500">Equipos de Cómputo</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm text-slate-600 dark:text-slate-400">LAP-MBP14-M2
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="px-6 py-4 text-center text-sm font-bold text-slate-900 dark:text-white">
|
|
||||||
5</td>
|
|
||||||
<td class="px-6 py-4 text-center">
|
|
||||||
<span class="text-sm font-black text-primary">1 / 5</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="text-xs text-slate-400 italic">Asignación por número de serie
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-2 px-3 py-1.5 bg-primary text-white text-xs font-bold rounded-lg hover:bg-primary/90 transition-all shadow-sm">
|
|
||||||
<span class="material-symbols-outlined text-sm">barcode_scanner</span>
|
|
||||||
GESTIONAR SERIES
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="bg-primary/[0.02] dark:bg-primary/[0.05]">
|
|
||||||
<td class="px-10 pb-6 pt-2" colspan="6">
|
|
||||||
<div
|
|
||||||
class="bg-white dark:bg-slate-800 rounded-xl border border-primary/20 p-5 shadow-sm">
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between mb-4 pb-3 border-b border-slate-100 dark:border-slate-700">
|
|
||||||
<div>
|
|
||||||
<h4
|
|
||||||
class="text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
||||||
Entrada de Números de Serie (4 Pendientes)</h4>
|
|
||||||
<p class="text-[11px] text-slate-500">Defina el almacén para
|
|
||||||
cada equipo escaneado</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 text-xs text-primary font-bold bg-primary/10 px-3 py-1.5 rounded-full">
|
|
||||||
<span class="material-symbols-outlined text-sm">barcode</span>
|
|
||||||
Listo para escanear
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
|
||||||
<div class="lg:col-span-5 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-[10px] font-bold text-slate-500 uppercase mb-1">Número
|
|
||||||
de Serie</label>
|
|
||||||
<div class="relative">
|
|
||||||
<span
|
|
||||||
class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">qr_code_scanner</span>
|
|
||||||
<input
|
|
||||||
class="w-full pl-10 pr-4 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm focus:ring-primary focus:border-primary"
|
|
||||||
placeholder="Escanear o escribir serie..."
|
|
||||||
type="text" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
class="block text-[10px] font-bold text-slate-500 uppercase mb-1">Almacén
|
|
||||||
Destino</label>
|
|
||||||
<select
|
|
||||||
class="w-full px-3 py-2 bg-slate-50 dark:bg-slate-700 border border-slate-200 dark:border-slate-600 rounded-lg text-sm focus:ring-primary focus:border-primary">
|
|
||||||
<option value="1">Almacén Principal (CDMX)</option>
|
|
||||||
<option value="2">Bodega Regional (MTY)</option>
|
|
||||||
<option value="3">CEDIS (GDL)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="w-full py-2 bg-slate-900 dark:bg-slate-700 text-white text-xs font-bold rounded-lg hover:bg-black transition-colors">
|
|
||||||
Registrar Serie
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="lg:col-span-7">
|
|
||||||
<div
|
|
||||||
class="bg-slate-50 dark:bg-slate-900/50 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
|
||||||
<table class="w-full text-xs">
|
|
||||||
<thead class="bg-slate-100 dark:bg-slate-800">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
class="px-3 py-2 text-left font-bold text-slate-500 uppercase">
|
|
||||||
Nº Serie</th>
|
|
||||||
<th
|
|
||||||
class="px-3 py-2 text-left font-bold text-slate-500 uppercase">
|
|
||||||
Almacén Destino</th>
|
|
||||||
<th class="px-3 py-2 text-right"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody
|
|
||||||
class="divide-y divide-slate-200 dark:divide-slate-700">
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
class="px-3 py-2 font-mono text-slate-900 dark:text-white">
|
|
||||||
SN-LAP-M2-00192</td>
|
|
||||||
<td class="px-3 py-2">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
|
||||||
<span
|
|
||||||
class="text-slate-600 dark:text-slate-300">Almacén
|
|
||||||
Principal (CDMX)</span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-2 text-right">
|
|
||||||
<button
|
|
||||||
class="text-slate-400 hover:text-red-500 transition-colors">
|
|
||||||
<span
|
|
||||||
class="material-symbols-outlined text-lg">delete</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="px-3 py-4 text-center text-slate-400 italic bg-slate-50/50 dark:bg-slate-800/30"
|
|
||||||
colspan="3">
|
|
||||||
Escanee el siguiente equipo para asignar
|
|
||||||
almacén...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="group hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="size-10 bg-slate-100 dark:bg-slate-800 rounded-lg flex items-center justify-center text-slate-400">
|
|
||||||
<span class="material-symbols-outlined">print</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span
|
|
||||||
class="text-sm font-semibold text-slate-900 dark:text-white">Impresora
|
|
||||||
Zebra ZT411</span>
|
|
||||||
<span class="text-xs text-slate-500">Hardware Almacén</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 text-sm text-slate-600 dark:text-slate-400">PRN-ZB411-IND
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="px-6 py-4 text-center text-sm font-bold text-slate-900 dark:text-white">
|
|
||||||
2</td>
|
|
||||||
<td class="px-6 py-4 text-center text-sm font-bold text-slate-400">0 / 2</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<div class="text-xs text-slate-400 italic">Asignación por serie requerida
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-2 px-3 py-1.5 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 text-xs font-bold rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
|
||||||
<span class="material-symbols-outlined text-sm">barcode_scanner</span>
|
|
||||||
GESTIONAR SERIES
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div
|
|
||||||
class="md:col-span-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-900/50 p-4 rounded-xl flex items-start gap-4">
|
|
||||||
<span class="material-symbols-outlined text-amber-600 dark:text-amber-500">warning</span>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-bold text-amber-800 dark:text-amber-400">Pendiente de Validación
|
|
||||||
y Distribución</p>
|
|
||||||
<p class="text-sm text-amber-700 dark:text-amber-500/80">Faltan registrar 6 números de
|
|
||||||
serie y asignar almacén de destino a 1 item de tipo estándar antes de confirmar.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="bg-white dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm">
|
|
||||||
<h4 class="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">Resumen de
|
|
||||||
Destinos</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex justify-between text-xs">
|
|
||||||
<span class="text-slate-600 dark:text-slate-400">Almacén Principal (CDMX)</span>
|
|
||||||
<span class="font-bold text-slate-900 dark:text-white">1 Unidad</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-xs">
|
|
||||||
<span class="text-slate-600 dark:text-slate-400">Bodega Regional (MTY)</span>
|
|
||||||
<span class="font-bold text-slate-900 dark:text-white">0 Unidades</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between text-xs">
|
|
||||||
<span class="text-slate-600 dark:text-slate-400">CEDIS (GDL)</span>
|
|
||||||
<span class="font-bold text-slate-900 dark:text-white">0 Unidades</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer
|
|
||||||
class="h-20 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 flex items-center justify-end px-8 gap-4 shrink-0">
|
|
||||||
<button
|
|
||||||
class="px-6 py-2.5 text-sm font-bold text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors">
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-8 py-2.5 bg-primary text-white text-sm font-bold rounded-lg shadow-lg shadow-primary/20 hover:bg-primary/90 transition-all opacity-50 cursor-not-allowed">
|
|
||||||
Confirmar Recepción Multi-Almacén
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@ -8,8 +8,7 @@ import WarehouseIndex from '../modules/warehouse/components/WarehouseIndex.vue';
|
|||||||
import WarehouseForm from '../modules/warehouse/components/WarehouseForm.vue';
|
import WarehouseForm from '../modules/warehouse/components/WarehouseForm.vue';
|
||||||
import WarehouseDetails from '../modules/warehouse/components/WarehouseDetails.vue';
|
import WarehouseDetails from '../modules/warehouse/components/WarehouseDetails.vue';
|
||||||
import WarehouseClassification from '../modules/warehouse/components/WarehouseClassification.vue';
|
import WarehouseClassification from '../modules/warehouse/components/WarehouseClassification.vue';
|
||||||
import UnitOfMeasure from '../modules/catalog/components/UnitOfMeasure.vue';
|
import Units from '../modules/catalog/components/units/Units.vue';
|
||||||
import ComercialClassification from '../modules/catalog/components/ComercialClassification.vue';
|
|
||||||
import ProductsIndex from '../modules/products/components/ProductsIndex.vue';
|
import ProductsIndex from '../modules/products/components/ProductsIndex.vue';
|
||||||
import ProductForm from '../modules/products/components/ProductForm.vue';
|
import ProductForm from '../modules/products/components/ProductForm.vue';
|
||||||
import StoresIndex from '../modules/stores/components/StoresIndex.vue';
|
import StoresIndex from '../modules/stores/components/StoresIndex.vue';
|
||||||
@ -19,7 +18,7 @@ import RoleForm from '../modules/users/components/RoleForm.vue';
|
|||||||
import UserIndex from '../modules/users/components/UserIndex.vue';
|
import UserIndex from '../modules/users/components/UserIndex.vue';
|
||||||
import StoreDetails from '../modules/stores/components/StoreDetails.vue';
|
import StoreDetails from '../modules/stores/components/StoreDetails.vue';
|
||||||
import Positions from '../modules/rh/components/Positions.vue';
|
import Positions from '../modules/rh/components/Positions.vue';
|
||||||
import Departments from '../modules/rh/components/Departments.vue';
|
import Departments from '../modules/rh/components/departments/Departments.vue';
|
||||||
|
|
||||||
import '../modules/catalog/components/suppliers/Suppliers.vue';
|
import '../modules/catalog/components/suppliers/Suppliers.vue';
|
||||||
import Suppliers from '../modules/catalog/components/suppliers/Suppliers.vue';
|
import Suppliers from '../modules/catalog/components/suppliers/Suppliers.vue';
|
||||||
@ -27,6 +26,11 @@ import Purchases from '../modules/purchases/components/Purchases.vue';
|
|||||||
import PurchaseDetails from '../modules/purchases/components/PurchaseDetails.vue';
|
import PurchaseDetails from '../modules/purchases/components/PurchaseDetails.vue';
|
||||||
import PurchaseForm from '../modules/purchases/components/PurchaseForm.vue';
|
import PurchaseForm from '../modules/purchases/components/PurchaseForm.vue';
|
||||||
import WarehouseAddInventory from '../modules/warehouse/components/WarehouseAddInventory.vue';
|
import WarehouseAddInventory from '../modules/warehouse/components/WarehouseAddInventory.vue';
|
||||||
|
import ModelDocuments from '../modules/catalog/components/ModelDocuments.vue';
|
||||||
|
import Requisitions from '../modules/requisitions/Requisitions.vue';
|
||||||
|
import CreateRequisition from '../modules/requisitions/CreateRequisition.vue';
|
||||||
|
import ClassificationsComercial from '../modules/catalog/components/comercial-classification/ClassificationsComercial.vue';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@ -137,7 +141,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'units-of-measure',
|
path: 'units-of-measure',
|
||||||
name: 'UnitsOfMeasure',
|
name: 'UnitsOfMeasure',
|
||||||
component: UnitOfMeasure,
|
component: Units,
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Unidades de Medida',
|
title: 'Unidades de Medida',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
@ -146,7 +150,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'classifications-comercial',
|
path: 'classifications-comercial',
|
||||||
name: 'ClassificationsComercial',
|
name: 'ClassificationsComercial',
|
||||||
component: ComercialClassification,
|
component: ClassificationsComercial,
|
||||||
meta: {
|
meta: {
|
||||||
title: 'Clasificaciones Comerciales',
|
title: 'Clasificaciones Comerciales',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
@ -161,6 +165,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'model-documents',
|
||||||
|
name: 'ModelDocuments',
|
||||||
|
component: ModelDocuments,
|
||||||
|
meta: {
|
||||||
|
title: 'Documentos del Modelo',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -298,6 +311,52 @@ const routes: RouteRecordRaw[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'requisitions',
|
||||||
|
name: 'RequisitionsModule',
|
||||||
|
meta: {
|
||||||
|
title: 'Requisiciones',
|
||||||
|
requiresAuth: true
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'Requisitions',
|
||||||
|
component: Requisitions,
|
||||||
|
meta: {
|
||||||
|
title: 'Gestión de Requisiciones',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'create',
|
||||||
|
name: 'RequisitionCreate',
|
||||||
|
component: CreateRequisition,
|
||||||
|
meta: {
|
||||||
|
title: 'Crear Requisición',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit/:id',
|
||||||
|
name: 'RequisitionEdit',
|
||||||
|
component: CreateRequisition,
|
||||||
|
meta: {
|
||||||
|
title: 'Editar Requisición',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
name: 'RequisitionView',
|
||||||
|
component: CreateRequisition,
|
||||||
|
meta: {
|
||||||
|
title: 'Ver Requisición',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,10 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import vue from "@vitejs/plugin-vue";
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import Components from 'unplugin-vue-components/vite';
|
import Components from 'unplugin-vue-components/vite';
|
||||||
import { PrimeVueResolver } from '@primevue/auto-import-resolver';
|
import { PrimeVueResolver } from '@primevue/auto-import-resolver';
|
||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@ -13,5 +14,10 @@ export default defineConfig({
|
|||||||
PrimeVueResolver()
|
PrimeVueResolver()
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
]
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user