feat: agregar secciones de vida útil y depreciación a la gestión de activos

This commit is contained in:
Juan Felipe Zapata Moreno 2026-04-01 11:33:07 -06:00
parent 0b1001b8d1
commit 817e6c76c4
5 changed files with 276 additions and 5 deletions

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import Card from 'primevue/card';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import type { FixedAssetFormData, FiscalDepreciationRateOption } from '../../types/fixedAsset';
import fixedAssetsService from '../../services/fixedAssetsService';
interface Props {
form: FixedAssetFormData;
}
const props = defineProps<Props>();
const fiscalRates = ref<FiscalDepreciationRateOption[]>([]);
const loadingRates = ref(false);
const depreciationOptions = [
{ label: 'Línea Recta', value: 'straight_line' },
{ label: 'Saldo Decreciente', value: 'declining_balance' },
{ label: 'Doble Saldo Decreciente', value: 'double_declining_balance' },
];
const selectedRate = computed<FiscalDepreciationRateOption | null>(
() => fiscalRates.value.find(r => r.id === props.form.fiscal_depreciation_rate_id) ?? null
);
const implicitLife = computed(() => {
if (!selectedRate.value) return null;
const rate = parseFloat(selectedRate.value.depreciation_rate);
if (!rate || rate <= 0) return null;
const years = 100 / rate;
const months = Math.round(years * 12);
return { years: Math.round(years * 10) / 10, months };
});
const fetchRates = async () => {
loadingRates.value = true;
try {
fiscalRates.value = await fixedAssetsService.getFiscalDepreciationRates();
} finally {
loadingRates.value = false;
}
};
onMounted(fetchRates);
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-chart-line text-primary"></i>
<span>Depreciación</span>
</div>
</template>
<template #content>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Método de Depreciación
</label>
<Select
v-model="form.depreciation_method"
:options="depreciationOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Tasa de Depreciación Fiscal (SAT)
</label>
<Select
v-model="form.fiscal_depreciation_rate_id"
:options="fiscalRates"
optionLabel="name"
optionValue="id"
placeholder="Seleccionar tasa SAT (opcional)"
:loading="loadingRates"
showClear
filter
class="w-full"
/>
<div
v-if="implicitLife"
class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
<i class="pi pi-info-circle mr-1"></i>
Vida útil implícita:
<span class="font-semibold">{{ implicitLife.years }} años / {{ implicitLife.months }} meses</span>
<span class="ml-1 text-amber-500 dark:text-amber-400">({{ selectedRate!.depreciation_rate }}% anual)</span>
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Valor Residual
</label>
<InputNumber
v-model="form.residual_value"
mode="currency"
currency="MXN"
locale="es-MX"
:min="0"
class="w-full"
/>
</div>
</div>
</template>
</Card>
</template>

View File

@ -6,7 +6,8 @@ import Toast from 'primevue/toast';
import Button from 'primevue/button';
import Card from 'primevue/card';
import FixedAssetGeneralInfoSection from './FixedAssetGeneralInfoSection.vue';
import FixedAssetAcquisitionSection from './FixedAssetAcquisitionSection.vue';
import FixedAssetUsefulLifeSection from './FixedAssetUsefulLifeSection.vue';
import FixedAssetDepreciationSection from './FixedAssetDepreciationSection.vue';
import FixedAssetAssignmentSection from './FixedAssetAssignmentSection.vue';
import fixedAssetsService from '../../services/fixedAssetsService';
import type { FixedAssetFormData } from '../../types/fixedAsset';
@ -23,6 +24,8 @@ const currentProductName = ref('');
const form = ref<FixedAssetFormData>({
inventory_warehouse_id: null,
useful_life_catalog_id: null,
fiscal_depreciation_rate_id: null,
estimated_useful_life: null,
depreciation_method: 'straight_line',
residual_value: null,
@ -39,6 +42,8 @@ const loadAsset = async () => {
const asset = response.data.data;
form.value = {
inventory_warehouse_id: asset.inventory_warehouse?.id ?? null,
useful_life_catalog_id: asset.useful_life_catalog_id ?? null,
fiscal_depreciation_rate_id: asset.fiscal_depreciation_rate_id ?? null,
estimated_useful_life: asset.estimated_useful_life,
depreciation_method: asset.depreciation_method ?? 'straight_line',
residual_value: asset.residual_value ? parseFloat(asset.residual_value) : null,
@ -67,8 +72,8 @@ const saveAsset = async () => {
toast.add({ severity: 'warn', summary: 'Campo requerido', detail: 'Selecciona un producto del almacén.', life: 3000 });
return;
}
if (!form.value.estimated_useful_life) {
toast.add({ severity: 'warn', summary: 'Campo requerido', detail: 'Indica la vida útil estimada.', life: 3000 });
if (!form.value.estimated_useful_life && !form.value.useful_life_catalog_id) {
toast.add({ severity: 'warn', summary: 'Campo requerido', detail: 'Indica la vida útil estimada o selecciona un catálogo.', life: 3000 });
return;
}
@ -80,6 +85,8 @@ const saveAsset = async () => {
depreciation_method: form.value.depreciation_method || 'straight_line',
};
if (form.value.useful_life_catalog_id != null) payload.useful_life_catalog_id = form.value.useful_life_catalog_id;
if (form.value.fiscal_depreciation_rate_id != null) payload.fiscal_depreciation_rate_id = form.value.fiscal_depreciation_rate_id;
if (form.value.residual_value != null) payload.residual_value = form.value.residual_value;
if (form.value.asset_tag) payload.asset_tag = form.value.asset_tag;
if (form.value.warranty_days != null) payload.warranty_days = form.value.warranty_days;
@ -139,8 +146,9 @@ const saveAsset = async () => {
</Card>
<div class="grid grid-cols-1 gap-5 xl:grid-cols-2">
<FixedAssetAcquisitionSection :form="form" />
<FixedAssetAssignmentSection :form="form" />
<FixedAssetUsefulLifeSection :form="form" />
<FixedAssetDepreciationSection :form="form" />
<FixedAssetAssignmentSection :form="form" class="xl:col-span-2" />
</div>
<div class="flex flex-wrap items-center justify-end gap-3">

View File

@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import Card from 'primevue/card';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import type { FixedAssetFormData, UsefulLifeCatalogOption } from '../../types/fixedAsset';
import fixedAssetsService from '../../services/fixedAssetsService';
interface Props {
form: FixedAssetFormData;
}
const props = defineProps<Props>();
const catalogs = ref<UsefulLifeCatalogOption[]>([]);
const loadingCatalogs = ref(false);
const autoCalculated = ref(false);
const selectedCatalog = computed<UsefulLifeCatalogOption | null>(
() => catalogs.value.find(c => c.id === props.form.useful_life_catalog_id) ?? null
);
const fetchCatalogs = async () => {
loadingCatalogs.value = true;
try {
catalogs.value = await fixedAssetsService.getUsefulLifeCatalog();
} finally {
loadingCatalogs.value = false;
}
};
watch(() => props.form.useful_life_catalog_id, async (id) => {
if (!id) {
autoCalculated.value = false;
return;
}
try {
const result = await fixedAssetsService.calculateUsefulLife({ useful_life_catalog_id: id });
if (result.from_useful_life_catalog) {
props.form.estimated_useful_life = result.from_useful_life_catalog.months;
autoCalculated.value = true;
}
} catch {
autoCalculated.value = false;
}
});
onMounted(fetchCatalogs);
</script>
<template>
<Card class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-calendar text-primary"></i>
<span>Vida Útil</span>
</div>
</template>
<template #content>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Catálogo de Vida Útil
</label>
<Select
v-model="form.useful_life_catalog_id"
:options="catalogs"
optionLabel="name"
optionValue="id"
placeholder="Seleccionar catálogo (opcional)"
:loading="loadingCatalogs"
showClear
filter
class="w-full"
/>
<div
v-if="selectedCatalog"
class="rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm text-primary-700 dark:border-primary-800 dark:bg-primary-950 dark:text-primary-300"
>
<span class="font-semibold">{{ selectedCatalog.min_years }}{{ selectedCatalog.max_years }} años</span>
<span v-if="selectedCatalog.practical_comment" class="ml-2 text-primary-600 dark:text-primary-400">
{{ selectedCatalog.practical_comment }}
</span>
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">
Vida Útil Estimada (meses) *
</label>
<InputNumber
v-model="form.estimated_useful_life"
:min="1"
suffix=" meses"
class="w-full"
placeholder="Ej: 60"
/>
<p v-if="autoCalculated" class="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<i class="pi pi-check-circle"></i>
Calculado automáticamente desde el catálogo. Puedes ajustarlo.
</p>
</div>
</div>
</template>
</Card>
</template>

View File

@ -1,4 +1,5 @@
import api from '../../../services/api';
import type { UsefulLifeCatalogOption, FiscalDepreciationRateOption, UsefulLifeCalculationResult } from '../types/fixedAsset';
export interface Asset {
id: number;
@ -12,6 +13,10 @@ export interface Asset {
book_value: string;
warranty_days: number | null;
warranty_end_date: string | null;
fiscal_depreciation_rate_id: number | null;
useful_life_catalog_id: number | null;
fiscal_depreciation_rate: { id: number; name: string; depreciation_rate: string } | null;
useful_life_catalog: { id: number; name: string; min_years: number; max_years: number } | null;
inventory_warehouse: {
id: number;
product: {
@ -207,6 +212,21 @@ class FixedAssetsService {
}));
}
async getUsefulLifeCatalog(): Promise<UsefulLifeCatalogOption[]> {
const response = await api.get('/api/catalogs/useful-life-catalog', { params: { paginate: false } });
return response.data.data.data;
}
async getFiscalDepreciationRates(): Promise<FiscalDepreciationRateOption[]> {
const response = await api.get('/api/catalogs/fiscal-depreciation-rates', { params: { paginate: false } });
return response.data.data.data;
}
async calculateUsefulLife(params: { useful_life_catalog_id?: number }): Promise<UsefulLifeCalculationResult> {
const response = await api.get('/api/assets/options/calculate-useful-life', { params });
return response.data.data;
}
async downloadResguardo(assetId: number, assignmentId: number): Promise<void> {
const response = await api.get(
`/api/assets/${assetId}/assignments/${assignmentId}/resguardo`,

View File

@ -1,5 +1,7 @@
export interface FixedAssetFormData {
inventory_warehouse_id: number | null;
useful_life_catalog_id: number | null;
fiscal_depreciation_rate_id: number | null;
estimated_useful_life: number | null;
depreciation_method: string;
residual_value: number | null;
@ -8,6 +10,27 @@ export interface FixedAssetFormData {
warranty_end_date: string;
}
export interface UsefulLifeCatalogOption {
id: number;
name: string;
min_years: number;
max_years: number;
practical_comment: string | null;
is_active: boolean;
}
export interface FiscalDepreciationRateOption {
id: number;
name: string;
depreciation_rate: string;
is_active: boolean;
}
export interface UsefulLifeCalculationResult {
from_useful_life_catalog?: { months: number; label: string };
from_fiscal_rate?: { months: number; label: string };
}
export interface InventoryWarehouseOption {
id: number;
serial_number: string | null;