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:
Edgar Méndez Mendoza 2026-03-04 15:06:24 +00:00
commit 585ac6bf4a
20 changed files with 2090 additions and 989 deletions

2
components.d.ts vendored
View File

@ -37,6 +37,7 @@ declare module 'vue' {
Menu: typeof import('primevue/menu')['default']
Message: typeof import('primevue/message')['default']
Paginator: typeof import('primevue/paginator')['default']
Panel: typeof import('primevue/panel')['default']
ProgressSpinner: typeof import('primevue/progressspinner')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@ -45,6 +46,7 @@ declare module 'vue' {
Tag: typeof import('primevue/tag')['default']
Textarea: typeof import('primevue/textarea')['default']
Toast: typeof import('primevue/toast')['default']
Toolbar: typeof import('primevue/toolbar')['default']
TopBar: typeof import('./src/components/layout/TopBar.vue')['default']
}
export interface GlobalDirectives {

View File

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

View File

@ -23,57 +23,6 @@
/>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card class="shadow-sm">
<template #content>
<div class="flex items-center gap-4">
<div class="size-12 rounded-full bg-primary-50 dark:bg-primary-900/20 text-primary flex items-center justify-center">
<i class="pi pi-users text-xl"></i>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400 text-xs font-medium uppercase tracking-wider">
Plantilla Total
</p>
<p class="text-2xl font-black text-gray-900 dark:text-white">{{ totalEmployees }}</p>
</div>
</div>
</template>
</Card>
<Card class="shadow-sm">
<template #content>
<div class="flex items-center gap-4">
<div class="size-12 rounded-full bg-green-50 dark:bg-green-900/20 text-green-600 flex items-center justify-center">
<i class="pi pi-chart-line text-xl"></i>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400 text-xs font-medium uppercase tracking-wider">
Nuevas Vacantes
</p>
<p class="text-2xl font-black text-gray-900 dark:text-white">5</p>
</div>
</div>
</template>
</Card>
<Card class="shadow-sm">
<template #content>
<div class="flex items-center gap-4">
<div class="size-12 rounded-full bg-purple-50 dark:bg-purple-900/20 text-purple-600 flex items-center justify-center">
<i class="pi pi-calendar text-xl"></i>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400 text-xs font-medium uppercase tracking-wider">
Próximas Evaluaciones
</p>
<p class="text-2xl font-black text-gray-900 dark:text-white">12</p>
</div>
</div>
</template>
</Card>
</div>
<!-- Departments Table -->
<Card class="shadow-sm">
<template #content>
@ -192,7 +141,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { DepartmentsService } from '../../services/departments.services';
@ -237,11 +186,6 @@ const formData = ref<Partial<Department>>({
// Departments Data
const departments = ref<Department[]>([]);
// Computed
const totalEmployees = computed(() => {
return departments.value.reduce((sum, dept) => sum + (dept.employeeCount || 0), 0);
});
// Fetch Departments from API
const fetchDepartments = async (showToast = true) => {
loading.value = true;

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

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

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

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

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

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

View File

@ -8,19 +8,14 @@ import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import Badge from 'primevue/badge';
import Toast from 'primevue/toast';
import Dialog from 'primevue/dialog';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import { useToast } from 'primevue/usetoast';
import { purchaseServices } from '../../purchases/services/purchaseServices';
import type { PurchaseDetailResponse } from '../../purchases/types/purchases';
import { useWarehouseStore } from '../../../stores/warehouseStore';
import { useProductStore } from '../../products/stores/productStore';
import { inventoryWarehouseServices } from '../services/inventoryWarehouse.services';
import type { InventoryProductItem, CreateInventoryRequest } from '../types/warehouse.inventory';
import type { Product as ProductType } from '../../products/types/product';
import ModalProducts from './ModalProducts.vue';
interface SerialNumber {
serial: string;
@ -45,7 +40,6 @@ const toast = useToast();
const route = useRoute();
const router = useRouter();
const warehouseStore = useWarehouseStore();
const productStore = useProductStore();
// Data from API
const purchaseData = ref<PurchaseDetailResponse | null>(null);
@ -112,9 +106,6 @@ const newSerialWarehouse = ref<number>(1);
// Modal de productos
const showProductModal = ref(false);
const productSearch = ref('');
const selectedProducts = ref<ProductType[]>([]);
const loadingProducts = ref(false);
const totalReceived = computed(() => {
return products.value.reduce((sum, p) => sum + p.quantityReceived, 0);
@ -368,55 +359,12 @@ function cancel() {
}
// Funciones para el modal de productos
const openProductModal = async () => {
const openProductModal = () => {
showProductModal.value = true;
loadingProducts.value = true;
try {
await productStore.fetchProducts();
} catch (error) {
console.error('Error al cargar productos:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudieron cargar los productos',
life: 3000
});
} finally {
loadingProducts.value = false;
}
};
const closeProductModal = () => {
showProductModal.value = false;
selectedProducts.value = [];
productSearch.value = '';
};
const filteredProducts = computed(() => {
if (!productSearch.value) {
return productStore.activeProducts;
}
const search = productSearch.value.toLowerCase();
return productStore.activeProducts.filter(p =>
p.name.toLowerCase().includes(search) ||
p.sku.toLowerCase().includes(search) ||
(p.code && p.code.toLowerCase().includes(search))
);
});
const addSelectedProducts = () => {
if (selectedProducts.value.length === 0) {
toast.add({
severity: 'warn',
summary: 'Selección Requerida',
detail: 'Por favor seleccione al menos un producto',
life: 3000
});
return;
}
selectedProducts.value.forEach(product => {
const handleProductsSelected = (selectedProductsList: ProductType[]) => {
selectedProductsList.forEach(product => {
// Verificar si el producto ya está en la lista
const exists = products.value.find(p => p.id === product.id);
if (!exists) {
@ -439,11 +387,9 @@ const addSelectedProducts = () => {
toast.add({
severity: 'success',
summary: 'Productos Agregados',
detail: `Se agregaron ${selectedProducts.value.length} producto(s)`,
detail: `Se agregaron ${selectedProductsList.length} producto(s)`,
life: 3000
});
closeProductModal();
};
const removeProduct = (productId: number) => {
@ -823,138 +769,10 @@ const removeProduct = (productId: number) => {
</template>
<!-- Modal de Selección de Productos -->
<Dialog
<ModalProducts
v-model:visible="showProductModal"
modal
header="Seleccionar Productos"
:style="{ width: '90vw', maxWidth: '1200px' }"
:contentStyle="{ padding: '0' }"
>
<template #header>
<div class="flex items-center gap-2">
<i class="pi pi-shopping-cart text-xl"></i>
<span class="font-bold text-lg">Seleccionar Productos del Catálogo</span>
</div>
</template>
<div class="p-6">
<!-- Search Bar -->
<div class="mb-4">
<IconField iconPosition="left">
<InputIcon class="pi pi-search" />
<InputText
v-model="productSearch"
placeholder="Buscar por nombre, SKU o código..."
class="w-full"
/>
</IconField>
</div>
<!-- Products Table -->
<DataTable
v-model:selection="selectedProducts"
:value="filteredProducts"
:loading="loadingProducts"
selectionMode="multiple"
dataKey="id"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[5, 10, 20, 50]"
stripedRows
responsiveLayout="scroll"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} productos"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
>
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
<Column field="name" header="Producto" sortable style="min-width: 250px">
<template #body="slotProps">
<div class="flex flex-col">
<span class="font-medium text-surface-900 dark:text-white">
{{ slotProps.data.name }}
</span>
<span class="text-xs text-surface-500 dark:text-surface-400">
{{ slotProps.data.description }}
</span>
</div>
</template>
</Column>
<Column field="sku" header="SKU" sortable style="min-width: 120px">
<template #body="slotProps">
<span class="font-mono text-sm text-surface-600 dark:text-surface-400">
{{ slotProps.data.sku }}
</span>
</template>
</Column>
<Column field="code" header="Código" sortable style="min-width: 100px">
<template #body="slotProps">
<span class="font-mono text-sm text-primary-600 dark:text-primary-400">
{{ slotProps.data.code }}
</span>
</template>
</Column>
<Column field="is_serial" header="Tipo" style="min-width: 100px">
<template #body="slotProps">
<Badge
:value="slotProps.data.is_serial ? 'Serial' : 'Estándar'"
:severity="slotProps.data.is_serial ? 'info' : 'secondary'"
/>
</template>
</Column>
<Column field="suggested_sale_price" header="Precio Sugerido" sortable style="min-width: 130px">
<template #body="slotProps">
<span class="font-semibold text-green-600 dark:text-green-400">
${{ slotProps.data.suggested_sale_price.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}
</span>
</template>
</Column>
<template #empty>
<div class="text-center py-8">
<i class="pi pi-inbox text-4xl text-surface-300 dark:text-surface-600 mb-4"></i>
<p class="text-surface-500 dark:text-surface-400">
No se encontraron productos
</p>
</div>
</template>
<template #loading>
<div class="text-center py-8">
<i class="pi pi-spin pi-spinner text-4xl text-primary mb-4"></i>
<p class="text-surface-500 dark:text-surface-400">
Cargando productos...
</p>
</div>
</template>
</DataTable>
</div>
<template #footer>
<div class="flex justify-between items-center">
<span class="text-sm text-surface-600 dark:text-surface-400">
{{ selectedProducts.length }} producto(s) seleccionado(s)
</span>
<div class="flex gap-2">
<Button
label="Cancelar"
severity="secondary"
outlined
@click="closeProductModal"
/>
<Button
label="Agregar Productos"
icon="pi pi-plus"
:disabled="selectedProducts.length === 0"
@click="addSelectedProducts"
/>
</div>
</div>
</template>
</Dialog>
@products-selected="handleProductsSelected"
/>
</div>
</template>

View File

@ -31,79 +31,11 @@
</div>
</div>
<div class="flex gap-3">
<Button label="Exportar Datos" icon="pi pi-download" outlined severity="secondary" />
<Button icon="pi pi-print" outlined severity="secondary" />
<Button label="Entradas" icon="pi pi-plus" @click="openBatchAdd" />
<Button label="Salidas" icon="pi pi-minus" @click="openBatchRemove" />
</div>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Total SKUs -->
<!-- <Card class="shadow-sm">
<template #content>
<div class="space-y-3">
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Total SKUs</p>
<p class="text-2xl font-bold text-surface-900 dark:text-white">
{{ warehouseData?.stocks.length || 0 }}
</p>
<div class="flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400">
<i class="pi pi-box text-xs"></i>
Productos en stock
</div>
</div>
</template>
</Card>
-->
<!-- Low Stock Items -->
<!-- <Card class="shadow-sm border-l-4 border-l-red-500">
<template #content>
<div class="space-y-3">
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Items con Stock Bajo</p>
<p class="text-2xl font-bold text-surface-900 dark:text-white">
{{ warehouseData?.stocks.filter(s => s.stock_min && s.stock < s.stock_min).length || 0 }}
</p>
<div class="flex items-center gap-1 text-xs font-semibold text-red-600 dark:text-red-400">
<i class="pi pi-exclamation-triangle text-xs"></i>
Acción requerida
</div>
</div>
</template>
</Card> -->
<!-- Total Items -->
<!-- <Card class="shadow-sm">
<template #content>
<div class="space-y-3">
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Items Totales</p>
<p class="text-2xl font-bold text-surface-900 dark:text-white">
{{ warehouseData?.items.length || 0 }}
</p>
<div class="flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400">
<i class="pi pi-database text-xs"></i>
Items registrados
</div>
</div>
</template>
</Card> -->
<!-- Total Stock -->
<Card class="shadow-sm">
<template #content>
<div class="space-y-3">
<p class="text-sm font-medium text-surface-500 dark:text-surface-400">Stock Total</p>
<p class="text-2xl font-bold text-surface-900 dark:text-white">
{{warehouseData?.stocks.reduce((sum, s) => sum + s.stock, 0).toLocaleString() || 0}}
</p>
<div class="flex items-center gap-1 text-xs font-semibold text-green-600 dark:text-green-400">
<i class="pi pi-chart-bar text-xs"></i>
Unidades en almacén
</div>
</div>
</template>
</Card>
</div>
<!-- Tabs & Main Content Table -->
<Card class="shadow-sm">
<template #content>
@ -198,7 +130,7 @@
:rowsPerPageOptions="[10, 25, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} resultados"
:loading="loading" stripedRows responsiveLayout="scroll" class="text-sm">
:loading="loadingMovements" stripedRows responsiveLayout="scroll" class="text-sm">
<Column field="date" header="Fecha" sortable />
<Column field="type" header="Tipo" sortable>
<template #body="slotProps">
@ -219,77 +151,6 @@
</TabView>
</template>
</Card>
<!-- Secondary Section: Recent Movements & Insights -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Quick Activity Log -->
<!-- <Card class="lg:col-span-2 shadow-sm">
<template #header>
<div class="flex items-center justify-between p-4">
<h3 class="font-bold text-surface-900 dark:text-white">Registro Rápido de Actividad</h3>
<Button label="Ver Todos los Movimientos" link class="text-sm" @click="viewAllMovements" />
</div>
</template>
<template #content>
<div class="space-y-4">
<div v-for="activity in recentActivities" :key="activity.id"
class="flex items-center gap-4 py-3 border-b border-surface-200 dark:border-surface-700 last:border-b-0">
<div :class="[
'flex items-center justify-center size-10 rounded-full',
activity.type === 'in' ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'
]">
<i :class="[
'pi text-sm',
activity.type === 'in' ? 'pi-arrow-down text-green-600' : 'pi-arrow-up text-red-600'
]"></i>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-surface-900 dark:text-white">{{ activity.action }}</p>
<p class="text-sm text-surface-500 dark:text-surface-400">{{ activity.product }}</p>
</div>
<div class="text-right">
<p class="font-bold text-surface-900 dark:text-white">{{ activity.quantity }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">{{ activity.time }}</p>
</div>
</div>
</div>
</template>
</Card> -->
<!-- Warehouse Health Insights -->
<!-- <Card
class="shadow-sm bg-primary-50 dark:bg-primary-900/10 border border-primary-200 dark:border-primary-800">
<template #content>
<div class="space-y-4">
<div>
<h3 class="font-bold text-surface-900 dark:text-white mb-2">Salud del Almacén</h3>
<p class="text-sm text-surface-600 dark:text-surface-300 leading-relaxed">
North Logistics Center está actualmente al 84% de capacidad. Recomendamos auditar la
Zona B
para optimización de espacio potencial.
</p>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-surface-700 dark:text-surface-200">Capacidad Total</span>
<span class="font-bold text-surface-900 dark:text-white">50,000 </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 </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 </span>
</div>
</div>
<Button label="Generar Reporte Completo" class="w-full" outlined @click="generateReport" />
</div>
</template>
</Card> -->
</div>
</div>
</template>
@ -298,7 +159,9 @@ import { ref, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { warehouseService } from '../services/warehouseService';
import { InventoryMovementsServices } from '../services/inventory-movements.services';
import type { WarehouseDetailData } from '../types/warehouse';
import type { InventoryMovement } from '../types/inventory-movements.interfaces';
// PrimeVue Components
import Toast from 'primevue/toast';
@ -316,12 +179,16 @@ const router = useRouter();
const route = useRoute();
const toast = useToast();
// Services
const inventoryMovementsService = new InventoryMovementsServices();
// Reactive State
const warehouseData = ref<WarehouseDetailData | null>(null);
const loading = ref(false);
const activeTab = ref(0);
const selectedCategory = ref('all');
const selectedStockLevel = ref('all');
const loadingMovements = ref(false);
// Breadcrumb
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
@ -348,68 +215,15 @@ const stockLevelOptions = [
// Inventory Data from API
const inventoryData = ref<any[]>([]);
// Mock Data - Movement History
const movementHistory = ref([
{
date: '2024-01-30 14:30',
type: 'Entrada',
product: 'Laptop Dell XPS 15',
quantity: '+50',
user: 'Juan Pérez',
reference: 'PO-2024-156'
},
{
date: '2024-01-30 12:15',
type: 'Salida',
product: 'Monitor LG 27" 4K',
quantity: '-25',
user: 'María García',
reference: 'SO-2024-892'
},
{
date: '2024-01-30 10:00',
type: 'Entrada',
product: 'Silla Ergonómica Pro',
quantity: '+100',
user: 'Carlos López',
reference: 'PO-2024-155'
},
]);
// Mock Data - Recent Activities (unused - template is commented out)
// const recentActivities = ref([
// {
// id: 1,
// type: 'in',
// action: 'Entrada de Stock',
// product: 'Laptop Dell XPS 15 - 50 unidades',
// quantity: '+50',
// time: 'Hace 2 horas'
// },
// {
// id: 2,
// type: 'out',
// action: 'Salida de Stock',
// product: 'Monitor LG 27" - 25 unidades',
// quantity: '-25',
// time: 'Hace 4 horas'
// },
// {
// id: 3,
// type: 'in',
// action: 'Entrada de Stock',
// product: 'Silla Ergonómica - 100 unidades',
// quantity: '+100',
// time: 'Hace 6 horas'
// },
// ]);
// Movement History from API
const movementHistory = ref<any[]>([]);
// Methods
const getStatusSeverity = (status: string) => {
const severityMap: Record<string, string> = {
'En Stock': 'success',
'Stock Bajo': 'warn',
'Stock Crítico': 'danger',
'Sin Stock': 'danger',
'Sobrestock': 'info',
};
return severityMap[status] || 'secondary';
@ -433,6 +247,14 @@ const openBatchAdd = () => {
});
};
const openBatchRemove = () => {
const warehouseId = route.params.id;
router.push({
name: 'WarehouseOutOfStock',
query: { warehouse: warehouseId }
});
};
const viewItem = (item: any) => {
toast.add({
severity: 'info',
@ -451,24 +273,10 @@ const editItem = (item: any) => {
});
};
// Unused methods (template sections are commented out)
// const viewAllMovements = () => {
// activeTab.value = 1;
// };
// const generateReport = () => {
// toast.add({
// severity: 'success',
// summary: 'Generando Reporte',
// detail: 'El reporte se está generando...',
// life: 3000
// });
// };
// Helper function to get stock status
const getStockStatus = (stock: number, stockMin: number | null) => {
if (!stockMin) return 'En Stock';
if (stock === 0) return 'Sin Stock';
if (!stockMin) return 'En Stock';
if (stock < stockMin) return 'Stock Bajo';
if (stock < stockMin * 1.5) return 'Stock Crítico';
return 'En Stock';
@ -487,6 +295,87 @@ const formatRelativeTime = (dateString: string) => {
return 'Hace menos de 1 hora';
};
// Helper function to format date for movements
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('es-MX', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// Helper function to capitalize first letter
const capitalizeFirstLetter = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
// Load inventory movements
const loadInventoryMovements = async (warehouseId: number) => {
loadingMovements.value = true;
try {
const response = await inventoryMovementsService.getInventoryMovements(warehouseId);
// Map API data to table format - flatten to show each product as a row
movementHistory.value = response.data.flatMap((movement: InventoryMovement) => {
const reference = movement.reference_document_id
? `${movement.reference_type || 'DOC'}-${movement.reference_document_id}`
: 'Sin referencia';
// If no products, show basic movement info
if (!movement.products || movement.products.length === 0) {
return [{
date: formatDate(movement.created_at),
type: capitalizeFirstLetter(movement.type),
product: 'Sin productos',
quantity: '0',
user: movement.user?.email || 'Sistema',
reference: reference
}];
}
// Create a row for each product in the movement
return movement.products.map((productItem) => {
const qty = typeof productItem.quantity === 'string'
? parseFloat(productItem.quantity)
: productItem.quantity;
const formattedQty = movement.type === 'entrada'
? `+${qty}`
: `-${qty}`;
return {
date: formatDate(movement.created_at),
type: capitalizeFirstLetter(movement.type),
product: productItem.product.name,
quantity: formattedQty,
user: movement.user?.email || 'Sistema',
reference: reference
};
});
});
toast.add({
severity: 'success',
summary: 'Movimientos Cargados',
detail: `${response.data.length} movimientos encontrados`,
life: 3000
});
} catch (error) {
console.error('Error al cargar movimientos:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Error al cargar el historial de movimientos',
life: 3000
});
} finally {
loadingMovements.value = false;
}
};
// Lifecycle
onMounted(async () => {
const warehouseId = route.params.id;
@ -529,6 +418,9 @@ onMounted(async () => {
detail: `Almacén ${response.data.warehouse.name} cargado exitosamente`,
life: 3000
});
// Load inventory movements
await loadInventoryMovements(Number(warehouseId));
} catch (error) {
console.error('Error al cargar los datos del almacén:', error);
toast.add({

View File

@ -93,6 +93,7 @@ onMounted(async () => {
Administra, rastrea y organiza todos tus almacenes en un solo lugar.
</p>
</div>
<Button
label="Crear Nuevo Almacén "
icon="pi pi-plus"

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

View File

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

View File

@ -1,4 +1,5 @@
import api from "../../../services/api";
import type { OutInventoryRequestDTO, OutInventoryResponseDTO } from "../types/warehouse-inventory.interfaces";
import type { CreateInventoryRequest, CreateInventoryResponse } from "../types/warehouse.inventory";
export const inventoryWarehouseServices = {
@ -14,3 +15,16 @@ export const inventoryWarehouseServices = {
}
}
export class InventoryWarehouseServices {
async exitInventory(data: OutInventoryRequestDTO): Promise<OutInventoryResponseDTO> {
try {
const response = await api.post('/api/inventory/exit', data);
console.log('📦 Exit Inventory response:', response.data);
return response.data;
} catch (error) {
console.error('❌ Error exiting inventory:', error);
throw error;
}
}
}

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

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

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

View File

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

View File

@ -17,7 +17,7 @@ import RolesIndex from '../modules/users/components/RoleIndex.vue';
import RoleForm from '../modules/users/components/RoleForm.vue';
import UserIndex from '../modules/users/components/UserIndex.vue';
import StoreDetails from '../modules/stores/components/StoreDetails.vue';
import Positions from '../modules/rh/components/Positions.vue';
import Positions from '../modules/rh/components/positions/Positions.vue';
import Departments from '../modules/rh/components/departments/Departments.vue';
import '../modules/catalog/components/suppliers/Suppliers.vue';
@ -30,6 +30,7 @@ import ModelDocuments from '../modules/catalog/components/ModelDocuments.vue';
import Requisitions from '../modules/requisitions/Requisitions.vue';
import CreateRequisition from '../modules/requisitions/CreateRequisition.vue';
import ClassificationsComercial from '../modules/catalog/components/comercial-classification/ClassificationsComercial.vue';
import WarehouseOutInventory from '../modules/warehouse/components/WarehouseOutInventory.vue';
const routes: RouteRecordRaw[] = [
{
@ -110,6 +111,15 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true
}
},
{
path: 'out-of-stock',
name: 'WarehouseOutOfStock',
component: WarehouseOutInventory,
meta: {
title: 'Salida de Inventario',
requiresAuth: true
}
},
{
path: 'inventory',
name: 'WarehouseInventory',