feature-comercial-module-ts #13

Merged
edgar.mendez merged 38 commits from feature-comercial-module-ts into develop 2026-03-04 15:07:09 +00:00
6 changed files with 1737 additions and 0 deletions
Showing only changes of commit 2f3a4d7da4 - Show all commits

View File

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

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

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

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

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

View File

@ -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
}
}
]
}
]
},