feat: agregar gestión de depreciaciones de activos fijos y detalles de activos

This commit is contained in:
Juan Felipe Zapata Moreno 2026-03-24 11:51:53 -06:00
parent c85200ed64
commit 5a2944663d
7 changed files with 544 additions and 4 deletions

View File

@ -103,6 +103,10 @@ const goToAssignment = () => {
router.push('/fixed-assets/assignments'); router.push('/fixed-assets/assignments');
}; };
const goToDetails = (asset: Asset) => {
router.push(`/fixed-assets/${asset.id}`);
};
const goToEdit = (asset: Asset) => { const goToEdit = (asset: Asset) => {
router.push(`/fixed-assets/${asset.id}/edit`); router.push(`/fixed-assets/${asset.id}/edit`);
}; };
@ -255,6 +259,7 @@ const handleDelete = (asset: Asset) => {
</td> </td>
<td class="px-4 py-4"> <td class="px-4 py-4">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<Button icon="pi pi-eye" text rounded size="small" severity="info" @click="goToDetails(asset)" />
<Button icon="pi pi-pencil" text rounded size="small" @click="goToEdit(asset)" /> <Button icon="pi pi-pencil" text rounded size="small" @click="goToEdit(asset)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="handleDelete(asset)" /> <Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="handleDelete(asset)" />
</div> </div>

View File

@ -0,0 +1,147 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import Textarea from 'primevue/textarea';
import { assetDepreciationService } from '../../services/fixedAssetsService';
const props = defineProps<{ assetId: number }>();
const emit = defineEmits<{ saved: [] }>();
const toast = useToast();
const visible = ref(false);
const saving = ref(false);
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const form = ref({
year: currentYear,
month: currentMonth,
amount: null as number | null,
notes: '',
});
const yearOptions = Array.from({ length: 11 }, (_, i) => ({
label: String(currentYear - 5 + i),
value: currentYear - 5 + i,
}));
const monthOptions = [
{ label: 'Enero', value: 1 },
{ label: 'Febrero', value: 2 },
{ label: 'Marzo', value: 3 },
{ label: 'Abril', value: 4 },
{ label: 'Mayo', value: 5 },
{ label: 'Junio', value: 6 },
{ label: 'Julio', value: 7 },
{ label: 'Agosto', value: 8 },
{ label: 'Septiembre', value: 9 },
{ label: 'Octubre', value: 10 },
{ label: 'Noviembre', value: 11 },
{ label: 'Diciembre', value: 12 },
];
const isManual = computed(() => form.value.amount !== null && form.value.amount > 0);
const open = () => {
form.value = { year: currentYear, month: currentMonth, amount: null, notes: '' };
visible.value = true;
};
const save = async () => {
saving.value = true;
try {
await assetDepreciationService.registerDepreciation(props.assetId, {
year: form.value.year,
month: form.value.month,
amount: form.value.amount ?? undefined,
notes: form.value.notes || undefined,
});
toast.add({
severity: 'success',
summary: 'Depreciación registrada',
detail: isManual.value ? 'Monto manual registrado correctamente.' : 'Depreciación calculada y registrada.',
life: 3000,
});
visible.value = false;
emit('saved');
} catch (error: any) {
const message = error.response?.data?.message || 'No se pudo registrar la depreciación.';
toast.add({ severity: 'error', summary: 'Error', detail: message, life: 5000 });
} finally {
saving.value = false;
}
};
defineExpose({ open });
</script>
<template>
<Dialog v-model:visible="visible" modal header="Registrar Depreciación" class="w-full max-w-md">
<div class="flex flex-col gap-4 py-2">
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Año</label>
<Select
v-model="form.year"
:options="yearOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Mes</label>
<Select
v-model="form.month"
:options="monthOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Monto <span class="text-surface-400 font-normal">(opcional)</span>
</label>
<InputNumber
v-model="form.amount"
:min="0.01"
:min-fraction-digits="2"
:max-fraction-digits="2"
mode="currency"
currency="MXN"
locale="es-MX"
placeholder="Dejar vacío para calcular automáticamente"
class="w-full"
/>
<p class="text-xs text-surface-400">
<template v-if="isManual">
Ingreso manual se registrará el monto exacto indicado.
</template>
<template v-else>
Sin monto el sistema calculará según el método configurado en el activo.
</template>
</p>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Notas <span class="text-surface-400 font-normal">(opcional)</span>
</label>
<Textarea v-model="form.notes" rows="2" placeholder="Observaciones..." class="w-full" />
</div>
</div>
<template #footer>
<Button label="Cancelar" text severity="secondary" @click="visible = false" />
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>

View File

@ -0,0 +1,144 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import Button from 'primevue/button';
import { assetDepreciationService, type Asset, type AssetDepreciation } from '../../services/fixedAssetsService';
import FixedAssetDepreciationDialog from './FixedAssetDepreciationDialog.vue';
const props = defineProps<{ asset: Asset }>();
const emit = defineEmits<{ refresh: [] }>();
const depreciations = ref<AssetDepreciation[]>([]);
const loading = ref(false);
const dialogRef = ref<InstanceType<typeof FixedAssetDepreciationDialog> | null>(null);
const fetchDepreciations = async () => {
loading.value = true;
try {
const response = await assetDepreciationService.getDepreciations(props.asset.id);
depreciations.value = (response as any).data?.data ?? [];
} catch (error) {
console.error('Error al cargar depreciaciones:', error);
} finally {
loading.value = false;
}
};
onMounted(fetchDepreciations);
const onSaved = () => {
fetchDepreciations();
emit('refresh');
};
const formatCurrency = (value: string | number) => {
const num = typeof value === 'string' ? parseFloat(value) : value;
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(num);
};
const formatPeriod = (year: number, month: number) => {
return new Date(year, month - 1).toLocaleString('es-MX', { month: 'long', year: 'numeric' });
};
const formatMethod = (method: string) => {
const map: Record<string, string> = {
straight_line: 'Línea Recta',
declining_balance: 'Saldo Decreciente',
double_declining_balance: 'Doble Saldo Decreciente',
manual: 'Manual',
};
return map[method] ?? method;
};
const formatDate = (date: string | null) => {
if (!date) return '—';
return new Date(date).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
};
</script>
<template>
<div class="space-y-4">
<FixedAssetDepreciationDialog ref="dialogRef" :asset-id="asset.id" @saved="onSaved" />
<!-- Métricas resumen -->
<div class="grid grid-cols-2 gap-4">
<div class="rounded-xl border border-surface-200 dark:border-surface-700 p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Valor en Libros</p>
<p class="text-2xl font-bold text-surface-900 dark:text-surface-0">
{{ formatCurrency(asset.book_value) }}
</p>
</div>
<div class="rounded-xl border border-surface-200 dark:border-surface-700 p-4">
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Depreciación Acumulada</p>
<p class="text-2xl font-bold text-surface-900 dark:text-surface-0">
{{ formatCurrency(asset.accumulated_depreciation) }}
</p>
</div>
</div>
<!-- Acciones -->
<div class="flex justify-end">
<Button
label="Registrar Depreciación"
icon="pi pi-plus"
size="small"
@click="dialogRef?.open()"
/>
</div>
<!-- Tabla historial -->
<div class="overflow-x-auto rounded-xl border border-surface-200 dark:border-surface-700">
<table class="min-w-full border-collapse">
<thead>
<tr class="bg-surface-50 text-left text-xs font-semibold uppercase tracking-wide text-surface-500 dark:bg-surface-800 dark:text-surface-300">
<th class="px-4 py-3">Período</th>
<th class="px-4 py-3">Monto</th>
<th class="px-4 py-3">Acumulado</th>
<th class="px-4 py-3">Valor en Libros</th>
<th class="px-4 py-3">Método</th>
<th class="px-4 py-3">Procesado por</th>
<th class="px-4 py-3">Fecha</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="px-4 py-10 text-center text-surface-500">
<i class="pi pi-spin pi-spinner mr-2"></i>Cargando historial...
</td>
</tr>
<tr
v-else
v-for="dep in depreciations"
:key="dep.id"
class="border-t border-surface-200 text-sm text-surface-700 dark:border-surface-700 dark:text-surface-200"
>
<td class="px-4 py-3 font-medium capitalize">
{{ formatPeriod(dep.period_year, dep.period_month) }}
</td>
<td class="px-4 py-3 text-red-600 font-semibold">
-{{ formatCurrency(dep.depreciation_amount) }}
</td>
<td class="px-4 py-3">{{ formatCurrency(dep.accumulated_depreciation) }}</td>
<td class="px-4 py-3 font-semibold">{{ formatCurrency(dep.book_value) }}</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium bg-surface-100 text-surface-700 dark:bg-surface-700 dark:text-surface-200">
{{ formatMethod(dep.method) }}
</span>
</td>
<td class="px-4 py-3">
<template v-if="dep.processed_by">
<span>{{ dep.processed_by.name }}</span>
</template>
<span v-else class="text-surface-400 text-xs">Sistema</span>
</td>
<td class="px-4 py-3 text-surface-500 text-xs">{{ formatDate(dep.processed_at) }}</td>
</tr>
<tr v-if="!loading && depreciations.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-surface-500">
No se han registrado depreciaciones para este activo.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@ -0,0 +1,193 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import Breadcrumb from 'primevue/breadcrumb';
import Button from 'primevue/button';
import Card from 'primevue/card';
import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Toast from 'primevue/toast';
import fixedAssetsService, { type Asset } from '../../services/fixedAssetsService';
import FixedAssetDepreciationTab from './FixedAssetDepreciationTab.vue';
const route = useRoute();
const router = useRouter();
const toast = useToast();
const asset = ref<Asset | null>(null);
const loading = ref(true);
const breadcrumbHome = { icon: 'pi pi-home', route: '/' };
const breadcrumbItems = ref([
{ label: 'Activos Fijos', route: '/fixed-assets' },
{ label: '...' },
]);
const fetchAsset = async () => {
loading.value = true;
try {
const id = Number(route.params.id);
const response = await fixedAssetsService.getAsset(id);
asset.value = (response as any).data?.data ?? null;
if (asset.value) {
breadcrumbItems.value[1].label = asset.value.sku;
}
} catch {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo cargar el activo.', life: 4000 });
} finally {
loading.value = false;
}
};
onMounted(fetchAsset);
const statusClasses: Record<number, string> = {
1: 'bg-emerald-100 text-emerald-700',
2: 'bg-gray-100 text-gray-700',
3: 'bg-amber-100 text-amber-700',
4: 'bg-red-100 text-red-700',
};
const formatCurrency = (value: string | number | null | undefined) => {
if (value === null || value === undefined) return '—';
const num = typeof value === 'string' ? parseFloat(value) : value;
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(num);
};
const methodLabels: Record<string, string> = {
straight_line: 'Línea Recta',
declining_balance: 'Saldo Decreciente',
double_declining_balance: 'Doble Saldo Decreciente',
};
</script>
<template>
<div class="space-y-6">
<Toast position="bottom-right" />
<Breadcrumb :home="breadcrumbHome" :model="breadcrumbItems" />
<div v-if="loading" class="flex items-center justify-center py-20 text-surface-500">
<i class="pi pi-spin pi-spinner mr-2 text-xl"></i> Cargando activo...
</div>
<template v-else-if="asset">
<!-- Header -->
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-surface-900 dark:text-white text-3xl md:text-4xl font-black leading-tight tracking-tight">
{{ asset.inventory_warehouse?.product?.name ?? 'Activo sin producto' }}
</h1>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-1.5 text-surface-500">
<i class="pi pi-barcode text-sm"></i>
<span class="text-sm font-mono">{{ asset.sku }}</span>
</div>
<template v-if="asset.asset_tag">
<div class="size-1 bg-surface-300 rounded-full"></div>
<div class="flex items-center gap-1.5 text-surface-500">
<i class="pi pi-tag text-sm"></i>
<span class="text-sm">{{ asset.asset_tag }}</span>
</div>
</template>
<div class="size-1 bg-surface-300 rounded-full"></div>
<span
class="inline-flex rounded-full px-3 py-1 text-xs font-semibold"
:class="statusClasses[asset.status?.id] ?? 'bg-gray-100 text-gray-700'"
>
{{ asset.status?.name ?? 'Desconocido' }}
</span>
</div>
</div>
<Button
label="Editar"
icon="pi pi-pencil"
severity="secondary"
outlined
@click="router.push(`/fixed-assets/${asset.id}/edit`)"
/>
</div>
<!-- Tabs -->
<Card class="shadow-sm">
<template #content>
<TabView class="w-full">
<!-- Tab: Información general -->
<TabPanel value="0">
<template #header>
<div class="flex items-center gap-2">
<i class="pi pi-info-circle"></i>
<span>Información</span>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-5 py-2">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Producto</p>
<p class="text-surface-900 dark:text-surface-0 font-medium">
{{ asset.inventory_warehouse?.product?.name ?? '—' }}
</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Método de Depreciación</p>
<p class="text-surface-900 dark:text-surface-0 font-medium">
{{ methodLabels[asset.depreciation_method] ?? asset.depreciation_method ?? '—' }}
</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Vida Útil Estimada</p>
<p class="text-surface-900 dark:text-surface-0 font-medium">
{{ asset.estimated_useful_life ? `${asset.estimated_useful_life} meses` : '—' }}
</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Valor Residual</p>
<p class="text-surface-900 dark:text-surface-0 font-medium">
{{ formatCurrency(asset.residual_value) }}
</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Valor en Libros</p>
<p class="text-2xl font-bold text-surface-900 dark:text-surface-0">
{{ formatCurrency(asset.book_value) }}
</p>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Depreciación Acumulada</p>
<p class="text-2xl font-bold text-red-600">
{{ formatCurrency(asset.accumulated_depreciation) }}
</p>
</div>
<div v-if="asset.warranty_days">
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Días de Garantía</p>
<p class="text-surface-900 dark:text-surface-0 font-medium">
{{ asset.warranty_days }} días
</p>
</div>
<div v-if="asset.warranty_end_date">
<p class="text-xs font-semibold uppercase tracking-wide text-surface-400 mb-1">Fin de Garantía</p>
<p class="text-surface-900 dark:text-surface-0 font-medium">
{{ new Date(asset.warranty_end_date).toLocaleDateString('es-MX') }}
</p>
</div>
</div>
</TabPanel>
<!-- Tab: Depreciación -->
<TabPanel value="1">
<template #header>
<div class="flex items-center gap-2">
<i class="pi pi-chart-line"></i>
<span>Depreciación</span>
</div>
</template>
<FixedAssetDepreciationTab :asset="asset" @refresh="fetchAsset" />
</TabPanel>
</TabView>
</template>
</Card>
</template>
</div>
</template>

View File

@ -125,7 +125,6 @@ onMounted(loadAssignments);
<table class="min-w-full border-collapse"> <table class="min-w-full border-collapse">
<thead> <thead>
<tr class="bg-surface-50 text-left text-xs font-semibold uppercase tracking-wide text-surface-500 dark:bg-surface-800 dark:text-surface-300"> <tr class="bg-surface-50 text-left text-xs font-semibold uppercase tracking-wide text-surface-500 dark:bg-surface-800 dark:text-surface-300">
<th class="px-4 py-3">ID</th>
<th class="px-4 py-3">Activo</th> <th class="px-4 py-3">Activo</th>
<th class="px-4 py-3">Empleado</th> <th class="px-4 py-3">Empleado</th>
<th class="px-4 py-3">Fecha Entrega</th> <th class="px-4 py-3">Fecha Entrega</th>
@ -146,9 +145,6 @@ onMounted(loadAssignments);
:key="assignment.id" :key="assignment.id"
class="border-t border-surface-200 text-sm dark:border-surface-700" class="border-t border-surface-200 text-sm dark:border-surface-700"
> >
<td class="px-4 py-3 font-medium text-surface-800 dark:text-surface-100">
#{{ assignment.id }}
</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<p class="font-semibold text-surface-900 dark:text-surface-0"> <p class="font-semibold text-surface-900 dark:text-surface-0">
{{ assignment.asset?.inventory_warehouse?.product?.name ?? assignment.asset?.sku ?? '—' }} {{ assignment.asset?.inventory_warehouse?.product?.name ?? assignment.asset?.sku ?? '—' }}

View File

@ -188,3 +188,48 @@ class FixedAssetsService {
export const fixedAssetsService = new FixedAssetsService(); export const fixedAssetsService = new FixedAssetsService();
export default fixedAssetsService; export default fixedAssetsService;
// ── Depreciation ──────────────────────────────────────────────────────────────
export interface AssetDepreciation {
id: number;
period_year: number;
period_month: number;
depreciation_amount: string;
accumulated_depreciation: string;
book_value: string;
method: string;
processed_by: { id: number; name: string } | null;
processed_at: string | null;
}
export interface AssetDepreciationResponse {
status: string;
data: { data: AssetDepreciation };
}
export interface AssetDepreciationsListResponse {
status: string;
data: { data: AssetDepreciation[] };
}
export interface RegisterDepreciationData {
year: number;
month: number;
amount?: number | null;
notes?: string;
}
class FixedAssetDepreciationService {
async getDepreciations(assetId: number): Promise<AssetDepreciationsListResponse> {
const response = await api.get<AssetDepreciationsListResponse>(`/api/assets/${assetId}/depreciations`);
return response.data;
}
async registerDepreciation(assetId: number, data: RegisterDepreciationData): Promise<AssetDepreciationResponse> {
const response = await api.post<AssetDepreciationResponse>(`/api/assets/${assetId}/depreciations`, data);
return response.data;
}
}
export const assetDepreciationService = new FixedAssetDepreciationService();

View File

@ -14,6 +14,7 @@ import ProductForm from '../modules/products/components/ProductForm.vue';
import StoresIndex from '../modules/stores/components/StoresIndex.vue'; import StoresIndex from '../modules/stores/components/StoresIndex.vue';
import FixedAssetsIndex from '../modules/fixed-assets/components/FixedAssetsIndex.vue'; import FixedAssetsIndex from '../modules/fixed-assets/components/FixedAssetsIndex.vue';
import FixedAssetForm from '../modules/fixed-assets/components/assets/FixedAssetForm.vue'; import FixedAssetForm from '../modules/fixed-assets/components/assets/FixedAssetForm.vue';
import FixedAssetDetails from '../modules/fixed-assets/components/assets/FixedAssetDetails.vue';
import FixedAssetAssignmentsIndex from '../modules/fixed-assets/components/assignments/FixedAssetAssignmentsIndex.vue'; import FixedAssetAssignmentsIndex from '../modules/fixed-assets/components/assignments/FixedAssetAssignmentsIndex.vue';
import FixedAssetAssignmentForm from '../modules/fixed-assets/components/assignments/FixedAssetAssignmentForm.vue'; import FixedAssetAssignmentForm from '../modules/fixed-assets/components/assignments/FixedAssetAssignmentForm.vue';
import FixedAssetAssignmentOffboardingForm from '../modules/fixed-assets/components/assignments/FixedAssetAssignmentOffboardingForm.vue'; import FixedAssetAssignmentOffboardingForm from '../modules/fixed-assets/components/assignments/FixedAssetAssignmentOffboardingForm.vue';
@ -293,6 +294,15 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true requiresAuth: true
} }
}, },
{
path: ':id',
name: 'FixedAssetDetails',
component: FixedAssetDetails,
meta: {
title: 'Detalle del Activo Fijo',
requiresAuth: true
}
},
{ {
path: 'assignments', path: 'assignments',
name: 'FixedAssetAssignmentsModule', name: 'FixedAssetAssignmentsModule',