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.
This commit is contained in:
Juan Felipe Zapata Moreno 2026-03-23 17:44:34 -06:00
parent 29e4497ff1
commit c85200ed64
13 changed files with 499 additions and 329 deletions

1
components.d.ts vendored
View File

@ -35,6 +35,7 @@ declare module 'vue' {
ProgressSpinner: typeof import('primevue/progressspinner')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Select: typeof import('primevue/select')['default']
Sidebar: typeof import('./src/components/layout/Sidebar.vue')['default']
Tag: typeof import('primevue/tag')['default']
Toast: typeof import('primevue/toast')['default']

View File

@ -102,8 +102,18 @@ const menuItems = ref<MenuItem[]>([
label: 'Activos Fijos',
icon: 'pi pi-building',
items: [
{ label: 'Registro de Activos', icon: 'pi pi-building', to: '/fixed-assets' },
{ label: 'Asignacion a Empleado', icon: 'pi pi-send', to: '/fixed-assets/assignments' },
{
label: 'Registro de Activos',
icon: 'pi pi-building',
to: '/fixed-assets',
permission: ['assets.index', 'assets.show', 'assets.store', 'assets.update', 'assets.destroy'],
},
{
label: 'Asignacion a Empleado',
icon: 'pi pi-send',
to: '/fixed-assets/assignments',
permission: ['assets.assignments.index', 'assets.assignments.store', 'assets.assignments.returnAsset'],
},
],
},
{
@ -126,7 +136,7 @@ const openItems = ref<string[]>([]);
const canAccessItem = (item: MenuItem): boolean => {
if (!item.permission) {
return false;
return true;
}
return hasPermission(item.permission);

View File

@ -1,6 +1,8 @@
<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';
@ -8,9 +10,13 @@ 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);
@ -18,9 +24,7 @@ const rowsPerPage = ref(15);
const currentPage = ref(1);
const totalRecords = ref(0);
const assets = ref<Asset[]>([]);
const statusOptions = ref<{ label: string; value: number | null }[]>([
{ label: 'Todos los Estatus', value: null }
]);
const statusOptions = ref<{ label: string; value: number | null }[]>([]);
const loading = ref(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
@ -34,10 +38,12 @@ const fetchAssets = async () => {
page: currentPage.value,
});
const pageData = response as any;
assets.value = pageData.data ?? [];
totalRecords.value = pageData.total ?? 0;
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;
}
@ -46,10 +52,7 @@ const fetchAssets = async () => {
const fetchStatusOptions = async () => {
try {
const options = await fixedAssetsService.getStatusOptions();
statusOptions.value = [
{ label: 'Todos los Estatus', value: null },
...options.map((opt: StatusOption) => ({ label: opt.name, value: opt.id }))
];
statusOptions.value = options.map((opt: StatusOption) => ({ label: opt.name, value: opt.id }));
} catch (error) {
console.error('Error al cargar estatus:', error);
}
@ -100,21 +103,38 @@ const goToAssignment = () => {
router.push('/fixed-assets/assignments');
};
const handleDelete = async (asset: Asset) => {
if (!confirm(`¿Estás seguro de eliminar el activo ${asset.sku}?`)) return;
try {
await fixedAssetsService.deleteAsset(asset.id);
fetchAssets();
} catch (error) {
console.error('Error al eliminar activo:', error);
}
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 -->
<!-- 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">
@ -164,6 +184,8 @@ const handleDelete = async (asset: Asset) => {
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="Todos los Estatus"
showClear
class="min-w-44"
/>
</div>
@ -233,8 +255,7 @@ const handleDelete = async (asset: Asset) => {
</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" />
<Button icon="pi pi-qrcode" text rounded size="small" />
<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>

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { ref, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
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 FixedAssetAssignmentSection from './FixedAssetAssignmentSection.vue';
@ -11,8 +12,14 @@ import fixedAssetsService from '../../services/fixedAssetsService';
import type { FixedAssetFormData } from '../../types/fixedAsset';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const saving = ref(false);
const loading = ref(false);
const isEditing = computed(() => !!route.params.id);
const assetId = computed(() => isEditing.value ? Number(route.params.id) : null);
const currentProductName = ref('');
const form = ref<FixedAssetFormData>({
inventory_warehouse_id: null,
@ -24,18 +31,44 @@ const form = ref<FixedAssetFormData>({
warranty_end_date: ''
});
const loadAsset = async () => {
if (!assetId.value) return;
loading.value = true;
try {
const response = await fixedAssetsService.getAsset(assetId.value);
const asset = response.data.data;
form.value = {
inventory_warehouse_id: asset.inventory_warehouse?.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,
asset_tag: asset.asset_tag ?? '',
warranty_days: asset.warranty_days,
warranty_end_date: asset.warranty_end_date ?? ''
};
currentProductName.value = asset.inventory_warehouse?.product?.name ?? '';
} catch {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo cargar el activo.', life: 4000 });
} finally {
loading.value = false;
}
};
onMounted(() => {
if (isEditing.value) loadAsset();
});
const cancel = () => {
router.push('/api/fixed-assets');
router.push('/fixed-assets');
};
const saveAsset = async () => {
if (!form.value.inventory_warehouse_id || !form.value.estimated_useful_life) {
toast.add({
severity: 'warn',
summary: 'Campos requeridos',
detail: 'Selecciona un producto de almacen e indica la vida util estimada.',
life: 3000
});
if (!isEditing.value && !form.value.inventory_warehouse_id) {
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 });
return;
}
@ -52,24 +85,18 @@ const saveAsset = async () => {
if (form.value.warranty_days != null) payload.warranty_days = form.value.warranty_days;
if (form.value.warranty_end_date) payload.warranty_end_date = form.value.warranty_end_date;
await fixedAssetsService.createAsset(payload);
if (isEditing.value && assetId.value) {
await fixedAssetsService.updateAsset(assetId.value, payload);
toast.add({ severity: 'success', summary: 'Activo actualizado', detail: 'Los cambios se guardaron correctamente.', life: 2600 });
} else {
await fixedAssetsService.createAsset(payload);
toast.add({ severity: 'success', summary: 'Activo registrado', detail: 'El activo fijo se registró correctamente.', life: 2600 });
}
toast.add({
severity: 'success',
summary: 'Activo registrado',
detail: 'El activo fijo se registro correctamente.',
life: 2600
});
router.push('/api/fixed-assets');
setTimeout(() => router.push('/fixed-assets'), 2600);
} catch (error: any) {
const message = error.response?.data?.message || 'Error al registrar el activo.';
toast.add({
severity: 'error',
summary: 'Error',
detail: message,
life: 4000
});
const message = error.response?.data?.message || 'Error al guardar el activo.';
toast.add({ severity: 'error', summary: 'Error', detail: message, life: 4000 });
} finally {
saving.value = false;
}
@ -82,28 +109,49 @@ const saveAsset = async () => {
<div>
<h1 class="text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
Registrar Nuevo Activo Fijo
{{ isEditing ? 'Editar Activo Fijo' : 'Registrar Nuevo Activo Fijo' }}
</h1>
<p class="mt-1 text-surface-500 dark:text-surface-400">
Selecciona un producto del inventario de almacen para darlo de alta como activo fijo.
{{ isEditing ? 'Modifica los datos del activo fijo.' : 'Selecciona un producto del inventario de almacén para darlo de alta como activo fijo.' }}
</p>
</div>
<FixedAssetGeneralInfoSection :form="form" />
<div class="grid grid-cols-1 gap-5 xl:grid-cols-2">
<FixedAssetAcquisitionSection :form="form" />
<FixedAssetAssignmentSection :form="form" />
<div v-if="loading" class="flex items-center justify-center py-12 text-surface-500">
<i class="pi pi-spin pi-spinner mr-2 text-xl"></i> Cargando activo...
</div>
<div class="flex flex-wrap items-center justify-end gap-3">
<Button label="Cancelar" text severity="secondary" @click="cancel" />
<Button
label="Guardar Registro"
icon="pi pi-save"
:loading="saving"
@click="saveAsset"
/>
</div>
<template v-else>
<FixedAssetGeneralInfoSection v-if="!isEditing" :form="form" />
<Card v-else class="shadow-sm">
<template #title>
<div class="flex items-center gap-2 text-xl">
<i class="pi pi-info-circle text-primary"></i>
<span>Información General</span>
</div>
</template>
<template #content>
<div class="space-y-1">
<p class="text-sm text-surface-500">Producto</p>
<p class="font-semibold text-surface-900 dark:text-surface-0">{{ currentProductName || '—' }}</p>
</div>
</template>
</Card>
<div class="grid grid-cols-1 gap-5 xl:grid-cols-2">
<FixedAssetAcquisitionSection :form="form" />
<FixedAssetAssignmentSection :form="form" />
</div>
<div class="flex flex-wrap items-center justify-end gap-3">
<Button label="Cancelar" text severity="secondary" @click="cancel" />
<Button
:label="isEditing ? 'Guardar Cambios' : 'Guardar Registro'"
icon="pi pi-save"
:loading="saving"
@click="saveAsset"
/>
</div>
</template>
</section>
</template>

View File

@ -7,14 +7,14 @@ import type { AssignmentAssetOption } from '../../types/fixedAssetAssignment';
interface Props {
assets: AssignmentAssetOption[];
searchTerm: string;
selectedAssetId: string;
selectedAssetId: number | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:searchTerm', value: string): void;
(e: 'update:selectedAssetId', value: string): void;
(e: 'update:selectedAssetId', value: number): void;
}>();
const selectedAsset = computed(() =>

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import Textarea from 'primevue/textarea';
import type { FixedAssetAssignmentFormData } from '../../types/fixedAssetAssignment';
@ -10,12 +9,6 @@ interface Props {
}
defineProps<Props>();
const conditionOptions = [
{ label: 'Excelente', value: 'Excelente' },
{ label: 'Bueno', value: 'Bueno' },
{ label: 'Regular', value: 'Regular' }
];
</script>
<template>
@ -31,21 +24,11 @@ const conditionOptions = [
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Fecha de Entrega</label>
<InputText
v-model="form.deliveredAt"
v-model="form.assignedAt"
type="date"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Condicion del Activo</label>
<Select
v-model="form.condition"
:options="conditionOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="space-y-2 md:col-span-2">
<label class="text-sm font-semibold text-surface-800 dark:text-surface-100">Notas o Comentarios (Opcional)</label>
<Textarea

View File

@ -7,14 +7,14 @@ import type { AssignmentEmployeeOption } from '../../types/fixedAssetAssignment'
interface Props {
employees: AssignmentEmployeeOption[];
searchTerm: string;
selectedEmployeeId: string;
selectedEmployeeId: number | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:searchTerm', value: string): void;
(e: 'update:selectedEmployeeId', value: string): void;
(e: 'update:selectedEmployeeId', value: number): void;
}>();
const visibleEmployees = computed(() => {
@ -23,7 +23,6 @@ const visibleEmployees = computed(() => {
return props.employees.filter((employee) =>
employee.fullName.toLowerCase().includes(query)
|| employee.id.toLowerCase().includes(query)
|| employee.department.toLowerCase().includes(query)
);
});

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import Toast from 'primevue/toast';
@ -12,42 +12,63 @@ import type {
AssignmentEmployeeOption,
FixedAssetAssignmentFormData
} from '../../types/fixedAssetAssignment';
import { fixedAssetsService } from '../../services/fixedAssetsService';
import { employeesService } from '@/modules/rh/components/employees/employees.services';
const router = useRouter();
const toast = useToast();
const loading = ref(false);
const loadingData = ref(false);
const assetSearch = ref('');
const employeeSearch = ref('');
const assets = ref<AssignmentAssetOption[]>([
{ id: '1', code: 'ACT-0001', name: 'Laptop Dell XPS 15', serial: 'DELL-8829-XP', category: 'Computo' },
{ id: '2', code: 'ACT-0042', name: 'Montacargas Hidraulico', serial: 'MH-1029', category: 'Maquinaria' },
{ id: '3', code: 'ACT-0056', name: 'Escritorio Ergonomico', serial: 'MOB-221', category: 'Mobiliario' }
]);
const employees = ref<AssignmentEmployeeOption[]>([
{ id: 'EMP-001', initials: 'RM', fullName: 'Roberto Mendez', role: 'Operador de Almacen', department: 'Logistica' },
{ id: 'EMP-008', initials: 'AS', fullName: 'Ana Salinas', role: 'Supervisora de Piso', department: 'Operaciones' },
{ id: 'EMP-017', initials: 'CG', fullName: 'Carlos Guerrero', role: 'Tecnico de Mantenimiento', department: 'Mantenimiento' }
]);
const assets = ref<AssignmentAssetOption[]>([]);
const employees = ref<AssignmentEmployeeOption[]>([]);
const form = ref<FixedAssetAssignmentFormData>({
assetId: '',
employeeId: '',
deliveredAt: new Date().toISOString().slice(0, 10),
condition: 'Excelente',
assetId: null,
employeeId: null,
assignedAt: new Date().toISOString().slice(0, 10),
notes: ''
});
const filteredAssets = computed(() => {
const query = assetSearch.value.trim().toLowerCase();
if (!query) return assets.value;
onMounted(async () => {
loadingData.value = true;
try {
const [assetsRes, employeesRes] = await Promise.all([
fixedAssetsService.getAssets({ paginate: false, status: 1 }),
employeesService.getEmployees({ paginate: false }),
]);
return assets.value.filter((asset) =>
asset.name.toLowerCase().includes(query)
|| asset.serial.toLowerCase().includes(query)
|| asset.code.toLowerCase().includes(query)
);
const allAssets = (assetsRes as any).data?.data ?? [];
assets.value = allAssets
.filter((a: any) => !a.active_assignment)
.map((a: any): AssignmentAssetOption => ({
id: a.id,
code: a.sku,
name: a.inventory_warehouse?.product?.name ?? a.sku,
serial: a.inventory_warehouse?.product?.serial_number ?? '—',
category: a.inventory_warehouse?.product?.category ?? '—',
}));
const allEmployees = (employeesRes as any).data ?? [];
employees.value = allEmployees.map((e: any): AssignmentEmployeeOption => ({
id: e.id,
initials: `${e.name[0]}${e.paternal[0]}`.toUpperCase(),
fullName: `${e.name} ${e.paternal} ${e.maternal}`.trim(),
role: e.job_position?.name ?? '—',
department: e.department?.name ?? '—',
}));
} catch {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudieron cargar los datos. Intente de nuevo.',
life: 4000
});
} finally {
loadingData.value = false;
}
});
const cancel = () => router.push('/fixed-assets/assignments');
@ -57,24 +78,34 @@ const save = async () => {
toast.add({
severity: 'warn',
summary: 'Campos pendientes',
detail: 'Seleccione activo y colaborador para confirmar la asignacion.',
detail: 'Seleccione un activo y un colaborador para confirmar la asignacion.',
life: 3000
});
return;
}
loading.value = true;
await new Promise((resolve) => setTimeout(resolve, 500));
loading.value = false;
try {
await fixedAssetsService.assignAsset(form.value.assetId, {
employee_id: form.value.employeeId,
assigned_at: form.value.assignedAt,
notes: form.value.notes || undefined,
});
toast.add({
severity: 'success',
summary: 'Asignacion registrada',
detail: 'La asignacion de activo al colaborador se registro correctamente.',
life: 2500
});
toast.add({
severity: 'success',
summary: 'Asignacion registrada',
detail: 'La asignacion del activo al colaborador se registro correctamente.',
life: 2500
});
router.push('/fixed-assets/assignments');
router.push('/fixed-assets/assignments');
} catch (error: any) {
const message = error?.response?.data?.message ?? 'Error al registrar la asignacion.';
toast.add({ severity: 'error', summary: 'Error', detail: message, life: 4000 });
} finally {
loading.value = false;
}
};
</script>
@ -92,9 +123,10 @@ const save = async () => {
</div>
<AssignmentAssetSelectorCard
:assets="filteredAssets"
:assets="assets"
:search-term="assetSearch"
:selected-asset-id="form.assetId"
:loading="loadingData"
@update:search-term="assetSearch = $event"
@update:selected-asset-id="form.assetId = $event"
/>
@ -115,6 +147,7 @@ const save = async () => {
label="Confirmar Asignacion"
icon="pi pi-check-circle"
:loading="loading"
:disabled="loadingData"
@click="save"
/>
</div>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import Toast from 'primevue/toast';
@ -7,40 +7,23 @@ import Button from 'primevue/button';
import AssignmentOffboardingSummaryCard from './offboarding/AssignmentOffboardingSummaryCard.vue';
import AssignmentOffboardingEventCard from './offboarding/AssignmentOffboardingEventCard.vue';
import AssignmentOffboardingEvidenceCard from './offboarding/AssignmentOffboardingEvidenceCard.vue';
import { fixedAssetsService } from '../../services/fixedAssetsService';
const route = useRoute();
const router = useRouter();
const toast = useToast();
const assetId = Number(route.params.assetId);
const assignmentId = Number(route.params.assignmentId);
const loading = ref(false);
const loadingData = ref(false);
const defaultAssignment = {
id: 'AS-DEFAULT',
assetCode: 'WH-NA-000',
assetName: 'Activo no encontrado',
serial: 'N/A',
custodian: 'Sin custodio'
};
const assignmentsMock = [
{
id: 'AS-00124',
assetCode: 'WH-LAP-2023-042',
assetName: `MacBook Pro 14" M2 Max`,
serial: 'SN-48291048-X',
custodian: 'Carlos Rodriguez'
},
{
id: 'AS-00125',
assetCode: 'WH-LAP-2023-051',
assetName: 'Laptop Latitude 5420',
serial: 'SN-22345008-K',
custodian: 'Ana Salinas'
}
];
const currentAssignment = computed(() => {
const id = String(route.params.id || '').replace('#', '');
return assignmentsMock.find((item) => item.id === id) ?? assignmentsMock[0] ?? defaultAssignment;
const summary = ref({
assetCode: '...',
assetName: '...',
serial: '...',
custodian: '...',
});
const form = ref({
@ -48,7 +31,37 @@ const form = ref({
happenedAt: new Date().toISOString().slice(0, 10),
details: '',
finalStatus: '',
evidenceFileName: ''
evidenceFileName: '',
});
// Mapa de finalStatus a AssetStatusEk (1=ACTIVE, 2=INACTIVE, 3=UNDER_MAINTENANCE, 4=DISPOSED)
const finalStatusToAssetStatus: Record<string, number> = {
'Devuelto a almacen': 1,
'Reasignacion pendiente': 1,
'En reparacion': 3,
'Baja definitiva': 4,
};
onMounted(async () => {
loadingData.value = true;
try {
const res = await fixedAssetsService.getAsset(assetId);
const asset = res.data.data;
const assignment = asset.active_assignment;
summary.value = {
assetCode: asset.sku,
assetName: asset.inventory_warehouse?.product?.name ?? asset.sku,
serial: asset.inventory_warehouse?.product?.serial_number ?? '—',
custodian: assignment?.employee
? `${assignment.employee.name} ${(assignment.employee as any).paternal ?? ''}`.trim()
: '—',
};
} catch {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo cargar la informacion del activo.', life: 4000 });
} finally {
loadingData.value = false;
}
});
const cancel = () => router.push('/fixed-assets/assignments');
@ -59,23 +72,39 @@ const confirmOffboarding = async () => {
severity: 'warn',
summary: 'Campos obligatorios',
detail: 'Complete motivo, fecha, descripcion y estado final.',
life: 3000
life: 3000,
});
return;
}
loading.value = true;
await new Promise((resolve) => setTimeout(resolve, 600));
loading.value = false;
try {
const notes = `${form.value.reason}: ${form.value.details}`;
toast.add({
severity: 'success',
summary: 'Baja confirmada',
detail: 'La baja de asignacion se registro correctamente.',
life: 2500
});
await fixedAssetsService.returnAsset(assetId, assignmentId, {
returned_at: form.value.happenedAt,
notes,
});
router.push('/fixed-assets/assignments');
const newStatus = finalStatusToAssetStatus[form.value.finalStatus];
if (newStatus && newStatus !== 1) {
await fixedAssetsService.updateAsset(assetId, { status: newStatus });
}
toast.add({
severity: 'success',
summary: 'Devolucion registrada',
detail: 'La devolucion del activo se registro correctamente.',
life: 2500,
});
router.push('/fixed-assets/assignments');
} catch (error: any) {
const message = error?.response?.data?.message ?? 'Error al registrar la devolucion.';
toast.add({ severity: 'error', summary: 'Error', detail: message, life: 4000 });
} finally {
loading.value = false;
}
};
</script>
@ -93,10 +122,10 @@ const confirmOffboarding = async () => {
</div>
<AssignmentOffboardingSummaryCard
:asset-code="currentAssignment.assetCode"
:asset-name="currentAssignment.assetName"
:serial="currentAssignment.serial"
:custodian="currentAssignment.custodian"
:asset-code="summary.assetCode"
:asset-name="summary.assetName"
:serial="summary.serial"
:custodian="summary.custodian"
/>
<AssignmentOffboardingEventCard :form="form" />
@ -109,6 +138,7 @@ const confirmOffboarding = async () => {
icon="pi pi-times-circle"
severity="danger"
:loading="loading"
:disabled="loadingData"
@click="confirmOffboarding"
/>
</div>

View File

@ -1,130 +1,89 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import Toast from 'primevue/toast';
import Button from 'primevue/button';
import Card from 'primevue/card';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import Paginator from 'primevue/paginator';
import Tag from 'primevue/tag';
interface AssignmentRow {
id: string;
assetName: string;
assetSerial: string;
employeeName: string;
department: string;
deliveryDate: string;
expectedReturn: string;
condition: 'Excelente' | 'Bueno' | 'Regular';
status: 'Activo' | 'Vencido' | 'Devuelto';
}
import { fixedAssetsService, type AssetAssignment } from '../../services/fixedAssetsService';
const router = useRouter();
const toast = useToast();
const assignments = ref<AssetAssignment[]>([]);
const loading = ref(false);
const searchTerm = ref('');
const selectedStatus = ref<'all' | AssignmentRow['status']>('all');
const first = ref(0);
const rows = ref(5);
const selectedStatus = ref<number | 'all'>('all');
const currentPage = ref(1);
const totalRecords = ref(0);
const rows = ref(15);
const statusOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Activo', value: 'Activo' },
{ label: 'Vencido', value: 'Vencido' },
{ label: 'Devuelto', value: 'Devuelto' }
{ label: 'Activo', value: 1 },
{ label: 'Devuelto', value: 2 },
];
const assignments = ref<AssignmentRow[]>([
{
id: '#AS-00124',
assetName: 'Montacargas Electrico',
assetSerial: 'TY-98231',
employeeName: 'Roberto Mendez',
department: 'Logistica',
deliveryDate: '20/Oct/2023',
expectedReturn: 'N/A',
condition: 'Excelente',
status: 'Activo'
},
{
id: '#AS-00125',
assetName: 'Laptop Latitude 5420',
assetSerial: 'DL-22345',
employeeName: 'Ana Salinas',
department: 'Administracion',
deliveryDate: '15/Nov/2023',
expectedReturn: '15/Dic/2023',
condition: 'Bueno',
status: 'Vencido'
},
{
id: '#AS-00126',
assetName: 'Escaner Zebra',
assetSerial: 'ZB-77788',
employeeName: 'Carlos Ruiz',
department: 'Almacen',
deliveryDate: '10/Oct/2023',
expectedReturn: '10/Nov/2023',
condition: 'Regular',
status: 'Devuelto'
},
{
id: '#AS-00127',
assetName: 'Tablet Samsung A8',
assetSerial: 'SM-99288',
employeeName: 'Lucia Ponce',
department: 'Operaciones',
deliveryDate: '02/Ene/2024',
expectedReturn: 'N/A',
condition: 'Excelente',
status: 'Activo'
const statusSeverity = (statusId: number) => {
if (statusId === 1) return 'info';
return 'secondary';
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
};
const employeeFullName = (assignment: AssetAssignment) => {
const e = assignment.employee;
if (!e) return '—';
return `${e.name} ${e.paternal} ${e.maternal ?? ''}`.trim();
};
const loadAssignments = async () => {
loading.value = true;
try {
const res = await fixedAssetsService.getAssignments({
q: searchTerm.value || undefined,
status: selectedStatus.value !== 'all' ? selectedStatus.value : undefined,
page: currentPage.value,
});
assignments.value = res.data.data.data;
totalRecords.value = res.data.data.total;
} catch {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar las asignaciones.', life: 4000 });
} finally {
loading.value = false;
}
]);
const conditionSeverity = (condition: AssignmentRow['condition']) => {
if (condition === 'Excelente') return 'success';
if (condition === 'Bueno') return 'warning';
return 'secondary';
};
const statusSeverity = (status: AssignmentRow['status']) => {
if (status === 'Activo') return 'info';
if (status === 'Vencido') return 'danger';
return 'secondary';
const onSearch = () => {
currentPage.value = 1;
loadAssignments();
};
const filteredAssignments = computed(() => {
const query = searchTerm.value.trim().toLowerCase();
return assignments.value.filter((assignment) => {
const matchesQuery = !query
|| assignment.assetName.toLowerCase().includes(query)
|| assignment.employeeName.toLowerCase().includes(query)
|| assignment.id.toLowerCase().includes(query);
const matchesStatus = selectedStatus.value === 'all' || assignment.status === selectedStatus.value;
return matchesQuery && matchesStatus;
});
});
const paginatedAssignments = computed(() =>
filteredAssignments.value.slice(first.value, first.value + rows.value)
);
const onPage = (event: { first: number; rows: number }) => {
first.value = event.first;
rows.value = event.rows;
const onPage = (event: { page: number }) => {
currentPage.value = event.page + 1;
loadAssignments();
};
const goToCreate = () => {
router.push('/fixed-assets/assignments/create');
const goToCreate = () => router.push('/fixed-assets/assignments/create');
const goToOffboarding = (assignment: AssetAssignment) => {
router.push(`/fixed-assets/assignments/${assignment.asset_id}/${assignment.id}/offboarding`);
};
const goToOffboarding = (assignmentId: string) => {
const cleanId = assignmentId.replace('#', '');
router.push(`/fixed-assets/assignments/${cleanId}/offboarding`);
};
onMounted(loadAssignments);
</script>
<template>
<section class="space-y-6">
<Toast position="bottom-right" />
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="mt-2 text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
@ -145,10 +104,11 @@ const goToOffboarding = (assignmentId: string) => {
v-model="searchTerm"
class="w-full lg:max-w-sm"
placeholder="Buscar por activo o empleado..."
@keyup.enter="onSearch"
/>
<div class="flex w-full flex-wrap items-center gap-2 lg:w-auto">
<span class="text-sm font-medium text-surface-600 dark:text-surface-300">
Estatus de Asignacion:
Estatus:
</span>
<Select
v-model="selectedStatus"
@ -156,12 +116,7 @@ const goToOffboarding = (assignmentId: string) => {
optionLabel="label"
optionValue="value"
class="w-full lg:w-44"
/>
<Button
label="Filtros Avanzados"
icon="pi pi-filter"
severity="secondary"
outlined
@change="onSearch"
/>
</div>
</div>
@ -170,74 +125,86 @@ const goToOffboarding = (assignmentId: string) => {
<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">ID Asignacion</th>
<th class="px-4 py-3">ID</th>
<th class="px-4 py-3">Activo</th>
<th class="px-4 py-3">Empleado</th>
<th class="px-4 py-3">Entrega</th>
<th class="px-4 py-3">Retorno Previsto</th>
<th class="px-4 py-3">Condicion</th>
<th class="px-4 py-3">Fecha Entrega</th>
<th class="px-4 py-3">Fecha Devolucion</th>
<th class="px-4 py-3">Estado</th>
<th class="px-4 py-3 text-right">Accion</th>
</tr>
</thead>
<tbody>
<tr
v-for="assignment in paginatedAssignments"
:key="assignment.id"
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 }}
<tr v-if="loading">
<td colspan="7" class="px-4 py-8 text-center text-surface-500">
Cargando...
</td>
<td class="px-4 py-3">
<p class="font-semibold text-surface-900 dark:text-surface-0">{{ assignment.assetName }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">SN: {{ assignment.assetSerial }}</p>
</td>
<td class="px-4 py-3">
<p class="font-semibold text-surface-900 dark:text-surface-0">{{ assignment.employeeName }}</p>
<p class="text-xs text-surface-500 dark:text-surface-400">{{ assignment.department }}</p>
</td>
<td class="px-4 py-3">{{ assignment.deliveryDate }}</td>
<td class="px-4 py-3">{{ assignment.expectedReturn }}</td>
<td class="px-4 py-3">
<Tag :value="assignment.condition" :severity="conditionSeverity(assignment.condition)" />
</td>
<td class="px-4 py-3">
<Tag :value="assignment.status" :severity="statusSeverity(assignment.status)" />
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<Button icon="pi pi-eye" text rounded size="small" />
<Button
icon="pi pi-times-circle"
text
rounded
size="small"
severity="danger"
v-tooltip.top="'Dar de baja asignacion'"
@click="goToOffboarding(assignment.id)"
</tr>
<template v-else>
<tr
v-for="assignment in assignments"
:key="assignment.id"
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">
<p class="font-semibold text-surface-900 dark:text-surface-0">
{{ assignment.asset?.inventory_warehouse?.product?.name ?? assignment.asset?.sku ?? '—' }}
</p>
<p class="text-xs text-surface-500 dark:text-surface-400">
{{ assignment.asset?.sku }}
</p>
</td>
<td class="px-4 py-3">
<p class="font-semibold text-surface-900 dark:text-surface-0">
{{ employeeFullName(assignment) }}
</p>
<p class="text-xs text-surface-500 dark:text-surface-400">
{{ assignment.employee?.department?.name ?? '—' }}
</p>
</td>
<td class="px-4 py-3">{{ formatDate(assignment.assigned_at) }}</td>
<td class="px-4 py-3">{{ formatDate(assignment.returned_at) }}</td>
<td class="px-4 py-3">
<Tag
:value="assignment.status.name"
:severity="statusSeverity(assignment.status.id)"
/>
</div>
</td>
</tr>
<tr v-if="paginatedAssignments.length === 0">
<td colspan="8" class="px-4 py-8 text-center text-surface-500 dark:text-surface-400">
No hay asignaciones para mostrar.
</td>
</tr>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<Button
v-if="assignment.status.id === 1"
icon="pi pi-times-circle"
text
rounded
size="small"
severity="danger"
v-tooltip.top="'Registrar devolucion'"
@click="goToOffboarding(assignment)"
/>
</div>
</td>
</tr>
<tr v-if="assignments.length === 0">
<td colspan="7" class="px-4 py-8 text-center text-surface-500 dark:text-surface-400">
No hay asignaciones para mostrar.
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<p class="text-sm text-surface-500 dark:text-surface-400">
Mostrando {{ paginatedAssignments.length }} de {{ filteredAssignments.length }} asignaciones
{{ totalRecords }} asignacion(es) en total
</p>
<Paginator
:first="first"
:rows="rows"
:totalRecords="filteredAssignments.length"
:rowsPerPageOptions="[5, 10, 20]"
:totalRecords="totalRecords"
template="PrevPageLink PageLinks NextPageLink"
@page="onPage"
/>

View File

@ -78,6 +78,55 @@ interface AssetFilters {
paginate?: boolean;
}
export interface AssetAssignment {
id: number;
asset_id: number;
employee_id: number;
assigned_at: string;
returned_at: string | null;
receipt_folio: string | null;
notes: string | null;
status: { id: number; name: string };
asset: Asset | null;
employee: {
id: number;
name: string;
paternal: string;
maternal: string;
department: { id: number; name: string } | null;
} | null;
}
export interface AssetAssignmentResponse {
status: string;
data: { data: AssetAssignment };
}
export interface AssetAssignmentsPaginatedResponse {
status: string;
data: {
data: {
current_page: number;
data: AssetAssignment[];
last_page: number;
per_page: number;
total: number;
};
};
}
interface AssignAssetData {
employee_id: number;
assigned_at?: string;
receipt_folio?: string;
notes?: string;
}
interface ReturnAssetData {
returned_at?: string;
notes?: string;
}
class FixedAssetsService {
async getAssets(filters: AssetFilters = {}): Promise<AssetsPaginatedResponse> {
const params: Record<string, string | number | boolean> = {};
@ -114,6 +163,27 @@ class FixedAssetsService {
const response = await api.get<StatusOptionsResponse>('/api/assets/options/status');
return response.data.data.data;
}
async getAssignments(filters: { q?: string; status?: number; page?: number; paginate?: boolean } = {}): Promise<AssetAssignmentsPaginatedResponse> {
const params: Record<string, string | number | boolean> = {};
if (filters.q) params.q = filters.q;
if (filters.status !== undefined) params.status = filters.status;
if (filters.page) params.page = filters.page;
if (filters.paginate !== undefined) params.paginate = filters.paginate;
const response = await api.get<AssetAssignmentsPaginatedResponse>('/api/asset-assignments', { params });
return response.data;
}
async assignAsset(assetId: number, data: AssignAssetData): Promise<AssetAssignmentResponse> {
const response = await api.post<AssetAssignmentResponse>(`/api/assets/${assetId}/assignments`, data);
return response.data;
}
async returnAsset(assetId: number, assignmentId: number, data: ReturnAssetData): Promise<AssetAssignmentResponse> {
const response = await api.put<AssetAssignmentResponse>(`/api/assets/${assetId}/assignments/${assignmentId}/return`, data);
return response.data;
}
}
export const fixedAssetsService = new FixedAssetsService();

View File

@ -1,5 +1,5 @@
export interface AssignmentAssetOption {
id: string;
id: number;
code: string;
name: string;
serial: string;
@ -7,7 +7,7 @@ export interface AssignmentAssetOption {
}
export interface AssignmentEmployeeOption {
id: string;
id: number;
initials: string;
fullName: string;
role: string;
@ -15,9 +15,8 @@ export interface AssignmentEmployeeOption {
}
export interface FixedAssetAssignmentFormData {
assetId: string;
employeeId: string;
deliveredAt: string;
condition: string;
assetId: number | null;
employeeId: number | null;
assignedAt: string;
notes: string;
}

View File

@ -284,6 +284,15 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true
}
},
{
path: ':id/edit',
name: 'FixedAssetEdit',
component: FixedAssetForm,
meta: {
title: 'Editar Activo Fijo',
requiresAuth: true
}
},
{
path: 'assignments',
name: 'FixedAssetAssignmentsModule',
@ -311,7 +320,7 @@ const routes: RouteRecordRaw[] = [
}
},
{
path: ':id/offboarding',
path: ':assetId/:assignmentId/offboarding',
name: 'FixedAssetAssignmentOffboarding',
component: FixedAssetAssignmentOffboardingForm,
meta: {