diff --git a/web-v2/src/pages/InspectionVisitEditorF4Page.tsx b/web-v2/src/pages/InspectionVisitEditorF4Page.tsx new file mode 100644 index 0000000..ab3280a --- /dev/null +++ b/web-v2/src/pages/InspectionVisitEditorF4Page.tsx @@ -0,0 +1,361 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Link, useNavigate, useParams } from 'react-router'; +import { useAuth } from '../auth/AuthContext'; +import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback'; +import { Icon } from '../components/Icon'; +import { SearchableSelect } from '../components/SearchableSelect'; +import { useOperationalContext } from '../context/OperationalContext'; +import { InspectionActsPanel } from '../features/inspections/InspectionActsPanel'; +import { + inspectionStatusClass, + inspectionVisitStatusLabel, +} from '../features/inspections/inspectionPresentation'; +import { + createInspectionVisit, + excludeInspectionVisitAsset, + generateInspectionVisitChecklist, + getInspectionVisit, + includeInspectionVisitAsset, + listAssets, + listInspectionAssignees, + listInspectionPlanningAreas, + listInspectionPlanningOperators, + replaceInspectionVisitAssets, + replaceInspectionVisitTeam, + updateInspectionVisit, +} from '../lib/api'; +import type { + AssetListItem, + InspectionPerson, + InspectionPlanningContextAsset, + InspectionVisit, + InspectionVisitAssetPlanningSource, +} from '../lib/api'; +import { + cancelInspectionVisit, + planInspectionVisit, + unplanInspectionVisit, +} from '../lib/inspectionVisitLifecycleApi'; +import { formatDate, formatDateOnly } from '../lib/format'; + +interface VisitForm { + objective: string; + operationalAreaId: string; + operatorCompanyId: string; + plannedStartAt: string; + instructions: string; +} + +const emptyForm: VisitForm = { + objective: '', + operationalAreaId: '', + operatorCompanyId: '', + plannedStartAt: '', + instructions: '', +}; + +function localDateTime(value: string | null): string { + if (!value) return ''; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return ''; + const local = new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +function toIso(value: string): string | null { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function personName(person: InspectionPerson): string { + return `${person.firstName} ${person.lastName}`; +} + +function sourceLabel(source: InspectionVisitAssetPlanningSource): string { + if (source === 'AUTOMATIC') return 'Checklist automático'; + if (source === 'PREVENTIVE') return 'Preventivo'; + if (source === 'VERIFICATION') return 'Verificación'; + return 'Plan previo'; +} + +function checklistLabel(kind: string): string { + if (kind === 'VERIFICATION_OVERDUE') return 'Control vencido'; + if (kind === 'UPCOMING_CONTROL') return 'Próximo control'; + return 'Antecedente'; +} + +export function InspectionVisitEditorF4Page() { + const { id } = useParams(); + const navigate = useNavigate(); + const { hasPermission } = useAuth(); + const context = useOperationalContext(); + const isNew = !id; + const canManage = hasPermission('inspections.manage'); + const canAssign = hasPermission('inspections.assign'); + + const [visit, setVisit] = useState(null); + const [form, setForm] = useState(emptyForm); + const [areas, setAreas] = useState([]); + const [operators, setOperators] = useState([]); + const [assignees, setAssignees] = useState([]); + const [assets, setAssets] = useState([]); + const [assetSearch, setAssetSearch] = useState(''); + const [newAssetId, setNewAssetId] = useState(''); + const [leadInspectorId, setLeadInspectorId] = useState(''); + const [memberIds, setMemberIds] = useState>(new Set()); + const [loading, setLoading] = useState(!isNew); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const applyVisit = (value: InspectionVisit, syncForm = true) => { + setVisit(value); + setLeadInspectorId(value.leadInspector?.id ?? ''); + setMemberIds(new Set(value.team.map((member) => member.id))); + if (value.operationalArea?.id) { + context.setContext(value.operationalArea.id, value.operatorCompany?.id ?? ''); + } + if (syncForm) { + setForm({ + objective: value.objective ?? '', + operationalAreaId: value.operationalArea?.id ?? '', + operatorCompanyId: value.operatorCompany?.id ?? '', + plannedStartAt: localDateTime(value.plannedStartAt), + instructions: value.instructions ?? '', + }); + } + }; + + const reload = async () => { + if (!id) return; + applyVisit(await getInspectionVisit(id)); + }; + + useEffect(() => { + listInspectionPlanningAreas().then(setAreas).catch(() => undefined); + if (canAssign) listInspectionAssignees().then(setAssignees).catch(() => undefined); + }, [canAssign]); + + useEffect(() => { + if (!isNew || form.operationalAreaId || !context.areaId) return; + setForm((current) => ({ + ...current, + operationalAreaId: context.areaId, + operatorCompanyId: context.companyId, + })); + }, [isNew, form.operationalAreaId, context.areaId, context.companyId]); + + useEffect(() => { + if (!id) return; + setLoading(true); + reload() + .catch((requestError) => setError(errorMessage(requestError))) + .finally(() => setLoading(false)); + }, [id]); + + useEffect(() => { + if (!form.operationalAreaId) { + setOperators([]); + return; + } + listInspectionPlanningOperators(form.operationalAreaId) + .then(setOperators) + .catch(() => setOperators([])); + }, [form.operationalAreaId]); + + useEffect(() => { + if (!form.operationalAreaId || !form.operatorCompanyId) { + setAssets([]); + return; + } + const timer = window.setTimeout(() => { + listAssets({ + pageSize: 100, + search: assetSearch.trim(), + operationalAreaId: form.operationalAreaId, + operatorCompanyId: form.operatorCompanyId, + }).then((response) => setAssets(response.data)).catch(() => undefined); + }, 220); + return () => window.clearTimeout(timer); + }, [assetSearch, form.operationalAreaId, form.operatorCompanyId]); + + const planningEditable = !visit || visit.status === 'DRAFT' || visit.status === 'PLANNED'; + const activeAssetIds = useMemo(() => new Set(visit?.assets.map((asset) => asset.id) ?? []), [visit]); + const linkedAssetIds = useMemo(() => new Set(visit?.planningAssets.map((asset) => asset.id) ?? []), [visit]); + const candidateAssets = assets.filter((asset) => !linkedAssetIds.has(asset.id)); + + const saveGeneral = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(''); + setSuccess(''); + try { + const plannedStartAt = toIso(form.plannedStartAt); + if (isNew) { + if (!form.operationalAreaId || !form.operatorCompanyId || !plannedStartAt || !leadInspectorId) { + setError('Seleccioná Área, Operadora, fecha de inicio e Inspector.'); + return; + } + const created = await createInspectionVisit({ + operationalAreaId: form.operationalAreaId, + operatorCompanyId: form.operatorCompanyId, + plannedStartAt, + leadInspectorUserId: leadInspectorId, + }); + navigate(`/inspecciones/${created.id}`, { replace: true }); + } else if (id) { + applyVisit(await updateInspectionVisit(id, { + objective: form.objective.trim() || null, + operationalAreaId: form.operationalAreaId || null, + operatorCompanyId: form.operatorCompanyId || null, + plannedStartAt, + instructions: form.instructions.trim() || null, + })); + setSuccess('Planificación actualizada. Regenerá el checklist si cambió contexto o fecha.'); + } + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setBusy(false); + } + }; + + const regenerateChecklist = async () => { + if (!id) return; + setBusy(true); setError(''); setSuccess(''); + try { + applyVisit(await generateInspectionVisitChecklist(id), false); + setSuccess('Checklist técnico recalculado con antecedentes y controles.'); + } catch (requestError) { setError(errorMessage(requestError)); } + finally { setBusy(false); } + }; + + const addPreventiveAsset = async (event: FormEvent) => { + event.preventDefault(); + if (!id || !newAssetId) return; + setBusy(true); setError(''); setSuccess(''); + try { + applyVisit(await replaceInspectionVisitAssets(id, [...activeAssetIds, newAssetId]), false); + setNewAssetId(''); + setSuccess('Registro agregado como preventivo.'); + } catch (requestError) { setError(errorMessage(requestError)); } + finally { setBusy(false); } + }; + + const excludeAsset = async (assetId: string) => { + if (!id) return; + const reason = window.prompt('Motivo de exclusión (mínimo 10 caracteres). Quedará auditado:')?.trim() ?? ''; + if (reason.length < 10) { + if (reason) setError('El motivo debe tener al menos 10 caracteres.'); + return; + } + setBusy(true); setError(''); setSuccess(''); + try { + applyVisit(await excludeInspectionVisitAsset(id, assetId, reason), false); + setSuccess('Registro excluido con trazabilidad.'); + } catch (requestError) { setError(errorMessage(requestError)); } + finally { setBusy(false); } + }; + + const reincludeAsset = async (assetId: string) => { + if (!id) return; + setBusy(true); setError(''); setSuccess(''); + try { + applyVisit(await includeInspectionVisitAsset(id, assetId), false); + setSuccess('Registro reincorporado; la exclusión anterior permanece en historial.'); + } catch (requestError) { setError(errorMessage(requestError)); } + finally { setBusy(false); } + }; + + const chooseLead = (value: string) => { + setLeadInspectorId(value); + if (value) setMemberIds((current) => new Set([...current, value])); + }; + + const saveTeam = async (event: FormEvent) => { + event.preventDefault(); + if (!id) return; + const nextMembers = new Set(memberIds); + if (leadInspectorId) nextMembers.add(leadInspectorId); + setBusy(true); setError(''); setSuccess(''); + try { + applyVisit(await replaceInspectionVisitTeam(id, leadInspectorId || null, [...nextMembers]), false); + setSuccess('Equipo actualizado.'); + } catch (requestError) { setError(errorMessage(requestError)); } + finally { setBusy(false); } + }; + + const lifecycleAction = async (action: 'PLAN' | 'UNPLAN' | 'CANCEL') => { + if (!id) return; + setBusy(true); setError(''); setSuccess(''); + try { + let updated: InspectionVisit; + if (action === 'PLAN') { + updated = await planInspectionVisit(id); + setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.'); + } else if (action === 'UNPLAN') { + updated = await unplanInspectionVisit(id); + setSuccess('La Inspección volvió a borrador para corregir la planificación.'); + } else { + const reason = window.prompt('Indicá el motivo de cancelación (mínimo 10 caracteres):')?.trim() ?? ''; + if (reason.length < 10) { + if (reason) setError('El motivo debe tener al menos 10 caracteres.'); + return; + } + updated = await cancelInspectionVisit(id, reason); + setSuccess('Inspección cancelada con motivo auditado.'); + } + applyVisit(updated); + } catch (requestError) { setError(errorMessage(requestError)); } + finally { setBusy(false); } + }; + + if (loading) return ; + + return
+
Inspecciones/{isNew ? 'Planificar inspección' : visit?.code ?? 'Detalle'}
+
+
INSPECCIÓN

{isNew ? 'Planificar inspección' : visit?.code ?? 'Inspección'}

{isNew ? 'Área, Operadora, inicio e Inspector. El identificador se genera automáticamente.' : `${visit?.operationalArea?.name ?? 'Sin Área'} · ${visit?.operatorCompany?.name ?? 'Sin Operadora'} · ${visit?.assetCount ?? 0} registros`}

+ {visit && {inspectionVisitStatusLabel(visit.status)}} +
+ {error && {error}}{success && {success}} + +
+
{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}

Contexto y fecha de inicio

No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.

{visit && Actualizado {formatDate(visit.updatedAt)}}
+ {visit &&
Identificador institucional{visit.code}
} +
+ + + + {isNew && } +
+ {!isNew && <>