Juan Felipe Zapata Moreno c85200ed64 feat: mejora gestión de activos fijos con edición y asignaciones
- Se agregó edición de activos fijos en FixedAssetForm.vue.
- Se mejoró el manejo de carga y errores al obtener detalles del activo.
- Se actualizaron formularios de asignación usando IDs numéricos.
- Se mejoró la gestión de asignaciones con carga dinámica de activos y empleados.
- Refactor de componentes de asignación para mejorar manejo de datos y UX.
- Se agregaron métodos API para asignación y devolución de activos.
- Se actualizaron rutas para edición y baja (offboarding) de asignaciones.
2026-03-23 17:44:34 -06:00

290 lines
13 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Button from 'primevue/button';
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import Select from 'primevue/select';
import Paginator from 'primevue/paginator';
import Toast from 'primevue/toast';
import ConfirmDialog from 'primevue/confirmdialog';
import fixedAssetsService, { type Asset, type StatusOption } from '../services/fixedAssetsService';
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const searchQuery = ref('');
const selectedStatus = ref<number | null>(null);
const rowsPerPage = ref(15);
const currentPage = ref(1);
const totalRecords = ref(0);
const assets = ref<Asset[]>([]);
const statusOptions = ref<{ label: string; value: number | null }[]>([]);
const loading = ref(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
const fetchAssets = async () => {
loading.value = true;
try {
const response = await fixedAssetsService.getAssets({
q: searchQuery.value || undefined,
status: selectedStatus.value ?? undefined,
page: currentPage.value,
});
const pageData = response as any;
const pagination = pageData.data?.data;
assets.value = pagination?.data ?? [];
totalRecords.value = pagination?.total ?? 0;
} catch (error) {
console.error('Error al cargar activos:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los activos.', life: 4000 });
} finally {
loading.value = false;
}
};
const fetchStatusOptions = async () => {
try {
const options = await fixedAssetsService.getStatusOptions();
statusOptions.value = options.map((opt: StatusOption) => ({ label: opt.name, value: opt.id }));
} catch (error) {
console.error('Error al cargar estatus:', error);
}
};
onMounted(() => {
fetchAssets();
fetchStatusOptions();
});
watch(selectedStatus, () => {
currentPage.value = 1;
fetchAssets();
});
watch(searchQuery, () => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage.value = 1;
fetchAssets();
}, 400);
});
const first = computed(() => (currentPage.value - 1) * rowsPerPage.value);
const onPageChange = (event: { first: number; rows: number; page: number }) => {
currentPage.value = event.page + 1;
fetchAssets();
};
const formatCurrency = (value: string | number) => {
const num = typeof value === 'string' ? parseFloat(value) : value;
return `$${num.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
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 goToCreateAsset = () => {
router.push('/fixed-assets/create');
};
const goToAssignment = () => {
router.push('/fixed-assets/assignments');
};
const goToEdit = (asset: Asset) => {
router.push(`/fixed-assets/${asset.id}/edit`);
};
const handleDelete = (asset: Asset) => {
confirm.require({
message: `¿Estás seguro de eliminar el activo ${asset.sku}?`,
header: 'Confirmar eliminación',
icon: 'pi pi-exclamation-triangle',
rejectLabel: 'Cancelar',
acceptLabel: 'Eliminar',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await fixedAssetsService.deleteAsset(asset.id);
toast.add({ severity: 'success', summary: 'Eliminado', detail: `Activo ${asset.sku} eliminado correctamente.`, life: 3000 });
fetchAssets();
} catch (error: any) {
const message = error.response?.data?.message || 'No se pudo eliminar el activo.';
toast.add({ severity: 'error', summary: 'Error', detail: message, life: 4000 });
}
}
});
};
</script>
<template>
<section class="space-y-6">
<Toast position="bottom-right" />
<ConfirmDialog />
<!-- Header -->
<div class="flex flex-wrap justify-between gap-4 items-center">
<div class="flex min-w-72 flex-col gap-1">
<h1 class="text-surface-900 dark:text-white text-3xl md:text-4xl font-black leading-tight tracking-tight">
Activos Fijos
</h1>
<p class="text-surface-500 dark:text-surface-400 text-base font-normal leading-normal">
Control detallado y trazabilidad de bienes corporativos
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button
label="Registrar Activo"
icon="pi pi-plus"
class="min-w-[200px]"
@click="goToCreateAsset"
/>
<Button
label="Asignar Activo"
icon="pi pi-send"
severity="secondary"
outlined
class="min-w-44"
@click="goToAssignment"
/>
</div>
</div>
<!-- Table Card -->
<Card class="shadow-sm">
<template #content>
<div class="space-y-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div class="flex w-full items-center gap-3 xl:max-w-xl">
<IconField iconPosition="left" class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="searchQuery"
class="w-full"
placeholder="Buscar por código o etiqueta..."
/>
</IconField>
</div>
<div class="flex flex-wrap items-center gap-2">
<Select
v-model="selectedStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="Todos los Estatus"
showClear
class="min-w-44"
/>
</div>
</div>
<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-4">SKU</th>
<th class="px-4 py-4">Producto</th>
<th class="px-4 py-4">Etiqueta</th>
<th class="px-4 py-4">Asignado a</th>
<th class="px-4 py-4">Estatus</th>
<th class="px-4 py-4">Valor contable</th>
<th class="px-4 py-4 text-right">Acciones</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="px-4 py-10 text-center text-surface-500 dark:text-surface-400">
<i class="pi pi-spin pi-spinner mr-2"></i>Cargando activos...
</td>
</tr>
<tr
v-else
v-for="asset in assets"
:key="asset.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-4 font-medium text-surface-500 dark:text-surface-400">
{{ asset.sku }}
</td>
<td class="px-4 py-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-surface-100 dark:bg-surface-800">
<i class="pi pi-image text-surface-400"></i>
</div>
<div>
<p class="font-semibold text-surface-900 dark:text-surface-0">
{{ asset.inventory_warehouse?.product?.name ?? 'Sin producto' }}
</p>
<p class="text-xs text-surface-500 dark:text-surface-400">
Serie: {{ asset.inventory_warehouse?.product?.serial_number ?? 'N/A' }}
</p>
</div>
</div>
</td>
<td class="px-4 py-4">{{ asset.asset_tag ?? '—' }}</td>
<td class="px-4 py-4">
<template v-if="asset.active_assignment">
<p class="font-medium">{{ asset.active_assignment.employee.name }}</p>
<p class="text-xs text-surface-500">{{ asset.active_assignment.employee.department?.name }}</p>
</template>
<span v-else class="text-surface-400">Sin asignar</span>
</td>
<td class="px-4 py-4">
<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>
</td>
<td class="px-4 py-4 text-lg font-semibold text-surface-900 dark:text-surface-0">
{{ formatCurrency(asset.book_value) }}
</td>
<td class="px-4 py-4">
<div class="flex items-center justify-end gap-1">
<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)" />
</div>
</td>
</tr>
<tr v-if="!loading && assets.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-surface-500 dark:text-surface-400">
No se encontraron activos con los filtros seleccionados.
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col gap-2 pt-1 md:flex-row md:items-center md:justify-between">
<p class="text-sm text-surface-500 dark:text-surface-400">
Mostrando {{ assets.length }} de {{ totalRecords }} activos
</p>
<Paginator
:first="first"
:rows="rowsPerPage"
:totalRecords="totalRecords"
template="PrevPageLink PageLinks NextPageLink"
@page="onPageChange"
/>
</div>
</div>
</template>
</Card>
</section>
</template>