edgar.mendez 8abf849306 feat: implement technical and financial approval workflows for requisitions
- Added approval and rejection dialogs for technical and financial requisitions in Requisitions.vue.
- Updated requisition statuses to include 'pending_technical', 'rejected_technical', 'pending_financial', and 'rejected_financial'.
- Enhanced requisition store to handle approval and rejection logic, including saving to localStorage.
- Modified requisition interface to include approval and rejection records.
- Updated initial requisition data to reflect new approval statuses and added comments field for requisition items.
2026-03-04 16:44:25 -06:00

773 lines
33 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import Card from 'primevue/card';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Dropdown from 'primevue/dropdown';
import Textarea from 'primevue/textarea';
import InputNumber from 'primevue/inputnumber';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Toast from 'primevue/toast';
import { useRequisitionStore } from './stores/requisitionStore';
import type { RequisitionItem } from './types/requisition.interfaces';
import { DepartmentsService } from '@/modules/rh/services/departments.services';
import type { Department } from '@/modules/rh/types/departments.interface';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const requisitionStore = useRequisitionStore();
const departmentsService = new DepartmentsService();
const isEditMode = ref(false);
const requisitionId = ref<number | null>(null);
const form = ref({
folio: '',
requester: 'Edgar Mendoza',
status: 'Borrador',
priority: 'medium',
department: '',
justification: ''
});
const items = ref<RequisitionItem[]>([]);
const isSaving = ref(false);
const priorityOptions = [
{ label: 'Baja', value: 'low' },
{ label: 'Media', value: 'medium' },
{ label: 'Alta', value: 'high' },
{ label: 'Urgente', value: 'urgent' }
];
// Cargar departamentos desde API
const departments = ref<Department[]>([]);
const loadingDepartments = ref(false);
const departmentOptions = computed(() => {
return departments.value.map(dept => ({
label: dept.name,
value: dept.id.toString()
}));
});
const commentsDialogVisible = ref(false);
const selectedItemIndex = ref<number | null>(null);
const tempComments = ref('');
const addItem = () => {
items.value.push({
id: items.value.length + 1,
product: '',
quantity: 0,
unit: '',
unitPrice: 0,
comments: ''
});
};
const openCommentsDialog = (index: number) => {
selectedItemIndex.value = index;
const item = items.value[index];
if (item) {
tempComments.value = item.comments || '';
}
commentsDialogVisible.value = true;
};
const saveComments = () => {
if (selectedItemIndex.value !== null) {
const item = items.value[selectedItemIndex.value];
if (item) {
item.comments = tempComments.value;
}
}
commentsDialogVisible.value = false;
tempComments.value = '';
selectedItemIndex.value = null;
};
const closeCommentsDialog = () => {
commentsDialogVisible.value = false;
tempComments.value = '';
selectedItemIndex.value = null;
};
const calculateSubtotal = (item: RequisitionItem): number => {
return item.quantity * item.unitPrice;
};
const calculateTotal = (): number => {
return items.value.reduce((total: number, item: RequisitionItem) => total + calculateSubtotal(item), 0);
};
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN'
}).format(value);
};
const removeItem = (index: number) => {
items.value.splice(index, 1);
};
/* const removeFile = (index: number) => {
uploadedFiles.value.splice(index, 1);
};
const onFileUpload = (event: any) => {
const files = event.files;
files.forEach((file: File) => {
uploadedFiles.value.push({
name: file.name,
size: (file.size / 1024 / 1024).toFixed(2) + ' MB',
type: file.type.includes('image') ? 'image' : 'pdf'
});
});
}; */
const loadRequisition = async () => {
const id = route.params.id;
if (id && typeof id === 'string') {
isEditMode.value = true;
requisitionId.value = parseInt(id);
const requisition = requisitionStore.getRequisitionById(requisitionId.value);
if (requisition) {
form.value = {
folio: requisition.folio,
requester: requisition.requester,
status: requisition.status,
priority: requisition.priority,
department: requisition.department,
justification: requisition.justification
};
items.value = [...requisition.items];
} else {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Requisición no encontrada',
life: 3000
});
router.push('/requisitions');
}
} else {
// Modo creación - generar nuevo folio
const nextNumber = requisitionStore.requisitions.length + 1;
form.value.folio = `REQ-2024-${String(nextNumber).padStart(3, '0')}`;
}
};
const validateForm = (): boolean => {
if (!form.value.priority) {
toast.add({
severity: 'warn',
summary: 'Validación',
detail: 'La prioridad es requerida',
life: 3000
});
return false;
}
if (!form.value.department) {
toast.add({
severity: 'warn',
summary: 'Validación',
detail: 'El departamento es requerido',
life: 3000
});
return false;
}
if (items.value.length === 0) {
toast.add({
severity: 'warn',
summary: 'Validación',
detail: 'Debe agregar al menos un item',
life: 3000
});
return false;
}
const hasInvalidItems = items.value.some((item: RequisitionItem) =>
!item.product || item.quantity <= 0 || !item.unit || item.unitPrice <= 0
);
if (hasInvalidItems) {
toast.add({
severity: 'warn',
summary: 'Validación',
detail: 'Todos los items deben tener producto, cantidad, unidad y precio válidos',
life: 3000
});
return false;
}
return true;
};
const handleCancel = () => {
router.push('/requisitions');
};
const handleSaveDraft = async () => {
if (!validateForm()) return;
isSaving.value = true;
try {
if (isEditMode.value && requisitionId.value) {
await requisitionStore.updateRequisition(requisitionId.value, form.value, items.value);
toast.add({
severity: 'success',
summary: 'Guardado',
detail: 'Requisición actualizada correctamente',
life: 3000
});
} else {
await requisitionStore.createRequisition(form.value, items.value);
toast.add({
severity: 'success',
summary: 'Guardado',
detail: 'Requisición guardada como borrador',
life: 3000
});
}
router.push('/requisitions');
} catch (error: any) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message || 'Error al guardar la requisición',
life: 3000
});
} finally {
isSaving.value = false;
}
};
const handleSubmit = async () => {
if (!validateForm()) return;
isSaving.value = true;
try {
let reqId: number;
if (isEditMode.value && requisitionId.value) {
await requisitionStore.updateRequisition(requisitionId.value, form.value, items.value);
reqId = requisitionId.value;
} else {
const newReq = await requisitionStore.createRequisition(form.value, items.value);
reqId = newReq.id;
}
// Enviar a aprobación
await requisitionStore.submitForApproval(reqId);
toast.add({
severity: 'success',
summary: 'Enviado',
detail: 'Requisición enviada a aprobación correctamente',
life: 3000
});
router.push('/requisitions');
} catch (error: any) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message || 'Error al enviar la requisición',
life: 3000
});
} finally {
isSaving.value = false;
}
};
const loadDepartments = async () => {
loadingDepartments.value = true;
try {
const response = await departmentsService.getDepartments();
departments.value = response.data;
} catch (error) {
console.error('Error al cargar departamentos:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudieron cargar los departamentos',
life: 3000
});
} finally {
loadingDepartments.value = false;
}
};
onMounted(() => {
loadDepartments();
loadRequisition();
});
const totalItems = () => {
return items.value.filter((item: RequisitionItem) => item.quantity > 0).length;
};
</script>
<template>
<div class="max-w-[1600px] mx-auto px-4 sm:px-6 lg:px-8">
<div class="space-y-6 py-4">
<!-- Header -->
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<Button icon="pi pi-arrow-left" text rounded @click="$router.back()" class="shrink-0" size="small" />
<h2 class="text-xl md:text-2xl lg:text-3xl font-black text-surface-900 dark:text-white tracking-tight">
Crear Requisición de Material
</h2>
</div>
<div class="flex items-center gap-3 flex-wrap">
<Button icon="pi pi-print" label="Imprimir" text size="small" />
<Button icon="pi pi-share-alt" label="Compartir" text size="small" />
</div>
</div>
<!-- Información General -->
<Card>
<template #header>
<div class="px-4 md:px-6 py-3 border-b border-surface-200 dark:border-surface-700 flex flex-col md:flex-row md:items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-info-circle text-primary text-lg"></i>
<h3 class="font-bold text-base">Información General</h3>
</div>
<span class="text-xs text-gray-500">Campos obligatorios marcados con *</span>
</div>
</template>
<template #content>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
Folio
</label>
<InputText v-model="form.folio" class="w-full bg-gray-50 dark:bg-gray-900" size="small" />
</div>
<div>
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
Solicitante
</label>
<InputText v-model="form.requester" readonly class="w-full bg-gray-50 dark:bg-gray-900" size="small" />
</div>
<div>
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
Estatus
</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-blue-400"></span>
<InputText v-model="form.status" readonly class="w-full bg-gray-50 dark:bg-gray-900 pl-8" size="small" />
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
Prioridad <span class="text-red-500">*</span>
</label>
<Dropdown
v-model="form.priority"
:options="priorityOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="sm:col-span-2 lg:col-span-4">
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
Departamento / Centro de Costos <span class="text-red-500">*</span>
</label>
<Dropdown
v-model="form.department"
:options="departmentOptions"
optionLabel="label"
optionValue="value"
placeholder="Seleccionar departamento"
:loading="loadingDepartments"
:disabled="loadingDepartments"
class="w-full"
/>
</div>
<div class="sm:col-span-2 lg:col-span-4">
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
Justificación de la Requisición
</label>
<Textarea
v-model="form.justification"
placeholder="Describe el motivo y la necesidad de esta requisición..."
rows="3"
autoResize
class="w-full"
/>
</div>
</div>
</template>
</Card>
<!-- Items de la Requisición -->
<Card>
<template #header>
<div class="px-4 md:px-6 py-3 border-b border-surface-200 dark:border-surface-700 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="flex items-center gap-2">
<i class="pi pi-list text-primary text-lg"></i>
<h3 class="font-bold text-base">Items de la Requisición</h3>
</div>
<Button label="Agregar" icon="pi pi-plus-circle" @click="addItem" size="small" class="w-full sm:w-auto" />
</div>
</template>
<template #content>
<!-- Mobile View - Cards -->
<div class="lg:hidden space-y-4">
<div v-for="(item, index) in items" :key="item.id"
class="border border-gray-200 dark:border-gray-800 rounded-lg p-4 space-y-3">
<div class="flex items-center justify-between border-b pb-2 mb-2">
<span class="text-sm font-bold text-gray-500">
Item #{{ String(index + 1).padStart(2, '0') }}
</span>
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
size="small"
@click="removeItem(index)"
/>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Producto / Servicio</label>
<InputText
v-model="item.product"
placeholder="Nombre del producto o servicio..."
class="w-full bg-gray-50 dark:bg-gray-900"
size="small"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Cantidad</label>
<InputNumber v-model="item.quantity" :min="0" class="w-full" size="small" />
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Unidad</label>
<InputText
v-model="item.unit"
placeholder="Pz, Kg, Lt, etc."
class="w-full bg-gray-50 dark:bg-gray-900"
size="small"
/>
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 mb-1">Precio Unitario</label>
<InputNumber
v-model="item.unitPrice"
:min="0"
:minFractionDigits="2"
:maxFractionDigits="2"
mode="currency"
currency="MXN"
locale="es-MX"
class="w-full"
size="small"
/>
</div>
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
<Button
:label="item.comments ? 'Editar Comentarios' : 'Agregar Comentarios'"
:icon="item.comments ? 'pi pi-pencil' : 'pi pi-comment'"
size="small"
outlined
class="w-full"
@click="openCommentsDialog(index)"
/>
</div>
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-gray-500 uppercase">Subtotal</span>
<span class="text-lg font-bold text-primary">{{ formatCurrency(calculateSubtotal(item)) }}</span>
</div>
</div>
</div>
</div>
<!-- Desktop View - DataTable -->
<div class="hidden lg:block">
<DataTable :value="items" class="text-sm" stripedRows>
<Column field="id" header="#" class="text-center" style="width: 60px">
<template #body="{ index }">
<span class="text-gray-400 font-medium text-xs">{{ String(index + 1).padStart(2, '0') }}</span>
</template>
</Column>
<Column header="Cantidad" style="width: 130px">
<template #body="{ data }">
<InputNumber v-model="data.quantity" :min="0" class="w-full" size="small" />
</template>
</Column>
<Column header="Unidad" style="width: 120px">
<template #body="{ data }">
<InputText
v-model="data.unit"
placeholder="Pz, Kg, Lt..."
class="w-full text-sm"
size="small"
/>
</template>
</Column>
<Column header="Producto / Servicio" style="min-width: 300px">
<template #body="{ data }">
<InputText
v-model="data.product"
placeholder="Nombre del producto o servicio..."
class="w-full border-none p-0 text-sm"
unstyled
/>
</template>
</Column>
<Column header="Precio Unitario" style="width: 180px">
<template #body="{ data }">
<InputNumber
v-model="data.unitPrice"
:min="0"
:minFractionDigits="2"
:maxFractionDigits="2"
mode="currency"
currency="MXN"
locale="es-MX"
class="w-full"
size="small"
/>
</template>
</Column>
<Column header="Subtotal" headerClass="text-right" style="width: 180px">
<template #body="{ data }">
<div class="text-right">
<span class="font-semibold text-gray-700 dark:text-gray-300">{{ formatCurrency(calculateSubtotal(data)) }}</span>
</div>
</template>
</Column>
<Column header="Comentarios" style="width: 140px" class="text-center">
<template #body="{ data, index }">
<Button
:icon="data.comments ? 'pi pi-comment' : 'pi pi-comment'"
:severity="data.comments ? 'success' : 'secondary'"
text
rounded
size="small"
@click="openCommentsDialog(index)"
v-tooltip.top="data.comments ? 'Editar Comentarios' : 'Agregar Comentarios'"
/>
</template>
</Column>
<Column header="Acciones" class="text-center" style="width: 80px">
<template #body="{ index }">
<Button
icon="pi pi-trash"
text
rounded
severity="danger"
size="small"
@click="removeItem(index)"
/>
</template>
</Column>
</DataTable>
</div>
<div class="mt-3 p-4 bg-gray-50 dark:bg-gray-800/30 flex flex-col sm:flex-row items-end justify-between gap-3">
<div class="text-left">
<p class="text-xs text-gray-500 uppercase font-semibold">Total Items</p>
<p class="text-lg font-bold">{{ totalItems() }}</p>
</div>
<div class="text-right">
<p class="text-xs text-gray-500 uppercase font-semibold mb-1">Total General</p>
<p class="text-2xl font-black text-primary">{{ formatCurrency(calculateTotal()) }}</p>
</div>
</div>
</template>
</Card>
<!-- Documentos y Fotos -->
<!-- <Card>
<template #header>
<div class="px-4 md:px-6 py-3 border-b border-surface-200 dark:border-surface-700">
<div class="flex items-center gap-2">
<i class="pi pi-paperclip text-primary text-lg"></i>
<h3 class="font-bold text-base">Documentos y Fotos</h3>
</div>
</div>
</template>
<template #content>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<FileUpload
mode="basic"
accept="image/*,application/pdf"
:maxFileSize="5000000"
chooseLabel="Seleccionar archivos"
@select="onFileUpload"
:auto="true"
customUpload
>
<template #empty>
<div class="border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-xl p-6 flex flex-col items-center justify-center text-center hover:border-primary hover:bg-primary/5 transition-all cursor-pointer">
<div class="bg-primary/10 w-12 h-12 rounded-full flex items-center justify-center mb-3">
<i class="pi pi-cloud-upload text-primary text-xl"></i>
</div>
<p class="font-semibold text-gray-700 dark:text-gray-300 text-sm">Suelte archivos aquí</p>
<p class="text-xs text-gray-400 mt-1">
O haga clic para explorar. JPG, PNG, PDF (Máx. 5MB)
</p>
</div>
</template>
</FileUpload>
</div>
<div class="space-y-2">
<p class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">Archivos Subidos</p>
<div v-for="(file, index) in uploadedFiles" :key="index"
class="flex items-center justify-between p-2.5 border border-gray-200 dark:border-gray-800 rounded-lg bg-white dark:bg-gray-900 shadow-sm">
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<div class="p-1.5 rounded shrink-0" :class="file.type === 'image' ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600' : 'bg-red-50 dark:bg-red-900/20 text-red-600'">
<i :class="file.type === 'image' ? 'pi pi-image' : 'pi pi-file-pdf'" class="text-base"></i>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{{ file.name }}</p>
<p class="text-[10px] text-gray-400 uppercase font-bold">{{ file.size }}</p>
</div>
</div>
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
@click="removeFile(index)"
class="shrink-0"
/>
</div>
</div>
</div>
</template>
</Card> -->
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 pt-4 border-t border-gray-200 dark:border-gray-800 pb-8">
<Button
label="Cancelar Registro"
icon="pi pi-times"
severity="danger"
text
size="small"
@click="handleCancel"
class="w-full sm:w-auto order-3 sm:order-1"
/>
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2.5 order-1 sm:order-2">
<Button
label="Guardar como Borrador"
severity="secondary"
outlined
size="small"
:loading="isSaving"
:disabled="isSaving"
@click="handleSaveDraft"
class="w-full sm:w-auto"
/>
<Button
label="Enviar a Aprobación"
icon="pi pi-send"
size="small"
:loading="isSaving"
:disabled="isSaving"
@click="handleSubmit"
class="w-full sm:w-auto"
/>
</div>
</div>
</div>
</div>
<!-- Comments Dialog -->
<Dialog
v-model:visible="commentsDialogVisible"
modal
:header="selectedItemIndex !== null && items[selectedItemIndex]?.comments ? 'Editar Comentarios del Item' : 'Agregar Comentarios al Item'"
:style="{ width: '600px' }"
:dismissableMask="true"
>
<div class="space-y-4 py-4">
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
<i class="pi pi-comment mr-2 text-primary"></i>
Comentarios o notas adicionales
</label>
<Textarea
v-model="tempComments"
rows="8"
placeholder="Agregue cualquier información adicional sobre este item: especificaciones técnicas, marcas preferidas, URLs de productos, notas especiales, etc."
class="w-full"
autoResize
autofocus
/>
<small class="text-gray-500 mt-2 block">
Puede incluir enlaces de productos, especificaciones, marcas, modelos o cualquier detalle relevante.
</small>
</div>
<div v-if="tempComments" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-2">
<i class="pi pi-info-circle text-blue-600 mt-0.5"></i>
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold text-blue-900 dark:text-blue-100 mb-1">Vista previa:</p>
<p class="text-xs text-blue-700 dark:text-blue-300 whitespace-pre-wrap">{{ tempComments }}</p>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2">
<Button
label="Cancelar"
severity="secondary"
text
@click="closeCommentsDialog"
/>
<Button
label="Guardar"
icon="pi pi-check"
@click="saveComments"
/>
</div>
</template>
</Dialog>
<Toast />
</template>