feature-comercial-module-ts #11

Merged
edgar.mendez merged 8 commits from feature-comercial-module-ts into qa 2026-02-27 19:37:24 +00:00
42 changed files with 4435 additions and 1441 deletions

View File

@ -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
View File

@ -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']

View File

@ -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:

View File

@ -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',

View File

@ -1,5 +0,0 @@
<template>
<p>
Clasificaciones Comerciales
</p>
</template>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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 }">

View File

@ -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>

View 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>

View 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;
}
};

View 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;
}
};

View 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;
}
};

View File

@ -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);

View File

@ -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;
},

View 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
};
});

View File

@ -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[]>([]);

View File

@ -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);

View 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'
};

View File

@ -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 {

View 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;
}

View File

@ -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;
};
}

View File

@ -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

View File

@ -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">

View File

@ -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[];
}

View File

@ -1,4 +1,4 @@
import type { Supplier } from '../../catalog/types/suppliers';
import type { Supplier } from '../../catalog/types/suppliers.interfaces';
export interface Product {
id: number;

View 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>

View 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>

View 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
};
});

View 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;
}

View 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>

View File

@ -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>

View File

@ -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&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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>

View 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;
}
}
}

View 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> {}

View File

@ -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>

View File

@ -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) => {

View File

@ -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&amp;display=swap"
rel="stylesheet" />
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;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>

View File

@ -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
}
}
]
}
]
},

View File

@ -3,6 +3,10 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,

View File

@ -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))
}
}
});