feature-comercial-module-ts #11
@ -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']
|
||||
InputIcon: typeof import('primevue/inputicon')['default']
|
||||
InputNumber: typeof import('primevue/inputnumber')['default']
|
||||
InputSwitch: typeof import('primevue/inputswitch')['default']
|
||||
InputText: typeof import('primevue/inputtext')['default']
|
||||
KpiCard: typeof import('./src/components/shared/KpiCard.vue')['default']
|
||||
Menu: typeof import('primevue/menu')['default']
|
||||
@ -39,6 +40,7 @@ declare module 'vue' {
|
||||
ProgressSpinner: typeof import('primevue/progressspinner')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('primevue/select')['default']
|
||||
Sidebar: typeof import('./src/components/layout/Sidebar.vue')['default']
|
||||
Tag: typeof import('primevue/tag')['default']
|
||||
Textarea: typeof import('primevue/textarea')['default']
|
||||
|
||||
@ -5,12 +5,13 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: ${VITE_API_URL}
|
||||
container_name: controls-front-prod
|
||||
container_name: front-controls
|
||||
ports:
|
||||
- "${APP_PORT}:80"
|
||||
networks:
|
||||
- controls-network
|
||||
restart: unless-stopped
|
||||
mem_limit: 512mb
|
||||
|
||||
networks:
|
||||
controls-network:
|
||||
|
||||
@ -25,6 +25,20 @@ const menuItems = ref<MenuItem[]>([
|
||||
{ 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: '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: 'Productos',
|
||||
icon: 'pi pi-shopping-cart',
|
||||
to: '/products'
|
||||
},
|
||||
{
|
||||
label: 'Puntos de venta',
|
||||
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">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import Textarea from 'primevue/textarea';
|
||||
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<{
|
||||
visible: boolean;
|
||||
isEditMode: boolean;
|
||||
supplier?: Supplier | null;
|
||||
formErrors: SupplierFormErrors;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -20,36 +23,66 @@ const emit = defineEmits<{
|
||||
(e: 'cancel'): void;
|
||||
}>();
|
||||
|
||||
const defaultAddress = (): SupplierAddress => ({
|
||||
country: 'México',
|
||||
postal_code: '',
|
||||
state: '',
|
||||
municipality: '',
|
||||
city: '',
|
||||
street: '',
|
||||
num_ext: '',
|
||||
num_int: ''
|
||||
});
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
type: '',
|
||||
address: ''
|
||||
comercial_name: '',
|
||||
rfc: '',
|
||||
curp: '',
|
||||
type: SupplierType.PROVIDER as number,
|
||||
credit_limit: 0,
|
||||
payment_days: 30,
|
||||
addresses: [defaultAddress()]
|
||||
});
|
||||
|
||||
const address = computed(() => form.value.addresses[0] || defaultAddress());
|
||||
|
||||
watch(
|
||||
() => props.supplier,
|
||||
(supplier) => {
|
||||
if (props.isEditMode && supplier) {
|
||||
form.value = {
|
||||
name: supplier.name,
|
||||
email: supplier.contact_email,
|
||||
phone: supplier.phone_number,
|
||||
comercial_name: supplier.comercial_name,
|
||||
rfc: supplier.rfc,
|
||||
curp: supplier.curp,
|
||||
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 {
|
||||
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 }
|
||||
);
|
||||
|
||||
const supplierTypeOptions = [
|
||||
{ label: 'General', value: 'general' },
|
||||
{ label: 'Compra', value: 'purchases' },
|
||||
{ label: 'Venta', value: 'sales' }
|
||||
{ label: 'Cliente', value: SupplierType.CLIENT },
|
||||
{ label: 'Proveedor', value: SupplierType.PROVIDER },
|
||||
{ label: 'Cliente/Proveedor', value: SupplierType.BOTH }
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
@ -62,42 +95,125 @@ const handleCancel = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :visible="visible" modal :style="{ width: '600px' }" :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">
|
||||
<label class="flex flex-col gap-2">
|
||||
<p class="text-[#111418] dark:text-white text-sm font-semibold leading-normal">
|
||||
Nombre del Proveedor <span class="text-red-500">*</span></p>
|
||||
<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 mt-1" v-for="err in formErrors.name" :key="err">{{ err }}</div>
|
||||
</label>
|
||||
<label class="flex flex-col gap-2">
|
||||
<p class="text-[#111418] dark:text-white text-sm font-semibold leading-normal">
|
||||
Correo de Contacto <span class="text-red-500">*</span></p>
|
||||
<InputText v-model="form.email" placeholder="contacto@proveedor.com" required class="w-full" type="email" />
|
||||
<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>
|
||||
<Dialog :visible="visible" modal :style="{ width: '800px' }" :header="isEditMode ? 'Editar Proveedor' : 'Crear Nuevo Proveedor'" :closable="true" @update:visible="val => emit('update:visible', val)">
|
||||
|
||||
<!-- 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">
|
||||
<p class="text-sm font-semibold">Razón Social <span class="text-red-500">*</span></p>
|
||||
<InputText v-model="form.name" placeholder="Ej: Distribuidora Nacional S.A. de C.V." required />
|
||||
<div v-if="formErrors.name" class="text-red-500 text-xs" v-for="err in formErrors.name" :key="err">{{ err }}</div>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<p class="text-sm font-semibold">Nombre Comercial <span class="text-red-500">*</span></p>
|
||||
<InputText v-model="form.comercial_name" placeholder="Ej: DistriNacional" required />
|
||||
<div v-if="formErrors.comercial_name" class="text-red-500 text-xs" v-for="err in formErrors.comercial_name" :key="err">{{ err }}</div>
|
||||
</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">RFC <span class="text-red-500">*</span></p>
|
||||
<InputText v-model="form.rfc" placeholder="XAXX010101000" required maxlength="13" class="font-mono uppercase" @input="form.rfc = form.rfc.toUpperCase()" />
|
||||
<div v-if="formErrors.rfc" class="text-red-500 text-xs" v-for="err in formErrors.rfc" :key="err">{{ err }}</div>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<p class="text-sm font-semibold">CURP <span class="text-red-500">*</span></p>
|
||||
<InputText v-model="form.curp" placeholder="XAXX010101HDFABC01" required maxlength="18" class="font-mono uppercase" @input="form.curp = form.curp.toUpperCase()" />
|
||||
<div v-if="formErrors.curp" class="text-red-500 text-xs" v-for="err in formErrors.curp" :key="err">{{ err }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<label class="flex flex-col gap-2">
|
||||
<p class="text-[#111418] dark:text-white text-sm font-semibold leading-normal">Teléfono</p>
|
||||
<InputText v-model="form.phone" placeholder="+52 ..." class="w-full" type="tel" />
|
||||
<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>
|
||||
</label>
|
||||
<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>
|
||||
<Dropdown v-model="form.type" :options="supplierTypeOptions" optionLabel="label" optionValue="value" placeholder="Seleccionar tipo" class="w-full" required />
|
||||
<div v-if="formErrors.type" class="text-red-500 text-xs mt-1" v-for="err in formErrors.type" :key="err">{{ err }}</div>
|
||||
</label>
|
||||
|
||||
<!-- 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">
|
||||
<p class="text-sm font-semibold">Tipo <span class="text-red-500">*</span></p>
|
||||
<Dropdown v-model="form.type" :options="supplierTypeOptions" optionLabel="label" optionValue="value" placeholder="Seleccionar tipo" required />
|
||||
<div v-if="formErrors.type" class="text-red-500 text-xs" v-for="err in formErrors.type" :key="err">{{ err }}</div>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<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>
|
||||
<label class="flex flex-col gap-2">
|
||||
<p class="text-[#111418] dark:text-white text-sm font-semibold leading-normal">Dirección</p>
|
||||
<Textarea v-model="form.address" placeholder="Calle, Número, Colonia, Ciudad, Estado, CP" class="w-full" autoResize />
|
||||
<div v-if="formErrors.address" class="text-red-500 text-xs mt-1" v-for="err in formErrors.address" :key="err">{{ err }}</div>
|
||||
</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" />
|
||||
<Button :label="isEditMode ? 'Actualizar Proveedor' : 'Guardar Proveedor'" type="submit" class="w-full sm:w-auto" />
|
||||
|
||||
<!-- 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>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@ -15,8 +15,9 @@ import { useToast } from 'primevue/usetoast';
|
||||
|
||||
|
||||
|
||||
import { supplierServices } from '../../services/supplierServices';
|
||||
import type { Supplier, SupplierPaginatedResponse, SupplierFormErrors } from '../../types/suppliers';
|
||||
import { supplierServices } from '../../services/supplier.services';
|
||||
import type { Supplier, SupplierPaginatedResponse, SupplierFormErrors } from '../../types/suppliers.interfaces';
|
||||
import { SupplierType } from '../../types/suppliers.interfaces';
|
||||
import SupplierModal from './SupplierModal.vue';
|
||||
|
||||
|
||||
@ -29,6 +30,7 @@ const pagination = ref({
|
||||
lastPage: 1,
|
||||
});
|
||||
const loading = ref(false);
|
||||
const loadingSupplier = ref(false);
|
||||
|
||||
// Modal state and form fields
|
||||
const showModal = ref(false);
|
||||
@ -58,11 +60,12 @@ const handleDelete = (supplierId: number) => {
|
||||
};
|
||||
|
||||
|
||||
const mapTypeToApi = (type: string) => {
|
||||
switch (type) {
|
||||
case 'General': return 'general';
|
||||
case 'Compra': return 'purchases';
|
||||
case 'Venta': return 'sales';
|
||||
const mapTypeToApi = (typeLabel: string | null) => {
|
||||
if (!typeLabel) return undefined;
|
||||
switch (typeLabel) {
|
||||
case 'Cliente': return SupplierType.CLIENT;
|
||||
case 'Proveedor': return SupplierType.PROVIDER;
|
||||
case 'Ambos': return SupplierType.BOTH;
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
@ -71,17 +74,11 @@ const fetchSuppliers = async (page = 1) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
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 });
|
||||
const response = await supplierServices.getSuppliers(true, name, type);
|
||||
const paginated = response as SupplierPaginatedResponse;
|
||||
suppliers.value = paginated.data.map(s => ({
|
||||
...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() : ''
|
||||
}));
|
||||
suppliers.value = paginated.data;
|
||||
pagination.value.total = paginated.total;
|
||||
pagination.value.page = paginated.current_page;
|
||||
pagination.value.lastPage = paginated.last_page;
|
||||
@ -100,9 +97,9 @@ onMounted(() => {
|
||||
|
||||
const supplierTypes = [
|
||||
{ label: 'Todos', value: null },
|
||||
{ label: 'General', value: 'General' },
|
||||
{ label: 'Compras', value: 'Compra' },
|
||||
{ label: 'Ventas', value: 'Venta' },
|
||||
{ label: 'Cliente', value: 'Cliente' },
|
||||
{ label: 'Proveedor', value: 'Proveedor' },
|
||||
{ label: 'Ambos', value: 'Ambos' },
|
||||
];
|
||||
|
||||
const selectedType = ref(null);
|
||||
@ -133,11 +130,27 @@ const openCreateModal = () => {
|
||||
formErrors.value = {};
|
||||
};
|
||||
|
||||
const openEditModal = (supplier: Supplier) => {
|
||||
const openEditModal = async (supplier: Supplier) => {
|
||||
isEditMode.value = true;
|
||||
showModal.value = true;
|
||||
currentSupplier.value = supplier;
|
||||
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 = () => {
|
||||
@ -151,24 +164,10 @@ const handleModalSubmit = async (form: any) => {
|
||||
formErrors.value = {};
|
||||
try {
|
||||
if (isEditMode.value && currentSupplier.value) {
|
||||
const payload = {
|
||||
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');
|
||||
await supplierServices.updateSupplier(currentSupplier.value.id, form, 'patch');
|
||||
toast.add({ severity: 'success', summary: 'Proveedor actualizado', detail: 'Proveedor actualizado correctamente', life: 3000 });
|
||||
} else {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
contact_email: form.email,
|
||||
phone_number: form.phone,
|
||||
address: form.address,
|
||||
type: form.type,
|
||||
};
|
||||
await supplierServices.createSupplier(payload);
|
||||
await supplierServices.createSupplier(form);
|
||||
toast.add({ severity: 'success', summary: 'Proveedor creado', detail: 'Proveedor registrado correctamente', life: 3000 });
|
||||
}
|
||||
closeModal();
|
||||
@ -182,14 +181,24 @@ const handleModalSubmit = async (form: any) => {
|
||||
};
|
||||
|
||||
// Mapeo para mostrar el tipo de proveedor con label legible
|
||||
const typeLabel = (type: string) => {
|
||||
const typeLabel = (type: number) => {
|
||||
switch (type) {
|
||||
case 'general': return 'General';
|
||||
case 'purchases': return 'Compras';
|
||||
case 'sales': return 'Ventas';
|
||||
default: return type;
|
||||
case SupplierType.CLIENT: return 'Cliente';
|
||||
case SupplierType.PROVIDER: return 'Proveedor';
|
||||
case SupplierType.BOTH: return 'Ambos';
|
||||
default: return 'Desconocido';
|
||||
}
|
||||
};
|
||||
|
||||
const typeSeverity = (type: number) => {
|
||||
switch (type) {
|
||||
case SupplierType.CLIENT: return 'info';
|
||||
case SupplierType.PROVIDER: return 'success';
|
||||
case SupplierType.BOTH: return 'warning';
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -204,7 +213,7 @@ const typeLabel = (type: string) => {
|
||||
</div>
|
||||
<Button label="Nuevo Proveedor" icon="pi pi-plus" @click="openCreateModal" />
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
@ -233,27 +242,45 @@ const typeLabel = (type: string) => {
|
||||
<Card>
|
||||
<template #content>
|
||||
<DataTable :value="suppliers" :loading="loading" stripedRows responsiveLayout="scroll"
|
||||
class="p-datatable-sm">
|
||||
<Column field="id" header="ID" style="min-width: 80px" />
|
||||
<Column field="name" header="Nombre" style="min-width: 200px">
|
||||
class="p-datatable-sm"><
|
||||
<Column field="name" header="Razón Social" style="min-width: 180px">
|
||||
<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>
|
||||
</Column>
|
||||
<Column field="email" header="Correo de Contacto" style="min-width: 200px">
|
||||
<Column field="rfc" header="RFC" style="min-width: 130px">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</Column>
|
||||
|
||||
<Column field="date" header="Fecha de Registro" style="min-width: 120px" />
|
||||
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right"
|
||||
style="min-width: 120px">
|
||||
<template #body="{ data }">
|
||||
|
||||
@ -8,17 +8,13 @@ import Button from 'primevue/button';
|
||||
import Card from 'primevue/card';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import InputSwitch from 'primevue/inputswitch';
|
||||
import Tag from 'primevue/tag';
|
||||
import Toast from 'primevue/toast';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import { useUnitOfMeasureStore } from '../stores/unitOfMeasureStore';
|
||||
import { unitTypesService } from '../services/unitsTypes';
|
||||
import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../types/unitOfMeasure';
|
||||
import { useUnitOfMeasureStore } from '../../stores/unitOfMeasureStore';
|
||||
import UnitsForm from './UnitsForm.vue';
|
||||
import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../../types/unit-measure.interfaces';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
@ -39,27 +35,12 @@ const home = ref({
|
||||
// State
|
||||
const showDialog = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const formData = ref<CreateUnitOfMeasureData>({
|
||||
code: '',
|
||||
name: '',
|
||||
type: 4, // Default: Unidad
|
||||
abbreviation: '',
|
||||
is_active: 1
|
||||
});
|
||||
const editingId = ref<number | null>(null);
|
||||
|
||||
// Type options - loaded from API
|
||||
const typeOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const loadingTypes = ref(false);
|
||||
const selectedUnit = ref<UnitOfMeasure | null>(null);
|
||||
|
||||
// Computed
|
||||
const units = computed(() => unitStore.units);
|
||||
const loading = computed(() => unitStore.loading);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
isEditing.value ? 'Editar Unidad de Medida' : 'Nueva Unidad de Medida'
|
||||
);
|
||||
|
||||
const getStatusConfig = (isActive: number) => {
|
||||
return isActive === 1
|
||||
? { label: 'Activa', severity: 'success' }
|
||||
@ -69,47 +50,20 @@ const getStatusConfig = (isActive: number) => {
|
||||
// Methods
|
||||
const openCreateDialog = () => {
|
||||
isEditing.value = false;
|
||||
editingId.value = null;
|
||||
formData.value = {
|
||||
code: '',
|
||||
name: '',
|
||||
type: 4,
|
||||
abbreviation: '',
|
||||
is_active: 1
|
||||
};
|
||||
selectedUnit.value = null;
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (unit: UnitOfMeasure) => {
|
||||
isEditing.value = true;
|
||||
editingId.value = unit.id;
|
||||
formData.value = {
|
||||
code: unit.code,
|
||||
name: unit.name,
|
||||
type: unit.type,
|
||||
abbreviation: unit.abbreviation,
|
||||
is_active: unit.is_active
|
||||
};
|
||||
selectedUnit.value = unit;
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
showDialog.value = false;
|
||||
formData.value = {
|
||||
code: '',
|
||||
name: '',
|
||||
type: 4,
|
||||
abbreviation: '',
|
||||
is_active: 1
|
||||
};
|
||||
editingId.value = null;
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
const saveUnit = async () => {
|
||||
const handleSaveUnit = async (data: CreateUnitOfMeasureData) => {
|
||||
try {
|
||||
if (isEditing.value && editingId.value) {
|
||||
await unitStore.updateUnit(editingId.value, formData.value);
|
||||
if (isEditing.value && selectedUnit.value) {
|
||||
await unitStore.updateUnit(selectedUnit.value.id, data);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Actualización Exitosa',
|
||||
@ -117,7 +71,7 @@ const saveUnit = async () => {
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
await unitStore.createUnit(formData.value);
|
||||
await unitStore.createUnit(data);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Creación Exitosa',
|
||||
@ -125,7 +79,7 @@ const saveUnit = async () => {
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
closeDialog();
|
||||
showDialog.value = false;
|
||||
} catch (error) {
|
||||
console.error('Error saving unit:', error);
|
||||
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
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
unitStore.fetchUnits(),
|
||||
loadUnitTypes()
|
||||
]);
|
||||
await unitStore.fetchUnits();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -263,14 +191,6 @@ onMounted(async () => {
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} unidades"
|
||||
>
|
||||
<Column field="code" header="Código" sortable style="width: 120px">
|
||||
<template #body="slotProps">
|
||||
<span class="font-mono font-medium text-surface-700 dark:text-surface-300">
|
||||
{{ slotProps.data.code }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="abbreviation" header="Abreviatura" sortable style="width: 150px">
|
||||
<template #body="slotProps">
|
||||
<span class="font-mono font-semibold text-primary">
|
||||
@ -287,10 +207,18 @@ onMounted(async () => {
|
||||
</template>
|
||||
</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">
|
||||
<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>
|
||||
</template>
|
||||
</Column>
|
||||
@ -336,107 +264,12 @@ onMounted(async () => {
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<Dialog
|
||||
<!-- Create/Edit Form Dialog -->
|
||||
<UnitsForm
|
||||
v-model:visible="showDialog"
|
||||
:header="dialogTitle"
|
||||
:modal="true"
|
||||
:closable="true"
|
||||
:draggable="false"
|
||||
class="w-full max-w-md"
|
||||
>
|
||||
<div class="space-y-4 pt-4">
|
||||
<!-- Code -->
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium mb-2">
|
||||
Código <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
id="code"
|
||||
v-model="formData.code"
|
||||
class="w-full"
|
||||
placeholder="Ej: GS1, KG01"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium mb-2">
|
||||
Nombre <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
class="w-full"
|
||||
placeholder="Ej: PIEZA, KILOGRAMO, METRO"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Abbreviation -->
|
||||
<div>
|
||||
<label for="abbreviation" class="block text-sm font-medium mb-2">
|
||||
Abreviatura <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<InputText
|
||||
id="abbreviation"
|
||||
v-model="formData.abbreviation"
|
||||
class="w-full"
|
||||
placeholder="Ej: PZA, kg, m"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label for="type" class="block text-sm font-medium mb-2">
|
||||
Tipo <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Dropdown
|
||||
id="type"
|
||||
v-model="formData.type"
|
||||
:options="typeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
placeholder="Selecciona un tipo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label for="is_active" class="block text-sm font-medium mb-2">
|
||||
Estado
|
||||
</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<InputSwitch
|
||||
id="is_active"
|
||||
:model-value="formData.is_active === 1"
|
||||
@update:model-value="formData.is_active = $event ? 1 : 0"
|
||||
/>
|
||||
<span class="text-sm font-medium">
|
||||
{{ formData.is_active === 1 ? 'Activa' : 'Inactiva' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="closeDialog"
|
||||
/>
|
||||
<Button
|
||||
:label="isEditing ? 'Actualizar' : 'Crear'"
|
||||
:disabled="!formData.code || !formData.name || !formData.abbreviation"
|
||||
@click="saveUnit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
:unit="selectedUnit"
|
||||
:is-editing="isEditing"
|
||||
@save="handleSaveUnit"
|
||||
/>
|
||||
</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 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 = {
|
||||
@ -26,7 +26,18 @@ const supplierServices = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
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> {
|
||||
try {
|
||||
const response = await api.post('/api/suppliers', data);
|
||||
@ -1,28 +1,27 @@
|
||||
import api from '../../../services/api';
|
||||
import type {
|
||||
UnitOfMeasureResponse,
|
||||
CreateUnitOfMeasureData,
|
||||
UnitOfMeasurePaginatedResponse,
|
||||
UnitOfMeasureUnpaginatedResponse,
|
||||
CreateUnitOfMeasureData,
|
||||
UpdateUnitOfMeasureData,
|
||||
SingleUnitOfMeasureResponse
|
||||
} from '../types/unitOfMeasure';
|
||||
SingleUnitOfMeasureResponse,
|
||||
UnitOfMeasureResponseById
|
||||
} from '../types/unit-measure.interfaces';
|
||||
|
||||
|
||||
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> {
|
||||
const response = await api.get(`/api/catalogs/units-of-measure`, {
|
||||
params: { page, per_page: perPage }
|
||||
});
|
||||
|
||||
console.log('Units of Measure response:', response);
|
||||
async getUnits(paginate: boolean = true, search: string = ''): Promise<UnitOfMeasurePaginatedResponse | UnitOfMeasureUnpaginatedResponse> {
|
||||
const response = await api.get(`/api/catalogs/units-of-measure?paginate=${paginate}&search=${search}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
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 { ref } from 'vue';
|
||||
import { supplierServices } from '../services/supplierServices';
|
||||
import type { Supplier } from '../types/suppliers';
|
||||
import { supplierServices } from '../services/supplier.services';
|
||||
import type { Supplier } from '../types/suppliers.interfaces';
|
||||
|
||||
export const useSupplierStore = defineStore('supplier', () => {
|
||||
const suppliers = ref<Supplier[]>([]);
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { unitOfMeasureService } from '../services/unitOfMeasureService';
|
||||
import type { UnitOfMeasure, CreateUnitOfMeasureData, UpdateUnitOfMeasureData } from '../types/unitOfMeasure';
|
||||
import { unitOfMeasureService } from '../services/unit-measure.services';
|
||||
import type {
|
||||
UnitOfMeasure,
|
||||
CreateUnitOfMeasureData,
|
||||
UpdateUnitOfMeasureData
|
||||
} from '../types/unit-measure.interfaces';
|
||||
|
||||
export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
|
||||
// State
|
||||
@ -31,7 +35,7 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
|
||||
});
|
||||
|
||||
// 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
|
||||
if (loaded.value && !force) {
|
||||
console.log('Units of measure already loaded from store');
|
||||
@ -42,8 +46,17 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await unitOfMeasureService.getUnits();
|
||||
units.value = response.data.units_of_measure.data;
|
||||
const response = await unitOfMeasureService.getUnits(paginate, search);
|
||||
|
||||
// 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;
|
||||
|
||||
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
|
||||
export interface SupplierFormErrors {
|
||||
name?: string[];
|
||||
contact_email?: string[];
|
||||
phone_number?: string[];
|
||||
address?: string[];
|
||||
comercial_name?: string[];
|
||||
rfc?: string[];
|
||||
curp?: string[];
|
||||
type?: string[];
|
||||
credit_limit?: string[];
|
||||
payment_days?: string[];
|
||||
}
|
||||
// Respuesta simple de proveedores (sin paginación)
|
||||
export interface SupplierListResponse {
|
||||
@ -15,10 +25,15 @@ export interface SupplierListResponse {
|
||||
export interface Supplier {
|
||||
id: number;
|
||||
name: string;
|
||||
contact_email: string;
|
||||
phone_number: string;
|
||||
address: string;
|
||||
type: string;
|
||||
comercial_name: string;
|
||||
rfc: string;
|
||||
curp: string;
|
||||
type: SupplierType;
|
||||
credit_limit: string;
|
||||
payment_days: number;
|
||||
contact_email?: string;
|
||||
phone_number?: string;
|
||||
addresses?: SupplierAddress[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
@ -51,12 +66,28 @@ export interface SupplierDeleteResponse {
|
||||
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 {
|
||||
name: string;
|
||||
contact_email: string;
|
||||
phone_number: string;
|
||||
address: string;
|
||||
type: string;
|
||||
comercial_name: string;
|
||||
rfc: string;
|
||||
curp: string;
|
||||
type: SupplierType;
|
||||
credit_limit: number;
|
||||
payment_days: number;
|
||||
contact_email?: string;
|
||||
phone_number?: string;
|
||||
addresses: SupplierAddress[];
|
||||
}
|
||||
|
||||
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 Button from 'primevue/button';
|
||||
import FileUpload from 'primevue/fileupload';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import { useComercialClassificationStore } from '../../catalog/stores/comercialClassificationStore';
|
||||
import { useUnitOfMeasureStore } from '../../catalog/stores/unitOfMeasureStore';
|
||||
import { satCodeProductsService, type SatCodeProduct } from '../../catalog/services/sat-code-products.services';
|
||||
import type { Product, CreateProductData } from '../types/product';
|
||||
|
||||
// Props
|
||||
@ -39,14 +41,22 @@ const formData = ref<CreateProductData>({
|
||||
description: '',
|
||||
unit_of_measure_id: 0,
|
||||
suggested_sale_price: 0,
|
||||
attributes: {},
|
||||
attributes: null,
|
||||
is_active: true,
|
||||
is_serial: false,
|
||||
sat_code_product_id: null,
|
||||
classifications: []
|
||||
});
|
||||
|
||||
// Temporary string for price input
|
||||
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
|
||||
interface AttributeValue {
|
||||
id: string;
|
||||
@ -131,6 +141,67 @@ const toggleClassification = (classificationId: number) => {
|
||||
|
||||
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
|
||||
onMounted(async () => {
|
||||
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,
|
||||
attributes: newProduct.attributes || {},
|
||||
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) || []
|
||||
};
|
||||
|
||||
// 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();
|
||||
|
||||
// Convert attributes object to array format
|
||||
@ -349,6 +427,48 @@ const onUpload = (event: any) => {
|
||||
/>
|
||||
</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 -->
|
||||
<div class="sm:col-span-2">
|
||||
<label for="description" class="block text-sm font-medium mb-2">
|
||||
@ -369,7 +489,7 @@ const onUpload = (event: any) => {
|
||||
<!-- Product Attributes Card -->
|
||||
<Card>
|
||||
<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 #content>
|
||||
<div class="space-y-6">
|
||||
@ -381,7 +501,7 @@ const onUpload = (event: any) => {
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="block text-sm font-medium">
|
||||
Nombre del Atributo
|
||||
Nombre de la característica
|
||||
</label>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
@ -399,7 +519,7 @@ const onUpload = (event: any) => {
|
||||
/>
|
||||
|
||||
<label class="block text-sm font-medium mb-2">
|
||||
Valores del Atributo
|
||||
Valores de la característica
|
||||
</label>
|
||||
|
||||
<!-- Attribute values -->
|
||||
@ -434,7 +554,7 @@ const onUpload = (event: any) => {
|
||||
|
||||
<!-- Add attribute button -->
|
||||
<Button
|
||||
label="Agregar Otro Atributo"
|
||||
label="Agregar Otra Característica"
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
|
||||
@ -282,6 +282,16 @@ onMounted(() => {
|
||||
</template>
|
||||
</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 -->
|
||||
<Column header="Acciones" headerStyle="text-align: right" bodyStyle="text-align: right" style="min-width: 120px">
|
||||
<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
|
||||
*/
|
||||
|
||||
import type { SatCodeProduct } from '../../catalog/services/sat-code-products.services';
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
code: string;
|
||||
@ -13,6 +15,9 @@ export interface Product {
|
||||
suggested_sale_price: number;
|
||||
attributes?: Record<string, string[]>;
|
||||
is_active: boolean;
|
||||
is_serial: boolean;
|
||||
sat_code_product_id?: number | null;
|
||||
sat_code_product?: SatCodeProduct;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
@ -28,8 +33,10 @@ export interface CreateProductData {
|
||||
description?: string;
|
||||
unit_of_measure_id: number;
|
||||
suggested_sale_price: number;
|
||||
attributes?: Record<string, string[]>;
|
||||
attributes?: Record<string, string[]> | null;
|
||||
is_active?: boolean;
|
||||
is_serial?: boolean;
|
||||
sat_code_product_id?: number | null;
|
||||
classifications?: number[];
|
||||
}
|
||||
|
||||
@ -41,8 +48,10 @@ export interface UpdateProductData {
|
||||
description?: string;
|
||||
unit_of_measure_id?: number;
|
||||
suggested_sale_price?: number;
|
||||
attributes?: Record<string, string[]>;
|
||||
attributes?: Record<string, string[]> | null;
|
||||
is_active?: boolean;
|
||||
is_serial?: boolean;
|
||||
sat_code_product_id?: number | null;
|
||||
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 {
|
||||
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>
|
||||
</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">
|
||||
<template #body="slotProps">
|
||||
<div class="flex gap-2">
|
||||
@ -156,72 +147,13 @@
|
||||
</Card>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<Dialog
|
||||
<DepartmentForm
|
||||
v-model:visible="showDialog"
|
||||
: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">
|
||||
{{ 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>
|
||||
:mode="dialogMode"
|
||||
:formData="formData"
|
||||
@save="saveDepartment"
|
||||
@cancel="showDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<Dialog
|
||||
@ -260,8 +192,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { DepartmentsService } from '../../services/departments.services';
|
||||
|
||||
// PrimeVue Components
|
||||
import Toast from 'primevue/toast';
|
||||
@ -271,17 +204,19 @@ import Button from 'primevue/button';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
import type { Department, CreateDepartmentDTO, UpdateDepartmentDTO } from '../../types/departments.interface';
|
||||
import DepartmentForm from './DepartmentForm.vue';
|
||||
|
||||
const toast = useToast();
|
||||
const departmentsService = new DepartmentsService();
|
||||
|
||||
// State
|
||||
const loading = ref(false);
|
||||
const showDialog = ref(false);
|
||||
const showDeleteDialog = ref(false);
|
||||
const dialogMode = ref<'create' | 'edit'>('create');
|
||||
const departmentToDelete = ref<any>(null);
|
||||
const departmentToDelete = ref<Department | null>(null);
|
||||
|
||||
// Breadcrumb
|
||||
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
|
||||
@ -291,7 +226,7 @@ const breadcrumbItems = ref([
|
||||
]);
|
||||
|
||||
// Form Data
|
||||
const formData = ref<{id?: number; name: string; description: string; employeeCount: number; color: string; icon: string}>({
|
||||
const formData = ref<Partial<Department>>({
|
||||
name: '',
|
||||
description: '',
|
||||
employeeCount: 0,
|
||||
@ -299,69 +234,45 @@ const formData = ref<{id?: number; name: string; description: string; employeeCo
|
||||
icon: 'pi pi-building'
|
||||
});
|
||||
|
||||
// Color Options (for future use)
|
||||
// const colorOptions = [
|
||||
// { 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'
|
||||
},
|
||||
]);
|
||||
// Departments Data
|
||||
const departments = ref<Department[]>([]);
|
||||
|
||||
// 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
|
||||
@ -377,7 +288,7 @@ const openCreateDialog = () => {
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
const viewDepartment = (department: any) => {
|
||||
const viewDepartment = (department: Department) => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Ver Departamento',
|
||||
@ -386,7 +297,7 @@ const viewDepartment = (department: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const editDepartment = (department: any) => {
|
||||
const editDepartment = (department: Department) => {
|
||||
dialogMode.value = 'edit';
|
||||
formData.value = { ...department };
|
||||
showDialog.value = true;
|
||||
@ -397,25 +308,37 @@ const confirmDelete = (department: any) => {
|
||||
showDeleteDialog.value = true;
|
||||
};
|
||||
|
||||
const deleteDepartment = () => {
|
||||
if (departmentToDelete.value) {
|
||||
const index = departments.value.findIndex(d => d.id === departmentToDelete.value.id);
|
||||
if (index > -1) {
|
||||
departments.value.splice(index, 1);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Departamento Eliminado',
|
||||
detail: `${departmentToDelete.value.name} ha sido eliminado`,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
const deleteDepartment = async () => {
|
||||
if (!departmentToDelete.value) return;
|
||||
|
||||
try {
|
||||
await departmentsService.deleteDepartment(departmentToDelete.value.id);
|
||||
|
||||
// Recargar la lista de departamentos
|
||||
await fetchDepartments(false);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Departamento Eliminado',
|
||||
detail: `${departmentToDelete.value.name} ha sido eliminado exitosamente`,
|
||||
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;
|
||||
departmentToDelete.value = null;
|
||||
}
|
||||
showDeleteDialog.value = false;
|
||||
departmentToDelete.value = null;
|
||||
};
|
||||
|
||||
const saveDepartment = () => {
|
||||
if (!formData.value.name) {
|
||||
const saveDepartment = async (data: Partial<Department>) => {
|
||||
if (!data.name) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validación',
|
||||
@ -425,47 +348,61 @@ const saveDepartment = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogMode.value === 'create') {
|
||||
// Create new department
|
||||
const newDepartment = {
|
||||
id: Math.max(...departments.value.map(d => d.id)) + 1,
|
||||
name: formData.value.name,
|
||||
description: formData.value.description,
|
||||
employeeCount: formData.value.employeeCount,
|
||||
color: formData.value.color,
|
||||
icon: formData.value.icon
|
||||
};
|
||||
departments.value.push(newDepartment);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Departamento Creado',
|
||||
detail: `${formData.value.name} ha sido creado exitosamente`,
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
// Update existing department
|
||||
const index = departments.value.findIndex(d => d.id === formData.value.id);
|
||||
if (index > -1) {
|
||||
const existingDept = departments.value[index]!;
|
||||
departments.value[index] = {
|
||||
id: existingDept.id,
|
||||
name: formData.value.name,
|
||||
description: formData.value.description,
|
||||
employeeCount: formData.value.employeeCount,
|
||||
color: formData.value.color,
|
||||
icon: formData.value.icon
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
// Create new department
|
||||
const createData: CreateDepartmentDTO = {
|
||||
name: data.name,
|
||||
description: data.description || ''
|
||||
};
|
||||
|
||||
await departmentsService.createDepartment(createData);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Departamento Creado',
|
||||
detail: `${data.name} ha sido creado exitosamente`,
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
// Update existing department
|
||||
if (!data.id) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'ID del departamento no encontrado',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData: UpdateDepartmentDTO = {
|
||||
name: data.name,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
await departmentsService.updateDepartment(data.id, updateData);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Departamento Actualizado',
|
||||
detail: `${formData.value.name} ha sido actualizado`,
|
||||
detail: `${data.name} ha sido actualizado exitosamente`,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showDialog.value = false;
|
||||
// Recargar la lista de departamentos
|
||||
await fetchDepartments(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>
|
||||
|
||||
@ -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 Badge from 'primevue/badge';
|
||||
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 { purchaseServices } from '../../purchases/services/purchaseServices';
|
||||
import type { PurchaseDetailResponse } from '../../purchases/types/purchases';
|
||||
import { useWarehouseStore } from '../../../stores/warehouseStore';
|
||||
import { useProductStore } from '../../products/stores/productStore';
|
||||
import { inventoryWarehouseServices } from '../services/inventoryWarehouse.services';
|
||||
import type { InventoryProductItem, CreateInventoryRequest } from '../types/warehouse.inventory';
|
||||
import type { Product as ProductType } from '../../products/types/product';
|
||||
|
||||
interface SerialNumber {
|
||||
serial: string;
|
||||
@ -38,11 +45,22 @@ const toast = useToast();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const warehouseStore = useWarehouseStore();
|
||||
const productStore = useProductStore();
|
||||
|
||||
// Data from API
|
||||
const purchaseData = ref<PurchaseDetailResponse | null>(null);
|
||||
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
|
||||
const purchaseOrderNumber = ref('ORD-2023-001');
|
||||
const totalItemsPO = ref(12);
|
||||
@ -92,6 +110,12 @@ const expandedRows = ref<any[]>([]);
|
||||
const newSerialNumber = ref('');
|
||||
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(() => {
|
||||
return products.value.reduce((sum, p) => sum + p.quantityReceived, 0);
|
||||
});
|
||||
@ -123,11 +147,16 @@ const warehouseSummary = 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 => {
|
||||
if (product.requiresSerial) {
|
||||
return product.serialNumbers.length === product.quantityOrdered;
|
||||
} else {
|
||||
return product.warehouseId !== null;
|
||||
return product.warehouseId !== null && product.quantityReceived > 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -183,12 +212,6 @@ async function confirmReceipt() {
|
||||
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 {
|
||||
loading.value = true;
|
||||
|
||||
@ -229,8 +252,10 @@ async function confirmReceipt() {
|
||||
// Enviar al API
|
||||
const response = await inventoryWarehouseServices.addInventory(requestData);
|
||||
|
||||
// Actualizar estado de la compra a "Ingresada a Inventario" (4)
|
||||
await purchaseServices.updatePurchaseStatus(Number(purchaseId), '4');
|
||||
// Si es desde una compra, actualizar estado
|
||||
if (operationMode.value === 'purchase' && route.query.purchaseId) {
|
||||
await purchaseServices.updatePurchaseStatus(Number(route.query.purchaseId), '4');
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
@ -239,7 +264,7 @@ async function confirmReceipt() {
|
||||
life: 4000
|
||||
});
|
||||
|
||||
// Regresar a la vista de compras
|
||||
// Regresar a la vista anterior
|
||||
setTimeout(() => {
|
||||
router.back();
|
||||
}, 1000);
|
||||
@ -261,13 +286,7 @@ async function fetchPurchaseDetails() {
|
||||
const purchaseId = route.query.purchaseId;
|
||||
|
||||
if (!purchaseId) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'No se proporcionó un ID de compra válido',
|
||||
life: 3000
|
||||
});
|
||||
router.back();
|
||||
// Si no hay purchaseId, estamos en modo manual
|
||||
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 () => {
|
||||
await warehouseStore.fetchWarehouses();
|
||||
// Establecer el primer almacén activo como predeterminado
|
||||
if (warehouseStore.activeWarehouses.length > 0) {
|
||||
newSerialWarehouse.value = warehouseStore.activeWarehouses[0]?.id || 1;
|
||||
}
|
||||
await fetchPurchaseDetails();
|
||||
|
||||
// Cargar datos según el modo
|
||||
if (operationMode.value === 'purchase') {
|
||||
await fetchPurchaseDetails();
|
||||
} else {
|
||||
initializeManualMode();
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -343,13 +480,21 @@ function cancel() {
|
||||
<div>
|
||||
<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">
|
||||
<i class="pi pi-receipt text-base"></i>
|
||||
No. Orden #{{ purchaseOrderNumber }} |
|
||||
<span class="text-primary font-semibold">Distribución Multi-Almacén</span>
|
||||
<i :class="operationMode === 'purchase' ? 'pi pi-receipt' : 'pi pi-warehouse'" class="text-base"></i>
|
||||
<template v-if="operationMode === 'purchase'">
|
||||
No. Orden #{{ purchaseOrderNumber }} |
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Card class="shadow-sm">
|
||||
<Card class="shadow-sm" v-if="operationMode === 'purchase'">
|
||||
<template #content>
|
||||
<div class="flex gap-4 px-2">
|
||||
<div class="text-center">
|
||||
@ -364,7 +509,21 @@ function cancel() {
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
@ -372,7 +531,9 @@ function cancel() {
|
||||
<Card>
|
||||
<template #header>
|
||||
<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">
|
||||
<i class="pi pi-info-circle text-sm"></i>
|
||||
Seleccione el almacén de destino por cada producto
|
||||
@ -380,7 +541,20 @@ function cancel() {
|
||||
</div>
|
||||
</template>
|
||||
<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">
|
||||
<thead>
|
||||
<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">
|
||||
<tr :class="[
|
||||
'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) }">
|
||||
<div class="flex items-center gap-3">
|
||||
@ -414,7 +588,22 @@ function cancel() {
|
||||
</div>
|
||||
</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">
|
||||
<InputNumber v-if="!product.requiresSerial" v-model="product.quantityReceived"
|
||||
:max="product.quantityOrdered" :min="0"
|
||||
@ -440,19 +629,37 @@ function cancel() {
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<Button v-if="product.requiresSerial"
|
||||
:label="isRowExpanded(product) ? 'OCULTAR' : 'GESTIONAR SERIES'"
|
||||
:icon="isRowExpanded(product) ? 'pi pi-chevron-up' : 'pi pi-qrcode'"
|
||||
:severity="isRowExpanded(product) ? 'secondary' : 'info'"
|
||||
:outlined="!isRowExpanded(product)"
|
||||
size="small"
|
||||
@click="toggleRow(product)" />
|
||||
<Badge v-else value="Estándar" severity="secondary" />
|
||||
<template v-if="product.requiresSerial">
|
||||
<Button
|
||||
:label="isRowExpanded(product) ? 'OCULTAR' : 'GESTIONAR SERIES'"
|
||||
:icon="isRowExpanded(product) ? 'pi pi-chevron-up' : 'pi pi-qrcode'"
|
||||
:severity="isRowExpanded(product) ? 'secondary' : 'info'"
|
||||
:outlined="!isRowExpanded(product)"
|
||||
size="small"
|
||||
@click="toggleRow(product)"
|
||||
/>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<!-- 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">
|
||||
<Card class="border border-primary/20 shadow-sm">
|
||||
<template #header>
|
||||
@ -606,12 +813,148 @@ function cancel() {
|
||||
<!-- Footer Actions -->
|
||||
<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="Confirmar Recepción Multi-Almacén"
|
||||
<Button
|
||||
:label="operationMode === 'purchase' ? 'Confirmar Recepción Multi-Almacén' : 'Confirmar Entrada de Inventario'"
|
||||
icon="pi pi-check"
|
||||
:disabled="!isFormValid"
|
||||
@click="confirmReceipt" />
|
||||
:disabled="!isFormValid || products.length === 0"
|
||||
@click="confirmReceipt"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
||||
@ -426,7 +426,11 @@ const getMovementTypeSeverity = (type: string) => {
|
||||
};
|
||||
|
||||
const openBatchAdd = () => {
|
||||
router.push({ name: 'BatchAddInventory' });
|
||||
const warehouseId = route.params.id;
|
||||
router.push({
|
||||
name: 'WarehouseAddInventory',
|
||||
query: { warehouse: warehouseId }
|
||||
});
|
||||
};
|
||||
|
||||
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 WarehouseDetails from '../modules/warehouse/components/WarehouseDetails.vue';
|
||||
import WarehouseClassification from '../modules/warehouse/components/WarehouseClassification.vue';
|
||||
import UnitOfMeasure from '../modules/catalog/components/UnitOfMeasure.vue';
|
||||
import ComercialClassification from '../modules/catalog/components/ComercialClassification.vue';
|
||||
import Units from '../modules/catalog/components/units/Units.vue';
|
||||
import ProductsIndex from '../modules/products/components/ProductsIndex.vue';
|
||||
import ProductForm from '../modules/products/components/ProductForm.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 StoreDetails from '../modules/stores/components/StoreDetails.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 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 PurchaseForm from '../modules/purchases/components/PurchaseForm.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[] = [
|
||||
{
|
||||
path: '/login',
|
||||
@ -137,7 +141,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'units-of-measure',
|
||||
name: 'UnitsOfMeasure',
|
||||
component: UnitOfMeasure,
|
||||
component: Units,
|
||||
meta: {
|
||||
title: 'Unidades de Medida',
|
||||
requiresAuth: true
|
||||
@ -146,7 +150,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'classifications-comercial',
|
||||
name: 'ClassificationsComercial',
|
||||
component: ComercialClassification,
|
||||
component: ClassificationsComercial,
|
||||
meta: {
|
||||
title: 'Clasificaciones Comerciales',
|
||||
requiresAuth: true
|
||||
@ -161,6 +165,15 @@ const routes: RouteRecordRaw[] = [
|
||||
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": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
||||
@ -3,6 +3,7 @@ import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { PrimeVueResolver } from '@primevue/auto-import-resolver';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@ -13,5 +14,10 @@ export default defineConfig({
|
||||
PrimeVueResolver()
|
||||
]
|
||||
})
|
||||
]
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user