Merge pull request 'feat: add WarehouseOutInventory component and related services' (#12) from feature-comercial-module-ts into qa
Reviewed-on: #12
This commit is contained in:
commit
585ac6bf4a
2
components.d.ts
vendored
2
components.d.ts
vendored
@ -37,6 +37,7 @@ declare module 'vue' {
|
||||
Menu: typeof import('primevue/menu')['default']
|
||||
Message: typeof import('primevue/message')['default']
|
||||
Paginator: typeof import('primevue/paginator')['default']
|
||||
Panel: typeof import('primevue/panel')['default']
|
||||
ProgressSpinner: typeof import('primevue/progressspinner')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
@ -45,6 +46,7 @@ declare module 'vue' {
|
||||
Tag: typeof import('primevue/tag')['default']
|
||||
Textarea: typeof import('primevue/textarea')['default']
|
||||
Toast: typeof import('primevue/toast')['default']
|
||||
Toolbar: typeof import('primevue/toolbar')['default']
|
||||
TopBar: typeof import('./src/components/layout/TopBar.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
|
||||
@ -1,529 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Toast Notifications -->
|
||||
<Toast position="bottom-right" />
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<Breadcrumb :home="breadcrumbHome" :model="breadcrumbItems" />
|
||||
|
||||
<!-- Page Heading -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
|
||||
Puestos de Trabajo
|
||||
</h1>
|
||||
<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
|
||||
label="Nuevo Puesto"
|
||||
icon="pi pi-plus-circle"
|
||||
@click="openCreateDialog"
|
||||
class="shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Bar -->
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1 relative">
|
||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
|
||||
<InputText
|
||||
v-model="searchQuery"
|
||||
placeholder="Buscar por nombre o departamento..."
|
||||
class="w-full h-11 pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-model="selectedDepartment"
|
||||
:options="departmentOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Todos los departamentos"
|
||||
class="w-full md:w-64"
|
||||
/>
|
||||
<Button
|
||||
label="Filtros"
|
||||
icon="pi pi-filter"
|
||||
severity="secondary"
|
||||
outlined
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Data Table Card -->
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<DataTable
|
||||
:value="filteredPositions"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[10, 25, 50]"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} puestos"
|
||||
:loading="loading"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
class="text-sm"
|
||||
>
|
||||
<Column field="name" header="Nombre del Puesto" sortable>
|
||||
<template #body="slotProps">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="size-8 rounded flex items-center justify-center"
|
||||
:class="slotProps.data.iconBg"
|
||||
>
|
||||
<i :class="slotProps.data.icon"></i>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ slotProps.data.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="department" header="Departamento" sortable>
|
||||
<template #body="slotProps">
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-full text-[11px] font-bold"
|
||||
:class="getDepartmentBadge(slotProps.data.department)"
|
||||
>
|
||||
{{ slotProps.data.department.toUpperCase() }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="description" header="Descripción / Actividades" sortable>
|
||||
<template #body="slotProps">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">
|
||||
{{ slotProps.data.description }}
|
||||
</p>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Acciones" :exportable="false">
|
||||
<template #body="slotProps">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
text
|
||||
rounded
|
||||
severity="secondary"
|
||||
@click="viewPosition(slotProps.data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
@click="editPosition(slotProps.data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
rounded
|
||||
severity="danger"
|
||||
@click="confirmDelete(slotProps.data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Summary Info Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="size-12 rounded-full bg-primary-50 dark:bg-primary-900/20 text-primary flex items-center justify-center">
|
||||
<i class="pi pi-briefcase text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 font-medium">Total de Puestos</p>
|
||||
<p class="text-xl font-black text-gray-900 dark:text-white">{{ positions.length }} Roles</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="size-12 rounded-full bg-green-50 dark:bg-green-900/20 text-green-600 flex items-center justify-center">
|
||||
<i class="pi pi-building text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 font-medium">Departamentos Activos</p>
|
||||
<p class="text-xl font-black text-gray-900 dark:text-white">{{ activeDepartments }} Áreas</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="size-12 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-600 flex items-center justify-center">
|
||||
<i class="pi pi-history text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 font-medium">Última Actualización</p>
|
||||
<p class="text-xl font-black text-gray-900 dark:text-white">Hoy, 09:12 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showDialog"
|
||||
:modal="true"
|
||||
:style="{ width: '720px' }"
|
||||
: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 Puesto' : 'Editar Puesto' }}
|
||||
</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 puesto en la estructura organizacional.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="p-6 pt-0 space-y-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
||||
Nombre del Puesto
|
||||
</label>
|
||||
<InputText
|
||||
v-model="formData.name"
|
||||
placeholder="Ej. Supervisor de Almacén"
|
||||
class="w-full h-14"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
||||
Departamento
|
||||
</label>
|
||||
<Select
|
||||
v-model="formData.department"
|
||||
:options="departmentOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Selecciona un departamento"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
||||
Descripción / Actividades
|
||||
</label>
|
||||
<Textarea
|
||||
v-model="formData.description"
|
||||
rows="5"
|
||||
placeholder="Describa las funciones y actividades principales del puesto..."
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-primary-50 dark:bg-primary-900/10 rounded-lg border border-primary-200 dark:border-primary-800 flex gap-4">
|
||||
<div class="text-primary pt-1">
|
||||
<i class="pi pi-info-circle text-xl"></i>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-gray-900 dark:text-white text-sm font-bold">Importante</p>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm leading-relaxed">
|
||||
Al crear un nuevo puesto, este podrá asignarse a empleados y centros de costo. Puede editar estos detalles en cualquier momento.
|
||||
</p>
|
||||
</div>
|
||||
</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 Puesto' : 'Actualizar Puesto'"
|
||||
icon="pi pi-save"
|
||||
@click="savePosition"
|
||||
class="px-8"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showDeleteDialog"
|
||||
header="Confirmar Eliminación"
|
||||
:modal="true"
|
||||
:style="{ width: '450px' }"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<i class="pi pi-exclamation-triangle text-3xl text-red-500"></i>
|
||||
<div>
|
||||
<p class="text-gray-900 dark:text-white mb-2">
|
||||
¿Estás seguro de que deseas eliminar el puesto <strong>{{ positionToDelete?.name }}</strong>?
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Esta acción no se puede deshacer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
@click="showDeleteDialog = false"
|
||||
/>
|
||||
<Button
|
||||
label="Eliminar"
|
||||
severity="danger"
|
||||
icon="pi pi-trash"
|
||||
@click="deletePosition"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
// PrimeVue Components
|
||||
import Toast from 'primevue/toast';
|
||||
import Breadcrumb from 'primevue/breadcrumb';
|
||||
import Card from 'primevue/card';
|
||||
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 Select from 'primevue/select';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// State
|
||||
const loading = ref(false);
|
||||
const showDialog = ref(false);
|
||||
const showDeleteDialog = ref(false);
|
||||
const dialogMode = ref<'create' | 'edit'>('create');
|
||||
const positionToDelete = ref<any>(null);
|
||||
const searchQuery = ref('');
|
||||
const selectedDepartment = ref('all');
|
||||
|
||||
// Breadcrumb
|
||||
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
|
||||
const breadcrumbItems = ref([
|
||||
{ label: 'RH', to: '/rh' },
|
||||
{ label: 'Puestos' }
|
||||
]);
|
||||
|
||||
// Options
|
||||
const departmentOptions = [
|
||||
{ label: 'Todos los departamentos', value: 'all' },
|
||||
{ label: 'Logística', value: 'logistica' },
|
||||
{ label: 'Producción', value: 'produccion' },
|
||||
{ label: 'Almacén', value: 'almacen' },
|
||||
{ label: 'EHS', value: 'ehs' },
|
||||
];
|
||||
|
||||
// Form Data
|
||||
const formData = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
department: 'logistica',
|
||||
description: ''
|
||||
});
|
||||
|
||||
// Mock Data - Positions
|
||||
const positions = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Supervisor de Almacén',
|
||||
department: 'logistica',
|
||||
description: 'Coordinar la recepción de mercancías, gestión de inventarios y despacho...',
|
||||
icon: 'pi pi-users',
|
||||
iconBg: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Operario de Línea A',
|
||||
department: 'produccion',
|
||||
description: 'Operación de maquinaria de ensamblado y control de calidad primario...',
|
||||
icon: 'pi pi-cog',
|
||||
iconBg: 'bg-green-50 dark:bg-green-900/20 text-green-600'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Montacarguista',
|
||||
department: 'almacen',
|
||||
description: 'Traslado de pallets a zona de racks y carga de camiones de distribución...',
|
||||
icon: 'pi pi-truck',
|
||||
iconBg: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Analista de Seguridad',
|
||||
department: 'ehs',
|
||||
description: 'Monitoreo de protocolos de seguridad e higiene industrial en planta...',
|
||||
icon: 'pi pi-shield',
|
||||
iconBg: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600'
|
||||
},
|
||||
]);
|
||||
|
||||
const departmentLabelMap: Record<string, string> = {
|
||||
logistica: 'Logística',
|
||||
produccion: 'Producción',
|
||||
almacen: 'Almacén',
|
||||
ehs: 'EHS',
|
||||
};
|
||||
|
||||
// Computed
|
||||
const filteredPositions = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return positions.value.filter((pos) => {
|
||||
const matchesQuery =
|
||||
pos.name.toLowerCase().includes(query) ||
|
||||
departmentLabelMap[pos.department]?.toLowerCase().includes(query);
|
||||
const matchesDept =
|
||||
selectedDepartment.value === 'all' || pos.department === selectedDepartment.value;
|
||||
return matchesQuery && matchesDept;
|
||||
});
|
||||
});
|
||||
|
||||
const activeDepartments = computed(() => {
|
||||
const unique = new Set(positions.value.map((pos) => pos.department));
|
||||
return unique.size;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const getDepartmentBadge = (dept: string) => {
|
||||
const map: Record<string, string> = {
|
||||
logistica: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
produccion: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
almacen: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
ehs: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
};
|
||||
return map[dept] || 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
dialogMode.value = 'create';
|
||||
formData.value = {
|
||||
id: 0,
|
||||
name: '',
|
||||
department: 'logistica',
|
||||
description: ''
|
||||
};
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
const viewPosition = (position: any) => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Ver Puesto',
|
||||
detail: `Visualizando: ${position.name}`,
|
||||
life: 3000
|
||||
});
|
||||
};
|
||||
|
||||
const editPosition = (position: any) => {
|
||||
dialogMode.value = 'edit';
|
||||
formData.value = { ...position };
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = (position: any) => {
|
||||
positionToDelete.value = position;
|
||||
showDeleteDialog.value = true;
|
||||
};
|
||||
|
||||
const deletePosition = () => {
|
||||
if (positionToDelete.value) {
|
||||
const index = positions.value.findIndex(p => p.id === positionToDelete.value.id);
|
||||
if (index > -1) {
|
||||
positions.value.splice(index, 1);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Puesto Eliminado',
|
||||
detail: `${positionToDelete.value.name} ha sido eliminado`,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
showDeleteDialog.value = false;
|
||||
positionToDelete.value = null;
|
||||
};
|
||||
|
||||
const savePosition = () => {
|
||||
if (!formData.value.name || !formData.value.department) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validación',
|
||||
detail: 'Nombre y departamento son requeridos',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogMode.value === 'create') {
|
||||
const newPosition = {
|
||||
id: Math.max(...positions.value.map(p => p.id)) + 1,
|
||||
name: formData.value.name,
|
||||
department: formData.value.department,
|
||||
description: formData.value.description,
|
||||
icon: 'pi pi-users',
|
||||
iconBg: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600'
|
||||
};
|
||||
positions.value.push(newPosition);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Puesto Creado',
|
||||
detail: `${formData.value.name} ha sido creado exitosamente`,
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
const index = positions.value.findIndex(p => p.id === formData.value.id);
|
||||
if (index > -1) {
|
||||
const existing = positions.value[index]!;
|
||||
positions.value[index] = {
|
||||
id: existing.id,
|
||||
name: formData.value.name,
|
||||
department: formData.value.department,
|
||||
description: formData.value.description,
|
||||
icon: existing.icon,
|
||||
iconBg: existing.iconBg
|
||||
};
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Puesto Actualizado',
|
||||
detail: `${formData.value.name} ha sido actualizado`,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showDialog.value = false;
|
||||
};
|
||||
</script>
|
||||
@ -23,57 +23,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="size-12 rounded-full bg-primary-50 dark:bg-primary-900/20 text-primary flex items-center justify-center">
|
||||
<i class="pi pi-users text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs font-medium uppercase tracking-wider">
|
||||
Plantilla Total
|
||||
</p>
|
||||
<p class="text-2xl font-black text-gray-900 dark:text-white">{{ totalEmployees }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="size-12 rounded-full bg-green-50 dark:bg-green-900/20 text-green-600 flex items-center justify-center">
|
||||
<i class="pi pi-chart-line text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs font-medium uppercase tracking-wider">
|
||||
Nuevas Vacantes
|
||||
</p>
|
||||
<p class="text-2xl font-black text-gray-900 dark:text-white">5</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="size-12 rounded-full bg-purple-50 dark:bg-purple-900/20 text-purple-600 flex items-center justify-center">
|
||||
<i class="pi pi-calendar text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-xs font-medium uppercase tracking-wider">
|
||||
Próximas Evaluaciones
|
||||
</p>
|
||||
<p class="text-2xl font-black text-gray-900 dark:text-white">12</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Departments Table -->
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
@ -192,7 +141,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { DepartmentsService } from '../../services/departments.services';
|
||||
|
||||
@ -237,11 +186,6 @@ const formData = ref<Partial<Department>>({
|
||||
// Departments Data
|
||||
const departments = ref<Department[]>([]);
|
||||
|
||||
// Computed
|
||||
const totalEmployees = computed(() => {
|
||||
return departments.value.reduce((sum, dept) => sum + (dept.employeeCount || 0), 0);
|
||||
});
|
||||
|
||||
// Fetch Departments from API
|
||||
const fetchDepartments = async (showToast = true) => {
|
||||
loading.value = true;
|
||||
|
||||
319
src/modules/rh/components/positions/Positions.vue
Normal file
319
src/modules/rh/components/positions/Positions.vue
Normal file
@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Toast Notifications -->
|
||||
<Toast position="bottom-right" />
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<Breadcrumb :home="breadcrumbHome" :model="breadcrumbItems" />
|
||||
|
||||
<!-- Page Heading -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
|
||||
Puestos de Trabajo
|
||||
</h1>
|
||||
<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 label="Nuevo Puesto" icon="pi pi-plus-circle" @click="openCreateDialog" class="shadow-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Data Table Card -->
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<DataTable :value="filteredPositions" :paginator="true" :rows="10" :rowsPerPageOptions="[10, 25, 50]"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} puestos" :loading="loading"
|
||||
stripedRows responsiveLayout="scroll" class="text-sm">
|
||||
|
||||
<Column field="code" header="Código" sortable>
|
||||
<template #body="slotProps">
|
||||
<span class="px-2.5 py-1 rounded-full text-[11px] font-bold"
|
||||
:class="'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'">
|
||||
{{ slotProps.data.code.toUpperCase() }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="name" header="Nombre del Puesto" sortable>
|
||||
<template #body="slotProps">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ slotProps.data.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="description" header="Descripción / Actividades" sortable>
|
||||
<template #body="slotProps">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-1">
|
||||
{{ slotProps.data.description }}
|
||||
</p>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Acciones" :exportable="false">
|
||||
<template #body="slotProps">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button icon="pi pi-eye" text rounded severity="secondary"
|
||||
@click="viewPosition(slotProps.data)" />
|
||||
<Button icon="pi pi-pencil" text rounded @click="editPosition(slotProps.data)" />
|
||||
<Button icon="pi pi-trash" text rounded severity="danger"
|
||||
@click="confirmDelete(slotProps.data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<PositionsForm v-model:visible="showDialog" :mode="dialogMode" :formData="formData" @save="savePosition"
|
||||
@cancel="showDialog = false" />
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<Dialog v-model:visible="showDeleteDialog" header="Confirmar Eliminación" :modal="true"
|
||||
:style="{ width: '450px' }">
|
||||
<div class="flex items-start gap-4">
|
||||
<i class="pi pi-exclamation-triangle text-3xl text-red-500"></i>
|
||||
<div>
|
||||
<p class="text-gray-900 dark:text-white mb-2">
|
||||
¿Estás seguro de que deseas eliminar el puesto <strong>{{ positionToDelete?.name }}</strong>?
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Esta acción no se puede deshacer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" @click="showDeleteDialog = false" />
|
||||
<Button label="Eliminar" severity="danger" icon="pi pi-trash" @click="deletePosition" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { PositionsService } from '../../services/positions.services';
|
||||
import type { Position, CreatePositionDTO, UpdatePositionDTO } from '../../types/positions.interface';
|
||||
|
||||
// PrimeVue Components
|
||||
import Toast from 'primevue/toast';
|
||||
import Breadcrumb from 'primevue/breadcrumb';
|
||||
import Card from 'primevue/card';
|
||||
import Button from 'primevue/button';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import PositionsForm from './PositionsForm.vue';
|
||||
|
||||
const toast = useToast();
|
||||
const positionsService = new PositionsService();
|
||||
|
||||
// State
|
||||
const loading = ref(false);
|
||||
const showDialog = ref(false);
|
||||
const showDeleteDialog = ref(false);
|
||||
const dialogMode = ref<'create' | 'edit'>('create');
|
||||
const positionToDelete = ref<Position | null>(null);
|
||||
const searchQuery = ref('');
|
||||
const selectedDepartment = ref('all');
|
||||
|
||||
// Breadcrumb
|
||||
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
|
||||
const breadcrumbItems = ref([
|
||||
{ label: 'RH', to: '/rh' },
|
||||
{ label: 'Puestos' }
|
||||
]);
|
||||
|
||||
// Options
|
||||
// Form Data
|
||||
const formData = ref<CreatePositionDTO | UpdatePositionDTO>({
|
||||
name: '',
|
||||
code: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
// Positions Data
|
||||
const positions = ref<Position[]>([]);
|
||||
|
||||
// Computed
|
||||
const filteredPositions = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return positions.value.filter((pos) => {
|
||||
const matchesQuery =
|
||||
pos.name.toLowerCase().includes(query) ||
|
||||
pos.code.toLowerCase().includes(query);
|
||||
const matchesDept =
|
||||
selectedDepartment.value === 'all' || pos.code === selectedDepartment.value;
|
||||
return matchesQuery && matchesDept;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Fetch Positions from API
|
||||
const fetchPositions = async (showToast = true) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await positionsService.getPositions();
|
||||
positions.value = response.data;
|
||||
|
||||
if (showToast) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Puestos Cargados',
|
||||
detail: `Se cargaron ${response.data.length} puestos`,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error al cargar puestos:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'No se pudieron cargar los puestos',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchPositions();
|
||||
});
|
||||
|
||||
const openCreateDialog = () => {
|
||||
dialogMode.value = 'create';
|
||||
formData.value = {
|
||||
name: '',
|
||||
code: '',
|
||||
description: ''
|
||||
};
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
const viewPosition = (position: Position) => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Ver Puesto',
|
||||
detail: `Visualizando: ${position.name}`,
|
||||
life: 3000
|
||||
});
|
||||
};
|
||||
|
||||
const editPosition = (position: Position) => {
|
||||
dialogMode.value = 'edit';
|
||||
formData.value = { ...position };
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = (position: Position) => {
|
||||
positionToDelete.value = position;
|
||||
showDeleteDialog.value = true;
|
||||
};
|
||||
|
||||
const deletePosition = async () => {
|
||||
if (!positionToDelete.value) return;
|
||||
|
||||
try {
|
||||
await positionsService.deletePosition(positionToDelete.value.id);
|
||||
|
||||
// Recargar la lista de puestos
|
||||
await fetchPositions(false);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Puesto Eliminado',
|
||||
detail: `${positionToDelete.value.name} ha sido eliminado exitosamente`,
|
||||
life: 3000
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error al eliminar puesto:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'No se pudo eliminar el puesto',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
showDeleteDialog.value = false;
|
||||
positionToDelete.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const savePosition = async (data: CreatePositionDTO | UpdatePositionDTO) => {
|
||||
if (!data.name || !data.code) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validación',
|
||||
detail: 'Nombre y departamento son requeridos',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
// Create new position
|
||||
const createData: CreatePositionDTO = {
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
description: data.description || null
|
||||
};
|
||||
|
||||
await positionsService.createPosition(createData);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Puesto Creado',
|
||||
detail: `${data.name} ha sido creado exitosamente`,
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
// Update existing position
|
||||
const updateDataWithId = data as UpdatePositionDTO & { id: number };
|
||||
if (!updateDataWithId.id) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'ID del puesto no encontrado',
|
||||
life: 3000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData: UpdatePositionDTO = {
|
||||
name: updateDataWithId.name,
|
||||
code: updateDataWithId.code,
|
||||
description: updateDataWithId.description || null
|
||||
};
|
||||
|
||||
await positionsService.updatePosition(updateDataWithId.id, updateData);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Puesto Actualizado',
|
||||
detail: `${data.name} ha sido actualizado exitosamente`,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
|
||||
// Recargar la lista de puestos
|
||||
await fetchPositions(false);
|
||||
showDialog.value = false;
|
||||
} catch (error) {
|
||||
console.error('Error al guardar puesto:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: `No se pudo ${dialogMode.value === 'create' ? 'crear' : 'actualizar'} el puesto`,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
127
src/modules/rh/components/positions/PositionsForm.vue
Normal file
127
src/modules/rh/components/positions/PositionsForm.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
:modal="true"
|
||||
:style="{ width: '720px' }"
|
||||
: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 Puesto' : 'Editar Puesto' }}
|
||||
</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 puesto en la estructura organizacional.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="p-6 pt-0 space-y-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
||||
Nombre del Puesto
|
||||
</label>
|
||||
<InputText
|
||||
v-model="localFormData.name"
|
||||
placeholder="Ej. Supervisor de Almacén"
|
||||
class="w-full h-14"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
||||
Código del Puesto
|
||||
</label>
|
||||
<InputText
|
||||
v-model="localFormData.code"
|
||||
placeholder="Ej. SUP-ALM-001"
|
||||
class="w-full h-14"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
|
||||
Descripción / Actividades
|
||||
</label>
|
||||
<Textarea
|
||||
v-model="localFormData.description"
|
||||
rows="5"
|
||||
placeholder="Describa las funciones y actividades principales del puesto..."
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-primary-50 dark:bg-primary-900/10 rounded-lg border border-primary-200 dark:border-primary-800 flex gap-4">
|
||||
<div class="text-primary pt-1">
|
||||
<i class="pi pi-info-circle text-xl"></i>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-gray-900 dark:text-white text-sm font-bold">Importante</p>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm leading-relaxed">
|
||||
Al crear un nuevo puesto, este podrá asignarse a empleados y centros de costo. Puede editar estos detalles en cualquier momento.
|
||||
</p>
|
||||
</div>
|
||||
</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 Puesto' : 'Actualizar Puesto'"
|
||||
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 { CreatePositionDTO, UpdatePositionDTO } from '../../types/positions.interface';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
formData: CreatePositionDTO | UpdatePositionDTO;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'save', data: CreatePositionDTO | UpdatePositionDTO): void;
|
||||
(e: 'cancel'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const localFormData = ref<CreatePositionDTO | UpdatePositionDTO>({ ...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>
|
||||
45
src/modules/rh/services/positions.services.ts
Normal file
45
src/modules/rh/services/positions.services.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import api from "@/services/api";
|
||||
import type { CreatePositionDTO, ResponsePositionsDTO, UpdatePositionDTO } from "../types/positions.interface";
|
||||
|
||||
export class PositionsService {
|
||||
|
||||
public async getPositions(): Promise<ResponsePositionsDTO> {
|
||||
try {
|
||||
const response = await api.get('/api/rh/job-positions');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching positions:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async createPosition(data: CreatePositionDTO): Promise<ResponsePositionsDTO> {
|
||||
try {
|
||||
const response = await api.post('/api/rh/job-positions', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating position:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async updatePosition(id: number, data: UpdatePositionDTO): Promise<ResponsePositionsDTO> {
|
||||
try {
|
||||
const response = await api.put(`/api/rh/job-positions/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error updating position:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async deletePosition(id: number): Promise<void> {
|
||||
try {
|
||||
await api.delete(`/api/rh/job-positions/${id}`);
|
||||
} catch (error) {
|
||||
console.error('Error deleting position:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
26
src/modules/rh/types/positions.interface.ts
Normal file
26
src/modules/rh/types/positions.interface.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export interface Position {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
is_active: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
deleted_at: Date | null;
|
||||
}
|
||||
|
||||
export interface CreatePositionDTO {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface UpdatePositionDTO extends Partial<CreatePositionDTO> {}
|
||||
|
||||
export interface ResponsePositionsDTO {
|
||||
data: Position[];
|
||||
}
|
||||
|
||||
export interface DeletePositionDTO {
|
||||
message: string;
|
||||
}
|
||||
212
src/modules/warehouse/components/ModalProducts.vue
Normal file
212
src/modules/warehouse/components/ModalProducts.vue
Normal file
@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
@update:visible="$emit('update:visible', $event)"
|
||||
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="loading"
|
||||
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="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
label="Agregar Productos"
|
||||
icon="pi pi-plus"
|
||||
:disabled="selectedProducts.length === 0"
|
||||
@click="handleConfirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Button from 'primevue/button';
|
||||
import Badge from 'primevue/badge';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import { useProductStore } from '../../products/stores/productStore';
|
||||
import type { Product } from '../../products/types/product';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
'products-selected': [products: Product[]];
|
||||
}>();
|
||||
|
||||
// Store
|
||||
const productStore = useProductStore();
|
||||
|
||||
// State
|
||||
const productSearch = ref('');
|
||||
const selectedProducts = ref<Product[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Computed
|
||||
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))
|
||||
);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handleCancel = () => {
|
||||
selectedProducts.value = [];
|
||||
productSearch.value = '';
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('products-selected', selectedProducts.value);
|
||||
selectedProducts.value = [];
|
||||
productSearch.value = '';
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// Cargar productos cuando el modal se abre
|
||||
watch(() => props.visible, async (newValue) => {
|
||||
if (newValue && productStore.products.length === 0) {
|
||||
loading.value = true;
|
||||
try {
|
||||
await productStore.fetchProducts();
|
||||
} catch (error) {
|
||||
console.error('Error al cargar productos:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
376
src/modules/warehouse/components/ModalStockProducts.vue
Normal file
376
src/modules/warehouse/components/ModalStockProducts.vue
Normal file
@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<Dialog v-model:visible="isVisible" modal :header="`Productos en Stock - ${warehouseName}`"
|
||||
:style="{ width: '95vw', maxWidth: '1400px' }" :dismissableMask="true" @hide="handleClose">
|
||||
<!-- Toolbar con filtros -->
|
||||
<div class="filter-toolbar mb-4">
|
||||
<div class="flex gap-3 align-items-center flex-wrap w-full">
|
||||
<div class="flex-1" style="min-width: 300px">
|
||||
<span class="p-input-icon-left w-full search-input">
|
||||
<InputText v-model="searchQuery" placeholder="Buscar por código, SKU o nombre..." class="w-full"
|
||||
icon="pi pi-search" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Dropdown v-model="filterSerial" :options="serialFilterOptions" optionLabel="label" optionValue="value"
|
||||
placeholder="Tipo de producto" style="min-width: 200px" showClear />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-content-center align-items-center" style="min-height: 400px">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<Message v-else-if="error" severity="error" :closable="false" class="mb-4">
|
||||
{{ error }}
|
||||
</Message>
|
||||
|
||||
<!-- Tabla de productos -->
|
||||
<DataTable v-else v-model:selection="selectedProducts" :value="filteredStocks" :paginator="true" :rows="10"
|
||||
:rowsPerPageOptions="[5, 10, 20, 50]" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} productos" class="p-datatable-sm">
|
||||
<template #empty>
|
||||
<div class="text-center py-6">
|
||||
<i class="pi pi-inbox text-4xl text-400 mb-3"></i>
|
||||
<p class="text-600 text-lg">No se encontraron productos en este almacén</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column selectionMode="multiple" headerStyle="width: 3rem" :exportable="false" frozen />
|
||||
|
||||
<Column field="product.code" header="Código" sortable frozen style="min-width: 120px">
|
||||
<template #body="{ data }">
|
||||
<span class="font-semibold text-900">{{ data.product.code }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="product.sku" header="SKU" sortable style="min-width: 130px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-700">{{ data.product.sku }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="product.name" header="Nombre del Producto" sortable style="min-width: 300px">
|
||||
<template #body="{ data }">
|
||||
<div>
|
||||
<div class="font-semibold text-900 mb-1">{{ data.product.name }}</div>
|
||||
<div class="text-sm text-600" v-if="data.product.description">{{ data.product.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="stock" header="Stock Disponible" sortable style="min-width: 150px" alignFrozen="right">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<Tag :value="data.stock.toString()" :severity="getStockSeverity(data)" class="font-semibold" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="product.unit_of_measure.abbreviation" header="Unidad" sortable style="min-width: 120px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<Tag severity="secondary" class="font-semibold">
|
||||
{{ data.product.unit_of_measure.name }}
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
|
||||
|
||||
<Column field="product.is_serial" header="Tipo" sortable style="min-width: 130px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.product.is_serial ? 'Serializado' : 'Normal'"
|
||||
:severity="data.product.is_serial ? 'info' : 'success'"
|
||||
:icon="data.product.is_serial ? 'pi pi-barcode' : 'pi pi-box'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Números de Serie" style="min-width: 150px">
|
||||
<template #body="{ data }">
|
||||
<Button v-if="data.product.is_serial && data.serial_numbers && data.serial_numbers.length > 0"
|
||||
:label="`${data.serial_numbers.length} serie(s)`" icon="pi pi-list" text size="small"
|
||||
severity="info" @click="viewSerialNumbers(data)" />
|
||||
<span v-else class="text-400">N/A</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="product.is_active" header="Estado" sortable style="min-width: 100px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.product.is_active ? 'Activo' : 'Inactivo'"
|
||||
:severity="data.product.is_active ? 'success' : 'danger'" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-content-between align-items-center">
|
||||
<span class="text-600">
|
||||
{{ filteredStocks.length }} productos disponibles
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Cancelar" icon="pi pi-times" text @click="handleClose" />
|
||||
<Button label="Confirmar Selección" icon="pi pi-check" :disabled="selectedProducts.length === 0"
|
||||
@click="handleConfirm" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Modal para ver números de serie -->
|
||||
<Dialog v-model:visible="showSerialDialog" modal header="Números de Serie del Producto" :style="{ width: '800px' }">
|
||||
<DataTable v-if="currentSerialNumbers" :value="currentSerialNumbers" stripedRows responsiveLayout="scroll"
|
||||
class="p-datatable-sm">
|
||||
<template #empty>
|
||||
<div class="text-center py-4">
|
||||
<p class="text-600">No hay números de serie disponibles</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="serial_number" header="Número de Serie" style="min-width: 200px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-barcode text-primary"></i>
|
||||
<span class="font-semibold">{{ data.serial_number }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="acquisition_date" header="Fecha de Adquisición" style="min-width: 180px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-calendar text-500"></i>
|
||||
<span>{{ formatDate(data.acquisition_date) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="purchase_cost" header="Costo de Compra" style="min-width: 150px">
|
||||
<template #body="{ data }">
|
||||
<span class="font-semibold text-900">{{ formatCurrency(data.purchase_cost) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cerrar" icon="pi pi-times" text @click="showSerialDialog = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import Tag from 'primevue/tag';
|
||||
import Message from 'primevue/message';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
import { StockService } from '../services/stock.services';
|
||||
import type { Stock, StocksResponse, SerialNumber } from '../types/stock.interfaces';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
warehouseId: number;
|
||||
warehouseName?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
warehouseName: 'Almacén'
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
'confirm': [products: Stock[]];
|
||||
}>();
|
||||
|
||||
// State
|
||||
const isVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
});
|
||||
|
||||
const stockService = new StockService();
|
||||
const stocks = ref<Stock[]>([]);
|
||||
const selectedProducts = ref<Stock[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const searchQuery = ref('');
|
||||
const filterSerial = ref<string | null>(null);
|
||||
const showOnlyAvailable = ref(false);
|
||||
const showSerialDialog = ref(false);
|
||||
const currentSerialNumbers = ref<SerialNumber[] | null>(null);
|
||||
|
||||
// Filter options
|
||||
const serialFilterOptions = [
|
||||
{ label: 'Todos', value: null },
|
||||
{ label: 'Serializado', value: 'true' },
|
||||
{ label: 'Normal', value: 'false' }
|
||||
];
|
||||
|
||||
// Computed
|
||||
const filteredStocks = computed(() => {
|
||||
let result = [...stocks.value];
|
||||
|
||||
// Filtrar por búsqueda
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
result = result.filter(stock =>
|
||||
stock.product.code.toLowerCase().includes(query) ||
|
||||
stock.product.sku.toLowerCase().includes(query) ||
|
||||
stock.product.name.toLowerCase().includes(query) ||
|
||||
stock.product.description.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filtrar por tipo serializado
|
||||
if (filterSerial.value !== null) {
|
||||
const isSerial = filterSerial.value === 'true';
|
||||
result = result.filter(stock => stock.product.is_serial === isSerial);
|
||||
}
|
||||
|
||||
// Filtrar solo con stock disponible
|
||||
if (showOnlyAvailable.value) {
|
||||
result = result.filter(stock => stock.stock > 0);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const fetchStocks = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response: StocksResponse = await stockService.getStocks(props.warehouseId);
|
||||
|
||||
if (response.status === 'success' && response.data) {
|
||||
stocks.value = response.data.stocks;
|
||||
} else {
|
||||
throw new Error('Respuesta inválida del servidor');
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Error al cargar los productos del almacén';
|
||||
console.error('Error fetching stocks:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
isVisible.value = false;
|
||||
selectedProducts.value = [];
|
||||
searchQuery.value = '';
|
||||
filterSerial.value = null;
|
||||
showOnlyAvailable.value = false;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm', selectedProducts.value);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const getStockSeverity = (stock: Stock) => {
|
||||
if (stock.stock === 0) return 'danger';
|
||||
if (stock.stock_min && stock.stock <= stock.stock_min) return 'warning';
|
||||
if (stock.stock <= 5) return 'info';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN'
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const viewSerialNumbers = (stock: Stock) => {
|
||||
currentSerialNumbers.value = stock.serial_numbers;
|
||||
showSerialDialog.value = true;
|
||||
};
|
||||
|
||||
// Watch para cargar datos cuando el modal se abre
|
||||
watch(() => props.visible, (newValue) => {
|
||||
if (newValue) {
|
||||
fetchStocks();
|
||||
}
|
||||
});
|
||||
|
||||
// Cargar datos al montar si el modal ya está visible
|
||||
onMounted(() => {
|
||||
if (props.visible) {
|
||||
fetchStocks();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-toolbar {
|
||||
background-color: var(--surface-0);
|
||||
border: 1px solid var(--surface-200);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.search-input .search-icon {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 1rem;
|
||||
left: 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-inputtext) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.p-dropdown) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.p-datatable .p-datatable-tbody > tr > td) {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-datatable .p-datatable-thead > tr > th) {
|
||||
padding: 1rem 0.75rem;
|
||||
font-weight: 600;
|
||||
background-color: var(--surface-50);
|
||||
}
|
||||
|
||||
:deep(.p-tag) {
|
||||
font-weight: 600;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
:deep(.p-panel .p-panel-header) {
|
||||
background-color: var(--green-50);
|
||||
border-color: var(--green-200);
|
||||
}
|
||||
|
||||
:deep(.p-card) {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--surface-200);
|
||||
}
|
||||
|
||||
:deep(.p-card .p-card-content) {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@ -8,19 +8,14 @@ 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';
|
||||
import ModalProducts from './ModalProducts.vue';
|
||||
|
||||
interface SerialNumber {
|
||||
serial: string;
|
||||
@ -45,7 +40,6 @@ const toast = useToast();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const warehouseStore = useWarehouseStore();
|
||||
const productStore = useProductStore();
|
||||
|
||||
// Data from API
|
||||
const purchaseData = ref<PurchaseDetailResponse | null>(null);
|
||||
@ -112,9 +106,6 @@ 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);
|
||||
@ -368,55 +359,12 @@ function cancel() {
|
||||
}
|
||||
|
||||
// Funciones para el modal de productos
|
||||
const openProductModal = async () => {
|
||||
const openProductModal = () => {
|
||||
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 => {
|
||||
const handleProductsSelected = (selectedProductsList: ProductType[]) => {
|
||||
selectedProductsList.forEach(product => {
|
||||
// Verificar si el producto ya está en la lista
|
||||
const exists = products.value.find(p => p.id === product.id);
|
||||
if (!exists) {
|
||||
@ -439,11 +387,9 @@ const addSelectedProducts = () => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Productos Agregados',
|
||||
detail: `Se agregaron ${selectedProducts.value.length} producto(s)`,
|
||||
detail: `Se agregaron ${selectedProductsList.length} producto(s)`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
closeProductModal();
|
||||
};
|
||||
|
||||
const removeProduct = (productId: number) => {
|
||||
@ -823,138 +769,10 @@ const removeProduct = (productId: number) => {
|
||||
</template>
|
||||
|
||||
<!-- Modal de Selección de Productos -->
|
||||
<Dialog
|
||||
<ModalProducts
|
||||
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>
|
||||
@products-selected="handleProductsSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -31,79 +31,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<Button label="Exportar Datos" icon="pi pi-download" outlined severity="secondary" />
|
||||
<Button icon="pi pi-print" outlined severity="secondary" />
|
||||
<Button label="Entradas" icon="pi pi-plus" @click="openBatchAdd" />
|
||||
<Button label="Salidas" icon="pi pi-minus" @click="openBatchRemove" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Total SKUs -->
|
||||
<!-- <Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Total SKUs</p>
|
||||
<p class="text-2xl font-bold text-surface-900 dark:text-white">
|
||||
{{ warehouseData?.stocks.length || 0 }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400">
|
||||
<i class="pi pi-box text-xs"></i>
|
||||
Productos en stock
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
-->
|
||||
<!-- Low Stock Items -->
|
||||
<!-- <Card class="shadow-sm border-l-4 border-l-red-500">
|
||||
<template #content>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Items con Stock Bajo</p>
|
||||
<p class="text-2xl font-bold text-surface-900 dark:text-white">
|
||||
{{ warehouseData?.stocks.filter(s => s.stock_min && s.stock < s.stock_min).length || 0 }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1 text-xs font-semibold text-red-600 dark:text-red-400">
|
||||
<i class="pi pi-exclamation-triangle text-xs"></i>
|
||||
Acción requerida
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card> -->
|
||||
|
||||
<!-- Total Items -->
|
||||
<!-- <Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Items Totales</p>
|
||||
<p class="text-2xl font-bold text-surface-900 dark:text-white">
|
||||
{{ warehouseData?.items.length || 0 }}
|
||||
</p>
|
||||
<div class="flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400">
|
||||
<i class="pi pi-database text-xs"></i>
|
||||
Items registrados
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card> -->
|
||||
|
||||
<!-- Total Stock -->
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Stock Total</p>
|
||||
<p class="text-2xl font-bold text-surface-900 dark:text-white">
|
||||
{{warehouseData?.stocks.reduce((sum, s) => sum + s.stock, 0).toLocaleString() || 0}}
|
||||
</p>
|
||||
<div class="flex items-center gap-1 text-xs font-semibold text-green-600 dark:text-green-400">
|
||||
<i class="pi pi-chart-bar text-xs"></i>
|
||||
Unidades en almacén
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tabs & Main Content Table -->
|
||||
<Card class="shadow-sm">
|
||||
<template #content>
|
||||
@ -198,7 +130,7 @@
|
||||
:rowsPerPageOptions="[10, 25, 50]"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} resultados"
|
||||
:loading="loading" stripedRows responsiveLayout="scroll" class="text-sm">
|
||||
:loading="loadingMovements" stripedRows responsiveLayout="scroll" class="text-sm">
|
||||
<Column field="date" header="Fecha" sortable />
|
||||
<Column field="type" header="Tipo" sortable>
|
||||
<template #body="slotProps">
|
||||
@ -219,77 +151,6 @@
|
||||
</TabView>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Secondary Section: Recent Movements & Insights -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Quick Activity Log -->
|
||||
<!-- <Card class="lg:col-span-2 shadow-sm">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<h3 class="font-bold text-surface-900 dark:text-white">Registro Rápido de Actividad</h3>
|
||||
<Button label="Ver Todos los Movimientos" link class="text-sm" @click="viewAllMovements" />
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div v-for="activity in recentActivities" :key="activity.id"
|
||||
class="flex items-center gap-4 py-3 border-b border-surface-200 dark:border-surface-700 last:border-b-0">
|
||||
<div :class="[
|
||||
'flex items-center justify-center size-10 rounded-full',
|
||||
activity.type === 'in' ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'
|
||||
]">
|
||||
<i :class="[
|
||||
'pi text-sm',
|
||||
activity.type === 'in' ? 'pi-arrow-down text-green-600' : 'pi-arrow-up text-red-600'
|
||||
]"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-surface-900 dark:text-white">{{ activity.action }}</p>
|
||||
<p class="text-sm text-surface-500 dark:text-surface-400">{{ activity.product }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-surface-900 dark:text-white">{{ activity.quantity }}</p>
|
||||
<p class="text-xs text-surface-500 dark:text-surface-400">{{ activity.time }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card> -->
|
||||
|
||||
<!-- Warehouse Health Insights -->
|
||||
<!-- <Card
|
||||
class="shadow-sm bg-primary-50 dark:bg-primary-900/10 border border-primary-200 dark:border-primary-800">
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="font-bold text-surface-900 dark:text-white mb-2">Salud del Almacén</h3>
|
||||
<p class="text-sm text-surface-600 dark:text-surface-300 leading-relaxed">
|
||||
North Logistics Center está actualmente al 84% de capacidad. Recomendamos auditar la
|
||||
Zona B
|
||||
para optimización de espacio potencial.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-surface-700 dark:text-surface-200">Capacidad Total</span>
|
||||
<span class="font-bold text-surface-900 dark:text-white">50,000 m³</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-surface-700 dark:text-surface-200">Espacio Utilizado</span>
|
||||
<span class="font-bold text-surface-900 dark:text-white">42,000 m³</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-surface-700 dark:text-surface-200">Espacio Disponible</span>
|
||||
<span class="font-bold text-primary-700 dark:text-primary-400">8,000 m³</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button label="Generar Reporte Completo" class="w-full" outlined @click="generateReport" />
|
||||
</div>
|
||||
</template>
|
||||
</Card> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -298,7 +159,9 @@ import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { warehouseService } from '../services/warehouseService';
|
||||
import { InventoryMovementsServices } from '../services/inventory-movements.services';
|
||||
import type { WarehouseDetailData } from '../types/warehouse';
|
||||
import type { InventoryMovement } from '../types/inventory-movements.interfaces';
|
||||
|
||||
// PrimeVue Components
|
||||
import Toast from 'primevue/toast';
|
||||
@ -316,12 +179,16 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
// Services
|
||||
const inventoryMovementsService = new InventoryMovementsServices();
|
||||
|
||||
// Reactive State
|
||||
const warehouseData = ref<WarehouseDetailData | null>(null);
|
||||
const loading = ref(false);
|
||||
const activeTab = ref(0);
|
||||
const selectedCategory = ref('all');
|
||||
const selectedStockLevel = ref('all');
|
||||
const loadingMovements = ref(false);
|
||||
|
||||
// Breadcrumb
|
||||
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
|
||||
@ -348,68 +215,15 @@ const stockLevelOptions = [
|
||||
// Inventory Data from API
|
||||
const inventoryData = ref<any[]>([]);
|
||||
|
||||
// Mock Data - Movement History
|
||||
const movementHistory = ref([
|
||||
{
|
||||
date: '2024-01-30 14:30',
|
||||
type: 'Entrada',
|
||||
product: 'Laptop Dell XPS 15',
|
||||
quantity: '+50',
|
||||
user: 'Juan Pérez',
|
||||
reference: 'PO-2024-156'
|
||||
},
|
||||
{
|
||||
date: '2024-01-30 12:15',
|
||||
type: 'Salida',
|
||||
product: 'Monitor LG 27" 4K',
|
||||
quantity: '-25',
|
||||
user: 'María García',
|
||||
reference: 'SO-2024-892'
|
||||
},
|
||||
{
|
||||
date: '2024-01-30 10:00',
|
||||
type: 'Entrada',
|
||||
product: 'Silla Ergonómica Pro',
|
||||
quantity: '+100',
|
||||
user: 'Carlos López',
|
||||
reference: 'PO-2024-155'
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock Data - Recent Activities (unused - template is commented out)
|
||||
// const recentActivities = ref([
|
||||
// {
|
||||
// id: 1,
|
||||
// type: 'in',
|
||||
// action: 'Entrada de Stock',
|
||||
// product: 'Laptop Dell XPS 15 - 50 unidades',
|
||||
// quantity: '+50',
|
||||
// time: 'Hace 2 horas'
|
||||
// },
|
||||
// {
|
||||
// id: 2,
|
||||
// type: 'out',
|
||||
// action: 'Salida de Stock',
|
||||
// product: 'Monitor LG 27" - 25 unidades',
|
||||
// quantity: '-25',
|
||||
// time: 'Hace 4 horas'
|
||||
// },
|
||||
// {
|
||||
// id: 3,
|
||||
// type: 'in',
|
||||
// action: 'Entrada de Stock',
|
||||
// product: 'Silla Ergonómica - 100 unidades',
|
||||
// quantity: '+100',
|
||||
// time: 'Hace 6 horas'
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// Movement History from API
|
||||
const movementHistory = ref<any[]>([]);
|
||||
// Methods
|
||||
const getStatusSeverity = (status: string) => {
|
||||
const severityMap: Record<string, string> = {
|
||||
'En Stock': 'success',
|
||||
'Stock Bajo': 'warn',
|
||||
'Stock Crítico': 'danger',
|
||||
'Sin Stock': 'danger',
|
||||
'Sobrestock': 'info',
|
||||
};
|
||||
return severityMap[status] || 'secondary';
|
||||
@ -433,6 +247,14 @@ const openBatchAdd = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const openBatchRemove = () => {
|
||||
const warehouseId = route.params.id;
|
||||
router.push({
|
||||
name: 'WarehouseOutOfStock',
|
||||
query: { warehouse: warehouseId }
|
||||
});
|
||||
};
|
||||
|
||||
const viewItem = (item: any) => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
@ -451,24 +273,10 @@ const editItem = (item: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Unused methods (template sections are commented out)
|
||||
// const viewAllMovements = () => {
|
||||
// activeTab.value = 1;
|
||||
// };
|
||||
|
||||
// const generateReport = () => {
|
||||
// toast.add({
|
||||
// severity: 'success',
|
||||
// summary: 'Generando Reporte',
|
||||
// detail: 'El reporte se está generando...',
|
||||
// life: 3000
|
||||
// });
|
||||
// };
|
||||
|
||||
// Helper function to get stock status
|
||||
const getStockStatus = (stock: number, stockMin: number | null) => {
|
||||
if (!stockMin) return 'En Stock';
|
||||
if (stock === 0) return 'Sin Stock';
|
||||
if (!stockMin) return 'En Stock';
|
||||
if (stock < stockMin) return 'Stock Bajo';
|
||||
if (stock < stockMin * 1.5) return 'Stock Crítico';
|
||||
return 'En Stock';
|
||||
@ -487,6 +295,87 @@ const formatRelativeTime = (dateString: string) => {
|
||||
return 'Hace menos de 1 hora';
|
||||
};
|
||||
|
||||
// Helper function to format date for movements
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to capitalize first letter
|
||||
const capitalizeFirstLetter = (str: string) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
// Load inventory movements
|
||||
const loadInventoryMovements = async (warehouseId: number) => {
|
||||
loadingMovements.value = true;
|
||||
try {
|
||||
const response = await inventoryMovementsService.getInventoryMovements(warehouseId);
|
||||
|
||||
// Map API data to table format - flatten to show each product as a row
|
||||
movementHistory.value = response.data.flatMap((movement: InventoryMovement) => {
|
||||
const reference = movement.reference_document_id
|
||||
? `${movement.reference_type || 'DOC'}-${movement.reference_document_id}`
|
||||
: 'Sin referencia';
|
||||
|
||||
// If no products, show basic movement info
|
||||
if (!movement.products || movement.products.length === 0) {
|
||||
return [{
|
||||
date: formatDate(movement.created_at),
|
||||
type: capitalizeFirstLetter(movement.type),
|
||||
product: 'Sin productos',
|
||||
quantity: '0',
|
||||
user: movement.user?.email || 'Sistema',
|
||||
reference: reference
|
||||
}];
|
||||
}
|
||||
|
||||
// Create a row for each product in the movement
|
||||
return movement.products.map((productItem) => {
|
||||
const qty = typeof productItem.quantity === 'string'
|
||||
? parseFloat(productItem.quantity)
|
||||
: productItem.quantity;
|
||||
|
||||
const formattedQty = movement.type === 'entrada'
|
||||
? `+${qty}`
|
||||
: `-${qty}`;
|
||||
|
||||
return {
|
||||
date: formatDate(movement.created_at),
|
||||
type: capitalizeFirstLetter(movement.type),
|
||||
product: productItem.product.name,
|
||||
quantity: formattedQty,
|
||||
user: movement.user?.email || 'Sistema',
|
||||
reference: reference
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Movimientos Cargados',
|
||||
detail: `${response.data.length} movimientos encontrados`,
|
||||
life: 3000
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error al cargar movimientos:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Error al cargar el historial de movimientos',
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
loadingMovements.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
const warehouseId = route.params.id;
|
||||
@ -529,6 +418,9 @@ onMounted(async () => {
|
||||
detail: `Almacén ${response.data.warehouse.name} cargado exitosamente`,
|
||||
life: 3000
|
||||
});
|
||||
|
||||
// Load inventory movements
|
||||
await loadInventoryMovements(Number(warehouseId));
|
||||
} catch (error) {
|
||||
console.error('Error al cargar los datos del almacén:', error);
|
||||
toast.add({
|
||||
|
||||
@ -93,6 +93,7 @@ onMounted(async () => {
|
||||
Administra, rastrea y organiza todos tus almacenes en un solo lugar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Crear Nuevo Almacén "
|
||||
icon="pi pi-plus"
|
||||
|
||||
552
src/modules/warehouse/components/WarehouseOutInventory.vue
Normal file
552
src/modules/warehouse/components/WarehouseOutInventory.vue
Normal file
@ -0,0 +1,552 @@
|
||||
<template>
|
||||
<Toast position="top-right" />
|
||||
<div class="p-8 max-w-7xl mx-auto w-full">
|
||||
<div class="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold tracking-tight text-slate-900">Nueva Salida de Almacén</h2>
|
||||
<p class="text-slate-500 mt-1 font-medium">Gestione la retirada de stock especificando motivos y destinos.</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<Button label="Cancelar" class="p-button-outlined p-button-sm" @click="handleCancel" :disabled="loading" />
|
||||
<Button label="Confirmar Salida" icon="pi pi-check" class="p-button-primary p-button-sm" @click="handleConfirmarSalida" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-8">
|
||||
<!-- Información del Movimiento y Resumen -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Información del Movimiento -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-6 shadow-sm">
|
||||
<h3 class="text-base font-bold text-slate-900 mb-6 flex items-center gap-2">
|
||||
<span class="pi pi-info-circle text-primary"></span>
|
||||
Información del Movimiento
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-500">Tipo de Salida</label>
|
||||
<Dropdown v-model="tipoSalida" :options="tipoSalidaOptions" optionLabel="label" optionValue="value" placeholder="Seleccione motivo..." class="w-full" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-500">Almacén de Origen</label>
|
||||
<Dropdown
|
||||
v-model="almacenSeleccionado"
|
||||
:options="almacenOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Seleccione almacén..."
|
||||
class="w-full"
|
||||
:disabled="warehouseFromQuery"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-500">Fecha de Registro</label>
|
||||
<Calendar v-model="fechaRegistro" dateFormat="yy-mm-dd" class="w-full" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-500">Referencia / Remisión</label>
|
||||
<InputText v-model="referenciaDocumento" placeholder="Ej: 100" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resumen de Salida -->
|
||||
<div>
|
||||
<div class="bg-primary/5 border border-primary/20 rounded-xl p-6 shadow-sm relative overflow-hidden h-full">
|
||||
<h3 class="text-xs font-bold uppercase tracking-widest text-primary mb-5 flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 bg-primary rounded-full"></span>
|
||||
Resumen de Salida
|
||||
</h3>
|
||||
<div class="space-y-4 relative z-10">
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-slate-500 font-medium">Total SKUs</span>
|
||||
<span class="font-bold text-slate-900 bg-white px-2 py-0.5 rounded border border-slate-100 shadow-sm">{{ productos.length }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-slate-500 font-medium">Total Unidades</span>
|
||||
<span class="font-bold text-slate-900 bg-white px-2 py-0.5 rounded border border-slate-100 shadow-sm">{{ totalUnidades }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-slate-500 font-medium">Serializados</span>
|
||||
<span class="font-bold text-primary bg-primary/10 px-2 py-0.5 rounded border border-primary/20">{{ totalSerializados }}</span>
|
||||
</div>
|
||||
<div class="pt-4 mt-2 border-t border-primary/10">
|
||||
<div class="flex items-start gap-3 p-3 bg-white/60 rounded-lg border border-primary/10">
|
||||
<span class="pi pi-info-circle text-primary text-[20px] mt-0.5"></span>
|
||||
<p class="text-[11px] text-slate-600 leading-relaxed">
|
||||
Requiere firma de transporte antes de la salida física de mercancía.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Productos a Retirar - Ancho Completo -->
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="p-6 border-b border-slate-100 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-slate-50/30">
|
||||
<h3 class="text-base font-bold text-slate-900 flex items-center gap-2">
|
||||
<span class="pi pi-box text-primary"></span>
|
||||
Productos a Retirar
|
||||
</h3>
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<Button label="Añadir Producto" icon="pi pi-plus" class="p-button-primary" @click="openProductModal" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable :value="productos" class="w-full" responsiveLayout="scroll" stripedRows>
|
||||
<template #empty>
|
||||
<div class="text-center py-6">
|
||||
<i class="pi pi-inbox text-4xl text-slate-300 mb-3"></i>
|
||||
<p class="text-slate-500">No hay productos agregados</p>
|
||||
<p class="text-xs text-slate-400 mt-1">Haga clic en "Añadir Producto" para comenzar</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="sku" header="SKU" style="min-width: 130px">
|
||||
<template #body="{ data }">
|
||||
<span class="font-semibold text-slate-700">{{ data.sku }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nombre" header="Nombre del Producto" style="min-width: 250px">
|
||||
<template #body="{ data }">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">{{ data.nombre }}</div>
|
||||
<div class="text-xs text-slate-500" v-if="data.variante !== 'Estándar'">{{ data.variante }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Disponible" style="min-width: 120px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="`${data.stockDisponible} ${data.unidadMedida}`" severity="info" class="font-semibold" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Cantidad a Sacar" style="min-width: 150px">
|
||||
<template #body="{ data }">
|
||||
<InputNumber
|
||||
v-if="!data.serializado"
|
||||
v-model="data.cantidad"
|
||||
:min="1"
|
||||
:max="data.stockDisponible"
|
||||
showButtons
|
||||
buttonLayout="horizontal"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
decrementButtonClass="p-button-danger"
|
||||
incrementButtonClass="p-button-success"
|
||||
incrementButtonIcon="pi pi-plus"
|
||||
decrementButtonIcon="pi pi-minus"
|
||||
/>
|
||||
<div v-else class="flex align-items-center gap-2">
|
||||
<InputNumber
|
||||
v-model="data.cantidad"
|
||||
:min="0"
|
||||
:max="data.stockDisponible"
|
||||
disabled
|
||||
class="flex-1"
|
||||
/>
|
||||
<Tag :severity="data.cantidad > 0 ? 'success' : 'secondary'" class="font-semibold">
|
||||
{{ data.cantidad }}
|
||||
</Tag>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Números de Serie" style="min-width: 180px">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
v-if="data.serializado"
|
||||
:label="data.seriesSeleccionadas?.length > 0 ? `${data.seriesSeleccionadas.length} serie(s)` : 'Seleccionar'"
|
||||
icon="pi pi-barcode"
|
||||
:severity="data.seriesSeleccionadas?.length > 0 ? 'success' : 'secondary'"
|
||||
outlined
|
||||
size="small"
|
||||
@click="openSerialModal(data)"
|
||||
/>
|
||||
<span v-else class="text-slate-400 text-sm">N/A</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Acción" style="width: 80px">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
@click="removeProduct(data.product_id)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
<div class="p-4 bg-slate-50 flex justify-center border-t border-slate-100">
|
||||
<Button label="Añadir Producto Manualmente" icon="pi pi-plus" class="p-button-link text-xs font-bold text-primary uppercase tracking-widest" @click="openProductModal" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Selección de Productos desde Stock -->
|
||||
<ModalStockProducts
|
||||
v-model:visible="showProductModal"
|
||||
:warehouse-id="almacenSeleccionado || 0"
|
||||
:warehouse-name="getWarehouseName()"
|
||||
@confirm="handleProductsSelected"
|
||||
/>
|
||||
|
||||
<!-- Modal de Selección de Números de Serie -->
|
||||
<Dialog
|
||||
v-model:visible="showSerialModal"
|
||||
modal
|
||||
:header="`Seleccionar Números de Serie - ${currentProduct?.nombre || ''}`"
|
||||
:style="{ width: '800px' }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Message severity="info" :closable="false">
|
||||
Seleccione los números de serie que desea retirar del inventario.
|
||||
Disponibles: {{ currentProduct?.seriesDisponibles?.length || 0 }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-if="currentProduct"
|
||||
v-model:selection="selectedSerials"
|
||||
:value="currentProduct.seriesDisponibles"
|
||||
dataKey="serial_number"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
class="p-datatable-sm"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="text-center py-4">
|
||||
<p class="text-600">No hay números de serie disponibles</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column selectionMode="multiple" headerStyle="width: 3rem" />
|
||||
|
||||
<Column field="serial_number" header="Número de Serie" style="min-width: 200px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i class="pi pi-barcode text-primary"></i>
|
||||
<span class="font-semibold">{{ data.serial_number }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="acquisition_date" header="Fecha de Adquisición" style="min-width: 150px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm">{{ formatDate(data.acquisition_date) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-content-between align-items-center">
|
||||
<span class="text-600">
|
||||
{{ selectedSerials.length }} serie(s) seleccionada(s)
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
icon="pi pi-times"
|
||||
text
|
||||
@click="closeSerialModal"
|
||||
/>
|
||||
<Button
|
||||
label="Confirmar"
|
||||
icon="pi pi-check"
|
||||
:disabled="selectedSerials.length === 0"
|
||||
@click="confirmSerialSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import Button from 'primevue/button';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import Calendar from 'primevue/calendar';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Toast from 'primevue/toast';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Tag from 'primevue/tag';
|
||||
import Message from 'primevue/message';
|
||||
import { InventoryWarehouseServices } from '../services/inventoryWarehouse.services';
|
||||
import type { OutInventoryRequestDTO } from '../types/warehouse-inventory.interfaces';
|
||||
import type { Stock, SerialNumber } from '../types/stock.interfaces';
|
||||
import ModalStockProducts from './ModalStockProducts.vue';
|
||||
import { useWarehouseStore } from '../../../stores/warehouseStore';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const inventoryService = new InventoryWarehouseServices();
|
||||
const warehouseStore = useWarehouseStore();
|
||||
|
||||
// Estado de carga
|
||||
const loading = ref(false);
|
||||
const showProductModal = ref(false);
|
||||
const showSerialModal = ref(false);
|
||||
|
||||
// Opciones para dropdowns
|
||||
const tipoSalidaOptions = [
|
||||
{ label: 'Venta', value: 1 },
|
||||
{ label: 'Merma / Desperdicio', value: 2 },
|
||||
{ label: 'Devolución a Proveedor', value: 3 },
|
||||
{ label: 'Ajuste de Inventario', value: 4 },
|
||||
];
|
||||
|
||||
const almacenOptions = computed(() =>
|
||||
warehouseStore.activeWarehouses.map(w => ({
|
||||
label: w.name,
|
||||
value: w.id,
|
||||
}))
|
||||
);
|
||||
|
||||
// Form fields
|
||||
const tipoSalida = ref<number | null>(null);
|
||||
const almacenSeleccionado = ref<number | null>(null);
|
||||
const fechaRegistro = ref<Date>(new Date());
|
||||
const referenciaDocumento = ref<string>('');
|
||||
|
||||
// Productos
|
||||
interface Producto {
|
||||
product_id: number;
|
||||
nombre: string;
|
||||
sku: string;
|
||||
variante: string;
|
||||
stockDisponible: number;
|
||||
unidadMedida: string;
|
||||
cantidad: number;
|
||||
serializado: boolean;
|
||||
seriesDisponibles?: SerialNumber[];
|
||||
seriesSeleccionadas?: SerialNumber[];
|
||||
}
|
||||
|
||||
const productos = ref<Producto[]>([]);
|
||||
const currentProduct = ref<Producto | null>(null);
|
||||
const selectedSerials = ref<SerialNumber[]>([]);
|
||||
|
||||
// Determinar si el almacén viene de query param (no editable)
|
||||
const warehouseFromQuery = computed(() => !!route.query.warehouse);
|
||||
|
||||
const totalUnidades = computed(() => productos.value.reduce((acc, p) => acc + p.cantidad, 0));
|
||||
const totalSerializados = computed(() => productos.value.filter(p => p.serializado).reduce((acc, p) => acc + p.cantidad, 0));
|
||||
|
||||
// Funciones
|
||||
const openProductModal = () => {
|
||||
if (!almacenSeleccionado.value) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validación',
|
||||
detail: 'Primero seleccione un almacén de origen',
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
showProductModal.value = true;
|
||||
};
|
||||
|
||||
const getWarehouseName = () => {
|
||||
if (!almacenSeleccionado.value) return 'Almacén';
|
||||
const warehouse = warehouseStore.activeWarehouses.find(w => w.id === almacenSeleccionado.value);
|
||||
return warehouse?.name || 'Almacén';
|
||||
};
|
||||
|
||||
const handleProductsSelected = (selectedStocks: Stock[]) => {
|
||||
selectedStocks.forEach(stock => {
|
||||
// Verificar si el producto ya está en la lista
|
||||
const exists = productos.value.find(p => p.product_id === stock.product.id);
|
||||
if (!exists) {
|
||||
productos.value.push({
|
||||
product_id: stock.product.id,
|
||||
nombre: stock.product.name,
|
||||
sku: stock.product.sku,
|
||||
variante: stock.product.description || 'Estándar',
|
||||
stockDisponible: stock.stock,
|
||||
unidadMedida: stock.product.unit_of_measure.abbreviation,
|
||||
cantidad: stock.product.is_serial ? 0 : 1,
|
||||
serializado: stock.product.is_serial,
|
||||
seriesDisponibles: stock.serial_numbers || [],
|
||||
seriesSeleccionadas: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Productos Agregados',
|
||||
detail: `Se agregaron ${selectedStocks.length} producto(s) del almacén`,
|
||||
life: 3000,
|
||||
});
|
||||
};
|
||||
|
||||
const openSerialModal = (producto: Producto) => {
|
||||
currentProduct.value = producto;
|
||||
selectedSerials.value = [...(producto.seriesSeleccionadas || [])];
|
||||
showSerialModal.value = true;
|
||||
};
|
||||
|
||||
const closeSerialModal = () => {
|
||||
showSerialModal.value = false;
|
||||
currentProduct.value = null;
|
||||
selectedSerials.value = [];
|
||||
};
|
||||
|
||||
const confirmSerialSelection = () => {
|
||||
if (currentProduct.value) {
|
||||
const producto = productos.value.find(p => p.product_id === currentProduct.value!.product_id);
|
||||
if (producto) {
|
||||
producto.seriesSeleccionadas = [...selectedSerials.value];
|
||||
producto.cantidad = selectedSerials.value.length;
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Series Seleccionadas',
|
||||
detail: `Se seleccionaron ${selectedSerials.value.length} número(s) de serie`,
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
closeSerialModal();
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-MX', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const removeProduct = (productId: number) => {
|
||||
const index = productos.value.findIndex(p => p.product_id === productId);
|
||||
if (index !== -1) {
|
||||
productos.value.splice(index, 1);
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Producto Eliminado',
|
||||
detail: 'El producto fue eliminado de la lista',
|
||||
life: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleConfirmarSalida = async () => {
|
||||
// Validaciones
|
||||
if (!almacenSeleccionado.value) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validación',
|
||||
detail: 'Debe seleccionar un almacén de origen',
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!productos.value.length) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validación',
|
||||
detail: 'Debe agregar al menos un producto',
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar productos serializados
|
||||
const productosSinSeries = productos.value.filter(p => p.serializado && (!p.seriesSeleccionadas || p.seriesSeleccionadas.length === 0));
|
||||
if (productosSinSeries.length > 0) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validación',
|
||||
detail: `Debe seleccionar números de serie para: ${productosSinSeries.map(p => p.nombre).join(', ')}`,
|
||||
life: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar cantidades
|
||||
const productosConCantidadCero = productos.value.filter(p => p.cantidad <= 0);
|
||||
if (productosConCantidadCero.length > 0) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validación',
|
||||
detail: 'Todos los productos deben tener cantidad mayor a 0',
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Construir el request
|
||||
const request: OutInventoryRequestDTO = {
|
||||
warehouse_id: almacenSeleccionado.value,
|
||||
reference_type: tipoSalida.value,
|
||||
reference_document_id: referenciaDocumento.value ? Number(referenciaDocumento.value) : null,
|
||||
products: productos.value.map((p) => ({
|
||||
product_id: p.product_id,
|
||||
quantity: p.cantidad,
|
||||
// Si es serializado, enviar los números de serie seleccionados
|
||||
...(p.serializado && p.seriesSeleccionadas && {
|
||||
serial_numbers: p.seriesSeleccionadas.map(s => s.serial_number)
|
||||
})
|
||||
})),
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await inventoryService.exitInventory(request);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Éxito',
|
||||
detail: response.message || 'Salida de inventario realizada exitosamente',
|
||||
life: 3000,
|
||||
});
|
||||
|
||||
// Opcional: redirigir después de un breve delay
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'WarehouseDetails', params: { id: almacenSeleccionado.value } });
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error al confirmar salida:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: error?.response?.data?.message || 'Error al procesar la salida de inventario',
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Cargar almacenes
|
||||
await warehouseStore.fetchWarehouses();
|
||||
|
||||
// Si viene warehouse_id desde el query, pre-seleccionarlo
|
||||
const warehouseId = route.query.warehouse;
|
||||
if (warehouseId) {
|
||||
almacenSeleccionado.value = Number(warehouseId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,16 @@
|
||||
import api from "@/services/api";
|
||||
import type { InventoryMovementsPaginatedResponse } from "../types/inventory-movements.interfaces";
|
||||
|
||||
export class InventoryMovementsServices {
|
||||
|
||||
async getInventoryMovements(warehouse: number): Promise<InventoryMovementsPaginatedResponse> {
|
||||
try {
|
||||
const response = await api.get(`/api/inventory-movements?warehouse=${warehouse}`);
|
||||
console.log('📦 Inventory Movements response:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching inventory movements:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import api from "../../../services/api";
|
||||
import type { OutInventoryRequestDTO, OutInventoryResponseDTO } from "../types/warehouse-inventory.interfaces";
|
||||
import type { CreateInventoryRequest, CreateInventoryResponse } from "../types/warehouse.inventory";
|
||||
|
||||
export const inventoryWarehouseServices = {
|
||||
@ -14,3 +15,16 @@ export const inventoryWarehouseServices = {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class InventoryWarehouseServices {
|
||||
async exitInventory(data: OutInventoryRequestDTO): Promise<OutInventoryResponseDTO> {
|
||||
try {
|
||||
const response = await api.post('/api/inventory/exit', data);
|
||||
console.log('📦 Exit Inventory response:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('❌ Error exiting inventory:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
src/modules/warehouse/services/stock.services.ts
Normal file
15
src/modules/warehouse/services/stock.services.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import api from "@/services/api";
|
||||
import type { StocksResponse } from "../types/stock.interfaces";
|
||||
|
||||
export class StockService {
|
||||
|
||||
async getStocks(warehouseId: number): Promise<StocksResponse> {
|
||||
try {
|
||||
const response = await api.get<StocksResponse>(`/api/product-warehouses/${warehouseId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching stocks:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/modules/warehouse/types/inventory-movements.interfaces.ts
Normal file
102
src/modules/warehouse/types/inventory-movements.interfaces.ts
Normal file
@ -0,0 +1,102 @@
|
||||
export interface Warehouse {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
address: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
email_verified_at: string | null;
|
||||
status: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
full_name: string;
|
||||
last_name: string;
|
||||
profile_photo_url: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
code: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
barcode: string;
|
||||
description: string;
|
||||
unit_of_measure_id: number;
|
||||
suggested_sale_price: number;
|
||||
attributes: Record<string, any> | any[];
|
||||
is_active: boolean;
|
||||
is_serial: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
sat_code_product_id: number | null;
|
||||
}
|
||||
|
||||
export interface MovementDetail {
|
||||
id: number;
|
||||
movement_id: number;
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
serial_number: string | null;
|
||||
inventory_item_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export interface MovementProduct {
|
||||
product_id: number;
|
||||
product: Product;
|
||||
quantity: string | number;
|
||||
serial_number: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryMovement {
|
||||
id: number;
|
||||
type: 'entrada' | 'salida';
|
||||
reference_type: number | null;
|
||||
reference_document_id: number | null;
|
||||
from_warehouse_id: number | null;
|
||||
to_warehouse_id: number | null;
|
||||
user_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
from_warehouse: Warehouse | null;
|
||||
to_warehouse: Warehouse | null;
|
||||
user: User | null;
|
||||
details?: MovementDetail[];
|
||||
products: MovementProduct[];
|
||||
}
|
||||
|
||||
export interface PaginationLink {
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface InventoryMovementsPaginatedResponse {
|
||||
current_page: number;
|
||||
data: InventoryMovement[];
|
||||
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;
|
||||
}
|
||||
66
src/modules/warehouse/types/stock.interfaces.ts
Normal file
66
src/modules/warehouse/types/stock.interfaces.ts
Normal file
@ -0,0 +1,66 @@
|
||||
// Serial Number Interface
|
||||
export interface SerialNumber {
|
||||
serial_number: string;
|
||||
acquisition_date: string;
|
||||
purchase_cost: number;
|
||||
attributes: Record<string, string[]> | any[];
|
||||
}
|
||||
|
||||
// Unit of Measure Interface
|
||||
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;
|
||||
}
|
||||
|
||||
// Product Interface
|
||||
export interface Product {
|
||||
id: number;
|
||||
code: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
barcode: string;
|
||||
description: string;
|
||||
unit_of_measure_id: number;
|
||||
suggested_sale_price: number;
|
||||
attributes: Record<string, any> | any[];
|
||||
is_active: boolean;
|
||||
is_serial: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
sat_code_product_id: number | null;
|
||||
unit_of_measure: UnitOfMeasure;
|
||||
}
|
||||
|
||||
// Stock Interface
|
||||
export interface Stock {
|
||||
id: number;
|
||||
product_id: number;
|
||||
warehouse_id: number;
|
||||
stock: number;
|
||||
stock_min: number | null;
|
||||
stock_max: number | null;
|
||||
reorder_point: number | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
product: Product;
|
||||
serial_numbers: SerialNumber[] | null;
|
||||
}
|
||||
|
||||
// Stocks Data Interface
|
||||
export interface StocksData {
|
||||
stocks: Stock[];
|
||||
}
|
||||
|
||||
// API Response Interface
|
||||
export interface StocksResponse {
|
||||
status: string;
|
||||
data: StocksData;
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
export interface InventoryProductItem {
|
||||
product_id: number;
|
||||
quantity?: number;
|
||||
serial_number?: string;
|
||||
serial_numbers?: string[];
|
||||
}
|
||||
|
||||
export interface OutInventoryRequestDTO {
|
||||
warehouse_id: number;
|
||||
reference_type: number | null;
|
||||
reference_document_id: number | null;
|
||||
products: InventoryProductItem[];
|
||||
}
|
||||
|
||||
export interface ProductAttributes {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface MovementProduct {
|
||||
id: number;
|
||||
code: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
barcode: string;
|
||||
description: string;
|
||||
unit_of_measure_id: number;
|
||||
suggested_sale_price: number;
|
||||
attributes: ProductAttributes;
|
||||
is_active: boolean;
|
||||
is_serial: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
sat_code_product_id: number | null;
|
||||
}
|
||||
|
||||
export interface MovementDetail {
|
||||
id: number;
|
||||
movement_id: number;
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
serial_number: string | null;
|
||||
inventory_item_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
product: MovementProduct;
|
||||
}
|
||||
|
||||
export interface Warehouse {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
address: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryMovement {
|
||||
id: number;
|
||||
type: string;
|
||||
reference_type: number;
|
||||
reference_document_id: number;
|
||||
from_warehouse_id: number;
|
||||
to_warehouse_id: number | null;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
from_warehouse: Warehouse;
|
||||
details: MovementDetail[];
|
||||
}
|
||||
|
||||
export interface InventoryResult {
|
||||
product_id: number;
|
||||
previous_stock: number;
|
||||
removed_quantity: number;
|
||||
new_stock: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface OutInventoryResponseDTO {
|
||||
message: string;
|
||||
data: {
|
||||
movement: InventoryMovement;
|
||||
results: InventoryResult[];
|
||||
};
|
||||
meta: {
|
||||
total_items: number;
|
||||
};
|
||||
}
|
||||
@ -17,7 +17,7 @@ import RolesIndex from '../modules/users/components/RoleIndex.vue';
|
||||
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 Positions from '../modules/rh/components/positions/Positions.vue';
|
||||
import Departments from '../modules/rh/components/departments/Departments.vue';
|
||||
|
||||
import '../modules/catalog/components/suppliers/Suppliers.vue';
|
||||
@ -30,6 +30,7 @@ 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';
|
||||
import WarehouseOutInventory from '../modules/warehouse/components/WarehouseOutInventory.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@ -110,6 +111,15 @@ const routes: RouteRecordRaw[] = [
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'out-of-stock',
|
||||
name: 'WarehouseOutOfStock',
|
||||
component: WarehouseOutInventory,
|
||||
meta: {
|
||||
title: 'Salida de Inventario',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'inventory',
|
||||
name: 'WarehouseInventory',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user