feat: add Companies management module with CRUD functionality and routing #15
@ -25,7 +25,8 @@ const menuItems = ref<MenuItem[]>([
|
||||
{ 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: '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;
|
||||
}
|
||||
@ -20,6 +20,7 @@ import StoreDetails from '../modules/stores/components/StoreDetails.vue';
|
||||
import Positions from '../modules/rh/components/positions/Positions.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 Suppliers from '../modules/catalog/components/suppliers/Suppliers.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 ClassificationsComercial from '../modules/catalog/components/comercial-classification/ClassificationsComercial.vue';
|
||||
import WarehouseOutInventory from '../modules/warehouse/components/WarehouseOutInventory.vue';
|
||||
import companiesRouter from '@/modules/catalog/components/companies/companies.router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@ -166,6 +168,15 @@ const routes: RouteRecordRaw[] = [
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'companies',
|
||||
name: 'Companies',
|
||||
component: Companies,
|
||||
meta: {
|
||||
title: 'Empresas',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'suppliers',
|
||||
name: 'Suppliers',
|
||||
@ -183,7 +194,8 @@ const routes: RouteRecordRaw[] = [
|
||||
title: 'Documentos del Modelo',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
},
|
||||
companiesRouter
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user