Merge pull request 'feature-comercial-module-ts' (#16) from feature-comercial-module-ts into develop
Reviewed-on: #16
This commit is contained in:
commit
0542950b7a
@ -25,7 +25,8 @@ const menuItems = ref<MenuItem[]>([
|
|||||||
{ label: 'Unidades de Medida', icon: 'pi pi-calculator', to: '/catalog/units-of-measure' },
|
{ label: 'Unidades de Medida', icon: 'pi pi-calculator', to: '/catalog/units-of-measure' },
|
||||||
{ label: 'Clasificaciones Comerciales', icon: 'pi pi-tags', to: '/catalog/classifications-comercial' },
|
{ label: 'Clasificaciones Comerciales', icon: 'pi pi-tags', to: '/catalog/classifications-comercial' },
|
||||||
{ label: 'Proveedores', icon: 'pi pi-briefcase', to: '/catalog/suppliers' },
|
{ label: 'Proveedores', icon: 'pi pi-briefcase', to: '/catalog/suppliers' },
|
||||||
{ label: 'Documentos del Modelo', icon: 'pi pi-file', to: '/catalog/model-documents' }
|
{ label: 'Documentos del Modelo', icon: 'pi pi-file', to: '/catalog/model-documents' },
|
||||||
|
{ label: 'Empresas', icon: 'pi pi-building', to: '/catalog/companies' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
344
src/modules/catalog/components/companies/Companies.vue
Normal file
344
src/modules/catalog/components/companies/Companies.vue
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Toast />
|
||||||
|
|
||||||
|
<!-- Page Title & CTA -->
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">Gestión de Empresas</h1>
|
||||||
|
<p class="text-slate-500 dark:text-slate-400">Administra y monitorea todas las entidades legales registradas en el sistema.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="Registrar Nueva Empresa"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
@click="openCreateDialog"
|
||||||
|
class="p-button-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Section -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<DataTable
|
||||||
|
:value="companies"
|
||||||
|
:lazy="true"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="perPage"
|
||||||
|
:totalRecords="totalRecords"
|
||||||
|
:loading="loading"
|
||||||
|
:rowsPerPageOptions="[10, 20, 50]"
|
||||||
|
@page="onPageChange"
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
class="p-datatable-sm"
|
||||||
|
>
|
||||||
|
|
||||||
|
<Column field="company_name" header="Empresa" :sortable="true" style="min-width: 250px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
:label="getInitials(data.company_name)"
|
||||||
|
shape="square"
|
||||||
|
size="large"
|
||||||
|
class="bg-primary/10 text-primary font-bold"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-sm">{{ data.company_name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="rfc" header="RFC" :sortable="true" style="min-width: 150px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-mono text-sm">{{ data.rfc }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="curp" header="CURP / Registro" :sortable="true" style="min-width: 150px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-mono text-sm">{{ data.curp || 'N/A' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="address" header="Ubicación" style="min-width: 200px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div v-if="data.address">
|
||||||
|
<p class="text-sm">{{ data.address.city }}</p>
|
||||||
|
<p class="text-xs text-slate-400">{{ data.address.state }}</p>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-slate-400">Sin dirección</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Acciones" :exportable="false" style="min-width: 120px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
class="p-button-rounded p-button-text p-button-sm"
|
||||||
|
@click="editCompany(data)"
|
||||||
|
v-tooltip.top="'Editar'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-eye"
|
||||||
|
class="p-button-rounded p-button-text p-button-sm"
|
||||||
|
@click="viewCompany(data)"
|
||||||
|
v-tooltip.top="'Ver detalles'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
class="p-button-rounded p-button-text p-button-sm p-button-danger"
|
||||||
|
@click="confirmDelete(data)"
|
||||||
|
v-tooltip.top="'Eliminar'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<i class="pi pi-inbox text-4xl text-slate-300 mb-3"></i>
|
||||||
|
<p class="text-slate-500">No se encontraron empresas registradas</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Form Dialog -->
|
||||||
|
<CompaniesForm
|
||||||
|
ref="companiesFormRef"
|
||||||
|
:visible="dialogVisible"
|
||||||
|
:company="selectedCompany"
|
||||||
|
@close="dialogVisible = false"
|
||||||
|
@save="handleSave"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<Dialog v-model:visible="deleteDialogVisible" :modal="true" header="Confirmar Eliminación" :style="{ width: '450px' }">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i class="pi pi-exclamation-triangle text-3xl text-amber-500"></i>
|
||||||
|
<span>¿Está seguro de eliminar la empresa <strong>{{ companyToDelete?.company_name }}</strong>?</span>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancelar" icon="pi pi-times" @click="deleteDialogVisible = false" class="p-button-text" />
|
||||||
|
<Button label="Eliminar" icon="pi pi-check" @click="deleteCompany" class="p-button-danger" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import CompaniesForm from './CompaniesForm.vue';
|
||||||
|
import { companyService } from './companies.service';
|
||||||
|
import type { Company, PaginatedResponse } from './companies.types';
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const companiesFormRef = ref<InstanceType<typeof CompaniesForm> | null>(null);
|
||||||
|
|
||||||
|
// State
|
||||||
|
const companies = ref<Company[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const deleteDialogVisible = ref(false);
|
||||||
|
const selectedCompany = ref<Company | null>(null);
|
||||||
|
const companyToDelete = ref<Company | null>(null);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const totalRecords = ref(0);
|
||||||
|
const perPage = ref(10);
|
||||||
|
|
||||||
|
// Search filters
|
||||||
|
const searchFilters = ref({
|
||||||
|
rfc: '',
|
||||||
|
curp: '',
|
||||||
|
company_name: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadCompanies = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
paginate: true,
|
||||||
|
page: currentPage.value,
|
||||||
|
...searchFilters.value
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await companyService.getAll(params);
|
||||||
|
|
||||||
|
if ('current_page' in response) {
|
||||||
|
// Respuesta paginada
|
||||||
|
const paginatedData = response as PaginatedResponse<Company>;
|
||||||
|
companies.value = paginatedData.data;
|
||||||
|
currentPage.value = paginatedData.current_page;
|
||||||
|
totalRecords.value = paginatedData.total;
|
||||||
|
perPage.value = paginatedData.per_page;
|
||||||
|
} else {
|
||||||
|
// Respuesta sin paginación
|
||||||
|
companies.value = response as Company[];
|
||||||
|
totalRecords.value = companies.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading companies:', error);
|
||||||
|
companies.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageChange = (event: any) => {
|
||||||
|
currentPage.value = event.page + 1; // PrimeVue usa índice base 0
|
||||||
|
loadCompanies();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
selectedCompany.value = null;
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editCompany = (company: any) => {
|
||||||
|
selectedCompany.value = { ...company };
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewCompany = (company: any) => {
|
||||||
|
// TODO: Implementar vista de detalles
|
||||||
|
console.log('View company:', company);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (company: any) => {
|
||||||
|
companyToDelete.value = company;
|
||||||
|
deleteDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCompany = async () => {
|
||||||
|
if (!companyToDelete.value?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await companyService.delete(companyToDelete.value.id);
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Empresa eliminada',
|
||||||
|
detail: `La empresa "${companyToDelete.value.company_name}" ha sido eliminada exitosamente`,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recargar la lista después de eliminar
|
||||||
|
await loadCompanies();
|
||||||
|
|
||||||
|
deleteDialogVisible.value = false;
|
||||||
|
companyToDelete.value = null;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting company:', error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error al eliminar',
|
||||||
|
detail: error.response?.data?.message || 'No se pudo eliminar la empresa',
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (companyData: any) => {
|
||||||
|
try {
|
||||||
|
if (selectedCompany.value?.id) {
|
||||||
|
// Actualizar empresa existente
|
||||||
|
await companyService.update(selectedCompany.value.id, companyData);
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Empresa actualizada',
|
||||||
|
detail: `La empresa "${companyData.company_name}" ha sido actualizada exitosamente`,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Crear nueva empresa
|
||||||
|
await companyService.create(companyData);
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Empresa creada',
|
||||||
|
detail: `La empresa "${companyData.company_name}" ha sido registrada exitosamente`,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recargar la lista después de guardar
|
||||||
|
await loadCompanies();
|
||||||
|
|
||||||
|
dialogVisible.value = false;
|
||||||
|
selectedCompany.value = null;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error saving company:', error);
|
||||||
|
|
||||||
|
// Verificar si es un error de validación (422)
|
||||||
|
if (error.response?.status === 422 && error.response?.data?.errors) {
|
||||||
|
const backendErrors = error.response.data.errors;
|
||||||
|
|
||||||
|
// Setear los errores en el formulario
|
||||||
|
if (companiesFormRef.value) {
|
||||||
|
companiesFormRef.value.setValidationErrors(backendErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mostrar toast con mensaje general
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Errores de validación',
|
||||||
|
detail: error.response.data.message || 'Por favor, corrija los errores en el formulario',
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Otros errores
|
||||||
|
const errorMessage = error.response?.data?.message ||
|
||||||
|
error.response?.data?.error ||
|
||||||
|
error.message ||
|
||||||
|
'No se pudo guardar la empresa';
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: selectedCompany.value?.id ? 'Error al actualizar' : 'Error al crear',
|
||||||
|
detail: errorMessage,
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resetear loading en el formulario para otros tipos de error
|
||||||
|
if (companiesFormRef.value) {
|
||||||
|
companiesFormRef.value.setValidationErrors({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (name: string): string => {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word[0])
|
||||||
|
.join('')
|
||||||
|
.substring(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadCompanies();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.space-y-6 > * + * {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
429
src/modules/catalog/components/companies/CompaniesForm.vue
Normal file
429
src/modules/catalog/components/companies/CompaniesForm.vue
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="isVisible"
|
||||||
|
:modal="true"
|
||||||
|
:closable="true"
|
||||||
|
:style="{ width: '900px' }"
|
||||||
|
:header="formTitle"
|
||||||
|
@hide="handleClose"
|
||||||
|
>
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
|
<!-- Información Fiscal -->
|
||||||
|
<div class="border-b pb-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Información Fiscal</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="rfc" class="font-medium text-sm">RFC *</label>
|
||||||
|
<InputText
|
||||||
|
id="rfc"
|
||||||
|
v-model="form.rfc"
|
||||||
|
placeholder="ABC123456789"
|
||||||
|
:class="{ 'p-invalid': errors.rfc }"
|
||||||
|
maxlength="13"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.rfc" class="p-error text-red-600">{{ errors.rfc }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="curp" class="font-medium text-sm">CURP</label>
|
||||||
|
<InputText
|
||||||
|
id="curp"
|
||||||
|
v-model="form.curp"
|
||||||
|
placeholder="XYZ987654321"
|
||||||
|
:class="{ 'p-invalid': errors.curp }"
|
||||||
|
maxlength="18"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.curp" class="p-error text-red-600">{{ errors.curp }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 md:col-span-2">
|
||||||
|
<label for="company_name" class="font-medium text-sm">Razón Social *</label>
|
||||||
|
<InputText
|
||||||
|
id="company_name"
|
||||||
|
v-model="form.company_name"
|
||||||
|
placeholder="Corporativo Ejemplo S.A. de C.V."
|
||||||
|
:class="{ 'p-invalid': errors.company_name }"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.company_name" class="p-error text-red-600">{{ errors.company_name }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tasas e Impuestos -->
|
||||||
|
<div class="border-b pb-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Tasas e Impuestos (%)</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="vat_rate" class="font-medium text-sm">Tasa de IVA *</label>
|
||||||
|
<InputNumber
|
||||||
|
id="vat_rate"
|
||||||
|
v-model="form.vat_rate"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:minFractionDigits="2"
|
||||||
|
:maxFractionDigits="2"
|
||||||
|
suffix="%"
|
||||||
|
:class="{ 'p-invalid': errors.vat_rate }"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.vat_rate" class="p-error text-red-600">{{ errors.vat_rate }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="isr_withholding" class="font-medium text-sm">Retención ISR</label>
|
||||||
|
<InputNumber
|
||||||
|
id="isr_withholding"
|
||||||
|
v-model="form.isr_withholding"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:minFractionDigits="2"
|
||||||
|
:maxFractionDigits="2"
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="vat_withholding" class="font-medium text-sm">Retención IVA</label>
|
||||||
|
<InputNumber
|
||||||
|
id="vat_withholding"
|
||||||
|
v-model="form.vat_withholding"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:minFractionDigits="2"
|
||||||
|
:maxFractionDigits="2"
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="additional_tax" class="font-medium text-sm">Impuesto Adicional</label>
|
||||||
|
<InputNumber
|
||||||
|
id="additional_tax"
|
||||||
|
v-model="form.additional_tax"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:minFractionDigits="2"
|
||||||
|
:maxFractionDigits="2"
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dirección -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Dirección Fiscal</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="country" class="font-medium text-sm">País *</label>
|
||||||
|
<InputText
|
||||||
|
id="country"
|
||||||
|
v-model="form.address.country"
|
||||||
|
placeholder="México"
|
||||||
|
:class="{ 'p-invalid': errors.country }"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.country" class="p-error text-red-600">{{ errors.country }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="postal_code" class="font-medium text-sm">Código Postal *</label>
|
||||||
|
<InputText
|
||||||
|
id="postal_code"
|
||||||
|
v-model="form.address.postal_code"
|
||||||
|
placeholder="12345"
|
||||||
|
:class="{ 'p-invalid': errors.postal_code }"
|
||||||
|
maxlength="10"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.postal_code" class="p-error text-red-600">{{ errors.postal_code }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="state" class="font-medium text-sm">Estado *</label>
|
||||||
|
<InputText
|
||||||
|
id="state"
|
||||||
|
v-model="form.address.state"
|
||||||
|
placeholder="CDMX"
|
||||||
|
:class="{ 'p-invalid': errors.state }"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.state" class="p-error text-red-600">{{ errors.state }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="municipality" class="font-medium text-sm">Municipio / Alcaldía *</label>
|
||||||
|
<InputText
|
||||||
|
id="municipality"
|
||||||
|
v-model="form.address.municipality"
|
||||||
|
placeholder="Benito Juárez"
|
||||||
|
:class="{ 'p-invalid': errors.municipality }"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.municipality" class="p-error text-red-600">{{ errors.municipality }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="city" class="font-medium text-sm">Ciudad *</label>
|
||||||
|
<InputText
|
||||||
|
id="city"
|
||||||
|
v-model="form.address.city"
|
||||||
|
placeholder="Ciudad de México"
|
||||||
|
:class="{ 'p-invalid': errors.city }"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.city" class="p-error text-red-600">{{ errors.city }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="street" class="font-medium text-sm">Calle *</label>
|
||||||
|
<InputText
|
||||||
|
id="street"
|
||||||
|
v-model="form.address.street"
|
||||||
|
placeholder="Av. Ejemplo"
|
||||||
|
:class="{ 'p-invalid': errors.street }"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.street" class="p-error text-red-600">{{ errors.street }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="num_ext" class="font-medium text-sm">Número Exterior *</label>
|
||||||
|
<InputText
|
||||||
|
id="num_ext"
|
||||||
|
v-model="form.address.num_ext"
|
||||||
|
placeholder="100"
|
||||||
|
:class="{ 'p-invalid': errors.num_ext }"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.num_ext" class="p-error text-red-600">{{ errors.num_ext }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="num_int" class="font-medium text-sm">Número Interior</label>
|
||||||
|
<InputText
|
||||||
|
id="num_int"
|
||||||
|
v-model="form.address.num_int"
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-slate-500">
|
||||||
|
* Campos obligatorios
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
icon="pi pi-times"
|
||||||
|
@click="handleClose"
|
||||||
|
class="p-button-text"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Guardar"
|
||||||
|
icon="pi pi-check"
|
||||||
|
@click="handleSubmit"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
company?: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
save: [data: any];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Métodos
|
||||||
|
// Método para setear errores de validación del backend
|
||||||
|
const setValidationErrors = (backendErrors: Record<string, string[]>) => {
|
||||||
|
loading.value = false;
|
||||||
|
errors.value = {};
|
||||||
|
|
||||||
|
// Mapear los errores del backend al formato del formulario
|
||||||
|
Object.keys(backendErrors).forEach(key => {
|
||||||
|
if (backendErrors[key] && backendErrors[key].length > 0) {
|
||||||
|
// Tomar el primer mensaje de error de cada campo
|
||||||
|
const firstError = backendErrors[key][0];
|
||||||
|
if (firstError) {
|
||||||
|
errors.value[key] = firstError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose method to set errors from parent
|
||||||
|
defineExpose({
|
||||||
|
setValidationErrors
|
||||||
|
});
|
||||||
|
|
||||||
|
// State
|
||||||
|
const loading = ref(false);
|
||||||
|
const isVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (value) => {
|
||||||
|
if (!value) emit('close');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formTitle = computed(() => {
|
||||||
|
return props.company ? 'Editar Empresa' : 'Registrar Nueva Empresa';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const defaultForm = () => ({
|
||||||
|
rfc: '',
|
||||||
|
curp: '',
|
||||||
|
company_name: '',
|
||||||
|
vat_rate: 16.00,
|
||||||
|
isr_withholding: 0.00,
|
||||||
|
vat_withholding: 0.00,
|
||||||
|
additional_tax: 0.00,
|
||||||
|
address: {
|
||||||
|
country: 'México',
|
||||||
|
postal_code: '',
|
||||||
|
state: '',
|
||||||
|
municipality: '',
|
||||||
|
city: '',
|
||||||
|
street: '',
|
||||||
|
num_ext: '',
|
||||||
|
num_int: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref(defaultForm());
|
||||||
|
const errors = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Watch for company changes
|
||||||
|
watch(() => props.company, (newCompany) => {
|
||||||
|
if (newCompany) {
|
||||||
|
form.value = {
|
||||||
|
...defaultForm(),
|
||||||
|
...newCompany,
|
||||||
|
address: {
|
||||||
|
...defaultForm().address,
|
||||||
|
...(newCompany.address || {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
form.value = defaultForm();
|
||||||
|
}
|
||||||
|
errors.value = {};
|
||||||
|
loading.value = false; // Reset loading state
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Watch for dialog visibility to reset loading
|
||||||
|
watch(() => props.visible, (isVisible) => {
|
||||||
|
if (!isVisible) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
errors.value = {};
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// RFC validation
|
||||||
|
if (!form.value.rfc || form.value.rfc.trim() === '') {
|
||||||
|
errors.value.rfc = 'El RFC es obligatorio';
|
||||||
|
isValid = false;
|
||||||
|
} else if (form.value.rfc.length < 12 || form.value.rfc.length > 13) {
|
||||||
|
errors.value.rfc = 'El RFC debe tener 12 o 13 caracteres';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Company name validation
|
||||||
|
if (!form.value.company_name || form.value.company_name.trim() === '') {
|
||||||
|
errors.value.company_name = 'La razón social es obligatoria';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAT rate validation
|
||||||
|
if (form.value.vat_rate === null || form.value.vat_rate === undefined) {
|
||||||
|
errors.value.vat_rate = 'La tasa de IVA es obligatoria';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address validations
|
||||||
|
if (!form.value.address.country || form.value.address.country.trim() === '') {
|
||||||
|
errors.value.country = 'El país es obligatorio';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.postal_code || form.value.address.postal_code.trim() === '') {
|
||||||
|
errors.value.postal_code = 'El código postal es obligatorio';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.state || form.value.address.state.trim() === '') {
|
||||||
|
errors.value.state = 'El estado es obligatorio';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.municipality || form.value.address.municipality.trim() === '') {
|
||||||
|
errors.value.municipality = 'El municipio es obligatorio';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.city || form.value.address.city.trim() === '') {
|
||||||
|
errors.value.city = 'La ciudad es obligatoria';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.street || form.value.address.street.trim() === '') {
|
||||||
|
errors.value.street = 'La calle es obligatoria';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.value.address.num_ext || form.value.address.num_ext.trim() === '') {
|
||||||
|
errors.value.num_ext = 'El número exterior es obligatorio';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
// Convert string values to uppercase for RFC and CURP
|
||||||
|
const formData = {
|
||||||
|
...form.value,
|
||||||
|
rfc: form.value.rfc.toUpperCase(),
|
||||||
|
curp: form.value.curp ? form.value.curp.toUpperCase() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('save', formData);
|
||||||
|
|
||||||
|
// El loading se mantendrá hasta que el padre cierre el diálogo
|
||||||
|
// o maneje el error
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting form:', error);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
form.value = defaultForm();
|
||||||
|
errors.value = {};
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.space-y-6 > * + * {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
14
src/modules/catalog/components/companies/companies.router.ts
Normal file
14
src/modules/catalog/components/companies/companies.router.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import Companies from "./Companies.vue";
|
||||||
|
|
||||||
|
const companiesRouter : RouteRecordRaw = {
|
||||||
|
path: 'companies',
|
||||||
|
name: 'companies',
|
||||||
|
component: Companies,
|
||||||
|
meta: {
|
||||||
|
title: 'Empresas',
|
||||||
|
requiresAuth: true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default companiesRouter;
|
||||||
123
src/modules/catalog/components/companies/companies.service.ts
Normal file
123
src/modules/catalog/components/companies/companies.service.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import api from '@/services/api';
|
||||||
|
import type {
|
||||||
|
Company,
|
||||||
|
CompanyCreateResponse,
|
||||||
|
CompanyFormData,
|
||||||
|
CompanyQueryParams,
|
||||||
|
PaginatedResponse
|
||||||
|
} from './companies.types';
|
||||||
|
|
||||||
|
|
||||||
|
export class CompanyService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener todas las empresas con paginación y filtros opcionales
|
||||||
|
*/
|
||||||
|
async getAll(params?: CompanyQueryParams): Promise<PaginatedResponse<Company> | Company[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.get('api/catalogs/companies', { params });
|
||||||
|
|
||||||
|
// Si la respuesta tiene paginación, devolver el objeto completo
|
||||||
|
if (response.data.current_page !== undefined) {
|
||||||
|
return response.data as PaginatedResponse<Company>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no tiene paginación, devolver solo el array de data
|
||||||
|
return response.data.data as Company[];
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error al obtener empresas:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener una empresa por ID
|
||||||
|
*/
|
||||||
|
async getById(id: number): Promise<Company> {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`api/catalogs/companies/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error al obtener empresa con ID ${id}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear una nueva empresa
|
||||||
|
*/
|
||||||
|
async create(data: CompanyFormData): Promise<CompanyCreateResponse> {
|
||||||
|
try {
|
||||||
|
const response = await api.post('api/catalogs/companies', data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error al crear empresa:', error);
|
||||||
|
// Mejorar el mensaje de error si viene del backend
|
||||||
|
if (error.response?.data) {
|
||||||
|
throw {
|
||||||
|
...error,
|
||||||
|
message: error.response.data.message || error.response.data.error || 'Error al crear la empresa'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar una empresa existente
|
||||||
|
*/
|
||||||
|
async update(id: number, data: Partial<CompanyFormData>): Promise<Company> {
|
||||||
|
try {
|
||||||
|
const response = await api.put(`api/catalogs/companies/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error al actualizar empresa con ID ${id}:`, error);
|
||||||
|
// Mejorar el mensaje de error si viene del backend
|
||||||
|
if (error.response?.data) {
|
||||||
|
throw {
|
||||||
|
...error,
|
||||||
|
message: error.response.data.message || error.response.data.error || 'Error al actualizar la empresa'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar una empresa
|
||||||
|
*/
|
||||||
|
async delete(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.delete(`api/catalogs/companies/${id}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error al eliminar empresa con ID ${id}:`, error);
|
||||||
|
// Mejorar el mensaje de error si viene del backend
|
||||||
|
if (error.response?.data) {
|
||||||
|
throw {
|
||||||
|
...error,
|
||||||
|
message: error.response.data.message || error.response.data.error || 'Error al eliminar la empresa'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar empresas por múltiples criterios
|
||||||
|
*/
|
||||||
|
async search(params: CompanyQueryParams): Promise<Company[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.get('api/catalogs/companies', {
|
||||||
|
params: { ...params, paginate: false }
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error al buscar empresas:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exportar una instancia única del servicio
|
||||||
|
export const companyService = new CompanyService();
|
||||||
72
src/modules/catalog/components/companies/companies.types.ts
Normal file
72
src/modules/catalog/components/companies/companies.types.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
export interface CompanyAddress {
|
||||||
|
country: string;
|
||||||
|
postal_code: string;
|
||||||
|
state: string;
|
||||||
|
municipality: string;
|
||||||
|
city: string;
|
||||||
|
street: string;
|
||||||
|
num_ext: string;
|
||||||
|
num_int?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Company {
|
||||||
|
id?: number;
|
||||||
|
rfc: string;
|
||||||
|
curp?: string;
|
||||||
|
company_name: string;
|
||||||
|
vat_rate: number;
|
||||||
|
isr_withholding: number;
|
||||||
|
vat_withholding: number;
|
||||||
|
additional_tax: number;
|
||||||
|
address: CompanyAddress;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyCreateResponse{
|
||||||
|
data: Company;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyFormData extends Omit<Company, 'id' | 'created_at' | 'updated_at'> {}
|
||||||
|
|
||||||
|
export interface CompanyStats {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
pending: number;
|
||||||
|
monthlyIncrease: number;
|
||||||
|
activePercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationLink {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
current_page: number;
|
||||||
|
data: T[];
|
||||||
|
first_page_url: string;
|
||||||
|
from: number;
|
||||||
|
last_page: number;
|
||||||
|
last_page_url: string;
|
||||||
|
links: PaginationLink[];
|
||||||
|
next_page_url: string | null;
|
||||||
|
path: string;
|
||||||
|
per_page: number;
|
||||||
|
prev_page_url: string | null;
|
||||||
|
to: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyListResponse {
|
||||||
|
data: Company[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyQueryParams {
|
||||||
|
paginate?: boolean;
|
||||||
|
rfc?: string;
|
||||||
|
curp?: string;
|
||||||
|
company_name?: string;
|
||||||
|
page?: number;
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import Card from 'primevue/card';
|
import Card from 'primevue/card';
|
||||||
@ -14,11 +14,14 @@ import Dialog from 'primevue/dialog';
|
|||||||
import Toast from 'primevue/toast';
|
import Toast from 'primevue/toast';
|
||||||
import { useRequisitionStore } from './stores/requisitionStore';
|
import { useRequisitionStore } from './stores/requisitionStore';
|
||||||
import type { RequisitionItem } from './types/requisition.interfaces';
|
import type { RequisitionItem } from './types/requisition.interfaces';
|
||||||
|
import { DepartmentsService } from '@/modules/rh/services/departments.services';
|
||||||
|
import type { Department } from '@/modules/rh/types/departments.interface';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const requisitionStore = useRequisitionStore();
|
const requisitionStore = useRequisitionStore();
|
||||||
|
const departmentsService = new DepartmentsService();
|
||||||
|
|
||||||
const isEditMode = ref(false);
|
const isEditMode = ref(false);
|
||||||
const requisitionId = ref<number | null>(null);
|
const requisitionId = ref<number | null>(null);
|
||||||
@ -42,16 +45,20 @@ const priorityOptions = [
|
|||||||
{ label: 'Urgente', value: 'urgent' }
|
{ label: 'Urgente', value: 'urgent' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const departmentOptions = [
|
// Cargar departamentos desde API
|
||||||
{ label: 'Producción - Línea A', value: 'prod_a' },
|
const departments = ref<Department[]>([]);
|
||||||
{ label: 'Mantenimiento Industrial', value: 'maintenance' },
|
const loadingDepartments = ref(false);
|
||||||
{ label: 'Logística y Almacén', value: 'logistics' },
|
|
||||||
{ label: 'Administración', value: 'admin' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const urlDialogVisible = ref(false);
|
const departmentOptions = computed(() => {
|
||||||
|
return departments.value.map(dept => ({
|
||||||
|
label: dept.name,
|
||||||
|
value: dept.id.toString()
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const commentsDialogVisible = ref(false);
|
||||||
const selectedItemIndex = ref<number | null>(null);
|
const selectedItemIndex = ref<number | null>(null);
|
||||||
const tempUrl = ref('');
|
const tempComments = ref('');
|
||||||
|
|
||||||
const addItem = () => {
|
const addItem = () => {
|
||||||
items.value.push({
|
items.value.push({
|
||||||
@ -60,46 +67,37 @@ const addItem = () => {
|
|||||||
quantity: 0,
|
quantity: 0,
|
||||||
unit: '',
|
unit: '',
|
||||||
unitPrice: 0,
|
unitPrice: 0,
|
||||||
url: ''
|
comments: ''
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openUrlDialog = (index: number) => {
|
const openCommentsDialog = (index: number) => {
|
||||||
selectedItemIndex.value = index;
|
selectedItemIndex.value = index;
|
||||||
const item = items.value[index];
|
const item = items.value[index];
|
||||||
if (item) {
|
if (item) {
|
||||||
tempUrl.value = item.url || '';
|
tempComments.value = item.comments || '';
|
||||||
}
|
}
|
||||||
urlDialogVisible.value = true;
|
commentsDialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveUrl = () => {
|
const saveComments = () => {
|
||||||
if (selectedItemIndex.value !== null) {
|
if (selectedItemIndex.value !== null) {
|
||||||
const item = items.value[selectedItemIndex.value];
|
const item = items.value[selectedItemIndex.value];
|
||||||
if (item) {
|
if (item) {
|
||||||
item.url = tempUrl.value;
|
item.comments = tempComments.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
urlDialogVisible.value = false;
|
commentsDialogVisible.value = false;
|
||||||
tempUrl.value = '';
|
tempComments.value = '';
|
||||||
selectedItemIndex.value = null;
|
selectedItemIndex.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeUrlDialog = () => {
|
const closeCommentsDialog = () => {
|
||||||
urlDialogVisible.value = false;
|
commentsDialogVisible.value = false;
|
||||||
tempUrl.value = '';
|
tempComments.value = '';
|
||||||
selectedItemIndex.value = null;
|
selectedItemIndex.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = async (text: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
console.log('URL copiado al portapapeles');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error al copiar:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateSubtotal = (item: RequisitionItem): number => {
|
const calculateSubtotal = (item: RequisitionItem): number => {
|
||||||
return item.quantity * item.unitPrice;
|
return item.quantity * item.unitPrice;
|
||||||
};
|
};
|
||||||
@ -145,7 +143,7 @@ const loadRequisition = async () => {
|
|||||||
form.value = {
|
form.value = {
|
||||||
folio: requisition.folio,
|
folio: requisition.folio,
|
||||||
requester: requisition.requester,
|
requester: requisition.requester,
|
||||||
status: requisition.status === 'draft' ? 'Borrador' : requisition.status,
|
status: requisition.status,
|
||||||
priority: requisition.priority,
|
priority: requisition.priority,
|
||||||
department: requisition.department,
|
department: requisition.department,
|
||||||
justification: requisition.justification
|
justification: requisition.justification
|
||||||
@ -291,7 +289,26 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDepartments = async () => {
|
||||||
|
loadingDepartments.value = true;
|
||||||
|
try {
|
||||||
|
const response = await departmentsService.getDepartments();
|
||||||
|
departments.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar departamentos:', error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'No se pudieron cargar los departamentos',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingDepartments.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadDepartments();
|
||||||
loadRequisition();
|
loadRequisition();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -370,7 +387,7 @@ const totalItems = () => {
|
|||||||
|
|
||||||
<div class="sm:col-span-2 lg:col-span-4">
|
<div class="sm:col-span-2 lg:col-span-4">
|
||||||
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
<label class="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||||
Departamento / Centro de Costos
|
Departamento / Centro de Costos <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
v-model="form.department"
|
v-model="form.department"
|
||||||
@ -378,6 +395,8 @@ const totalItems = () => {
|
|||||||
optionLabel="label"
|
optionLabel="label"
|
||||||
optionValue="value"
|
optionValue="value"
|
||||||
placeholder="Seleccionar departamento"
|
placeholder="Seleccionar departamento"
|
||||||
|
:loading="loadingDepartments"
|
||||||
|
:disabled="loadingDepartments"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -472,12 +491,12 @@ const totalItems = () => {
|
|||||||
|
|
||||||
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
|
<div class="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
<Button
|
<Button
|
||||||
:label="item.url ? 'Editar URL' : 'Agregar URL'"
|
:label="item.comments ? 'Editar Comentarios' : 'Agregar Comentarios'"
|
||||||
:icon="item.url ? 'pi pi-pencil' : 'pi pi-link'"
|
:icon="item.comments ? 'pi pi-pencil' : 'pi pi-comment'"
|
||||||
size="small"
|
size="small"
|
||||||
outlined
|
outlined
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@click="openUrlDialog(index)"
|
@click="openCommentsDialog(index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -551,16 +570,16 @@ const totalItems = () => {
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="URL" style="width: 120px" class="text-center">
|
<Column header="Comentarios" style="width: 140px" class="text-center">
|
||||||
<template #body="{ data, index }">
|
<template #body="{ data, index }">
|
||||||
<Button
|
<Button
|
||||||
:icon="data.url ? 'pi pi-check-circle' : 'pi pi-link'"
|
:icon="data.comments ? 'pi pi-comment' : 'pi pi-comment'"
|
||||||
:severity="data.url ? 'success' : 'secondary'"
|
:severity="data.comments ? 'success' : 'secondary'"
|
||||||
text
|
text
|
||||||
rounded
|
rounded
|
||||||
size="small"
|
size="small"
|
||||||
@click="openUrlDialog(index)"
|
@click="openCommentsDialog(index)"
|
||||||
v-tooltip.top="data.url ? 'Editar URL' : 'Agregar URL'"
|
v-tooltip.top="data.comments ? 'Editar Comentarios' : 'Agregar Comentarios'"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@ -694,46 +713,40 @@ const totalItems = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL Dialog -->
|
<!-- Comments Dialog -->
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:visible="urlDialogVisible"
|
v-model:visible="commentsDialogVisible"
|
||||||
modal
|
modal
|
||||||
:header="selectedItemIndex !== null && items[selectedItemIndex]?.url ? 'Editar URL del Producto' : 'Agregar URL del Producto'"
|
:header="selectedItemIndex !== null && items[selectedItemIndex]?.comments ? 'Editar Comentarios del Item' : 'Agregar Comentarios al Item'"
|
||||||
:style="{ width: '500px' }"
|
:style="{ width: '600px' }"
|
||||||
:dismissableMask="true"
|
:dismissableMask="true"
|
||||||
>
|
>
|
||||||
<div class="space-y-4 py-4">
|
<div class="space-y-4 py-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||||
<i class="pi pi-link mr-2 text-primary"></i>
|
<i class="pi pi-comment mr-2 text-primary"></i>
|
||||||
URL del producto
|
Comentarios o notas adicionales
|
||||||
</label>
|
</label>
|
||||||
<InputText
|
<Textarea
|
||||||
v-model="tempUrl"
|
v-model="tempComments"
|
||||||
placeholder="https://ejemplo.com/producto"
|
rows="8"
|
||||||
|
placeholder="Agregue cualquier información adicional sobre este item: especificaciones técnicas, marcas preferidas, URLs de productos, notas especiales, etc."
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
autoResize
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
<small class="text-gray-500 mt-1 block">
|
<small class="text-gray-500 mt-2 block">
|
||||||
Puede ser un enlace de Amazon, MercadoLibre, página del fabricante, etc.
|
Puede incluir enlaces de productos, especificaciones, marcas, modelos o cualquier detalle relevante.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="tempUrl" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
<div v-if="tempComments" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<i class="pi pi-info-circle text-blue-600 mt-0.5"></i>
|
<i class="pi pi-info-circle text-blue-600 mt-0.5"></i>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs font-semibold text-blue-900 dark:text-blue-100 mb-1">Vista previa:</p>
|
<p class="text-xs font-semibold text-blue-900 dark:text-blue-100 mb-1">Vista previa:</p>
|
||||||
<p class="text-xs text-blue-700 dark:text-blue-300 break-all">{{ tempUrl }}</p>
|
<p class="text-xs text-blue-700 dark:text-blue-300 whitespace-pre-wrap">{{ tempComments }}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
icon="pi pi-copy"
|
|
||||||
text
|
|
||||||
rounded
|
|
||||||
size="small"
|
|
||||||
@click="copyToClipboard(tempUrl)"
|
|
||||||
v-tooltip.top="'Copiar'"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -744,12 +757,12 @@ const totalItems = () => {
|
|||||||
label="Cancelar"
|
label="Cancelar"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
text
|
text
|
||||||
@click="closeUrlDialog"
|
@click="closeCommentsDialog"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Guardar"
|
label="Guardar"
|
||||||
icon="pi pi-check"
|
icon="pi pi-check"
|
||||||
@click="saveUrl"
|
@click="saveComments"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -16,10 +16,16 @@ import Dialog from 'primevue/dialog';
|
|||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
import { useRequisitionStore } from './stores/requisitionStore';
|
import { useRequisitionStore } from './stores/requisitionStore';
|
||||||
import type { Requisition } from './types/requisition.interfaces';
|
import type { Requisition } from './types/requisition.interfaces';
|
||||||
|
import { DepartmentsService } from '@/modules/rh/services/departments.services';
|
||||||
|
import type { Department } from '@/modules/rh/types/departments.interface';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const requisitionStore = useRequisitionStore();
|
const requisitionStore = useRequisitionStore();
|
||||||
|
const departmentsService = new DepartmentsService();
|
||||||
|
|
||||||
|
// Departamentos desde API
|
||||||
|
const departments = ref<Department[]>([]);
|
||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const selectedStatus = ref<string | null>(null);
|
const selectedStatus = ref<string | null>(null);
|
||||||
@ -35,6 +41,15 @@ const showCancelDialog = ref(false);
|
|||||||
const cancelComment = ref('');
|
const cancelComment = ref('');
|
||||||
const requisitionToCancel = ref<Requisition | null>(null);
|
const requisitionToCancel = ref<Requisition | null>(null);
|
||||||
|
|
||||||
|
// Approval/Rejection dialogs
|
||||||
|
const showApprovalDialog = ref(false);
|
||||||
|
const showRejectionDialog = ref(false);
|
||||||
|
const approvalType = ref<'technical' | 'financial'>('technical');
|
||||||
|
const approvalComments = ref('');
|
||||||
|
const rejectionReason = ref('');
|
||||||
|
const requisitionToApprove = ref<Requisition | null>(null);
|
||||||
|
const requisitionToReject = ref<Requisition | null>(null);
|
||||||
|
|
||||||
// Computed filtered requisitions
|
// Computed filtered requisitions
|
||||||
const filteredRequisitions = computed(() => {
|
const filteredRequisitions = computed(() => {
|
||||||
let filtered = [...requisitionStore.requisitions];
|
let filtered = [...requisitionStore.requisitions];
|
||||||
@ -69,9 +84,11 @@ const totalRecords = computed(() => filteredRequisitions.value.length);
|
|||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ label: 'Todos', value: null },
|
{ label: 'Todos', value: null },
|
||||||
{ label: 'Borrador', value: 'draft' },
|
{ label: 'Borrador', value: 'draft' },
|
||||||
{ label: 'Pendiente', value: 'pending' },
|
{ label: 'Pendiente Técnica', value: 'pending_technical' },
|
||||||
|
{ label: 'Rechazado Técnica', value: 'rejected_technical' },
|
||||||
|
{ label: 'Pendiente Financiera', value: 'pending_financial' },
|
||||||
|
{ label: 'Rechazado Financiera', value: 'rejected_financial' },
|
||||||
{ label: 'Aprobado', value: 'approved' },
|
{ label: 'Aprobado', value: 'approved' },
|
||||||
{ label: 'Rechazado', value: 'rejected' },
|
|
||||||
{ label: 'Cancelado', value: 'cancelled' }
|
{ label: 'Cancelado', value: 'cancelled' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -85,10 +102,12 @@ const priorityOptions = [
|
|||||||
|
|
||||||
const getStatusSeverity = (status: string) => {
|
const getStatusSeverity = (status: string) => {
|
||||||
const severityMap: Record<string, string> = {
|
const severityMap: Record<string, string> = {
|
||||||
pending: 'warning',
|
pending_technical: 'warning',
|
||||||
|
pending_financial: 'warning',
|
||||||
approved: 'success',
|
approved: 'success',
|
||||||
draft: 'secondary',
|
draft: 'secondary',
|
||||||
rejected: 'danger',
|
rejected_technical: 'danger',
|
||||||
|
rejected_financial: 'danger',
|
||||||
cancelled: 'contrast'
|
cancelled: 'contrast'
|
||||||
};
|
};
|
||||||
return severityMap[status] || 'info';
|
return severityMap[status] || 'info';
|
||||||
@ -96,10 +115,12 @@ const getStatusSeverity = (status: string) => {
|
|||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const labelMap: Record<string, string> = {
|
const labelMap: Record<string, string> = {
|
||||||
pending: 'Pendiente',
|
pending_technical: 'Pend. Aprobación Técnica',
|
||||||
|
pending_financial: 'Pend. Aprobación Financiera',
|
||||||
approved: 'Aprobado',
|
approved: 'Aprobado',
|
||||||
draft: 'Borrador',
|
draft: 'Borrador',
|
||||||
rejected: 'Rechazado',
|
rejected_technical: 'Rechazado por Técnico',
|
||||||
|
rejected_financial: 'Rechazado por Finanzas',
|
||||||
cancelled: 'Cancelado'
|
cancelled: 'Cancelado'
|
||||||
};
|
};
|
||||||
return labelMap[status] || status;
|
return labelMap[status] || status;
|
||||||
@ -125,6 +146,11 @@ const getPriorityLabel = (priority: string) => {
|
|||||||
return labelMap[priority] || priority;
|
return labelMap[priority] || priority;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* const getDepartmentName = (departmentId: string) => {
|
||||||
|
const dept = departments.value.find(d => d.id.toString() === departmentId);
|
||||||
|
return dept ? dept.name : departmentId;
|
||||||
|
}; */
|
||||||
|
|
||||||
const onPageChange = (event: any) => {
|
const onPageChange = (event: any) => {
|
||||||
pagination.value.first = event.first;
|
pagination.value.first = event.first;
|
||||||
pagination.value.rows = event.rows;
|
pagination.value.rows = event.rows;
|
||||||
@ -135,18 +161,161 @@ const handleView = (requisition: Requisition) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (requisition: Requisition) => {
|
const handleEdit = (requisition: Requisition) => {
|
||||||
if (requisition.status === 'draft' || requisition.status === 'pending') {
|
// Permitir editar si está en borrador o fue rechazada
|
||||||
|
if (requisition.status === 'draft' ||
|
||||||
|
requisition.status === 'rejected_technical' ||
|
||||||
|
requisition.status === 'rejected_financial') {
|
||||||
router.push(`/requisitions/edit/${requisition.id}`);
|
router.push(`/requisitions/edit/${requisition.id}`);
|
||||||
} else {
|
} else {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'warn',
|
severity: 'warn',
|
||||||
summary: 'No permitido',
|
summary: 'No permitido',
|
||||||
detail: 'Solo se pueden editar requisiciones en borrador o pendientes',
|
detail: 'Solo se pueden editar requisiciones en borrador o rechazadas',
|
||||||
life: 3000
|
life: 3000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApproveTechnical = (requisition: Requisition) => {
|
||||||
|
requisitionToApprove.value = requisition;
|
||||||
|
approvalType.value = 'technical';
|
||||||
|
approvalComments.value = '';
|
||||||
|
showApprovalDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectTechnical = (requisition: Requisition) => {
|
||||||
|
requisitionToReject.value = requisition;
|
||||||
|
approvalType.value = 'technical';
|
||||||
|
rejectionReason.value = '';
|
||||||
|
showRejectionDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveFinancial = (requisition: Requisition) => {
|
||||||
|
requisitionToApprove.value = requisition;
|
||||||
|
approvalType.value = 'financial';
|
||||||
|
approvalComments.value = '';
|
||||||
|
showApprovalDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectFinancial = (requisition: Requisition) => {
|
||||||
|
requisitionToReject.value = requisition;
|
||||||
|
approvalType.value = 'financial';
|
||||||
|
rejectionReason.value = '';
|
||||||
|
showRejectionDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmApproval = async () => {
|
||||||
|
if (!requisitionToApprove.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const approver = 'Usuario Actual'; // Aquí iría el usuario del sistema de autenticación
|
||||||
|
|
||||||
|
if (approvalType.value === 'technical') {
|
||||||
|
await requisitionStore.approveTechnical(
|
||||||
|
requisitionToApprove.value.id,
|
||||||
|
approver,
|
||||||
|
approvalComments.value.trim() || undefined
|
||||||
|
);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Aprobado',
|
||||||
|
detail: 'Requisición aprobada técnicamente. Ahora pasa a finanzas.',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await requisitionStore.approveFinancial(
|
||||||
|
requisitionToApprove.value.id,
|
||||||
|
approver,
|
||||||
|
approvalComments.value.trim() || undefined
|
||||||
|
);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Aprobado',
|
||||||
|
detail: 'Requisición aprobada. Ahora pasa a almacén.',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showApprovalDialog.value = false;
|
||||||
|
requisitionToApprove.value = null;
|
||||||
|
approvalComments.value = '';
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message || 'Error al aprobar la requisición',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRejection = async () => {
|
||||||
|
if (!requisitionToReject.value) return;
|
||||||
|
|
||||||
|
if (!rejectionReason.value.trim() || rejectionReason.value.trim().length < 10) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Campo requerido',
|
||||||
|
detail: 'Debe proporcionar un motivo válido (mínimo 10 caracteres)',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rejector = 'Usuario Actual'; // Aquí iría el usuario del sistema de autenticación
|
||||||
|
|
||||||
|
if (approvalType.value === 'technical') {
|
||||||
|
await requisitionStore.rejectTechnical(
|
||||||
|
requisitionToReject.value.id,
|
||||||
|
rejector,
|
||||||
|
rejectionReason.value.trim()
|
||||||
|
);
|
||||||
|
toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Rechazado',
|
||||||
|
detail: 'Requisición rechazada. El solicitante puede editarla y reenviarla.',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await requisitionStore.rejectFinancial(
|
||||||
|
requisitionToReject.value.id,
|
||||||
|
rejector,
|
||||||
|
rejectionReason.value.trim()
|
||||||
|
);
|
||||||
|
toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Rechazado',
|
||||||
|
detail: 'Requisición rechazada. El solicitante puede editarla y reenviarla.',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showRejectionDialog.value = false;
|
||||||
|
requisitionToReject.value = null;
|
||||||
|
rejectionReason.value = '';
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message || 'Error al rechazar la requisición',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeApprovalDialog = () => {
|
||||||
|
showApprovalDialog.value = false;
|
||||||
|
requisitionToApprove.value = null;
|
||||||
|
approvalComments.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRejectionDialog = () => {
|
||||||
|
showRejectionDialog.value = false;
|
||||||
|
requisitionToReject.value = null;
|
||||||
|
rejectionReason.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancel = (requisition: Requisition) => {
|
const handleCancel = (requisition: Requisition) => {
|
||||||
requisitionToCancel.value = requisition;
|
requisitionToCancel.value = requisition;
|
||||||
cancelComment.value = '';
|
cancelComment.value = '';
|
||||||
@ -204,7 +373,17 @@ const handleNewRequisition = () => {
|
|||||||
router.push('/requisitions/create');
|
router.push('/requisitions/create');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDepartments = async () => {
|
||||||
|
try {
|
||||||
|
const response = await departmentsService.getDepartments();
|
||||||
|
departments.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar departamentos:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadDepartments();
|
||||||
await requisitionStore.fetchRequisitions();
|
await requisitionStore.fetchRequisitions();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -262,7 +441,7 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<!-- <Card>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Presupuesto Mensual</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Presupuesto Mensual</p>
|
||||||
@ -272,7 +451,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters and Table -->
|
<!-- Filters and Table -->
|
||||||
@ -347,6 +526,12 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<Column field="requester" header="Solicitante" style="min-width: 150px" />
|
<Column field="requester" header="Solicitante" style="min-width: 150px" />
|
||||||
|
|
||||||
|
<!-- <Column field="department" header="Departamento" style="min-width: 180px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-sm">{{ getDepartmentName(data.department) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column> -->
|
||||||
|
|
||||||
<Column field="status" header="Estado" style="min-width: 120px">
|
<Column field="status" header="Estado" style="min-width: 120px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
|
<Tag :value="getStatusLabel(data.status)" :severity="getStatusSeverity(data.status)" />
|
||||||
@ -374,7 +559,7 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Acciones" headerStyle="text-align: center" bodyStyle="text-align: center" style="min-width: 150px">
|
<Column header="Acciones" headerStyle="text-align: center" bodyStyle="text-align: center" style="min-width: 200px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@ -385,7 +570,10 @@ onMounted(async () => {
|
|||||||
@click="handleView(data)"
|
@click="handleView(data)"
|
||||||
v-tooltip.top="'Ver Detalles'"
|
v-tooltip.top="'Ver Detalles'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Editar - Solo borrador o rechazadas -->
|
||||||
<Button
|
<Button
|
||||||
|
v-if="data.status === 'draft' || data.status === 'rejected_technical' || data.status === 'rejected_financial'"
|
||||||
icon="pi pi-pencil"
|
icon="pi pi-pencil"
|
||||||
text
|
text
|
||||||
rounded
|
rounded
|
||||||
@ -393,12 +581,63 @@ onMounted(async () => {
|
|||||||
@click="handleEdit(data)"
|
@click="handleEdit(data)"
|
||||||
v-tooltip.top="'Editar'"
|
v-tooltip.top="'Editar'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Aprobar Técnica -->
|
||||||
<Button
|
<Button
|
||||||
|
v-if="data.status === 'pending_technical'"
|
||||||
|
icon="pi pi-check"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
@click="handleApproveTechnical(data)"
|
||||||
|
v-tooltip.top="'Aprobar (Técnico)'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Rechazar Técnica -->
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'pending_technical'"
|
||||||
icon="pi pi-times"
|
icon="pi pi-times"
|
||||||
text
|
text
|
||||||
rounded
|
rounded
|
||||||
size="small"
|
size="small"
|
||||||
severity="danger"
|
severity="danger"
|
||||||
|
@click="handleRejectTechnical(data)"
|
||||||
|
v-tooltip.top="'Rechazar (Técnico)'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Aprobar Financiera -->
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'pending_financial'"
|
||||||
|
icon="pi pi-check"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
@click="handleApproveFinancial(data)"
|
||||||
|
v-tooltip.top="'Aprobar (Finanzas)'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Rechazar Financiera -->
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'pending_financial'"
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
@click="handleRejectFinancial(data)"
|
||||||
|
v-tooltip.top="'Rechazar (Finanzas)'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cancelar - Solo borrador o pendientes -->
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'draft' || data.status === 'pending_technical' || data.status === 'pending_financial'"
|
||||||
|
icon="pi pi-ban"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="warning"
|
||||||
@click="handleCancel(data)"
|
@click="handleCancel(data)"
|
||||||
v-tooltip.top="'Cancelar'"
|
v-tooltip.top="'Cancelar'"
|
||||||
/>
|
/>
|
||||||
@ -521,6 +760,135 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Approval Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="showApprovalDialog"
|
||||||
|
modal
|
||||||
|
:header="approvalType === 'technical' ? 'Aprobación Técnica' : 'Aprobación Financiera'"
|
||||||
|
:style="{ width: '500px' }"
|
||||||
|
:closable="true"
|
||||||
|
@hide="closeApprovalDialog"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="requisitionToApprove" class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-900/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="pi pi-check-circle text-green-500 text-xl mt-0.5"></i>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold text-green-900 dark:text-green-300 text-sm mb-1">
|
||||||
|
{{ approvalType === 'technical' ? '¿Aprobar requisición técnicamente?' : '¿Aprobar requisición financieramente?' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-green-700 dark:text-green-400 text-xs">
|
||||||
|
Folio: <span class="font-bold">{{ requisitionToApprove.folio }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-green-700 dark:text-green-400 text-xs">
|
||||||
|
Solicitante: {{ requisitionToApprove.requester }}
|
||||||
|
</p>
|
||||||
|
<p class="text-green-700 dark:text-green-400 text-xs">
|
||||||
|
Monto: <span class="font-bold">${{ requisitionToApprove.totalAmount.toLocaleString('es-MX') }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-green-700 dark:text-green-400 text-xs mt-2">
|
||||||
|
{{ approvalType === 'technical'
|
||||||
|
? 'Al aprobar, la requisición pasará automáticamente a finanzas.'
|
||||||
|
: 'Al aprobar, la requisición pasará a almacén.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="approvalComments" class="block text-sm font-semibold text-surface-900 dark:text-white mb-2">
|
||||||
|
Comentarios (opcional)
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="approvalComments"
|
||||||
|
v-model="approvalComments"
|
||||||
|
rows="3"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Agregue cualquier comentario o nota sobre esta aprobación..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
@click="closeApprovalDialog"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Aprobar"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="success"
|
||||||
|
@click="confirmApproval"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Rejection Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="showRejectionDialog"
|
||||||
|
modal
|
||||||
|
:header="approvalType === 'technical' ? 'Rechazo Técnico' : 'Rechazo Financiero'"
|
||||||
|
:style="{ width: '500px' }"
|
||||||
|
:closable="true"
|
||||||
|
@hide="closeRejectionDialog"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="requisitionToReject" class="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<i class="pi pi-times-circle text-red-500 text-xl mt-0.5"></i>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h4 class="font-semibold text-red-900 dark:text-red-300 text-sm mb-1">
|
||||||
|
{{ approvalType === 'technical' ? '¿Rechazar requisición (Técnico)?' : '¿Rechazar requisición (Finanzas)?' }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-red-700 dark:text-red-400 text-xs">
|
||||||
|
Folio: <span class="font-bold">{{ requisitionToReject.folio }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-red-700 dark:text-red-400 text-xs">
|
||||||
|
Solicitante: {{ requisitionToReject.requester }}
|
||||||
|
</p>
|
||||||
|
<p class="text-red-700 dark:text-red-400 text-xs mt-2">
|
||||||
|
El solicitante podrá editar su requisición y reenviarla para aprobación.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="rejectionReason" class="block text-sm font-semibold text-surface-900 dark:text-white mb-2">
|
||||||
|
Motivo del rechazo <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="rejectionReason"
|
||||||
|
v-model="rejectionReason"
|
||||||
|
rows="4"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Describa claramente por qué se rechaza esta requisición..."
|
||||||
|
:class="{ 'p-invalid': !rejectionReason.trim() }"
|
||||||
|
/>
|
||||||
|
<small class="text-gray-500 dark:text-gray-400">Mínimo 10 caracteres requeridos</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button
|
||||||
|
label="Cancelar"
|
||||||
|
icon="pi pi-times"
|
||||||
|
text
|
||||||
|
@click="closeRejectionDialog"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Rechazar"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="danger"
|
||||||
|
@click="confirmRejection"
|
||||||
|
:disabled="!rejectionReason.trim() || rejectionReason.trim().length < 10"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</template>
|
</template>
|
||||||
@ -2,38 +2,72 @@ import { defineStore } from 'pinia';
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import type { Requisition, RequisitionForm, RequisitionItem } from '../types/requisition.interfaces';
|
import type { Requisition, RequisitionForm, RequisitionItem } from '../types/requisition.interfaces';
|
||||||
|
|
||||||
export const useRequisitionStore = defineStore('requisition', () => {
|
const STORAGE_KEY = 'gols_requisitions';
|
||||||
const requisitions = ref<Requisition[]>([
|
const STORAGE_COUNTERS_KEY = 'gols_requisitions_counters';
|
||||||
|
|
||||||
]);
|
|
||||||
|
|
||||||
/* {
|
// Funciones helper para localStorage
|
||||||
|
const saveToStorage = (requisitions: Requisition[], nextId: number, nextFolio: number) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(requisitions));
|
||||||
|
localStorage.setItem(STORAGE_COUNTERS_KEY, JSON.stringify({ nextId, nextFolio }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error al guardar en localStorage:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFromStorage = (): { requisitions: Requisition[], nextId: number, nextFolio: number } => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const counters = localStorage.getItem(STORAGE_COUNTERS_KEY);
|
||||||
|
|
||||||
|
if (stored && counters) {
|
||||||
|
const parsedCounters = JSON.parse(counters);
|
||||||
|
return {
|
||||||
|
requisitions: JSON.parse(stored),
|
||||||
|
nextId: parsedCounters.nextId,
|
||||||
|
nextFolio: parsedCounters.nextFolio
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error al cargar desde localStorage:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no hay datos en localStorage, retornar datos iniciales
|
||||||
|
return {
|
||||||
|
requisitions: getInitialData(),
|
||||||
|
nextId: 6,
|
||||||
|
nextFolio: 6
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitialData = (): Requisition[] => [
|
||||||
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
folio: 'REQ-2024-001',
|
folio: 'REQ-2024-001',
|
||||||
requester: 'Edgar Mendoza',
|
requester: 'Edgar Mendoza',
|
||||||
department: 'prod_a',
|
department: '1', // Desarrollo
|
||||||
status: 'draft',
|
status: 'pending_technical',
|
||||||
priority: 'medium',
|
priority: 'high',
|
||||||
justification: 'Necesitamos equipos para la línea de producción A',
|
justification: 'Equipamiento técnico para nuevos desarrolladores del equipo',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
product: 'Motor eléctrico 3HP',
|
product: 'Laptop HP ZBook Studio G9',
|
||||||
quantity: 2,
|
quantity: 3,
|
||||||
unit: 'Pz',
|
unit: 'Pz',
|
||||||
unitPrice: 1250.00,
|
unitPrice: 25000.00,
|
||||||
url: 'https://amazon.com/product/123'
|
comments: 'Intel i9, 32GB RAM, 1TB SSD, NVIDIA RTX A2000. Para desarrolladores full-stack.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
product: 'Cable calibre 12',
|
product: 'Monitor Dell UltraSharp 27"',
|
||||||
quantity: 50,
|
quantity: 3,
|
||||||
unit: 'M',
|
unit: 'Pz',
|
||||||
unitPrice: 15.50,
|
unitPrice: 3000.00,
|
||||||
url: ''
|
comments: '4K, IPS, USB-C'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalAmount: 3275.00,
|
totalAmount: 84000.00,
|
||||||
createdAt: '2024-02-20',
|
createdAt: '2024-02-20',
|
||||||
updatedAt: '2024-02-20'
|
updatedAt: '2024-02-20'
|
||||||
},
|
},
|
||||||
@ -41,85 +75,144 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
folio: 'REQ-2024-002',
|
folio: 'REQ-2024-002',
|
||||||
requester: 'María González',
|
requester: 'María González',
|
||||||
department: 'maintenance',
|
department: '1', // Desarrollo
|
||||||
status: 'pending',
|
status: 'pending_financial',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
justification: 'Mantenimiento preventivo de equipos críticos',
|
justification: 'Licencias de software para el equipo de desarrollo',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
product: 'Aceite hidráulico SAE 68',
|
product: 'Licencia JetBrains All Products Pack',
|
||||||
quantity: 20,
|
quantity: 10,
|
||||||
unit: 'Lt',
|
unit: 'Lic',
|
||||||
unitPrice: 85.00,
|
unitPrice: 6990.00,
|
||||||
url: 'https://mercadolibre.com/product/456'
|
comments: 'Licencias anuales. Ver en: https://www.jetbrains.com/store/'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalAmount: 1700.00,
|
totalAmount: 69900.00,
|
||||||
createdAt: '2024-02-22',
|
createdAt: '2024-02-22',
|
||||||
updatedAt: '2024-02-22'
|
updatedAt: '2024-02-23',
|
||||||
|
technicalApproval: {
|
||||||
|
approver: 'Juan Pérez',
|
||||||
|
approvedAt: '2024-02-23T10:30:00',
|
||||||
|
comments: 'Aprobado. Licencias necesarias para el desarrollo.'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
folio: 'REQ-2024-003',
|
folio: 'REQ-2024-003',
|
||||||
requester: 'Carlos Ruiz',
|
requester: 'Carlos Ruiz',
|
||||||
department: 'logistics',
|
department: '1', // Desarrollo
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
justification: 'Material de empaque para envíos',
|
justification: 'Mobiliario ergonómico para el área de desarrollo',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
product: 'Caja de cartón corrugado 40x30x20',
|
product: 'Silla ergonómica Herman Miller Aeron',
|
||||||
quantity: 100,
|
quantity: 10,
|
||||||
unit: 'Pz',
|
unit: 'Pz',
|
||||||
unitPrice: 12.50,
|
unitPrice: 8500.00,
|
||||||
url: ''
|
comments: 'Silla ergonómica de alta gama para programadores'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
product: 'Cinta adhesiva transparente',
|
product: 'Escritorio ajustable en altura',
|
||||||
quantity: 50,
|
quantity: 10,
|
||||||
unit: 'Pz',
|
unit: 'Pz',
|
||||||
unitPrice: 8.00,
|
unitPrice: 3250.00,
|
||||||
url: ''
|
comments: 'Escritorio sit-stand eléctrico'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalAmount: 1650.00,
|
totalAmount: 117500.00,
|
||||||
createdAt: '2024-02-18',
|
createdAt: '2024-02-18',
|
||||||
updatedAt: '2024-02-23'
|
updatedAt: '2024-02-23',
|
||||||
|
technicalApproval: {
|
||||||
|
approver: 'Juan Pérez',
|
||||||
|
approvedAt: '2024-02-19T09:15:00',
|
||||||
|
comments: 'Aprobado técnicamente.'
|
||||||
|
},
|
||||||
|
financialApproval: {
|
||||||
|
approver: 'Ana López',
|
||||||
|
approvedAt: '2024-02-23T14:20:00',
|
||||||
|
comments: 'Aprobado. Dentro del presupuesto.'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
folio: 'REQ-2024-004',
|
folio: 'REQ-2024-004',
|
||||||
requester: 'Ana Martínez',
|
requester: 'Ana Martínez',
|
||||||
department: 'admin',
|
department: '1', // Desarrollo
|
||||||
status: 'rejected',
|
status: 'rejected_technical',
|
||||||
priority: 'low',
|
priority: 'low',
|
||||||
justification: 'Suministros de oficina',
|
justification: 'Suscripción a servicios cloud adicionales',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
product: 'Papel bond tamaño carta',
|
product: 'Créditos AWS',
|
||||||
quantity: 10,
|
quantity: 1,
|
||||||
unit: 'Pq',
|
unit: 'Servicio',
|
||||||
unitPrice: 120.00,
|
unitPrice: 50000.00,
|
||||||
url: ''
|
comments: 'Créditos mensuales para servicios EC2, S3 y RDS'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalAmount: 1200.00,
|
totalAmount: 50000.00,
|
||||||
createdAt: '2024-02-15',
|
createdAt: '2024-02-15',
|
||||||
updatedAt: '2024-02-21'
|
updatedAt: '2024-02-21',
|
||||||
} */
|
technicalRejection: {
|
||||||
|
rejector: 'Juan Pérez',
|
||||||
|
rejectedAt: '2024-02-21T11:00:00',
|
||||||
|
reason: 'Ya contamos con créditos de AWS suficientes. Revisar uso actual antes de solicitar más.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
folio: 'REQ-2024-005',
|
||||||
|
requester: 'Pedro Sánchez',
|
||||||
|
department: '1', // Desarrollo
|
||||||
|
status: 'rejected_financial',
|
||||||
|
priority: 'urgent',
|
||||||
|
justification: 'Servidores de desarrollo y testing para nuevos proyectos',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
product: 'Servidor Dell PowerEdge R750',
|
||||||
|
quantity: 2,
|
||||||
|
unit: 'Pz',
|
||||||
|
unitPrice: 85000.00,
|
||||||
|
comments: 'Intel Xeon, 64GB RAM, 2TB SSD. Para ambiente de staging.'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalAmount: 170000.00,
|
||||||
|
createdAt: '2024-02-19',
|
||||||
|
updatedAt: '2024-02-24',
|
||||||
|
technicalApproval: {
|
||||||
|
approver: 'Juan Pérez',
|
||||||
|
approvedAt: '2024-02-20T08:30:00',
|
||||||
|
comments: 'Aprobado. Servidores necesarios para el proyecto.'
|
||||||
|
},
|
||||||
|
financialRejection: {
|
||||||
|
rejector: 'Ana López',
|
||||||
|
rejectedAt: '2024-02-24T16:45:00',
|
||||||
|
reason: 'Presupuesto excedido este mes. Reagendar para el próximo período.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useRequisitionStore = defineStore('requisition', () => {
|
||||||
|
// Cargar datos desde localStorage o usar datos iniciales
|
||||||
|
const initialData = loadFromStorage();
|
||||||
|
const requisitions = ref<Requisition[]>(initialData.requisitions);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
let nextId = ref(5);
|
let nextId = ref(initialData.nextId);
|
||||||
let nextFolio = ref(5);
|
let nextFolio = ref(initialData.nextFolio);
|
||||||
|
|
||||||
// Computed properties for statistics
|
// Computed properties for statistics
|
||||||
const pendingCount = computed(() =>
|
const pendingCount = computed(() =>
|
||||||
requisitions.value.filter(r => r.status === 'pending').length
|
requisitions.value.filter(r =>
|
||||||
|
r.status === 'pending_technical' || r.status === 'pending_financial'
|
||||||
|
).length
|
||||||
);
|
);
|
||||||
|
|
||||||
const approvedTodayCount = computed(() => {
|
const approvedTodayCount = computed(() => {
|
||||||
@ -192,6 +285,7 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
requisitions.value.unshift(newRequisition);
|
requisitions.value.unshift(newRequisition);
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
return newRequisition;
|
return newRequisition;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.message || 'Error al crear requisición';
|
error.value = e?.message || 'Error al crear requisición';
|
||||||
@ -222,6 +316,9 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
const totalAmount = items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
const totalAmount = items.reduce((sum, item) => sum + (item.quantity * item.unitPrice), 0);
|
||||||
const now = new Date().toISOString().split('T')[0] as string;
|
const now = new Date().toISOString().split('T')[0] as string;
|
||||||
|
|
||||||
|
// Si la requisición ya fue enviada a aprobación y se edita, vuelve a pending_technical
|
||||||
|
const shouldResetStatus = existingReq.status !== 'draft';
|
||||||
|
|
||||||
const updatedRequisition: Requisition = {
|
const updatedRequisition: Requisition = {
|
||||||
...existingReq,
|
...existingReq,
|
||||||
department: form.department,
|
department: form.department,
|
||||||
@ -229,10 +326,18 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
justification: form.justification,
|
justification: form.justification,
|
||||||
items: items.map((item, idx) => ({ ...item, id: idx + 1 })),
|
items: items.map((item, idx) => ({ ...item, id: idx + 1 })),
|
||||||
totalAmount,
|
totalAmount,
|
||||||
updatedAt: now
|
updatedAt: now,
|
||||||
|
// Si fue editada después de enviarse, limpiar aprobaciones/rechazos
|
||||||
|
...(shouldResetStatus && {
|
||||||
|
technicalApproval: undefined,
|
||||||
|
financialApproval: undefined,
|
||||||
|
technicalRejection: undefined,
|
||||||
|
financialRejection: undefined
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
requisitions.value[index] = updatedRequisition;
|
requisitions.value[index] = updatedRequisition;
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
return updatedRequisition;
|
return updatedRequisition;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.message || 'Error al actualizar requisición';
|
error.value = e?.message || 'Error al actualizar requisición';
|
||||||
@ -256,9 +361,13 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
|
|
||||||
const requisition = requisitions.value[index];
|
const requisition = requisitions.value[index];
|
||||||
if (requisition) {
|
if (requisition) {
|
||||||
requisition.status = 'pending';
|
requisition.status = 'pending_technical';
|
||||||
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
|
// Limpiar rechazos anteriores
|
||||||
|
requisition.technicalRejection = undefined;
|
||||||
|
requisition.financialRejection = undefined;
|
||||||
}
|
}
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.message || 'Error al enviar requisición';
|
error.value = e?.message || 'Error al enviar requisición';
|
||||||
throw e;
|
throw e;
|
||||||
@ -280,6 +389,7 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requisitions.value.splice(index, 1);
|
requisitions.value.splice(index, 1);
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.message || 'Error al eliminar requisición';
|
error.value = e?.message || 'Error al eliminar requisición';
|
||||||
throw e;
|
throw e;
|
||||||
@ -305,6 +415,7 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
requisition.status = status;
|
requisition.status = status;
|
||||||
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
}
|
}
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.message || 'Error al cambiar estado';
|
error.value = e?.message || 'Error al cambiar estado';
|
||||||
throw e;
|
throw e;
|
||||||
@ -333,6 +444,7 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
requisition.cancelledBy = cancelledBy;
|
requisition.cancelledBy = cancelledBy;
|
||||||
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
}
|
}
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.message || 'Error al cancelar requisición';
|
error.value = e?.message || 'Error al cancelar requisición';
|
||||||
throw e;
|
throw e;
|
||||||
@ -341,6 +453,168 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aprobación técnica
|
||||||
|
async function approveTechnical(id: number, approver: string, comments?: string): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const index = requisitions.value.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requisition = requisitions.value[index];
|
||||||
|
if (requisition) {
|
||||||
|
if (requisition.status !== 'pending_technical') {
|
||||||
|
throw new Error('La requisición no está pendiente de aprobación técnica');
|
||||||
|
}
|
||||||
|
|
||||||
|
requisition.status = 'pending_financial';
|
||||||
|
requisition.technicalApproval = {
|
||||||
|
approver,
|
||||||
|
approvedAt: new Date().toISOString(),
|
||||||
|
comments
|
||||||
|
};
|
||||||
|
requisition.technicalRejection = undefined;
|
||||||
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
|
}
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al aprobar requisición';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechazo técnico
|
||||||
|
async function rejectTechnical(id: number, rejector: string, reason: string): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const index = requisitions.value.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requisition = requisitions.value[index];
|
||||||
|
if (requisition) {
|
||||||
|
if (requisition.status !== 'pending_technical') {
|
||||||
|
throw new Error('La requisición no está pendiente de aprobación técnica');
|
||||||
|
}
|
||||||
|
|
||||||
|
requisition.status = 'rejected_technical';
|
||||||
|
requisition.technicalRejection = {
|
||||||
|
rejector,
|
||||||
|
rejectedAt: new Date().toISOString(),
|
||||||
|
reason
|
||||||
|
};
|
||||||
|
requisition.technicalApproval = undefined;
|
||||||
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
|
}
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al rechazar requisición';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aprobación financiera
|
||||||
|
async function approveFinancial(id: number, approver: string, comments?: string): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const index = requisitions.value.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requisition = requisitions.value[index];
|
||||||
|
if (requisition) {
|
||||||
|
if (requisition.status !== 'pending_financial') {
|
||||||
|
throw new Error('La requisición no está pendiente de aprobación financiera');
|
||||||
|
}
|
||||||
|
|
||||||
|
requisition.status = 'approved';
|
||||||
|
requisition.financialApproval = {
|
||||||
|
approver,
|
||||||
|
approvedAt: new Date().toISOString(),
|
||||||
|
comments
|
||||||
|
};
|
||||||
|
requisition.financialRejection = undefined;
|
||||||
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
|
}
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al aprobar requisición';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechazo financiero
|
||||||
|
async function rejectFinancial(id: number, rejector: string, reason: string): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const index = requisitions.value.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Requisición no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requisition = requisitions.value[index];
|
||||||
|
if (requisition) {
|
||||||
|
if (requisition.status !== 'pending_financial') {
|
||||||
|
throw new Error('La requisición no está pendiente de aprobación financiera');
|
||||||
|
}
|
||||||
|
|
||||||
|
requisition.status = 'rejected_financial';
|
||||||
|
requisition.financialRejection = {
|
||||||
|
rejector,
|
||||||
|
rejectedAt: new Date().toISOString(),
|
||||||
|
reason
|
||||||
|
};
|
||||||
|
requisition.financialApproval = undefined;
|
||||||
|
requisition.updatedAt = new Date().toISOString().split('T')[0] as string;
|
||||||
|
}
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || 'Error al rechazar requisición';
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para resetear datos (útil para desarrollo/testing)
|
||||||
|
function resetToInitialData(): void {
|
||||||
|
requisitions.value = getInitialData();
|
||||||
|
nextId.value = 6;
|
||||||
|
nextFolio.value = 6;
|
||||||
|
saveToStorage(requisitions.value, nextId.value, nextFolio.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Función para limpiar localStorage
|
||||||
|
function clearStorage(): void {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(STORAGE_COUNTERS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requisitions,
|
requisitions,
|
||||||
loading,
|
loading,
|
||||||
@ -356,6 +630,12 @@ export const useRequisitionStore = defineStore('requisition', () => {
|
|||||||
submitForApproval,
|
submitForApproval,
|
||||||
deleteRequisition,
|
deleteRequisition,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
cancelRequisition
|
cancelRequisition,
|
||||||
|
approveTechnical,
|
||||||
|
rejectTechnical,
|
||||||
|
approveFinancial,
|
||||||
|
rejectFinancial,
|
||||||
|
resetToInitialData,
|
||||||
|
clearStorage
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,7 @@ export interface RequisitionItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
url: string;
|
comments: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequisitionForm {
|
export interface RequisitionForm {
|
||||||
@ -16,12 +16,44 @@ export interface RequisitionForm {
|
|||||||
justification: string;
|
justification: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Estados del flujo de aprobación
|
||||||
|
export type RequisitionStatus =
|
||||||
|
| 'draft' // Borrador
|
||||||
|
| 'pending_technical' // Pendiente de aprobación técnica
|
||||||
|
| 'rejected_technical' // Rechazado por técnico
|
||||||
|
| 'pending_financial' // Pendiente de aprobación financiera
|
||||||
|
| 'rejected_financial' // Rechazado por finanzas
|
||||||
|
| 'approved' // Aprobado (pasa a almacén)
|
||||||
|
| 'cancelled'; // Cancelado por usuario
|
||||||
|
|
||||||
|
export interface ApprovalRecord {
|
||||||
|
approver: string;
|
||||||
|
approvedAt: string;
|
||||||
|
comments?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RejectionRecord {
|
||||||
|
rejector: string;
|
||||||
|
rejectedAt: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Requisition extends RequisitionForm {
|
export interface Requisition extends RequisitionForm {
|
||||||
id: number;
|
id: number;
|
||||||
items: RequisitionItem[];
|
items: RequisitionItem[];
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
||||||
|
// Aprobaciones
|
||||||
|
technicalApproval?: ApprovalRecord;
|
||||||
|
financialApproval?: ApprovalRecord;
|
||||||
|
|
||||||
|
// Rechazos
|
||||||
|
technicalRejection?: RejectionRecord;
|
||||||
|
financialRejection?: RejectionRecord;
|
||||||
|
|
||||||
|
// Cancelación
|
||||||
cancellationReason?: string;
|
cancellationReason?: string;
|
||||||
cancelledAt?: string;
|
cancelledAt?: string;
|
||||||
cancelledBy?: string;
|
cancelledBy?: string;
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import StoreDetails from '../modules/stores/components/StoreDetails.vue';
|
|||||||
import Positions from '../modules/rh/components/positions/Positions.vue';
|
import Positions from '../modules/rh/components/positions/Positions.vue';
|
||||||
import Departments from '../modules/rh/components/departments/Departments.vue';
|
import Departments from '../modules/rh/components/departments/Departments.vue';
|
||||||
|
|
||||||
|
import Companies from '../modules/catalog/components/companies/Companies.vue';
|
||||||
import '../modules/catalog/components/suppliers/Suppliers.vue';
|
import '../modules/catalog/components/suppliers/Suppliers.vue';
|
||||||
import Suppliers from '../modules/catalog/components/suppliers/Suppliers.vue';
|
import Suppliers from '../modules/catalog/components/suppliers/Suppliers.vue';
|
||||||
import Purchases from '../modules/purchases/components/Purchases.vue';
|
import Purchases from '../modules/purchases/components/Purchases.vue';
|
||||||
@ -31,6 +32,7 @@ import Requisitions from '../modules/requisitions/Requisitions.vue';
|
|||||||
import CreateRequisition from '../modules/requisitions/CreateRequisition.vue';
|
import CreateRequisition from '../modules/requisitions/CreateRequisition.vue';
|
||||||
import ClassificationsComercial from '../modules/catalog/components/comercial-classification/ClassificationsComercial.vue';
|
import ClassificationsComercial from '../modules/catalog/components/comercial-classification/ClassificationsComercial.vue';
|
||||||
import WarehouseOutInventory from '../modules/warehouse/components/WarehouseOutInventory.vue';
|
import WarehouseOutInventory from '../modules/warehouse/components/WarehouseOutInventory.vue';
|
||||||
|
import companiesRouter from '@/modules/catalog/components/companies/companies.router';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@ -166,6 +168,15 @@ const routes: RouteRecordRaw[] = [
|
|||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'companies',
|
||||||
|
name: 'Companies',
|
||||||
|
component: Companies,
|
||||||
|
meta: {
|
||||||
|
title: 'Empresas',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'suppliers',
|
path: 'suppliers',
|
||||||
name: 'Suppliers',
|
name: 'Suppliers',
|
||||||
@ -183,7 +194,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: 'Documentos del Modelo',
|
title: 'Documentos del Modelo',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
companiesRouter
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user