- 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.
290 lines
13 KiB
Vue
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>
|