feat: agrega gestión de mantenimiento de activos fijos

- Se implementaron componentes de listado y formulario para mantenimientos.
- Se agregaron rutas para listar, crear y editar mantenimientos.
- Se integraron servicios para operaciones CRUD de mantenimiento.
- Se mejoró el índice de activos con acceso a mantenimientos.
- Se actualizó la lógica de asignaciones para soportar mantenimientos.
- Se refactorizó la configuración de PrimeVue para manejo de temas.
- Se agregaron tipos para mantenimientos y estructuras relacionadas.
This commit is contained in:
Juan Felipe Zapata Moreno 2026-04-07 12:04:39 -06:00
parent 8632e5c34a
commit c4b8ba2037
12 changed files with 1671 additions and 2493 deletions

1176
package-lock.json generated

File diff suppressed because it is too large Load Diff

1923
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

40
src/config/primevue.ts Normal file
View File

@ -0,0 +1,40 @@
import Aura from "@primeuix/themes/aura";
import { definePreset } from "@primeuix/themes";
// Crear un preset personalizado basado en Aura con color azul
const MyPreset = definePreset(Aura, {
semantic: {
primary: {
50: "{blue.50}",
100: "{blue.100}",
200: "{blue.200}",
300: "{blue.300}",
400: "{blue.400}",
500: "{blue.500}",
600: "{blue.600}",
700: "{blue.700}",
800: "{blue.800}",
900: "{blue.900}",
950: "{blue.950}"
}
}
});
export const primeVueConfig = {
theme: {
preset: MyPreset,
options: {
darkModeSelector: ".p-dark",
},
},
locale: {
dayNames: ['domingo','lunes','martes','miércoles','jueves','viernes','sábado'],
dayNamesShort: ['dom','lun','mar','mié','jue','vie','sáb'],
dayNamesMin: ['D','L','M','M','J','V','S'],
monthNames: ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'],
monthNamesShort: ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic'],
today: 'Hoy',
weekHeader: 'Sem',
firstDayOfWeek: 1
}
}

View File

@ -1,7 +1,5 @@
import "./assets/styles/main.css"; import "./assets/styles/main.css";
import Aura from "@primeuix/themes/aura";
import { definePreset } from "@primeuix/themes";
import PrimeVue from "primevue/config"; import PrimeVue from "primevue/config";
import ConfirmationService from 'primevue/confirmationservice'; import ConfirmationService from 'primevue/confirmationservice';
import ToastService from 'primevue/toastservice'; import ToastService from 'primevue/toastservice';
@ -12,25 +10,7 @@ import { createPinia } from "pinia";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import { useAuth } from "./modules/auth/composables/useAuth"; import { useAuth } from "./modules/auth/composables/useAuth";
import { primeVueConfig } from './config/primevue'
// Crear un preset personalizado basado en Aura con color azul
const MyPreset = definePreset(Aura, {
semantic: {
primary: {
50: "{blue.50}",
100: "{blue.100}",
200: "{blue.200}",
300: "{blue.300}",
400: "{blue.400}",
500: "{blue.500}",
600: "{blue.600}",
700: "{blue.700}",
800: "{blue.800}",
900: "{blue.900}",
950: "{blue.950}"
}
}
});
const app = createApp(App); const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
@ -39,14 +19,7 @@ app.use(pinia);
app.use(router); app.use(router);
app.use(ConfirmationService); app.use(ConfirmationService);
app.use(ToastService); app.use(ToastService);
app.use(PrimeVue, { app.use(PrimeVue, primeVueConfig );
theme: {
preset: MyPreset,
options: {
darkModeSelector: ".p-dark",
},
},
});
app.directive("styleclass", StyleClass); app.directive("styleclass", StyleClass);
app.directive("tooltip", Tooltip); app.directive("tooltip", Tooltip);

View File

@ -107,6 +107,10 @@ const goToEdit = (asset: Asset) => {
router.push(`/fixed-assets/${asset.id}/edit`); router.push(`/fixed-assets/${asset.id}/edit`);
}; };
const goToMaintenance = (asset: Asset) => {
router.push(`/fixed-assets/${asset.id}/maintenances`);
};
const handleDelete = (asset: Asset) => { const handleDelete = (asset: Asset) => {
confirm.require({ confirm.require({
message: `¿Estás seguro de eliminar el activo ${asset.sku}?`, message: `¿Estás seguro de eliminar el activo ${asset.sku}?`,
@ -255,8 +259,9 @@ 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-pencil" text rounded size="small" @click="goToEdit(asset)" /> <Button icon="pi pi-pencil" text rounded size="small" v-tooltip.top="'Editar'" @click="goToEdit(asset)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="handleDelete(asset)" /> <Button icon="pi pi-wrench" text rounded size="small" severity="info" v-tooltip.top="'Mantenimientos'" @click="goToMaintenance(asset)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'Eliminar'" @click="handleDelete(asset)" />
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -0,0 +1,464 @@
<script setup lang="ts">
import { onMounted, ref, 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 InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import Select from 'primevue/select';
import DatePicker from 'primevue/datepicker';
import { fixedAssetsService } from '../../services/fixedAssetsService';
import type { MaintenanceFormData, MaintenanceEvidence } from '../../types/assetMaintenance';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const assetId = computed(() => Number(route.params.id));
const maintenanceId = computed(() => {
const v = route.params.maintenanceId;
return v ? Number(v) : null;
});
const isEditing = computed(() => !!maintenanceId.value);
const loading = ref(false);
const loadingData = ref(false);
const assetLabel = ref('');
const evidence = ref<MaintenanceEvidence[]>([]);
const uploadingEvidence = ref(false);
const deletingEvidenceId = ref<number | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const form = ref<MaintenanceFormData>({
assetId: null,
type: null,
status: 1,
description: '',
maintenanceDate: '',
performedDate: '',
cost: '',
performedBy: '',
odometerReading: '',
nextMaintenanceDate: '',
nextMaintenanceKm: '',
serviceOrderNumber: '',
});
const typeOptions = [
{ label: 'Preventivo', value: 1 },
{ label: 'Correctivo', value: 2 },
];
const statusOptions = [
{ label: 'Pendiente', value: 1 },
{ label: 'En progreso', value: 2 },
];
const toDateString = (d: Date | null | undefined): string => {
if (!d) return '';
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
const toDateObj = (s: string): Date | null => {
if (!s) return null;
const datePart = (s.includes('T') ? s.split('T')[0] : s) ?? '';
const [y = NaN, m = NaN, d = NaN] = datePart.split('-').map(Number);
if (Number.isNaN(y) || Number.isNaN(m) || Number.isNaN(d)) return null;
return new Date(y, m - 1, d);
};
const maintenanceDateModel = computed({
get: () => toDateObj(form.value.maintenanceDate),
set: (v: Date | null) => { form.value.maintenanceDate = v ? toDateString(v) : ''; },
});
const performedDateModel = computed({
get: () => toDateObj(form.value.performedDate),
set: (v: Date | null) => { form.value.performedDate = v ? toDateString(v) : ''; },
});
const nextMaintenanceDateModel = computed({
get: () => toDateObj(form.value.nextMaintenanceDate),
set: (v: Date | null) => { form.value.nextMaintenanceDate = v ? toDateString(v) : ''; },
});
const loadData = async () => {
loadingData.value = true;
try {
const assetRes = await fixedAssetsService.getAsset(assetId.value);
const asset = (assetRes as any).data?.data;
assetLabel.value = asset?.inventory_warehouse?.product?.name ?? asset?.sku ?? '';
} catch {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los datos.', life: 4000 });
} finally {
loadingData.value = false;
}
};
const loadMaintenance = async () => {
if (!maintenanceId.value) return;
loadingData.value = true;
try {
const res = await fixedAssetsService.getMaintenance(assetId.value, maintenanceId.value);
const m = res.data.data;
form.value = {
assetId: m.asset_id,
type: m.type.id,
status: m.status.id,
description: m.description ?? '',
maintenanceDate: m.maintenance_date ?? '',
performedDate: m.performed_date ?? '',
cost: m.cost ?? '',
performedBy: m.performed_by ?? '',
odometerReading: m.odometer_reading ?? '',
nextMaintenanceDate: m.next_maintenance_date ?? '',
nextMaintenanceKm: m.next_maintenance_km ?? '',
serviceOrderNumber: m.service_order_number ?? '',
};
evidence.value = m.evidence ?? [];
} catch {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo cargar el mantenimiento.', life: 4000 });
} finally {
loadingData.value = false;
}
};
const validate = (): boolean => {
if (!form.value.type) {
toast.add({ severity: 'warn', summary: 'Campo requerido', detail: 'Seleccione el tipo de mantenimiento.', life: 3000 });
return false;
}
return true;
};
const buildPayload = () => {
const p: Record<string, unknown> = {
type: form.value.type,
status: form.value.status,
};
if (form.value.description.trim()) p.description = form.value.description.trim();
if (form.value.maintenanceDate) p.maintenance_date = form.value.maintenanceDate;
if (form.value.performedDate) p.performed_date = form.value.performedDate;
if (form.value.cost !== '') p.cost = form.value.cost;
if (form.value.performedBy) p.performed_by = form.value.performedBy;
if (form.value.odometerReading !== '') p.odometer_reading = form.value.odometerReading;
if (form.value.nextMaintenanceDate) p.next_maintenance_date = form.value.nextMaintenanceDate;
if (form.value.nextMaintenanceKm !== '') p.next_maintenance_km = form.value.nextMaintenanceKm;
if (form.value.serviceOrderNumber.trim()) p.service_order_number = form.value.serviceOrderNumber.trim();
return p;
};
const save = async () => {
if (!validate()) return;
loading.value = true;
try {
if (isEditing.value) {
await fixedAssetsService.updateMaintenance(assetId.value, maintenanceId.value!, buildPayload());
toast.add({ severity: 'success', summary: 'Actualizado', detail: 'Mantenimiento actualizado correctamente.', life: 2500 });
} else {
await fixedAssetsService.createMaintenance(assetId.value, buildPayload());
toast.add({ severity: 'success', summary: 'Registrado', detail: 'Mantenimiento registrado correctamente.', life: 2500 });
}
router.push(`/fixed-assets/${assetId.value}/maintenances`);
} catch (error: any) {
const message = error?.response?.data?.message ?? 'Error al guardar el mantenimiento.';
toast.add({ severity: 'error', summary: 'Error', detail: message, life: 4000 });
} finally {
loading.value = false;
}
};
const cancel = () => router.push(`/fixed-assets/${assetId.value}/maintenances`);
const evidenceUrl = (e: MaintenanceEvidence) =>
`${import.meta.env.VITE_API_URL}/storage/${e.file_path}`;
const isImage = (e: MaintenanceEvidence) =>
['image/jpeg', 'image/png', 'image/webp'].includes(e.file_type);
const handleEvidenceUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file || !maintenanceId.value) return;
uploadingEvidence.value = true;
try {
const res = await fixedAssetsService.uploadEvidence(assetId.value, maintenanceId.value, file);
evidence.value.push(res.data.data);
toast.add({ severity: 'success', summary: 'Evidencia subida', life: 2500 });
} catch {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo subir la evidencia.', life: 4000 });
} finally {
uploadingEvidence.value = false;
if (fileInputRef.value) fileInputRef.value.value = '';
}
};
const deleteEvidence = async (e: MaintenanceEvidence) => {
if (!maintenanceId.value) return;
deletingEvidenceId.value = e.id;
try {
await fixedAssetsService.deleteEvidence(assetId.value, maintenanceId.value, e.id);
evidence.value = evidence.value.filter(ev => ev.id !== e.id);
toast.add({ severity: 'info', summary: 'Eliminado', detail: 'Evidencia eliminada.', life: 2500 });
} catch {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudo eliminar la evidencia.', life: 4000 });
} finally {
deletingEvidenceId.value = null;
}
};
onMounted(async () => {
await loadData();
if (isEditing.value) await loadMaintenance();
});
</script>
<template>
<section class="space-y-6">
<Toast position="bottom-right" />
<div>
<div class="flex items-center gap-2">
<Button icon="pi pi-arrow-left" text rounded severity="secondary" @click="cancel" />
<h1 class="text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
{{ isEditing ? 'Editar Mantenimiento' : 'Registrar Mantenimiento' }}
</h1>
</div>
<p class="mt-1 text-surface-500 dark:text-surface-400">
{{ assetLabel ? `Activo: ${assetLabel}` : 'Complete los campos para registrar el mantenimiento.' }}
</p>
</div>
<!-- Información general -->
<Card class="shadow-sm">
<template #title>Información general</template>
<template #content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Tipo -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Tipo <span class="text-red-500">*</span>
</label>
<Select
v-model="form.type"
:options="typeOptions"
optionLabel="label"
optionValue="value"
placeholder="Seleccione el tipo..."
class="w-full"
/>
</div>
<!-- Estatus -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Estatus <span class="text-red-500">*</span>
</label>
<Select
v-model="form.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<!-- Descripción -->
<div class="flex flex-col gap-1 md:col-span-2">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Descripción
</label>
<Textarea
v-model="form.description"
:maxlength="2000"
rows="3"
placeholder="Describa el trabajo de mantenimiento..."
class="w-full"
autoResize
/>
</div>
<!-- Fecha programada -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Fecha programada
</label>
<DatePicker
v-model="maintenanceDateModel"
dateFormat="dd/mm/yy"
placeholder="DD/MM/AA"
showIcon
class="w-80"
/>
</div>
<!-- Fecha realizado -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Fecha realizado
</label>
<DatePicker
v-model="performedDateModel"
dateFormat="dd/mm/yy"
placeholder="DD/MM/AA"
showIcon
class="w-80"
/>
</div>
<!-- Costo -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Costo (MXN)
</label>
<InputText
v-model="form.cost"
placeholder="0.00"
class="w-full"
/>
</div>
<!-- Realizado por -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Realizado por
</label>
<InputText
v-model="form.performedBy"
placeholder="Nombre del técnico o empresa..."
:maxlength="255"
class="w-full"
/>
</div>
<!-- Orden de servicio -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
No. orden de servicio
</label>
<InputText
v-model="form.serviceOrderNumber"
placeholder="Ej. OS-2026-001"
:maxlength="100"
class="w-full"
/>
</div>
</div>
</template>
</Card>
<!-- Campos de vehículo -->
<Card class="shadow-sm">
<template #title>Datos de vehículo (opcional)</template>
<template #subtitle>Complete estos campos si el activo es un vehículo.</template>
<template #content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Lectura odómetro -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Lectura odómetro (km)
</label>
<InputText
v-model="form.odometerReading"
placeholder="Ej. 45,000.00"
class="w-full"
/>
</div>
<!-- Próximo mantenimiento km -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Próximo mantenimiento (km)
</label>
<InputText
v-model="form.nextMaintenanceKm"
placeholder="Ej. 50,000.00"
class="w-full"
/>
</div>
<!-- Próxima fecha de mantenimiento -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">
Próxima fecha de mantenimiento
</label>
<DatePicker
v-model="nextMaintenanceDateModel"
dateFormat="dd/mm/yy"
placeholder="DD/MM/AA"
showIcon
/>
</div>
</div>
</template>
</Card>
<!-- Evidencias (solo en edición) -->
<Card v-if="isEditing" class="shadow-sm">
<template #title>Evidencias</template>
<template #subtitle>Fotos y documentos del mantenimiento (jpg, png, webp, pdf máx. 10 MB)</template>
<template #content>
<div class="space-y-4">
<div v-if="evidence.length > 0" class="flex flex-wrap gap-3">
<div
v-for="ev in evidence"
:key="ev.id"
class="relative group w-28 h-28 rounded-xl border border-surface-200 dark:border-surface-700 overflow-hidden flex items-center justify-center bg-surface-50 dark:bg-surface-800"
>
<a v-if="isImage(ev)" :href="evidenceUrl(ev)" target="_blank" class="w-full h-full">
<img :src="evidenceUrl(ev)" class="w-full h-full object-cover" />
</a>
<a v-else :href="evidenceUrl(ev)" target="_blank" class="flex flex-col items-center gap-1 p-2 text-center text-xs text-surface-500">
<i class="pi pi-file-pdf text-3xl text-red-500" />
<span class="truncate w-full">PDF</span>
</a>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
rounded
class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity"
:loading="deletingEvidenceId === ev.id"
@click.prevent="deleteEvidence(ev)"
/>
</div>
</div>
<p v-else class="text-sm text-surface-400">No hay evidencias adjuntas.</p>
<input
ref="fileInputRef"
type="file"
accept=".jpg,.jpeg,.png,.webp,.pdf"
class="hidden"
@change="handleEvidenceUpload"
/>
<Button
label="Subir archivo"
icon="pi pi-upload"
outlined
:loading="uploadingEvidence"
@click="fileInputRef?.click()"
/>
</div>
</template>
</Card>
<div class="flex flex-wrap items-center justify-end gap-3">
<Button label="Cancelar" text severity="secondary" @click="cancel" />
<Button
:label="isEditing ? 'Guardar cambios' : 'Registrar mantenimiento'"
icon="pi pi-check"
:loading="loading"
:disabled="loadingData"
@click="save"
/>
</div>
</section>
</template>

View File

@ -0,0 +1,325 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Toast from 'primevue/toast';
import ConfirmDialog from 'primevue/confirmdialog';
import Button from 'primevue/button';
import Card from 'primevue/card';
import Select from 'primevue/select';
import Paginator from 'primevue/paginator';
import Tag from 'primevue/tag';
import { fixedAssetsService } from '../../services/fixedAssetsService';
import type { AssetMaintenance } from '../../types/assetMaintenance';
const router = useRouter();
const route = useRoute();
const toast = useToast();
const confirm = useConfirm();
const assetId = computed(() => Number(route.params.id));
const maintenances = ref<AssetMaintenance[]>([]);
const assetName = ref('');
const assetSku = ref('');
const selectedType = ref<number | 'all'>('all');
const selectedStatus = ref<number | 'all'>('all');
const loading = ref(false);
const currentPage = ref(1);
const totalRecords = ref(0);
const rows = ref(15);
const typeOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Preventivo', value: 1 },
{ label: 'Correctivo', value: 2 },
];
const statusOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Pendiente', value: 1 },
{ label: 'En progreso', value: 2 },
{ label: 'Completado', value: 3 },
{ label: 'Cancelado', value: 4 },
];
const statusSeverity = (statusId: number) => {
if (statusId === 1) return 'warn';
if (statusId === 2) return 'info';
if (statusId === 3) return 'success';
return 'secondary';
};
const typeSeverity = (typeId: number) => {
return typeId === 1 ? 'success' : 'danger';
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
};
const formatCost = (cost: string | null) => {
if (!cost) return '—';
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(Number(cost));
};
const performedByName = (m: AssetMaintenance) => m.performed_by || '—';
const loadAssetInfo = async () => {
try {
const res = await fixedAssetsService.getAsset(assetId.value);
const asset = (res as any).data?.data;
assetName.value = asset?.inventory_warehouse?.product?.name ?? asset?.sku ?? '';
assetSku.value = asset?.sku ?? '';
} catch {
// silently fail, header will show fallback
}
};
const loadMaintenances = async () => {
loading.value = true;
try {
const res = await fixedAssetsService.getMaintenances(assetId.value, {
type: selectedType.value !== 'all' ? selectedType.value : undefined,
status: selectedStatus.value !== 'all' ? selectedStatus.value : undefined,
page: currentPage.value,
});
maintenances.value = res.data.data.data;
totalRecords.value = res.data.data.total;
} catch {
toast.add({ severity: 'error', summary: 'Error', detail: 'No se pudieron cargar los mantenimientos.', life: 4000 });
} finally {
loading.value = false;
}
};
const onFilter = () => {
currentPage.value = 1;
loadMaintenances();
};
const onPage = (event: { page: number }) => {
currentPage.value = event.page + 1;
loadMaintenances();
};
const goBack = () => router.push('/fixed-assets');
const goToCreate = () => {
router.push(`/fixed-assets/${assetId.value}/maintenances/create`);
};
const goToEdit = (m: AssetMaintenance) => {
router.push(`/fixed-assets/${assetId.value}/maintenances/${m.id}/edit`);
};
const completing = ref<number | null>(null);
const cancelling = ref<number | null>(null);
const confirmComplete = (m: AssetMaintenance) => {
confirm.require({
message: '¿Desea marcar este mantenimiento como completado? El activo volverá a estado Activo.',
header: 'Completar mantenimiento',
icon: 'pi pi-check-circle',
acceptLabel: 'Completar',
rejectLabel: 'Cancelar',
accept: () => doComplete(m),
});
};
const doComplete = async (m: AssetMaintenance) => {
completing.value = m.id;
try {
await fixedAssetsService.completeMaintenance(assetId.value, m.id);
toast.add({ severity: 'success', summary: 'Completado', detail: 'Mantenimiento marcado como completado.', life: 3000 });
loadMaintenances();
} catch (error: any) {
const msg = error?.response?.data?.message ?? 'Error al completar el mantenimiento.';
toast.add({ severity: 'error', summary: 'Error', detail: msg, life: 4000 });
} finally {
completing.value = null;
}
};
const confirmCancel = (m: AssetMaintenance) => {
confirm.require({
message: '¿Desea cancelar este mantenimiento?',
header: 'Cancelar mantenimiento',
icon: 'pi pi-times-circle',
acceptLabel: 'Cancelar mantenimiento',
rejectLabel: 'Volver',
acceptClass: 'p-button-danger',
accept: () => doCancel(m),
});
};
const doCancel = async (m: AssetMaintenance) => {
cancelling.value = m.id;
try {
await fixedAssetsService.cancelMaintenance(assetId.value, m.id);
toast.add({ severity: 'info', summary: 'Cancelado', detail: 'Mantenimiento cancelado.', life: 3000 });
loadMaintenances();
} catch (error: any) {
const msg = error?.response?.data?.message ?? 'Error al cancelar el mantenimiento.';
toast.add({ severity: 'error', summary: 'Error', detail: msg, life: 4000 });
} finally {
cancelling.value = null;
}
};
const canEdit = (m: AssetMaintenance) => m.status.id !== 3 && m.status.id !== 4;
const canComplete = (m: AssetMaintenance) => m.status.id !== 3 && m.status.id !== 4;
const canCancel = (m: AssetMaintenance) => m.status.id !== 3 && m.status.id !== 4;
onMounted(() => {
loadAssetInfo();
loadMaintenances();
});
</script>
<template>
<section class="space-y-6">
<Toast position="bottom-right" />
<ConfirmDialog />
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<div class="flex items-center gap-2">
<Button icon="pi pi-arrow-left" text rounded severity="secondary" @click="goBack" />
<h1 class="text-3xl font-black tracking-tight text-surface-900 dark:text-surface-0">
Mantenimientos
</h1>
</div>
<p class="mt-1 text-surface-500 dark:text-surface-400">
{{ assetName ? `${assetName}${assetSku}` : 'Historial de mantenimiento del activo' }}
</p>
</div>
<Button label="Nuevo Mantenimiento" icon="pi pi-plus" @click="goToCreate" />
</div>
<Card class="shadow-sm">
<template #content>
<div class="space-y-4">
<div class="flex w-full flex-wrap items-center gap-2">
<span class="text-sm font-medium text-surface-600 dark:text-surface-300">Tipo:</span>
<Select
v-model="selectedType"
:options="typeOptions"
optionLabel="label"
optionValue="value"
class="w-36"
@change="onFilter"
/>
<span class="text-sm font-medium text-surface-600 dark:text-surface-300">Estatus:</span>
<Select
v-model="selectedStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-40"
@change="onFilter"
/>
</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-3">Tipo</th>
<th class="px-4 py-3">Estatus</th>
<th class="px-4 py-3">Descripción</th>
<th class="px-4 py-3">Fecha Programada</th>
<th class="px-4 py-3">Fecha Realizado</th>
<th class="px-4 py-3">Costo</th>
<th class="px-4 py-3">Realizado por</th>
<th class="px-4 py-3 text-right">Acciones</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="8" class="px-4 py-8 text-center text-surface-500">Cargando...</td>
</tr>
<template v-else>
<tr
v-for="m in maintenances"
:key="m.id"
class="border-t border-surface-200 text-sm dark:border-surface-700"
>
<td class="px-4 py-3">
<Tag :value="m.type.name" :severity="typeSeverity(m.type.id)" />
</td>
<td class="px-4 py-3">
<Tag :value="m.status.name" :severity="statusSeverity(m.status.id)" />
</td>
<td class="px-4 py-3 max-w-xs truncate">
{{ m.description || '—' }}
</td>
<td class="px-4 py-3">{{ formatDate(m.maintenance_date) }}</td>
<td class="px-4 py-3">{{ formatDate(m.performed_date) }}</td>
<td class="px-4 py-3">{{ formatCost(m.cost) }}</td>
<td class="px-4 py-3">{{ performedByName(m) }}</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<Button
v-if="canEdit(m)"
icon="pi pi-pencil"
text
rounded
size="small"
severity="secondary"
v-tooltip.top="'Editar'"
@click="goToEdit(m)"
/>
<Button
v-if="canComplete(m)"
icon="pi pi-check-circle"
text
rounded
size="small"
severity="success"
v-tooltip.top="'Marcar como completado'"
:loading="completing === m.id"
@click="confirmComplete(m)"
/>
<Button
v-if="canCancel(m)"
icon="pi pi-times-circle"
text
rounded
size="small"
severity="danger"
v-tooltip.top="'Cancelar'"
:loading="cancelling === m.id"
@click="confirmCancel(m)"
/>
</div>
</td>
</tr>
<tr v-if="maintenances.length === 0">
<td colspan="8" class="px-4 py-8 text-center text-surface-500 dark:text-surface-400">
No hay mantenimientos registrados para este activo.
</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">
{{ totalRecords }} mantenimiento(s) en total
</p>
<Paginator
:rows="rows"
:totalRecords="totalRecords"
template="PrevPageLink PageLinks NextPageLink"
@page="onPage"
/>
</div>
</div>
</template>
</Card>
</section>
</template>

View File

@ -39,7 +39,7 @@ const formatDate = (dateStr: string | null) => {
}; };
const assigneeName = (assignment: AssetAssignment) => { const assigneeName = (assignment: AssetAssignment) => {
if (assignment.assignee_type?.id === 2) { if (assignment.assignee_type === 2) {
return [assignment.external_name, assignment.external_paternal, assignment.external_maternal] return [assignment.external_name, assignment.external_paternal, assignment.external_maternal]
.filter(Boolean).join(' ') || '—'; .filter(Boolean).join(' ') || '—';
} }
@ -49,7 +49,7 @@ const assigneeName = (assignment: AssetAssignment) => {
}; };
const assigneeSubtitle = (assignment: AssetAssignment) => { const assigneeSubtitle = (assignment: AssetAssignment) => {
if (assignment.assignee_type?.id === 2) return assignment.external_company ?? 'Externo'; if (assignment.assignee_type === 2) return assignment.external_company ?? 'Externo';
return assignment.employee?.department?.name ?? '—'; return assignment.employee?.department?.name ?? '—';
}; };

View File

@ -19,9 +19,9 @@ defineProps<Props>();
const reasonOptions = [ const reasonOptions = [
{ label: 'Seleccione un motivo', value: '' }, { label: 'Seleccione un motivo', value: '' },
{ label: 'Cambio de colaborador', value: 'Cambio de colaborador' }, { label: 'Cambio de colaborador', value: 'Cambio de colaborador' },
{ label: 'Baja por danio', value: 'Baja por danio' }, { label: 'Baja por daño', value: 'Baja por daño' },
{ label: 'Robo o extravio', value: 'Robo o extravio' }, { label: 'Robo o extravio', value: 'Robo o extravio' },
{ label: 'Fin de proyecto', value: 'Fin de proyecto' } { label: 'Retorno de externo', value: 'Retorno de externo' }
]; ];
</script> </script>

View File

@ -1,5 +1,13 @@
import api from '../../../services/api'; import api from '../../../services/api';
import type { UsefulLifeCatalogOption, FiscalDepreciationRateOption, UsefulLifeCalculationResult } from '../types/fixedAsset'; import type { UsefulLifeCatalogOption, FiscalDepreciationRateOption, UsefulLifeCalculationResult } from '../types/fixedAsset';
import type {
AssetMaintenance,
AssetMaintenancePaginatedResponse,
AssetMaintenanceResponse,
MaintenanceEvidence,
} from '../types/assetMaintenance';
export type { AssetMaintenance, AssetMaintenancePaginatedResponse, AssetMaintenanceResponse, MaintenanceEvidence };
export interface Asset { export interface Asset {
id: number; id: number;
@ -87,7 +95,7 @@ export interface AssetAssignment {
id: number; id: number;
asset_id: number; asset_id: number;
employee_id: number | null; employee_id: number | null;
assignee_type: { id: number; name: string }; assignee_type: number;
external_name: string | null; external_name: string | null;
external_paternal: string | null; external_paternal: string | null;
external_maternal: string | null; external_maternal: string | null;
@ -253,6 +261,77 @@ class FixedAssetsService {
link.click(); link.click();
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
} }
async getMaintenances(
assetId: number,
filters: { type?: number; status?: number; page?: number; paginate?: boolean } = {}
): Promise<AssetMaintenancePaginatedResponse> {
const params: Record<string, string | number | boolean> = {};
if (filters.type !== undefined) params.type = filters.type;
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<AssetMaintenancePaginatedResponse>(
`/api/assets/${assetId}/maintenances`,
{ params }
);
return response.data;
}
async createMaintenance(assetId: number, data: Record<string, unknown>): Promise<AssetMaintenanceResponse> {
const response = await api.post<AssetMaintenanceResponse>(
`/api/assets/${assetId}/maintenances`,
data
);
return response.data;
}
async getMaintenance(assetId: number, maintenanceId: number): Promise<AssetMaintenanceResponse> {
const response = await api.get<AssetMaintenanceResponse>(
`/api/assets/${assetId}/maintenances/${maintenanceId}`
);
return response.data;
}
async updateMaintenance(assetId: number, maintenanceId: number, data: Record<string, unknown>): Promise<AssetMaintenanceResponse> {
const response = await api.put<AssetMaintenanceResponse>(
`/api/assets/${assetId}/maintenances/${maintenanceId}`,
data
);
return response.data;
}
async completeMaintenance(assetId: number, maintenanceId: number, data: Record<string, unknown> = {}): Promise<AssetMaintenanceResponse> {
const response = await api.put<AssetMaintenanceResponse>(
`/api/assets/${assetId}/maintenances/${maintenanceId}/complete`,
data
);
return response.data;
}
async cancelMaintenance(assetId: number, maintenanceId: number): Promise<AssetMaintenanceResponse> {
const response = await api.put<AssetMaintenanceResponse>(
`/api/assets/${assetId}/maintenances/${maintenanceId}/cancel`,
{}
);
return response.data;
}
async uploadEvidence(assetId: number, maintenanceId: number, file: File): Promise<{ status: string; data: { data: MaintenanceEvidence } }> {
const formData = new FormData();
formData.append('file', file);
const response = await api.post(
`/api/assets/${assetId}/maintenances/${maintenanceId}/evidence`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
return response.data;
}
async deleteEvidence(assetId: number, maintenanceId: number, evidenceId: number): Promise<void> {
await api.delete(`/api/assets/${assetId}/maintenances/${maintenanceId}/evidence/${evidenceId}`);
}
} }
export const fixedAssetsService = new FixedAssetsService(); export const fixedAssetsService = new FixedAssetsService();

View File

@ -0,0 +1,68 @@
export interface AssetMaintenance {
id: number;
asset_id: number;
type: { id: number; name: string };
status: { id: number; name: string };
description: string | null;
maintenance_date: string | null;
performed_date: string | null;
cost: string | null;
performed_by: string | null;
supplier_id: number | null;
odometer_reading: string | null;
next_maintenance_date: string | null;
next_maintenance_km: string | null;
service_order_number: string | null;
created_at: string;
updated_at: string;
supplier: { id: number; name: string } | null;
evidence: MaintenanceEvidence[];
asset?: {
id: number;
sku: string;
inventory_warehouse?: {
product?: { name: string };
} | null;
};
}
export interface MaintenanceEvidence {
id: number;
maintenance_id: number;
file_path: string;
file_type: string;
uploaded_at: string;
}
export interface AssetMaintenancePaginatedResponse {
status: string;
data: {
data: {
current_page: number;
data: AssetMaintenance[];
last_page: number;
per_page: number;
total: number;
};
};
}
export interface AssetMaintenanceResponse {
status: string;
data: { data: AssetMaintenance };
}
export interface MaintenanceFormData {
assetId: number | null;
type: number | null;
status: number;
description: string;
maintenanceDate: string;
performedDate: string;
cost: string;
performedBy: string;
odometerReading: string;
nextMaintenanceDate: string;
nextMaintenanceKm: string;
serviceOrderNumber: string;
}

View File

@ -17,6 +17,8 @@ import FixedAssetForm from '../modules/fixed-assets/components/assets/FixedAsset
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';
import MaintenanceIndex from '../modules/fixed-assets/components/Maintenance/MaintenanceIndex.vue';
import MaintenanceForm from '../modules/fixed-assets/components/Maintenance/MaintenanceForm.vue';
import RolesIndex from '../modules/users/components/RoleIndex.vue'; import RolesIndex from '../modules/users/components/RoleIndex.vue';
import RoleForm from '../modules/users/components/RoleForm.vue'; import RoleForm from '../modules/users/components/RoleForm.vue';
@ -340,6 +342,43 @@ const routes: RouteRecordRaw[] = [
} }
] ]
}, },
{
path: ':id/maintenances',
name: 'FixedAssetMaintenancesModule',
meta: {
title: 'Mantenimientos de Activos',
requiresAuth: true
},
children: [
{
path: '',
name: 'FixedAssetMaintenances',
component: MaintenanceIndex,
meta: {
title: 'Listado de Mantenimientos',
requiresAuth: true
}
},
{
path: 'create',
name: 'FixedAssetMaintenanceCreate',
component: MaintenanceForm,
meta: {
title: 'Registrar Mantenimiento',
requiresAuth: true
}
},
{
path: ':maintenanceId/edit',
name: 'FixedAssetMaintenanceEdit',
component: MaintenanceForm,
meta: {
title: 'Editar Mantenimiento',
requiresAuth: true
}
}
]
},
] ]
}, },
{ {