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']
|
Menu: typeof import('primevue/menu')['default']
|
||||||
Message: typeof import('primevue/message')['default']
|
Message: typeof import('primevue/message')['default']
|
||||||
Paginator: typeof import('primevue/paginator')['default']
|
Paginator: typeof import('primevue/paginator')['default']
|
||||||
|
Panel: typeof import('primevue/panel')['default']
|
||||||
ProgressSpinner: typeof import('primevue/progressspinner')['default']
|
ProgressSpinner: typeof import('primevue/progressspinner')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
@ -45,6 +46,7 @@ declare module 'vue' {
|
|||||||
Tag: typeof import('primevue/tag')['default']
|
Tag: typeof import('primevue/tag')['default']
|
||||||
Textarea: typeof import('primevue/textarea')['default']
|
Textarea: typeof import('primevue/textarea')['default']
|
||||||
Toast: typeof import('primevue/toast')['default']
|
Toast: typeof import('primevue/toast')['default']
|
||||||
|
Toolbar: typeof import('primevue/toolbar')['default']
|
||||||
TopBar: typeof import('./src/components/layout/TopBar.vue')['default']
|
TopBar: typeof import('./src/components/layout/TopBar.vue')['default']
|
||||||
}
|
}
|
||||||
export interface GlobalDirectives {
|
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>
|
</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 -->
|
<!-- Departments Table -->
|
||||||
<Card class="shadow-sm">
|
<Card class="shadow-sm">
|
||||||
<template #content>
|
<template #content>
|
||||||
@ -192,7 +141,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { DepartmentsService } from '../../services/departments.services';
|
import { DepartmentsService } from '../../services/departments.services';
|
||||||
|
|
||||||
@ -237,11 +186,6 @@ const formData = ref<Partial<Department>>({
|
|||||||
// Departments Data
|
// Departments Data
|
||||||
const departments = ref<Department[]>([]);
|
const departments = ref<Department[]>([]);
|
||||||
|
|
||||||
// Computed
|
|
||||||
const totalEmployees = computed(() => {
|
|
||||||
return departments.value.reduce((sum, dept) => sum + (dept.employeeCount || 0), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch Departments from API
|
// Fetch Departments from API
|
||||||
const fetchDepartments = async (showToast = true) => {
|
const fetchDepartments = async (showToast = true) => {
|
||||||
loading.value = 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 InputText from 'primevue/inputtext';
|
||||||
import Badge from 'primevue/badge';
|
import Badge from 'primevue/badge';
|
||||||
import Toast from 'primevue/toast';
|
import Toast from 'primevue/toast';
|
||||||
import Dialog from 'primevue/dialog';
|
|
||||||
import DataTable from 'primevue/datatable';
|
|
||||||
import Column from 'primevue/column';
|
|
||||||
import IconField from 'primevue/iconfield';
|
|
||||||
import InputIcon from 'primevue/inputicon';
|
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { purchaseServices } from '../../purchases/services/purchaseServices';
|
import { purchaseServices } from '../../purchases/services/purchaseServices';
|
||||||
import type { PurchaseDetailResponse } from '../../purchases/types/purchases';
|
import type { PurchaseDetailResponse } from '../../purchases/types/purchases';
|
||||||
import { useWarehouseStore } from '../../../stores/warehouseStore';
|
import { useWarehouseStore } from '../../../stores/warehouseStore';
|
||||||
import { useProductStore } from '../../products/stores/productStore';
|
|
||||||
import { inventoryWarehouseServices } from '../services/inventoryWarehouse.services';
|
import { inventoryWarehouseServices } from '../services/inventoryWarehouse.services';
|
||||||
import type { InventoryProductItem, CreateInventoryRequest } from '../types/warehouse.inventory';
|
import type { InventoryProductItem, CreateInventoryRequest } from '../types/warehouse.inventory';
|
||||||
import type { Product as ProductType } from '../../products/types/product';
|
import type { Product as ProductType } from '../../products/types/product';
|
||||||
|
import ModalProducts from './ModalProducts.vue';
|
||||||
|
|
||||||
interface SerialNumber {
|
interface SerialNumber {
|
||||||
serial: string;
|
serial: string;
|
||||||
@ -45,7 +40,6 @@ const toast = useToast();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const warehouseStore = useWarehouseStore();
|
const warehouseStore = useWarehouseStore();
|
||||||
const productStore = useProductStore();
|
|
||||||
|
|
||||||
// Data from API
|
// Data from API
|
||||||
const purchaseData = ref<PurchaseDetailResponse | null>(null);
|
const purchaseData = ref<PurchaseDetailResponse | null>(null);
|
||||||
@ -112,9 +106,6 @@ const newSerialWarehouse = ref<number>(1);
|
|||||||
|
|
||||||
// Modal de productos
|
// Modal de productos
|
||||||
const showProductModal = ref(false);
|
const showProductModal = ref(false);
|
||||||
const productSearch = ref('');
|
|
||||||
const selectedProducts = ref<ProductType[]>([]);
|
|
||||||
const loadingProducts = ref(false);
|
|
||||||
|
|
||||||
const totalReceived = computed(() => {
|
const totalReceived = computed(() => {
|
||||||
return products.value.reduce((sum, p) => sum + p.quantityReceived, 0);
|
return products.value.reduce((sum, p) => sum + p.quantityReceived, 0);
|
||||||
@ -368,55 +359,12 @@ function cancel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Funciones para el modal de productos
|
// Funciones para el modal de productos
|
||||||
const openProductModal = async () => {
|
const openProductModal = () => {
|
||||||
showProductModal.value = true;
|
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 = () => {
|
const handleProductsSelected = (selectedProductsList: ProductType[]) => {
|
||||||
showProductModal.value = false;
|
selectedProductsList.forEach(product => {
|
||||||
selectedProducts.value = [];
|
|
||||||
productSearch.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredProducts = computed(() => {
|
|
||||||
if (!productSearch.value) {
|
|
||||||
return productStore.activeProducts;
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = productSearch.value.toLowerCase();
|
|
||||||
return productStore.activeProducts.filter(p =>
|
|
||||||
p.name.toLowerCase().includes(search) ||
|
|
||||||
p.sku.toLowerCase().includes(search) ||
|
|
||||||
(p.code && p.code.toLowerCase().includes(search))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const addSelectedProducts = () => {
|
|
||||||
if (selectedProducts.value.length === 0) {
|
|
||||||
toast.add({
|
|
||||||
severity: 'warn',
|
|
||||||
summary: 'Selección Requerida',
|
|
||||||
detail: 'Por favor seleccione al menos un producto',
|
|
||||||
life: 3000
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedProducts.value.forEach(product => {
|
|
||||||
// Verificar si el producto ya está en la lista
|
// Verificar si el producto ya está en la lista
|
||||||
const exists = products.value.find(p => p.id === product.id);
|
const exists = products.value.find(p => p.id === product.id);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@ -439,11 +387,9 @@ const addSelectedProducts = () => {
|
|||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Productos Agregados',
|
summary: 'Productos Agregados',
|
||||||
detail: `Se agregaron ${selectedProducts.value.length} producto(s)`,
|
detail: `Se agregaron ${selectedProductsList.length} producto(s)`,
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
|
|
||||||
closeProductModal();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeProduct = (productId: number) => {
|
const removeProduct = (productId: number) => {
|
||||||
@ -823,138 +769,10 @@ const removeProduct = (productId: number) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Modal de Selección de Productos -->
|
<!-- Modal de Selección de Productos -->
|
||||||
<Dialog
|
<ModalProducts
|
||||||
v-model:visible="showProductModal"
|
v-model:visible="showProductModal"
|
||||||
modal
|
@products-selected="handleProductsSelected"
|
||||||
header="Seleccionar Productos"
|
|
||||||
:style="{ width: '90vw', maxWidth: '1200px' }"
|
|
||||||
:contentStyle="{ padding: '0' }"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<i class="pi pi-shopping-cart text-xl"></i>
|
|
||||||
<span class="font-bold text-lg">Seleccionar Productos del Catálogo</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="p-6">
|
|
||||||
<!-- Search Bar -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<IconField iconPosition="left">
|
|
||||||
<InputIcon class="pi pi-search" />
|
|
||||||
<InputText
|
|
||||||
v-model="productSearch"
|
|
||||||
placeholder="Buscar por nombre, SKU o código..."
|
|
||||||
class="w-full"
|
|
||||||
/>
|
/>
|
||||||
</IconField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Products Table -->
|
|
||||||
<DataTable
|
|
||||||
v-model:selection="selectedProducts"
|
|
||||||
:value="filteredProducts"
|
|
||||||
:loading="loadingProducts"
|
|
||||||
selectionMode="multiple"
|
|
||||||
dataKey="id"
|
|
||||||
:paginator="true"
|
|
||||||
:rows="10"
|
|
||||||
:rowsPerPageOptions="[5, 10, 20, 50]"
|
|
||||||
stripedRows
|
|
||||||
responsiveLayout="scroll"
|
|
||||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} productos"
|
|
||||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
|
||||||
>
|
|
||||||
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
|
||||||
|
|
||||||
<Column field="name" header="Producto" sortable style="min-width: 250px">
|
|
||||||
<template #body="slotProps">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="font-medium text-surface-900 dark:text-white">
|
|
||||||
{{ slotProps.data.name }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-surface-500 dark:text-surface-400">
|
|
||||||
{{ slotProps.data.description }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column field="sku" header="SKU" sortable style="min-width: 120px">
|
|
||||||
<template #body="slotProps">
|
|
||||||
<span class="font-mono text-sm text-surface-600 dark:text-surface-400">
|
|
||||||
{{ slotProps.data.sku }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column field="code" header="Código" sortable style="min-width: 100px">
|
|
||||||
<template #body="slotProps">
|
|
||||||
<span class="font-mono text-sm text-primary-600 dark:text-primary-400">
|
|
||||||
{{ slotProps.data.code }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column field="is_serial" header="Tipo" style="min-width: 100px">
|
|
||||||
<template #body="slotProps">
|
|
||||||
<Badge
|
|
||||||
:value="slotProps.data.is_serial ? 'Serial' : 'Estándar'"
|
|
||||||
:severity="slotProps.data.is_serial ? 'info' : 'secondary'"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column field="suggested_sale_price" header="Precio Sugerido" sortable style="min-width: 130px">
|
|
||||||
<template #body="slotProps">
|
|
||||||
<span class="font-semibold text-green-600 dark:text-green-400">
|
|
||||||
${{ slotProps.data.suggested_sale_price.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<template #empty>
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<i class="pi pi-inbox text-4xl text-surface-300 dark:text-surface-600 mb-4"></i>
|
|
||||||
<p class="text-surface-500 dark:text-surface-400">
|
|
||||||
No se encontraron productos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #loading>
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<i class="pi pi-spin pi-spinner text-4xl text-primary mb-4"></i>
|
|
||||||
<p class="text-surface-500 dark:text-surface-400">
|
|
||||||
Cargando productos...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="text-sm text-surface-600 dark:text-surface-400">
|
|
||||||
{{ selectedProducts.length }} producto(s) seleccionado(s)
|
|
||||||
</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
label="Cancelar"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
@click="closeProductModal"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Agregar Productos"
|
|
||||||
icon="pi pi-plus"
|
|
||||||
:disabled="selectedProducts.length === 0"
|
|
||||||
@click="addSelectedProducts"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -31,79 +31,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<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="Entradas" icon="pi pi-plus" @click="openBatchAdd" />
|
||||||
|
<Button label="Salidas" icon="pi pi-minus" @click="openBatchRemove" />
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Tabs & Main Content Table -->
|
||||||
<Card class="shadow-sm">
|
<Card class="shadow-sm">
|
||||||
<template #content>
|
<template #content>
|
||||||
@ -198,7 +130,7 @@
|
|||||||
:rowsPerPageOptions="[10, 25, 50]"
|
:rowsPerPageOptions="[10, 25, 50]"
|
||||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
|
||||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} resultados"
|
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="date" header="Fecha" sortable />
|
||||||
<Column field="type" header="Tipo" sortable>
|
<Column field="type" header="Tipo" sortable>
|
||||||
<template #body="slotProps">
|
<template #body="slotProps">
|
||||||
@ -219,77 +151,6 @@
|
|||||||
</TabView>
|
</TabView>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -298,7 +159,9 @@ import { ref, onMounted, computed } from 'vue';
|
|||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { warehouseService } from '../services/warehouseService';
|
import { warehouseService } from '../services/warehouseService';
|
||||||
|
import { InventoryMovementsServices } from '../services/inventory-movements.services';
|
||||||
import type { WarehouseDetailData } from '../types/warehouse';
|
import type { WarehouseDetailData } from '../types/warehouse';
|
||||||
|
import type { InventoryMovement } from '../types/inventory-movements.interfaces';
|
||||||
|
|
||||||
// PrimeVue Components
|
// PrimeVue Components
|
||||||
import Toast from 'primevue/toast';
|
import Toast from 'primevue/toast';
|
||||||
@ -316,12 +179,16 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Services
|
||||||
|
const inventoryMovementsService = new InventoryMovementsServices();
|
||||||
|
|
||||||
// Reactive State
|
// Reactive State
|
||||||
const warehouseData = ref<WarehouseDetailData | null>(null);
|
const warehouseData = ref<WarehouseDetailData | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const activeTab = ref(0);
|
const activeTab = ref(0);
|
||||||
const selectedCategory = ref('all');
|
const selectedCategory = ref('all');
|
||||||
const selectedStockLevel = ref('all');
|
const selectedStockLevel = ref('all');
|
||||||
|
const loadingMovements = ref(false);
|
||||||
|
|
||||||
// Breadcrumb
|
// Breadcrumb
|
||||||
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
|
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
|
||||||
@ -348,68 +215,15 @@ const stockLevelOptions = [
|
|||||||
// Inventory Data from API
|
// Inventory Data from API
|
||||||
const inventoryData = ref<any[]>([]);
|
const inventoryData = ref<any[]>([]);
|
||||||
|
|
||||||
// Mock Data - Movement History
|
// Movement History from API
|
||||||
const movementHistory = ref([
|
const movementHistory = ref<any[]>([]);
|
||||||
{
|
|
||||||
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'
|
|
||||||
// },
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const getStatusSeverity = (status: string) => {
|
const getStatusSeverity = (status: string) => {
|
||||||
const severityMap: Record<string, string> = {
|
const severityMap: Record<string, string> = {
|
||||||
'En Stock': 'success',
|
'En Stock': 'success',
|
||||||
'Stock Bajo': 'warn',
|
'Stock Bajo': 'warn',
|
||||||
'Stock Crítico': 'danger',
|
'Stock Crítico': 'danger',
|
||||||
|
'Sin Stock': 'danger',
|
||||||
'Sobrestock': 'info',
|
'Sobrestock': 'info',
|
||||||
};
|
};
|
||||||
return severityMap[status] || 'secondary';
|
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) => {
|
const viewItem = (item: any) => {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'info',
|
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
|
// Helper function to get stock status
|
||||||
const getStockStatus = (stock: number, stockMin: number | null) => {
|
const getStockStatus = (stock: number, stockMin: number | null) => {
|
||||||
if (!stockMin) return 'En Stock';
|
|
||||||
if (stock === 0) return 'Sin Stock';
|
if (stock === 0) return 'Sin Stock';
|
||||||
|
if (!stockMin) return 'En Stock';
|
||||||
if (stock < stockMin) return 'Stock Bajo';
|
if (stock < stockMin) return 'Stock Bajo';
|
||||||
if (stock < stockMin * 1.5) return 'Stock Crítico';
|
if (stock < stockMin * 1.5) return 'Stock Crítico';
|
||||||
return 'En Stock';
|
return 'En Stock';
|
||||||
@ -487,6 +295,87 @@ const formatRelativeTime = (dateString: string) => {
|
|||||||
return 'Hace menos de 1 hora';
|
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
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const warehouseId = route.params.id;
|
const warehouseId = route.params.id;
|
||||||
@ -529,6 +418,9 @@ onMounted(async () => {
|
|||||||
detail: `Almacén ${response.data.warehouse.name} cargado exitosamente`,
|
detail: `Almacén ${response.data.warehouse.name} cargado exitosamente`,
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load inventory movements
|
||||||
|
await loadInventoryMovements(Number(warehouseId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error al cargar los datos del almacén:', error);
|
console.error('Error al cargar los datos del almacén:', error);
|
||||||
toast.add({
|
toast.add({
|
||||||
|
|||||||
@ -93,6 +93,7 @@ onMounted(async () => {
|
|||||||
Administra, rastrea y organiza todos tus almacenes en un solo lugar.
|
Administra, rastrea y organiza todos tus almacenes en un solo lugar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
label="Crear Nuevo Almacén "
|
label="Crear Nuevo Almacén "
|
||||||
icon="pi pi-plus"
|
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 api from "../../../services/api";
|
||||||
|
import type { OutInventoryRequestDTO, OutInventoryResponseDTO } from "../types/warehouse-inventory.interfaces";
|
||||||
import type { CreateInventoryRequest, CreateInventoryResponse } from "../types/warehouse.inventory";
|
import type { CreateInventoryRequest, CreateInventoryResponse } from "../types/warehouse.inventory";
|
||||||
|
|
||||||
export const inventoryWarehouseServices = {
|
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 RoleForm from '../modules/users/components/RoleForm.vue';
|
||||||
import UserIndex from '../modules/users/components/UserIndex.vue';
|
import UserIndex from '../modules/users/components/UserIndex.vue';
|
||||||
import StoreDetails from '../modules/stores/components/StoreDetails.vue';
|
import StoreDetails from '../modules/stores/components/StoreDetails.vue';
|
||||||
import Positions from '../modules/rh/components/Positions.vue';
|
import Positions from '../modules/rh/components/positions/Positions.vue';
|
||||||
import Departments from '../modules/rh/components/departments/Departments.vue';
|
import Departments from '../modules/rh/components/departments/Departments.vue';
|
||||||
|
|
||||||
import '../modules/catalog/components/suppliers/Suppliers.vue';
|
import '../modules/catalog/components/suppliers/Suppliers.vue';
|
||||||
@ -30,6 +30,7 @@ import ModelDocuments from '../modules/catalog/components/ModelDocuments.vue';
|
|||||||
import Requisitions from '../modules/requisitions/Requisitions.vue';
|
import Requisitions from '../modules/requisitions/Requisitions.vue';
|
||||||
import CreateRequisition from '../modules/requisitions/CreateRequisition.vue';
|
import CreateRequisition from '../modules/requisitions/CreateRequisition.vue';
|
||||||
import ClassificationsComercial from '../modules/catalog/components/comercial-classification/ClassificationsComercial.vue';
|
import ClassificationsComercial from '../modules/catalog/components/comercial-classification/ClassificationsComercial.vue';
|
||||||
|
import WarehouseOutInventory from '../modules/warehouse/components/WarehouseOutInventory.vue';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@ -110,6 +111,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'out-of-stock',
|
||||||
|
name: 'WarehouseOutOfStock',
|
||||||
|
component: WarehouseOutInventory,
|
||||||
|
meta: {
|
||||||
|
title: 'Salida de Inventario',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'inventory',
|
path: 'inventory',
|
||||||
name: 'WarehouseInventory',
|
name: 'WarehouseInventory',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user