feat: implement technical and financial approval workflows for requisitions #14

Merged
edgar.mendez merged 1 commits from feature-comercial-module-ts into qa 2026-03-04 22:47:19 +00:00
4 changed files with 829 additions and 136 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import Card from 'primevue/card'; import Card from 'primevue/card';
@ -14,11 +14,14 @@ import Dialog from 'primevue/dialog';
import Toast from 'primevue/toast'; import Toast from 'primevue/toast';
import { useRequisitionStore } from './stores/requisitionStore'; import { useRequisitionStore } from './stores/requisitionStore';
import type { RequisitionItem } from './types/requisition.interfaces'; 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 router = useRouter();
const route = useRoute(); const route = useRoute();
const toast = useToast(); const toast = useToast();
const requisitionStore = useRequisitionStore(); const requisitionStore = useRequisitionStore();
const departmentsService = new DepartmentsService();
const isEditMode = ref(false); const isEditMode = ref(false);
const requisitionId = ref<number | null>(null); const requisitionId = ref<number | null>(null);
@ -42,16 +45,20 @@ const priorityOptions = [
{ label: 'Urgente', value: 'urgent' } { label: 'Urgente', value: 'urgent' }
]; ];
const departmentOptions = [ // Cargar departamentos desde API
{ label: 'Producción - Línea A', value: 'prod_a' }, const departments = ref<Department[]>([]);
{ label: 'Mantenimiento Industrial', value: 'maintenance' }, const loadingDepartments = ref(false);
{ label: 'Logística y Almacén', value: 'logistics' },
{ label: 'Administración', value: 'admin' }
];
const urlDialogVisible = 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 selectedItemIndex = ref<number | null>(null);
const tempUrl = ref(''); const tempComments = ref('');
const addItem = () => { const addItem = () => {
items.value.push({ items.value.push({
@ -60,46 +67,37 @@ const addItem = () => {
quantity: 0, quantity: 0,
unit: '', unit: '',
unitPrice: 0, unitPrice: 0,
url: '' comments: ''
}); });
}; };
const openUrlDialog = (index: number) => { const openCommentsDialog = (index: number) => {
selectedItemIndex.value = index; selectedItemIndex.value = index;
const item = items.value[index]; const item = items.value[index];
if (item) { if (item) {
tempUrl.value = item.url || ''; tempComments.value = item.comments || '';
} }
urlDialogVisible.value = true; commentsDialogVisible.value = true;
}; };
const saveUrl = () => { const saveComments = () => {
if (selectedItemIndex.value !== null) { if (selectedItemIndex.value !== null) {
const item = items.value[selectedItemIndex.value]; const item = items.value[selectedItemIndex.value];
if (item) { if (item) {
item.url = tempUrl.value; item.comments = tempComments.value;
} }
} }
urlDialogVisible.value = false; commentsDialogVisible.value = false;
tempUrl.value = ''; tempComments.value = '';
selectedItemIndex.value = null; selectedItemIndex.value = null;
}; };
const closeUrlDialog = () => { const closeCommentsDialog = () => {
urlDialogVisible.value = false; commentsDialogVisible.value = false;
tempUrl.value = ''; tempComments.value = '';
selectedItemIndex.value = null; selectedItemIndex.value = null;
}; };
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
console.log('URL copiado al portapapeles');
} catch (err) {
console.error('Error al copiar:', err);
}
};
const calculateSubtotal = (item: RequisitionItem): number => { const calculateSubtotal = (item: RequisitionItem): number => {
return item.quantity * item.unitPrice; return item.quantity * item.unitPrice;
}; };
@ -145,7 +143,7 @@ const loadRequisition = async () => {
form.value = { form.value = {
folio: requisition.folio, folio: requisition.folio,
requester: requisition.requester, requester: requisition.requester,
status: requisition.status === 'draft' ? 'Borrador' : requisition.status, status: requisition.status,
priority: requisition.priority, priority: requisition.priority,
department: requisition.department, department: requisition.department,
justification: requisition.justification justification: requisition.justification
@ -291,7 +289,26 @@ const handleSubmit = async () => {
} }
}; };
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(() => { onMounted(() => {
loadDepartments();
loadRequisition(); loadRequisition();
}); });
@ -370,7 +387,7 @@ const totalItems = () => {
<div class="sm:col-span-2 lg:col-span-4"> <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"> <label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
Departamento / Centro de Costos Departamento / Centro de Costos <span class="text-red-500">*</span>
</label> </label>
<Dropdown <Dropdown
v-model="form.department" v-model="form.department"
@ -378,6 +395,8 @@ const totalItems = () => {
optionLabel="label" optionLabel="label"
optionValue="value" optionValue="value"
placeholder="Seleccionar departamento" placeholder="Seleccionar departamento"
:loading="loadingDepartments"
:disabled="loadingDepartments"
class="w-full" class="w-full"
/> />
</div> </div>
@ -472,12 +491,12 @@ const totalItems = () => {
<div class="pt-2 border-t border-gray-200 dark:border-gray-700"> <div class="pt-2 border-t border-gray-200 dark:border-gray-700">
<Button <Button
:label="item.url ? 'Editar URL' : 'Agregar URL'" :label="item.comments ? 'Editar Comentarios' : 'Agregar Comentarios'"
:icon="item.url ? 'pi pi-pencil' : 'pi pi-link'" :icon="item.comments ? 'pi pi-pencil' : 'pi pi-comment'"
size="small" size="small"
outlined outlined
class="w-full" class="w-full"
@click="openUrlDialog(index)" @click="openCommentsDialog(index)"
/> />
</div> </div>
@ -551,16 +570,16 @@ const totalItems = () => {
</template> </template>
</Column> </Column>
<Column header="URL" style="width: 120px" class="text-center"> <Column header="Comentarios" style="width: 140px" class="text-center">
<template #body="{ data, index }"> <template #body="{ data, index }">
<Button <Button
:icon="data.url ? 'pi pi-check-circle' : 'pi pi-link'" :icon="data.comments ? 'pi pi-comment' : 'pi pi-comment'"
:severity="data.url ? 'success' : 'secondary'" :severity="data.comments ? 'success' : 'secondary'"
text text
rounded rounded
size="small" size="small"
@click="openUrlDialog(index)" @click="openCommentsDialog(index)"
v-tooltip.top="data.url ? 'Editar URL' : 'Agregar URL'" v-tooltip.top="data.comments ? 'Editar Comentarios' : 'Agregar Comentarios'"
/> />
</template> </template>
</Column> </Column>
@ -694,46 +713,40 @@ const totalItems = () => {
</div> </div>
</div> </div>
<!-- URL Dialog --> <!-- Comments Dialog -->
<Dialog <Dialog
v-model:visible="urlDialogVisible" v-model:visible="commentsDialogVisible"
modal modal
:header="selectedItemIndex !== null && items[selectedItemIndex]?.url ? 'Editar URL del Producto' : 'Agregar URL del Producto'" :header="selectedItemIndex !== null && items[selectedItemIndex]?.comments ? 'Editar Comentarios del Item' : 'Agregar Comentarios al Item'"
:style="{ width: '500px' }" :style="{ width: '600px' }"
:dismissableMask="true" :dismissableMask="true"
> >
<div class="space-y-4 py-4"> <div class="space-y-4 py-4">
<div> <div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2"> <label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
<i class="pi pi-link mr-2 text-primary"></i> <i class="pi pi-comment mr-2 text-primary"></i>
URL del producto Comentarios o notas adicionales
</label> </label>
<InputText <Textarea
v-model="tempUrl" v-model="tempComments"
placeholder="https://ejemplo.com/producto" 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" class="w-full"
autoResize
autofocus autofocus
/> />
<small class="text-gray-500 mt-1 block"> <small class="text-gray-500 mt-2 block">
Puede ser un enlace de Amazon, MercadoLibre, página del fabricante, etc. Puede incluir enlaces de productos, especificaciones, marcas, modelos o cualquier detalle relevante.
</small> </small>
</div> </div>
<div v-if="tempUrl" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800"> <div 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"> <div class="flex items-start gap-2">
<i class="pi pi-info-circle text-blue-600 mt-0.5"></i> <i class="pi pi-info-circle text-blue-600 mt-0.5"></i>
<div class="flex-1 min-w-0"> <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 font-semibold text-blue-900 dark:text-blue-100 mb-1">Vista previa:</p>
<p class="text-xs text-blue-700 dark:text-blue-300 break-all">{{ tempUrl }}</p> <p class="text-xs text-blue-700 dark:text-blue-300 whitespace-pre-wrap">{{ tempComments }}</p>
</div> </div>
<Button
icon="pi pi-copy"
text
rounded
size="small"
@click="copyToClipboard(tempUrl)"
v-tooltip.top="'Copiar'"
/>
</div> </div>
</div> </div>
</div> </div>
@ -744,12 +757,12 @@ const totalItems = () => {
label="Cancelar" label="Cancelar"
severity="secondary" severity="secondary"
text text
@click="closeUrlDialog" @click="closeCommentsDialog"
/> />
<Button <Button
label="Guardar" label="Guardar"
icon="pi pi-check" icon="pi pi-check"
@click="saveUrl" @click="saveComments"
/> />
</div> </div>
</template> </template>

View File

@ -16,10 +16,16 @@ import Dialog from 'primevue/dialog';
import Textarea from 'primevue/textarea'; import Textarea from 'primevue/textarea';
import { useRequisitionStore } from './stores/requisitionStore'; import { useRequisitionStore } from './stores/requisitionStore';
import type { Requisition } from './types/requisition.interfaces'; import type { Requisition } 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 router = useRouter();
const toast = useToast(); const toast = useToast();
const requisitionStore = useRequisitionStore(); const requisitionStore = useRequisitionStore();
const departmentsService = new DepartmentsService();
// Departamentos desde API
const departments = ref<Department[]>([]);
const searchQuery = ref(''); const searchQuery = ref('');
const selectedStatus = ref<string | null>(null); const selectedStatus = ref<string | null>(null);
@ -35,6 +41,15 @@ const showCancelDialog = ref(false);
const cancelComment = ref(''); const cancelComment = ref('');
const requisitionToCancel = ref<Requisition | null>(null); const requisitionToCancel = ref<Requisition | null>(null);
// Approval/Rejection dialogs
const showApprovalDialog = ref(false);
const showRejectionDialog = ref(false);
const approvalType = ref<'technical' | 'financial'>('technical');
const approvalComments = ref('');
const rejectionReason = ref('');
const requisitionToApprove = ref<Requisition | null>(null);
const requisitionToReject = ref<Requisition | null>(null);
// Computed filtered requisitions // Computed filtered requisitions
const filteredRequisitions = computed(() => { const filteredRequisitions = computed(() => {
let filtered = [...requisitionStore.requisitions]; let filtered = [...requisitionStore.requisitions];
@ -69,9 +84,11 @@ const totalRecords = computed(() => filteredRequisitions.value.length);
const statusOptions = [ const statusOptions = [
{ label: 'Todos', value: null }, { label: 'Todos', value: null },
{ label: 'Borrador', value: 'draft' }, { label: 'Borrador', value: 'draft' },
{ label: 'Pendiente', value: 'pending' }, { label: 'Pendiente Técnica', value: 'pending_technical' },
{ label: 'Rechazado Técnica', value: 'rejected_technical' },
{ label: 'Pendiente Financiera', value: 'pending_financial' },
{ label: 'Rechazado Financiera', value: 'rejected_financial' },
{ label: 'Aprobado', value: 'approved' }, { label: 'Aprobado', value: 'approved' },
{ label: 'Rechazado', value: 'rejected' },
{ label: 'Cancelado', value: 'cancelled' } { label: 'Cancelado', value: 'cancelled' }
]; ];
@ -85,10 +102,12 @@ const priorityOptions = [
const getStatusSeverity = (status: string) => { const getStatusSeverity = (status: string) => {
const severityMap: Record<string, string> = { const severityMap: Record<string, string> = {
pending: 'warning', pending_technical: 'warning',
pending_financial: 'warning',
approved: 'success', approved: 'success',
draft: 'secondary', draft: 'secondary',
rejected: 'danger', rejected_technical: 'danger',
rejected_financial: 'danger',
cancelled: 'contrast' cancelled: 'contrast'
}; };
return severityMap[status] || 'info'; return severityMap[status] || 'info';
@ -96,10 +115,12 @@ const getStatusSeverity = (status: string) => {
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
pending: 'Pendiente', pending_technical: 'Pend. Aprobación Técnica',
pending_financial: 'Pend. Aprobación Financiera',
approved: 'Aprobado', approved: 'Aprobado',
draft: 'Borrador', draft: 'Borrador',
rejected: 'Rechazado', rejected_technical: 'Rechazado por Técnico',
rejected_financial: 'Rechazado por Finanzas',
cancelled: 'Cancelado' cancelled: 'Cancelado'
}; };
return labelMap[status] || status; return labelMap[status] || status;
@ -125,6 +146,11 @@ const getPriorityLabel = (priority: string) => {
return labelMap[priority] || priority; return labelMap[priority] || priority;
}; };
/* const getDepartmentName = (departmentId: string) => {
const dept = departments.value.find(d => d.id.toString() === departmentId);
return dept ? dept.name : departmentId;
}; */
const onPageChange = (event: any) => { const onPageChange = (event: any) => {
pagination.value.first = event.first; pagination.value.first = event.first;
pagination.value.rows = event.rows; pagination.value.rows = event.rows;
@ -135,18 +161,161 @@ const handleView = (requisition: Requisition) => {
}; };
const handleEdit = (requisition: Requisition) => { const handleEdit = (requisition: Requisition) => {
if (requisition.status === 'draft' || requisition.status === 'pending') { // Permitir editar si está en borrador o fue rechazada
if (requisition.status === 'draft' ||
requisition.status === 'rejected_technical' ||
requisition.status === 'rejected_financial') {
router.push(`/requisitions/edit/${requisition.id}`); router.push(`/requisitions/edit/${requisition.id}`);
} else { } else {
toast.add({ toast.add({
severity: 'warn', severity: 'warn',
summary: 'No permitido', summary: 'No permitido',
detail: 'Solo se pueden editar requisiciones en borrador o pendientes', detail: 'Solo se pueden editar requisiciones en borrador o rechazadas',
life: 3000 life: 3000
}); });
} }
}; };
const handleApproveTechnical = (requisition: Requisition) => {
requisitionToApprove.value = requisition;
approvalType.value = 'technical';
approvalComments.value = '';
showApprovalDialog.value = true;
};
const handleRejectTechnical = (requisition: Requisition) => {
requisitionToReject.value = requisition;
approvalType.value = 'technical';
rejectionReason.value = '';
showRejectionDialog.value = true;
};
const handleApproveFinancial = (requisition: Requisition) => {
requisitionToApprove.value = requisition;
approvalType.value = 'financial';
approvalComments.value = '';
showApprovalDialog.value = true;
};
const handleRejectFinancial = (requisition: Requisition) => {
requisitionToReject.value = requisition;
approvalType.value = 'financial';
rejectionReason.value = '';
showRejectionDialog.value = true;
};
const confirmApproval = async () => {
if (!requisitionToApprove.value) return;
try {
const approver = 'Usuario Actual'; // Aquí iría el usuario del sistema de autenticación
if (approvalType.value === 'technical') {
await requisitionStore.approveTechnical(
requisitionToApprove.value.id,
approver,
approvalComments.value.trim() || undefined
);
toast.add({
severity: 'success',
summary: 'Aprobado',
detail: 'Requisición aprobada técnicamente. Ahora pasa a finanzas.',
life: 3000
});
} else {
await requisitionStore.approveFinancial(
requisitionToApprove.value.id,
approver,
approvalComments.value.trim() || undefined
);
toast.add({
severity: 'success',
summary: 'Aprobado',
detail: 'Requisición aprobada. Ahora pasa a almacén.',
life: 3000
});
}
showApprovalDialog.value = false;
requisitionToApprove.value = null;
approvalComments.value = '';
} catch (error: any) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message || 'Error al aprobar la requisición',
life: 3000
});
}
};
const confirmRejection = async () => {
if (!requisitionToReject.value) return;
if (!rejectionReason.value.trim() || rejectionReason.value.trim().length < 10) {
toast.add({
severity: 'warn',
summary: 'Campo requerido',
detail: 'Debe proporcionar un motivo válido (mínimo 10 caracteres)',
life: 3000
});
return;
}
try {
const rejector = 'Usuario Actual'; // Aquí iría el usuario del sistema de autenticación
if (approvalType.value === 'technical') {
await requisitionStore.rejectTechnical(
requisitionToReject.value.id,
rejector,
rejectionReason.value.trim()
);
toast.add({
severity: 'info',
summary: 'Rechazado',
detail: 'Requisición rechazada. El solicitante puede editarla y reenviarla.',
life: 3000
});
} else {
await requisitionStore.rejectFinancial(
requisitionToReject.value.id,
rejector,
rejectionReason.value.trim()
);
toast.add({
severity: 'info',
summary: 'Rechazado',
detail: 'Requisición rechazada. El solicitante puede editarla y reenviarla.',
life: 3000
});
}
showRejectionDialog.value = false;
requisitionToReject.value = null;
rejectionReason.value = '';
} catch (error: any) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message || 'Error al rechazar la requisición',
life: 3000
});
}
};
const closeApprovalDialog = () => {
showApprovalDialog.value = false;
requisitionToApprove.value = null;
approvalComments.value = '';
};
const closeRejectionDialog = () => {
showRejectionDialog.value = false;
requisitionToReject.value = null;
rejectionReason.value = '';
};
const handleCancel = (requisition: Requisition) => { const handleCancel = (requisition: Requisition) => {
requisitionToCancel.value = requisition; requisitionToCancel.value = requisition;
cancelComment.value = ''; cancelComment.value = '';
@ -204,7 +373,17 @@ const handleNewRequisition = () => {
router.push('/requisitions/create'); router.push('/requisitions/create');
}; };
const loadDepartments = async () => {
try {
const response = await departmentsService.getDepartments();
departments.value = response.data;
} catch (error) {
console.error('Error al cargar departamentos:', error);
}
};
onMounted(async () => { onMounted(async () => {
await loadDepartments();
await requisitionStore.fetchRequisitions(); await requisitionStore.fetchRequisitions();
}); });
</script> </script>
@ -262,7 +441,7 @@ onMounted(async () => {
</template> </template>
</Card> </Card>
<Card> <!-- <Card>
<template #content> <template #content>
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm text-gray-500 dark:text-gray-400">Presupuesto Mensual</p> <p class="text-sm text-gray-500 dark:text-gray-400">Presupuesto Mensual</p>
@ -272,7 +451,7 @@ onMounted(async () => {
</div> </div>
</div> </div>
</template> </template>
</Card> </Card> -->
</div> </div>
<!-- Filters and Table --> <!-- Filters and Table -->
@ -347,6 +526,12 @@ onMounted(async () => {
<Column field="requester" header="Solicitante" style="min-width: 150px" /> <Column field="requester" header="Solicitante" style="min-width: 150px" />
<!-- <Column field="department" header="Departamento" style="min-width: 180px">
<template #body="{ data }">
<span class="text-sm">{{ getDepartmentName(data.department) }}</span>
</template>
</Column> -->
<Column field="status" header="Estado" style="min-width: 120px"> <Column field="status" header="Estado" style="min-width: 120px">
<template #body="{ data }"> <template #body="{ data }">
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" /> <Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
@ -374,7 +559,7 @@ onMounted(async () => {
</template> </template>
</Column> </Column>
<Column header="Acciones" headerStyle="text-align: center" bodyStyle="text-align: center" style="min-width: 150px"> <Column header="Acciones" headerStyle="text-align: center" bodyStyle="text-align: center" style="min-width: 200px">
<template #body="{ data }"> <template #body="{ data }">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<Button <Button
@ -385,7 +570,10 @@ onMounted(async () => {
@click="handleView(data)" @click="handleView(data)"
v-tooltip.top="'Ver Detalles'" v-tooltip.top="'Ver Detalles'"
/> />
<!-- Editar - Solo borrador o rechazadas -->
<Button <Button
v-if="data.status === 'draft' || data.status === 'rejected_technical' || data.status === 'rejected_financial'"
icon="pi pi-pencil" icon="pi pi-pencil"
text text
rounded rounded
@ -393,12 +581,63 @@ onMounted(async () => {
@click="handleEdit(data)" @click="handleEdit(data)"
v-tooltip.top="'Editar'" v-tooltip.top="'Editar'"
/> />
<!-- Aprobar Técnica -->
<Button <Button
v-if="data.status === 'pending_technical'"
icon="pi pi-check"
text
rounded
size="small"
severity="success"
@click="handleApproveTechnical(data)"
v-tooltip.top="'Aprobar (Técnico)'"
/>
<!-- Rechazar Técnica -->
<Button
v-if="data.status === 'pending_technical'"
icon="pi pi-times" icon="pi pi-times"
text text
rounded rounded
size="small" size="small"
severity="danger" severity="danger"
@click="handleRejectTechnical(data)"
v-tooltip.top="'Rechazar (Técnico)'"
/>
<!-- Aprobar Financiera -->
<Button
v-if="data.status === 'pending_financial'"
icon="pi pi-check"
text
rounded
size="small"
severity="success"
@click="handleApproveFinancial(data)"
v-tooltip.top="'Aprobar (Finanzas)'"
/>
<!-- Rechazar Financiera -->
<Button
v-if="data.status === 'pending_financial'"
icon="pi pi-times"
text
rounded
size="small"
severity="danger"
@click="handleRejectFinancial(data)"
v-tooltip.top="'Rechazar (Finanzas)'"
/>
<!-- Cancelar - Solo borrador o pendientes -->
<Button
v-if="data.status === 'draft' || data.status === 'pending_technical' || data.status === 'pending_financial'"
icon="pi pi-ban"
text
rounded
size="small"
severity="warning"
@click="handleCancel(data)" @click="handleCancel(data)"
v-tooltip.top="'Cancelar'" v-tooltip.top="'Cancelar'"
/> />
@ -521,6 +760,135 @@ onMounted(async () => {
</template> </template>
</Dialog> </Dialog>
<!-- Approval Dialog -->
<Dialog
v-model:visible="showApprovalDialog"
modal
:header="approvalType === 'technical' ? 'Aprobación Técnica' : 'Aprobación Financiera'"
:style="{ width: '500px' }"
:closable="true"
@hide="closeApprovalDialog"
>
<div class="space-y-4">
<div v-if="requisitionToApprove" class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-900/30 rounded-lg p-4">
<div class="flex items-start gap-3">
<i class="pi pi-check-circle text-green-500 text-xl mt-0.5"></i>
<div class="flex-1">
<h4 class="font-semibold text-green-900 dark:text-green-300 text-sm mb-1">
{{ approvalType === 'technical' ? '¿Aprobar requisición técnicamente?' : '¿Aprobar requisición financieramente?' }}
</h4>
<p class="text-green-700 dark:text-green-400 text-xs">
Folio: <span class="font-bold">{{ requisitionToApprove.folio }}</span>
</p>
<p class="text-green-700 dark:text-green-400 text-xs">
Solicitante: {{ requisitionToApprove.requester }}
</p>
<p class="text-green-700 dark:text-green-400 text-xs">
Monto: <span class="font-bold">${{ requisitionToApprove.totalAmount.toLocaleString('es-MX') }}</span>
</p>
<p class="text-green-700 dark:text-green-400 text-xs mt-2">
{{ approvalType === 'technical'
? 'Al aprobar, la requisición pasará automáticamente a finanzas.'
: 'Al aprobar, la requisición pasará a almacén.'
}}
</p>
</div>
</div>
</div>
<div>
<label for="approvalComments" class="block text-sm font-semibold text-surface-900 dark:text-white mb-2">
Comentarios (opcional)
</label>
<Textarea
id="approvalComments"
v-model="approvalComments"
rows="3"
class="w-full"
placeholder="Agregue cualquier comentario o nota sobre esta aprobación..."
/>
</div>
</div>
<template #footer>
<Button
label="Cancelar"
icon="pi pi-times"
text
@click="closeApprovalDialog"
/>
<Button
label="Aprobar"
icon="pi pi-check"
severity="success"
@click="confirmApproval"
/>
</template>
</Dialog>
<!-- Rejection Dialog -->
<Dialog
v-model:visible="showRejectionDialog"
modal
:header="approvalType === 'technical' ? 'Rechazo Técnico' : 'Rechazo Financiero'"
:style="{ width: '500px' }"
:closable="true"
@hide="closeRejectionDialog"
>
<div class="space-y-4">
<div v-if="requisitionToReject" class="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900/30 rounded-lg p-4">
<div class="flex items-start gap-3">
<i class="pi pi-times-circle text-red-500 text-xl mt-0.5"></i>
<div class="flex-1">
<h4 class="font-semibold text-red-900 dark:text-red-300 text-sm mb-1">
{{ approvalType === 'technical' ? '¿Rechazar requisición (Técnico)?' : '¿Rechazar requisición (Finanzas)?' }}
</h4>
<p class="text-red-700 dark:text-red-400 text-xs">
Folio: <span class="font-bold">{{ requisitionToReject.folio }}</span>
</p>
<p class="text-red-700 dark:text-red-400 text-xs">
Solicitante: {{ requisitionToReject.requester }}
</p>
<p class="text-red-700 dark:text-red-400 text-xs mt-2">
El solicitante podrá editar su requisición y reenviarla para aprobación.
</p>
</div>
</div>
</div>
<div>
<label for="rejectionReason" class="block text-sm font-semibold text-surface-900 dark:text-white mb-2">
Motivo del rechazo <span class="text-red-500">*</span>
</label>
<Textarea
id="rejectionReason"
v-model="rejectionReason"
rows="4"
class="w-full"
placeholder="Describa claramente por qué se rechaza esta requisición..."
:class="{ 'p-invalid': !rejectionReason.trim() }"
/>
<small class="text-gray-500 dark:text-gray-400">Mínimo 10 caracteres requeridos</small>
</div>
</div>
<template #footer>
<Button
label="Cancelar"
icon="pi pi-times"
text
@click="closeRejectionDialog"
/>
<Button
label="Rechazar"
icon="pi pi-check"
severity="danger"
@click="confirmRejection"
:disabled="!rejectionReason.trim() || rejectionReason.trim().length < 10"
/>
</template>
</Dialog>
<Toast /> <Toast />
<ConfirmDialog /> <ConfirmDialog />
</template> </template>

View File

@ -2,38 +2,72 @@ import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import type { Requisition, RequisitionForm, RequisitionItem } from '../types/requisition.interfaces'; import type { Requisition, RequisitionForm, RequisitionItem } from '../types/requisition.interfaces';
export const useRequisitionStore = defineStore('requisition', () => { const STORAGE_KEY = 'gols_requisitions';
const requisitions = ref<Requisition[]>([ const STORAGE_COUNTERS_KEY = 'gols_requisitions_counters';
]); // Funciones helper para localStorage
const saveToStorage = (requisitions: Requisition[], nextId: number, nextFolio: number) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(requisitions));
localStorage.setItem(STORAGE_COUNTERS_KEY, JSON.stringify({ nextId, nextFolio }));
} catch (e) {
console.error('Error al guardar en localStorage:', e);
}
};
/* { const loadFromStorage = (): { requisitions: Requisition[], nextId: number, nextFolio: number } => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
const counters = localStorage.getItem(STORAGE_COUNTERS_KEY);
if (stored && counters) {
const parsedCounters = JSON.parse(counters);
return {
requisitions: JSON.parse(stored),
nextId: parsedCounters.nextId,
nextFolio: parsedCounters.nextFolio
};
}
} catch (e) {
console.error('Error al cargar desde localStorage:', e);
}
// Si no hay datos en localStorage, retornar datos iniciales
return {
requisitions: getInitialData(),
nextId: 6,
nextFolio: 6
};
};
const getInitialData = (): Requisition[] => [
{
id: 1, id: 1,
folio: 'REQ-2024-001', folio: 'REQ-2024-001',
requester: 'Edgar Mendoza', requester: 'Edgar Mendoza',
department: 'prod_a', department: '1', // Desarrollo
status: 'draft', status: 'pending_technical',
priority: 'medium', priority: 'high',
justification: 'Necesitamos equipos para la línea de producción A', justification: 'Equipamiento técnico para nuevos desarrolladores del equipo',
items: [ items: [
{ {
id: 1, id: 1,
product: 'Motor eléctrico 3HP', product: 'Laptop HP ZBook Studio G9',
quantity: 2, quantity: 3,
unit: 'Pz', unit: 'Pz',
unitPrice: 1250.00, unitPrice: 25000.00,
url: 'https://amazon.com/product/123' comments: 'Intel i9, 32GB RAM, 1TB SSD, NVIDIA RTX A2000. Para desarrolladores full-stack.'
}, },
{ {
id: 2, id: 2,
product: 'Cable calibre 12', product: 'Monitor Dell UltraSharp 27"',
quantity: 50, quantity: 3,
unit: 'M', unit: 'Pz',
unitPrice: 15.50, unitPrice: 3000.00,
url: '' comments: '4K, IPS, USB-C'
} }
], ],
totalAmount: 3275.00, totalAmount: 84000.00,
createdAt: '2024-02-20', createdAt: '2024-02-20',
updatedAt: '2024-02-20' updatedAt: '2024-02-20'
}, },
@ -41,85 +75,144 @@ export const useRequisitionStore = defineStore('requisition', () => {
id: 2, id: 2,
folio: 'REQ-2024-002', folio: 'REQ-2024-002',
requester: 'María González', requester: 'María González',
department: 'maintenance', department: '1', // Desarrollo
status: 'pending', status: 'pending_financial',
priority: 'high', priority: 'high',
justification: 'Mantenimiento preventivo de equipos críticos', justification: 'Licencias de software para el equipo de desarrollo',
items: [ items: [
{ {
id: 1, id: 1,
product: 'Aceite hidráulico SAE 68', product: 'Licencia JetBrains All Products Pack',
quantity: 20, quantity: 10,
unit: 'Lt', unit: 'Lic',
unitPrice: 85.00, unitPrice: 6990.00,
url: 'https://mercadolibre.com/product/456' comments: 'Licencias anuales. Ver en: https://www.jetbrains.com/store/'
} }
], ],
totalAmount: 1700.00, totalAmount: 69900.00,
createdAt: '2024-02-22', createdAt: '2024-02-22',
updatedAt: '2024-02-22' updatedAt: '2024-02-23',
technicalApproval: {
approver: 'Juan Pérez',
approvedAt: '2024-02-23T10:30:00',
comments: 'Aprobado. Licencias necesarias para el desarrollo.'
}
}, },
{ {
id: 3, id: 3,
folio: 'REQ-2024-003', folio: 'REQ-2024-003',
requester: 'Carlos Ruiz', requester: 'Carlos Ruiz',
department: 'logistics', department: '1', // Desarrollo
status: 'approved', status: 'approved',
priority: 'normal', priority: 'normal',
justification: 'Material de empaque para envíos', justification: 'Mobiliario ergonómico para el área de desarrollo',
items: [ items: [
{ {
id: 1, id: 1,
product: 'Caja de cartón corrugado 40x30x20', product: 'Silla ergonómica Herman Miller Aeron',
quantity: 100, quantity: 10,
unit: 'Pz', unit: 'Pz',
unitPrice: 12.50, unitPrice: 8500.00,
url: '' comments: 'Silla ergonómica de alta gama para programadores'
}, },
{ {
id: 2, id: 2,
product: 'Cinta adhesiva transparente', product: 'Escritorio ajustable en altura',
quantity: 50, quantity: 10,
unit: 'Pz', unit: 'Pz',
unitPrice: 8.00, unitPrice: 3250.00,
url: '' comments: 'Escritorio sit-stand eléctrico'
} }
], ],
totalAmount: 1650.00, totalAmount: 117500.00,
createdAt: '2024-02-18', createdAt: '2024-02-18',
updatedAt: '2024-02-23' updatedAt: '2024-02-23',
technicalApproval: {
approver: 'Juan Pérez',
approvedAt: '2024-02-19T09:15:00',
comments: 'Aprobado técnicamente.'
},
financialApproval: {
approver: 'Ana López',
approvedAt: '2024-02-23T14:20:00',
comments: 'Aprobado. Dentro del presupuesto.'
}
}, },
{ {
id: 4, id: 4,
folio: 'REQ-2024-004', folio: 'REQ-2024-004',
requester: 'Ana Martínez', requester: 'Ana Martínez',
department: 'admin', department: '1', // Desarrollo
status: 'rejected', status: 'rejected_technical',
priority: 'low', priority: 'low',
justification: 'Suministros de oficina', justification: 'Suscripción a servicios cloud adicionales',
items: [ items: [
{ {
id: 1, id: 1,
product: 'Papel bond tamaño carta', product: 'Créditos AWS',
quantity: 10, quantity: 1,
unit: 'Pq', unit: 'Servicio',
unitPrice: 120.00, unitPrice: 50000.00,
url: '' comments: 'Créditos mensuales para servicios EC2, S3 y RDS'
} }
], ],
totalAmount: 1200.00, totalAmount: 50000.00,
createdAt: '2024-02-15', createdAt: '2024-02-15',
updatedAt: '2024-02-21' updatedAt: '2024-02-21',
} */ technicalRejection: {
rejector: 'Juan Pérez',
rejectedAt: '2024-02-21T11:00:00',
reason: 'Ya contamos con créditos de AWS suficientes. Revisar uso actual antes de solicitar más.'
}
},
{
id: 5,
folio: 'REQ-2024-005',
requester: 'Pedro Sánchez',
department: '1', // Desarrollo
status: 'rejected_financial',
priority: 'urgent',
justification: 'Servidores de desarrollo y testing para nuevos proyectos',
items: [
{
id: 1,
product: 'Servidor Dell PowerEdge R750',
quantity: 2,
unit: 'Pz',
unitPrice: 85000.00,
comments: 'Intel Xeon, 64GB RAM, 2TB SSD. Para ambiente de staging.'
}
],
totalAmount: 170000.00,
createdAt: '2024-02-19',
updatedAt: '2024-02-24',
technicalApproval: {
approver: 'Juan Pérez',
approvedAt: '2024-02-20T08:30:00',
comments: 'Aprobado. Servidores necesarios para el proyecto.'
},
financialRejection: {
rejector: 'Ana López',
rejectedAt: '2024-02-24T16:45:00',
reason: 'Presupuesto excedido este mes. Reagendar para el próximo período.'
}
}
];
export const useRequisitionStore = defineStore('requisition', () => {
// Cargar datos desde localStorage o usar datos iniciales
const initialData = loadFromStorage();
const requisitions = ref<Requisition[]>(initialData.requisitions);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
let nextId = ref(5); let nextId = ref(initialData.nextId);
let nextFolio = ref(5); let nextFolio = ref(initialData.nextFolio);
// Computed properties for statistics // Computed properties for statistics
const pendingCount = computed(() => const pendingCount = computed(() =>
requisitions.value.filter(r => r.status === 'pending').length requisitions.value.filter(r =>
r.status === 'pending_technical' || r.status === 'pending_financial'
).length
); );
const approvedTodayCount = computed(() => { const approvedTodayCount = computed(() => {
@ -192,6 +285,7 @@ export const useRequisitionStore = defineStore('requisition', () => {
}; };
requisitions.value.unshift(newRequisition); requisitions.value.unshift(newRequisition);
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
return newRequisition; return newRequisition;
} catch (e: any) { } catch (e: any) {
error.value = e?.message || 'Error al crear requisición'; error.value = e?.message || 'Error al crear requisición';
@ -222,6 +316,9 @@ export const useRequisitionStore = defineStore('requisition', () => {
const totalAmount = items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0); const totalAmount = items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
const now = new Date().toISOString().split('T')[0] as string; const now = new Date().toISOString().split('T')[0] as string;
// Si la requisición ya fue enviada a aprobación y se edita, vuelve a pending_technical
const shouldResetStatus = existingReq.status !== 'draft';
const updatedRequisition: Requisition = { const updatedRequisition: Requisition = {
...existingReq, ...existingReq,
department: form.department, department: form.department,
@ -229,10 +326,18 @@ export const useRequisitionStore = defineStore('requisition', () => {
justification: form.justification, justification: form.justification,
items: items.map((item, idx) => ({ ...item, id: idx + 1 })), items: items.map((item, idx) => ({ ...item, id: idx + 1 })),
totalAmount, totalAmount,
updatedAt: now updatedAt: now,
// Si fue editada después de enviarse, limpiar aprobaciones/rechazos
...(shouldResetStatus && {
technicalApproval: undefined,
financialApproval: undefined,
technicalRejection: undefined,
financialRejection: undefined
})
}; };
requisitions.value[index] = updatedRequisition; requisitions.value[index] = updatedRequisition;
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
return updatedRequisition; return updatedRequisition;
} catch (e: any) { } catch (e: any) {
error.value = e?.message || 'Error al actualizar requisición'; error.value = e?.message || 'Error al actualizar requisición';
@ -256,9 +361,13 @@ export const useRequisitionStore = defineStore('requisition', () => {
const requisition = requisitions.value[index]; const requisition = requisitions.value[index];
if (requisition) { if (requisition) {
requisition.status = 'pending'; requisition.status = 'pending_technical';
requisition.updatedAt = new Date().toISOString().split('T')[0] as string; requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
// Limpiar rechazos anteriores
requisition.technicalRejection = undefined;
requisition.financialRejection = undefined;
} }
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
} catch (e: any) { } catch (e: any) {
error.value = e?.message || 'Error al enviar requisición'; error.value = e?.message || 'Error al enviar requisición';
throw e; throw e;
@ -280,6 +389,7 @@ export const useRequisitionStore = defineStore('requisition', () => {
} }
requisitions.value.splice(index, 1); requisitions.value.splice(index, 1);
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
} catch (e: any) { } catch (e: any) {
error.value = e?.message || 'Error al eliminar requisición'; error.value = e?.message || 'Error al eliminar requisición';
throw e; throw e;
@ -305,6 +415,7 @@ export const useRequisitionStore = defineStore('requisition', () => {
requisition.status = status; requisition.status = status;
requisition.updatedAt = new Date().toISOString().split('T')[0] as string; requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
} }
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
} catch (e: any) { } catch (e: any) {
error.value = e?.message || 'Error al cambiar estado'; error.value = e?.message || 'Error al cambiar estado';
throw e; throw e;
@ -333,6 +444,7 @@ export const useRequisitionStore = defineStore('requisition', () => {
requisition.cancelledBy = cancelledBy; requisition.cancelledBy = cancelledBy;
requisition.updatedAt = new Date().toISOString().split('T')[0] as string; requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
} }
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
} catch (e: any) { } catch (e: any) {
error.value = e?.message || 'Error al cancelar requisición'; error.value = e?.message || 'Error al cancelar requisición';
throw e; throw e;
@ -341,6 +453,168 @@ export const useRequisitionStore = defineStore('requisition', () => {
} }
} }
// Aprobación técnica
async function approveTechnical(id: number, approver: string, comments?: string): Promise<void> {
loading.value = true;
error.value = null;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const index = requisitions.value.findIndex(r => r.id === id);
if (index === -1) {
throw new Error('Requisición no encontrada');
}
const requisition = requisitions.value[index];
if (requisition) {
if (requisition.status !== 'pending_technical') {
throw new Error('La requisición no está pendiente de aprobación técnica');
}
requisition.status = 'pending_financial';
requisition.technicalApproval = {
approver,
approvedAt: new Date().toISOString(),
comments
};
requisition.technicalRejection = undefined;
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
}
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
} catch (e: any) {
error.value = e?.message || 'Error al aprobar requisición';
throw e;
} finally {
loading.value = false;
}
}
// Rechazo técnico
async function rejectTechnical(id: number, rejector: string, reason: string): Promise<void> {
loading.value = true;
error.value = null;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const index = requisitions.value.findIndex(r => r.id === id);
if (index === -1) {
throw new Error('Requisición no encontrada');
}
const requisition = requisitions.value[index];
if (requisition) {
if (requisition.status !== 'pending_technical') {
throw new Error('La requisición no está pendiente de aprobación técnica');
}
requisition.status = 'rejected_technical';
requisition.technicalRejection = {
rejector,
rejectedAt: new Date().toISOString(),
reason
};
requisition.technicalApproval = undefined;
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
}
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
} catch (e: any) {
error.value = e?.message || 'Error al rechazar requisición';
throw e;
} finally {
loading.value = false;
}
}
// Aprobación financiera
async function approveFinancial(id: number, approver: string, comments?: string): Promise<void> {
loading.value = true;
error.value = null;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const index = requisitions.value.findIndex(r => r.id === id);
if (index === -1) {
throw new Error('Requisición no encontrada');
}
const requisition = requisitions.value[index];
if (requisition) {
if (requisition.status !== 'pending_financial') {
throw new Error('La requisición no está pendiente de aprobación financiera');
}
requisition.status = 'approved';
requisition.financialApproval = {
approver,
approvedAt: new Date().toISOString(),
comments
};
requisition.financialRejection = undefined;
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
}
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
} catch (e: any) {
error.value = e?.message || 'Error al aprobar requisición';
throw e;
} finally {
loading.value = false;
}
}
// Rechazo financiero
async function rejectFinancial(id: number, rejector: string, reason: string): Promise<void> {
loading.value = true;
error.value = null;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const index = requisitions.value.findIndex(r => r.id === id);
if (index === -1) {
throw new Error('Requisición no encontrada');
}
const requisition = requisitions.value[index];
if (requisition) {
if (requisition.status !== 'pending_financial') {
throw new Error('La requisición no está pendiente de aprobación financiera');
}
requisition.status = 'rejected_financial';
requisition.financialRejection = {
rejector,
rejectedAt: new Date().toISOString(),
reason
};
requisition.financialApproval = undefined;
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
}
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
} catch (e: any) {
error.value = e?.message || 'Error al rechazar requisición';
throw e;
} finally {
loading.value = false;
}
}
// Función para resetear datos (útil para desarrollo/testing)
function resetToInitialData(): void {
requisitions.value = getInitialData();
nextId.value = 6;
nextFolio.value = 6;
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
}
// Función para limpiar localStorage
function clearStorage(): void {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_COUNTERS_KEY);
}
return { return {
requisitions, requisitions,
loading, loading,
@ -356,6 +630,12 @@ export const useRequisitionStore = defineStore('requisition', () => {
submitForApproval, submitForApproval,
deleteRequisition, deleteRequisition,
changeStatus, changeStatus,
cancelRequisition cancelRequisition,
approveTechnical,
rejectTechnical,
approveFinancial,
rejectFinancial,
resetToInitialData,
clearStorage
}; };
}); });

View File

@ -4,7 +4,7 @@ export interface RequisitionItem {
quantity: number; quantity: number;
unit: string; unit: string;
unitPrice: number; unitPrice: number;
url: string; comments: string;
} }
export interface RequisitionForm { export interface RequisitionForm {
@ -16,12 +16,44 @@ export interface RequisitionForm {
justification: string; justification: string;
} }
// Estados del flujo de aprobación
export type RequisitionStatus =
| 'draft' // Borrador
| 'pending_technical' // Pendiente de aprobación técnica
| 'rejected_technical' // Rechazado por técnico
| 'pending_financial' // Pendiente de aprobación financiera
| 'rejected_financial' // Rechazado por finanzas
| 'approved' // Aprobado (pasa a almacén)
| 'cancelled'; // Cancelado por usuario
export interface ApprovalRecord {
approver: string;
approvedAt: string;
comments?: string;
}
export interface RejectionRecord {
rejector: string;
rejectedAt: string;
reason: string;
}
export interface Requisition extends RequisitionForm { export interface Requisition extends RequisitionForm {
id: number; id: number;
items: RequisitionItem[]; items: RequisitionItem[];
totalAmount: number; totalAmount: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
// Aprobaciones
technicalApproval?: ApprovalRecord;
financialApproval?: ApprovalRecord;
// Rechazos
technicalRejection?: RejectionRecord;
financialRejection?: RejectionRecord;
// Cancelación
cancellationReason?: string; cancellationReason?: string;
cancelledAt?: string; cancelledAt?: string;
cancelledBy?: string; cancelledBy?: string;