feat: implement requisition management module with CRUD operations
- Added Requisitions.vue component for managing requisitions, including search, filter, and pagination functionalities. - Created requisitionStore.ts for state management using Pinia, including actions for fetching, creating, updating, and canceling requisitions. - Defined requisition interfaces in requisition.interfaces.ts to structure requisition data. - Integrated PrimeVue components for UI elements such as DataTable, Dropdown, and Dialogs. - Implemented cancelation logic with user confirmation and validation for cancelation reasons.
This commit is contained in:
parent
522235d441
commit
2f3a4d7da4
@ -28,6 +28,14 @@ const menuItems = ref<MenuItem[]>([
|
||||
{ label: 'Documentos del Modelo', icon: 'pi pi-file', to: '/catalog/model-documents' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Requisiciones',
|
||||
icon: 'pi pi-file-edit',
|
||||
items: [
|
||||
{ label: 'Requisiciones', icon: 'pi pi-file', to: '/requisitions/request' },
|
||||
{ label: 'Crear Requisición', icon: 'pi pi-plus', to: '/requisitions/create' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Compras',
|
||||
icon: 'pi pi-shopping-bag',
|
||||
|
||||
760
src/modules/requisitions/CreateRequisition.vue
Normal file
760
src/modules/requisitions/CreateRequisition.vue
Normal file
@ -0,0 +1,760 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, 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';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
const requisitionStore = useRequisitionStore();
|
||||
|
||||
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' }
|
||||
];
|
||||
|
||||
const departmentOptions = [
|
||||
{ label: 'Producción - Línea A', value: 'prod_a' },
|
||||
{ label: 'Mantenimiento Industrial', value: 'maintenance' },
|
||||
{ label: 'Logística y Almacén', value: 'logistics' },
|
||||
{ label: 'Administración', value: 'admin' }
|
||||
];
|
||||
|
||||
const urlDialogVisible = ref(false);
|
||||
const selectedItemIndex = ref<number | null>(null);
|
||||
const tempUrl = ref('');
|
||||
|
||||
const addItem = () => {
|
||||
items.value.push({
|
||||
id: items.value.length + 1,
|
||||
product: '',
|
||||
quantity: 0,
|
||||
unit: '',
|
||||
unitPrice: 0,
|
||||
url: ''
|
||||
});
|
||||
};
|
||||
|
||||
const openUrlDialog = (index: number) => {
|
||||
selectedItemIndex.value = index;
|
||||
const item = items.value[index];
|
||||
if (item) {
|
||||
tempUrl.value = item.url || '';
|
||||
}
|
||||
urlDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveUrl = () => {
|
||||
if (selectedItemIndex.value !== null) {
|
||||
const item = items.value[selectedItemIndex.value];
|
||||
if (item) {
|
||||
item.url = tempUrl.value;
|
||||
}
|
||||
}
|
||||
urlDialogVisible.value = false;
|
||||
tempUrl.value = '';
|
||||
selectedItemIndex.value = null;
|
||||
};
|
||||
|
||||
const closeUrlDialog = () => {
|
||||
urlDialogVisible.value = false;
|
||||
tempUrl.value = '';
|
||||
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 => {
|
||||
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 === 'draft' ? 'Borrador' : 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;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
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
|
||||
</label>
|
||||
<Dropdown
|
||||
v-model="form.department"
|
||||
:options="departmentOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Seleccionar departamento"
|
||||
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.url ? 'Editar URL' : 'Agregar URL'"
|
||||
:icon="item.url ? 'pi pi-pencil' : 'pi pi-link'"
|
||||
size="small"
|
||||
outlined
|
||||
class="w-full"
|
||||
@click="openUrlDialog(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="URL" style="width: 120px" class="text-center">
|
||||
<template #body="{ data, index }">
|
||||
<Button
|
||||
:icon="data.url ? 'pi pi-check-circle' : 'pi pi-link'"
|
||||
:severity="data.url ? 'success' : 'secondary'"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="openUrlDialog(index)"
|
||||
v-tooltip.top="data.url ? 'Editar URL' : 'Agregar URL'"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- URL Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="urlDialogVisible"
|
||||
modal
|
||||
:header="selectedItemIndex !== null && items[selectedItemIndex]?.url ? 'Editar URL del Producto' : 'Agregar URL del Producto'"
|
||||
:style="{ width: '500px' }"
|
||||
: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-link mr-2 text-primary"></i>
|
||||
URL del producto
|
||||
</label>
|
||||
<InputText
|
||||
v-model="tempUrl"
|
||||
placeholder="https://ejemplo.com/producto"
|
||||
class="w-full"
|
||||
autofocus
|
||||
/>
|
||||
<small class="text-gray-500 mt-1 block">
|
||||
Puede ser un enlace de Amazon, MercadoLibre, página del fabricante, etc.
|
||||
</small>
|
||||
</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 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 break-all">{{ tempUrl }}</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="copyToClipboard(tempUrl)"
|
||||
v-tooltip.top="'Copiar'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="closeUrlDialog"
|
||||
/>
|
||||
<Button
|
||||
label="Guardar"
|
||||
icon="pi pi-check"
|
||||
@click="saveUrl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
526
src/modules/requisitions/Requisitions.vue
Normal file
526
src/modules/requisitions/Requisitions.vue
Normal file
@ -0,0 +1,526 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import Card from 'primevue/card';
|
||||
import Button from 'primevue/button';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import Tag from 'primevue/tag';
|
||||
import Paginator from 'primevue/paginator';
|
||||
import Toast from 'primevue/toast';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import { useRequisitionStore } from './stores/requisitionStore';
|
||||
import type { Requisition } from './types/requisition.interfaces';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const requisitionStore = useRequisitionStore();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedStatus = ref<string | null>(null);
|
||||
const selectedPriority = ref<string | null>(null);
|
||||
|
||||
const pagination = ref({
|
||||
first: 0,
|
||||
rows: 10
|
||||
});
|
||||
|
||||
// Cancel dialog
|
||||
const showCancelDialog = ref(false);
|
||||
const cancelComment = ref('');
|
||||
const requisitionToCancel = ref<Requisition | null>(null);
|
||||
|
||||
// Computed filtered requisitions
|
||||
const filteredRequisitions = computed(() => {
|
||||
let filtered = [...requisitionStore.requisitions];
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
filtered = filtered.filter(r =>
|
||||
r.folio.toLowerCase().includes(query) ||
|
||||
r.requester.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedStatus.value) {
|
||||
filtered = filtered.filter(r => r.status === selectedStatus.value);
|
||||
}
|
||||
|
||||
if (selectedPriority.value) {
|
||||
filtered = filtered.filter(r => r.priority === selectedPriority.value);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const paginatedRequisitions = computed(() => {
|
||||
const start = pagination.value.first;
|
||||
const end = start + pagination.value.rows;
|
||||
return filteredRequisitions.value.slice(start, end);
|
||||
});
|
||||
|
||||
const totalRecords = computed(() => filteredRequisitions.value.length);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Todos', value: null },
|
||||
{ label: 'Borrador', value: 'draft' },
|
||||
{ label: 'Pendiente', value: 'pending' },
|
||||
{ label: 'Aprobado', value: 'approved' },
|
||||
{ label: 'Rechazado', value: 'rejected' },
|
||||
{ label: 'Cancelado', value: 'cancelled' }
|
||||
];
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: 'Todas', value: null },
|
||||
{ label: 'Baja', value: 'low' },
|
||||
{ label: 'Normal', value: 'normal' },
|
||||
{ label: 'Alta', value: 'high' },
|
||||
{ label: 'Urgente', value: 'urgent' }
|
||||
];
|
||||
|
||||
const getStatusSeverity = (status: string) => {
|
||||
const severityMap: Record<string, string> = {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
draft: 'secondary',
|
||||
rejected: 'danger',
|
||||
cancelled: 'contrast'
|
||||
};
|
||||
return severityMap[status] || 'info';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
approved: 'Aprobado',
|
||||
draft: 'Borrador',
|
||||
rejected: 'Rechazado',
|
||||
cancelled: 'Cancelado'
|
||||
};
|
||||
return labelMap[status] || status;
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
low: 'bg-slate-300 dark:bg-slate-600',
|
||||
normal: 'bg-blue-500',
|
||||
high: 'bg-red-500',
|
||||
urgent: 'bg-red-600'
|
||||
};
|
||||
return colorMap[priority] || 'bg-gray-400';
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
low: 'Baja',
|
||||
normal: 'Normal',
|
||||
high: 'Alta',
|
||||
urgent: 'Urgente'
|
||||
};
|
||||
return labelMap[priority] || priority;
|
||||
};
|
||||
|
||||
const onPageChange = (event: any) => {
|
||||
pagination.value.first = event.first;
|
||||
pagination.value.rows = event.rows;
|
||||
};
|
||||
|
||||
const handleView = (requisition: Requisition) => {
|
||||
router.push(`/requisitions/${requisition.id}`);
|
||||
};
|
||||
|
||||
const handleEdit = (requisition: Requisition) => {
|
||||
if (requisition.status === 'draft' || requisition.status === 'pending') {
|
||||
router.push(`/requisitions/edit/${requisition.id}`);
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No permitido',
|
||||
detail: 'Solo se pueden editar requisiciones en borrador o pendientes',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = (requisition: Requisition) => {
|
||||
requisitionToCancel.value = requisition;
|
||||
cancelComment.value = '';
|
||||
showCancelDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmCancel = async () => {
|
||||
if (!requisitionToCancel.value) return;
|
||||
|
||||
if (!cancelComment.value.trim() || cancelComment.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 {
|
||||
// Usar el método cancelRequisition del store en lugar de deleteRequisition
|
||||
await requisitionStore.cancelRequisition(
|
||||
requisitionToCancel.value.id,
|
||||
cancelComment.value.trim(),
|
||||
requisitionToCancel.value.requester // O el usuario actual del sistema
|
||||
);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Cancelado',
|
||||
detail: 'Requisición cancelada correctamente',
|
||||
life: 3000
|
||||
});
|
||||
|
||||
showCancelDialog.value = false;
|
||||
requisitionToCancel.value = null;
|
||||
cancelComment.value = '';
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: error.message || 'Error al cancelar la requisición',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const closeCancelDialog = () => {
|
||||
showCancelDialog.value = false;
|
||||
requisitionToCancel.value = null;
|
||||
cancelComment.value = '';
|
||||
};
|
||||
|
||||
const handleNewRequisition = () => {
|
||||
router.push('/requisitions/create');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await requisitionStore.fetchRequisitions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h2 class="text-3xl font-black text-surface-900 dark:text-white tracking-tight">
|
||||
Gestión de Requisiciones
|
||||
</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
Revisa y administra las requisiciones de almacén y producción.
|
||||
</p>
|
||||
</div>
|
||||
<Button label="Nueva Requisición" icon="pi pi-plus" @click="handleNewRequisition" />
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Pendientes</p>
|
||||
<div class="flex items-end justify-between">
|
||||
<h3 class="text-2xl font-bold">{{ requisitionStore.pendingCount }}</h3>
|
||||
<Tag value="Acción requerida" severity="warning" class="text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Aprobados Hoy</p>
|
||||
<div class="flex items-end justify-between">
|
||||
<h3 class="text-2xl font-bold">{{ requisitionStore.approvedTodayCount }}</h3>
|
||||
<Tag value="Actualizado" severity="success" class="text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Requisiciones</p>
|
||||
<div class="flex items-end justify-between">
|
||||
<h3 class="text-2xl font-bold">{{ requisitionStore.requisitions.length }}</h3>
|
||||
<Tag value="Sistema" severity="info" class="text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Presupuesto Mensual</p>
|
||||
<div class="flex items-end justify-between">
|
||||
<h3 class="text-2xl font-bold">${{ (requisitionStore.totalBudgetThisMonth / 1000).toFixed(1) }}k</h3>
|
||||
<Tag value="Este mes" severity="secondary" class="text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Table -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<!-- Filters Toolbar -->
|
||||
<div class="flex flex-wrap gap-4 items-end mb-6">
|
||||
<div class="flex-1 min-w-[300px]">
|
||||
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||
Buscar
|
||||
</label>
|
||||
<span class="p-input-icon-left w-full">
|
||||
<i class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="searchQuery"
|
||||
placeholder="Buscar por número de folio (ej. REQ-2023...)"
|
||||
class="w-full"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-48">
|
||||
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||
Estado
|
||||
</label>
|
||||
<Dropdown
|
||||
v-model="selectedStatus"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Todos"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-48">
|
||||
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||
Prioridad
|
||||
</label>
|
||||
<Dropdown
|
||||
v-model="selectedPriority"
|
||||
:options="priorityOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Todas"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button icon="pi pi-filter" label="Más Filtros" text />
|
||||
</div>
|
||||
|
||||
<!-- DataTable -->
|
||||
<DataTable
|
||||
:value="paginatedRequisitions"
|
||||
:loading="requisitionStore.loading"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
class="p-datatable-sm"
|
||||
>
|
||||
<Column field="id" header="ID" style="min-width: 80px">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-gray-500">{{ data.id }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="folio" header="Folio" style="min-width: 150px">
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold">{{ data.folio }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="requester" header="Solicitante" style="min-width: 150px" />
|
||||
|
||||
<Column field="status" header="Estado" style="min-width: 120px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="priority" header="Prioridad" style="min-width: 120px">
|
||||
<template #body="{ data }">
|
||||
<span class="flex items-center gap-2">
|
||||
<span :class="['w-2 h-2 rounded-full', getPriorityColor(data.priority)]"></span>
|
||||
{{ getPriorityLabel(data.priority) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="totalAmount" header="Monto Total" style="min-width: 140px">
|
||||
<template #body="{ data }">
|
||||
<span class="font-semibold">${{ data.totalAmount.toLocaleString('es-MX', { minimumFractionDigits: 2 }) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="createdAt" header="Fecha de Creación" style="min-width: 140px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm">{{ new Date(data.createdAt).toLocaleDateString('es-MX') }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Acciones" headerStyle="text-align: center" bodyStyle="text-align: center" style="min-width: 150px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="handleView(data)"
|
||||
v-tooltip.top="'Ver Detalles'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
@click="handleEdit(data)"
|
||||
v-tooltip.top="'Editar'"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="danger"
|
||||
@click="handleCancel(data)"
|
||||
v-tooltip.top="'Cancelar'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-4">
|
||||
<Paginator
|
||||
:first="pagination.first"
|
||||
:rows="pagination.rows"
|
||||
:totalRecords="totalRecords"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
@page="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card class="bg-blue-50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-900/30">
|
||||
<template #content>
|
||||
<div class="flex gap-4">
|
||||
<i class="pi pi-info-circle text-blue-500 text-xl"></i>
|
||||
<div>
|
||||
<h4 class="font-semibold text-blue-900 dark:text-blue-300 text-sm mb-1">
|
||||
¿Necesitas Ayuda?
|
||||
</h4>
|
||||
<p class="text-blue-700 dark:text-blue-400 text-xs leading-relaxed">
|
||||
Los flujos de aprobación requieren al menos una firma de supervisor para requisiciones que excedan $500.
|
||||
Consulta la sección "Ver Detalles" para la jerarquía de aprobación.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
|
||||
<template #content>
|
||||
<div class="flex gap-4">
|
||||
<i class="pi pi-link text-slate-500 text-xl"></i>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 dark:text-slate-200 text-sm mb-2">
|
||||
Enlaces Rápidos
|
||||
</h4>
|
||||
<div class="flex gap-3 text-xs">
|
||||
<a href="#" class="text-primary hover:underline">Descargar Reporte</a>
|
||||
<span class="text-slate-300 dark:text-slate-700">|</span>
|
||||
<a href="#" class="text-primary hover:underline">Política de Aprobación</a>
|
||||
<span class="text-slate-300 dark:text-slate-700">|</span>
|
||||
<a href="#" class="text-primary hover:underline">Estado del Sistema</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showCancelDialog"
|
||||
modal
|
||||
header="Cancelar Requisición"
|
||||
:style="{ width: '500px' }"
|
||||
:closable="true"
|
||||
@hide="closeCancelDialog"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div v-if="requisitionToCancel" class="bg-orange-50 dark:bg-orange-900/10 border border-orange-200 dark:border-orange-900/30 rounded-lg p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="pi pi-exclamation-triangle text-orange-500 text-xl mt-0.5"></i>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-orange-900 dark:text-orange-300 text-sm mb-1">
|
||||
¿Está seguro de cancelar esta requisición?
|
||||
</h4>
|
||||
<p class="text-orange-700 dark:text-orange-400 text-xs">
|
||||
Folio: <span class="font-bold">{{ requisitionToCancel.folio }}</span>
|
||||
</p>
|
||||
<p class="text-orange-700 dark:text-orange-400 text-xs">
|
||||
Solicitante: {{ requisitionToCancel.requester }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="cancelReason" class="block text-sm font-semibold text-surface-900 dark:text-white mb-2">
|
||||
Motivo de cancelación <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="cancelReason"
|
||||
v-model="cancelComment"
|
||||
rows="4"
|
||||
class="w-full"
|
||||
placeholder="Describa el motivo por el cual se cancela esta requisición..."
|
||||
:class="{ 'p-invalid': !cancelComment.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="closeCancelDialog"
|
||||
/>
|
||||
<Button
|
||||
label="Confirmar Cancelación"
|
||||
icon="pi pi-check"
|
||||
severity="danger"
|
||||
@click="confirmCancel"
|
||||
:disabled="!cancelComment.trim() || cancelComment.trim().length < 10"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
361
src/modules/requisitions/stores/requisitionStore.ts
Normal file
361
src/modules/requisitions/stores/requisitionStore.ts
Normal file
@ -0,0 +1,361 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Requisition, RequisitionForm, RequisitionItem } from '../types/requisition.interfaces';
|
||||
|
||||
export const useRequisitionStore = defineStore('requisition', () => {
|
||||
const requisitions = ref<Requisition[]>([
|
||||
|
||||
]);
|
||||
|
||||
/* {
|
||||
id: 1,
|
||||
folio: 'REQ-2024-001',
|
||||
requester: 'Edgar Mendoza',
|
||||
department: 'prod_a',
|
||||
status: 'draft',
|
||||
priority: 'medium',
|
||||
justification: 'Necesitamos equipos para la línea de producción A',
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
product: 'Motor eléctrico 3HP',
|
||||
quantity: 2,
|
||||
unit: 'Pz',
|
||||
unitPrice: 1250.00,
|
||||
url: 'https://amazon.com/product/123'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
product: 'Cable calibre 12',
|
||||
quantity: 50,
|
||||
unit: 'M',
|
||||
unitPrice: 15.50,
|
||||
url: ''
|
||||
}
|
||||
],
|
||||
totalAmount: 3275.00,
|
||||
createdAt: '2024-02-20',
|
||||
updatedAt: '2024-02-20'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
folio: 'REQ-2024-002',
|
||||
requester: 'María González',
|
||||
department: 'maintenance',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
justification: 'Mantenimiento preventivo de equipos críticos',
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
product: 'Aceite hidráulico SAE 68',
|
||||
quantity: 20,
|
||||
unit: 'Lt',
|
||||
unitPrice: 85.00,
|
||||
url: 'https://mercadolibre.com/product/456'
|
||||
}
|
||||
],
|
||||
totalAmount: 1700.00,
|
||||
createdAt: '2024-02-22',
|
||||
updatedAt: '2024-02-22'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
folio: 'REQ-2024-003',
|
||||
requester: 'Carlos Ruiz',
|
||||
department: 'logistics',
|
||||
status: 'approved',
|
||||
priority: 'normal',
|
||||
justification: 'Material de empaque para envíos',
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
product: 'Caja de cartón corrugado 40x30x20',
|
||||
quantity: 100,
|
||||
unit: 'Pz',
|
||||
unitPrice: 12.50,
|
||||
url: ''
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
product: 'Cinta adhesiva transparente',
|
||||
quantity: 50,
|
||||
unit: 'Pz',
|
||||
unitPrice: 8.00,
|
||||
url: ''
|
||||
}
|
||||
],
|
||||
totalAmount: 1650.00,
|
||||
createdAt: '2024-02-18',
|
||||
updatedAt: '2024-02-23'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
folio: 'REQ-2024-004',
|
||||
requester: 'Ana Martínez',
|
||||
department: 'admin',
|
||||
status: 'rejected',
|
||||
priority: 'low',
|
||||
justification: 'Suministros de oficina',
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
product: 'Papel bond tamaño carta',
|
||||
quantity: 10,
|
||||
unit: 'Pq',
|
||||
unitPrice: 120.00,
|
||||
url: ''
|
||||
}
|
||||
],
|
||||
totalAmount: 1200.00,
|
||||
createdAt: '2024-02-15',
|
||||
updatedAt: '2024-02-21'
|
||||
} */
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
let nextId = ref(5);
|
||||
let nextFolio = ref(5);
|
||||
|
||||
// Computed properties for statistics
|
||||
const pendingCount = computed(() =>
|
||||
requisitions.value.filter(r => r.status === 'pending').length
|
||||
);
|
||||
|
||||
const approvedTodayCount = computed(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return requisitions.value.filter(
|
||||
r => r.status === 'approved' && r.updatedAt === today
|
||||
).length;
|
||||
});
|
||||
|
||||
const totalBudgetThisMonth = computed(() => {
|
||||
const now = new Date();
|
||||
const currentMonth = now.getMonth();
|
||||
const currentYear = now.getFullYear();
|
||||
|
||||
return requisitions.value
|
||||
.filter(r => {
|
||||
const reqDate = new Date(r.createdAt);
|
||||
return reqDate.getMonth() === currentMonth &&
|
||||
reqDate.getFullYear() === currentYear;
|
||||
})
|
||||
.reduce((sum, r) => sum + r.totalAmount, 0);
|
||||
});
|
||||
|
||||
// Actions
|
||||
async function fetchRequisitions() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
// Simular delay de API
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
// Los datos ya están cargados en el estado inicial
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Error al cargar requisiciones';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getRequisitionById(id: number): Requisition | undefined {
|
||||
return requisitions.value.find(r => r.id === id);
|
||||
}
|
||||
|
||||
function getRequisitionByFolio(folio: string): Requisition | undefined {
|
||||
return requisitions.value.find(r => r.folio === folio);
|
||||
}
|
||||
|
||||
async function createRequisition(form: RequisitionForm, items: RequisitionItem[]): Promise<Requisition> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Simular delay de API
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const totalAmount = items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||
const now = new Date().toISOString().split('T')[0] as string;
|
||||
|
||||
const newRequisition: Requisition = {
|
||||
id: nextId.value++,
|
||||
folio: `REQ-2024-${String(nextFolio.value++).padStart(3, '0')}`,
|
||||
requester: form.requester,
|
||||
department: form.department,
|
||||
status: 'draft',
|
||||
priority: form.priority,
|
||||
justification: form.justification,
|
||||
items: items.map((item, index) => ({ ...item, id: index + 1 })),
|
||||
totalAmount,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
requisitions.value.unshift(newRequisition);
|
||||
return newRequisition;
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Error al crear requisición';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRequisition(id: number, form: RequisitionForm, items: RequisitionItem[]): Promise<Requisition | null> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Simular delay de API
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const index = requisitions.value.findIndex(r => r.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error('Requisición no encontrada');
|
||||
}
|
||||
|
||||
const existingReq = requisitions.value[index];
|
||||
if (!existingReq) {
|
||||
throw new Error('Requisición no encontrada');
|
||||
}
|
||||
|
||||
const totalAmount = items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||
const now = new Date().toISOString().split('T')[0] as string;
|
||||
|
||||
const updatedRequisition: Requisition = {
|
||||
...existingReq,
|
||||
department: form.department,
|
||||
priority: form.priority,
|
||||
justification: form.justification,
|
||||
items: items.map((item, idx) => ({ ...item, id: idx + 1 })),
|
||||
totalAmount,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
requisitions.value[index] = updatedRequisition;
|
||||
return updatedRequisition;
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Error al actualizar requisición';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForApproval(id: number): 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) {
|
||||
requisition.status = 'pending';
|
||||
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Error al enviar requisición';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRequisition(id: number): 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');
|
||||
}
|
||||
|
||||
requisitions.value.splice(index, 1);
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Error al eliminar requisición';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStatus(id: number, status: 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) {
|
||||
requisition.status = status;
|
||||
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Error al cambiar estado';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRequisition(id: number, reason: string, cancelledBy: 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) {
|
||||
requisition.status = 'cancelled';
|
||||
requisition.cancellationReason = reason;
|
||||
requisition.cancelledAt = new Date().toISOString();
|
||||
requisition.cancelledBy = cancelledBy;
|
||||
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || 'Error al cancelar requisición';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requisitions,
|
||||
loading,
|
||||
error,
|
||||
pendingCount,
|
||||
approvedTodayCount,
|
||||
totalBudgetThisMonth,
|
||||
fetchRequisitions,
|
||||
getRequisitionById,
|
||||
getRequisitionByFolio,
|
||||
createRequisition,
|
||||
updateRequisition,
|
||||
submitForApproval,
|
||||
deleteRequisition,
|
||||
changeStatus,
|
||||
cancelRequisition
|
||||
};
|
||||
});
|
||||
34
src/modules/requisitions/types/requisition.interfaces.ts
Normal file
34
src/modules/requisitions/types/requisition.interfaces.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export interface RequisitionItem {
|
||||
id: number;
|
||||
product: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RequisitionForm {
|
||||
folio: string;
|
||||
requester: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
department: string;
|
||||
justification: string;
|
||||
}
|
||||
|
||||
export interface Requisition extends RequisitionForm {
|
||||
id: number;
|
||||
items: RequisitionItem[];
|
||||
totalAmount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
cancellationReason?: string;
|
||||
cancelledAt?: string;
|
||||
cancelledBy?: string;
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
}
|
||||
@ -28,6 +28,8 @@ import PurchaseDetails from '../modules/purchases/components/PurchaseDetails.vue
|
||||
import PurchaseForm from '../modules/purchases/components/PurchaseForm.vue';
|
||||
import WarehouseAddInventory from '../modules/warehouse/components/WarehouseAddInventory.vue';
|
||||
import ModelDocuments from '../modules/catalog/components/ModelDocuments.vue';
|
||||
import Requisitions from '../modules/requisitions/Requisitions.vue';
|
||||
import CreateRequisition from '../modules/requisitions/CreateRequisition.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@ -309,6 +311,52 @@ const routes: RouteRecordRaw[] = [
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'requisitions',
|
||||
name: 'RequisitionsModule',
|
||||
meta: {
|
||||
title: 'Requisiciones',
|
||||
requiresAuth: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Requisitions',
|
||||
component: Requisitions,
|
||||
meta: {
|
||||
title: 'Gestión de Requisiciones',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
name: 'RequisitionCreate',
|
||||
component: CreateRequisition,
|
||||
meta: {
|
||||
title: 'Crear Requisición',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'edit/:id',
|
||||
name: 'RequisitionEdit',
|
||||
component: CreateRequisition,
|
||||
meta: {
|
||||
title: 'Editar Requisición',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
name: 'RequisitionView',
|
||||
component: CreateRequisition,
|
||||
meta: {
|
||||
title: 'Ver Requisición',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user