185 lines
9.7 KiB
TypeScript
185 lines
9.7 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import type { FormEvent } from 'react';
|
||
import { Link, useNavigate, useParams } from 'react-router';
|
||
import { useAuth } from '../auth/AuthContext';
|
||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||
import { Icon } from '../components/Icon';
|
||
import { InspectionEvidencePanel } from '../features/inspections/InspectionEvidencePanel';
|
||
import { closeInspectionFinding, getInspectionFinding } from '../lib/api';
|
||
import type { InspectionFinding } from '../lib/api';
|
||
import { formatDate, formatDateOnly } from '../lib/format';
|
||
|
||
export function FindingDetailPage() {
|
||
const { id } = useParams();
|
||
const navigate = useNavigate();
|
||
const { hasPermission } = useAuth();
|
||
const [finding, setFinding] = useState<InspectionFinding | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [success, setSuccess] = useState('');
|
||
const [closureNotes, setClosureNotes] = useState('');
|
||
const [evidenceRefreshKey, setEvidenceRefreshKey] = useState(0);
|
||
|
||
const canClose = hasPermission('inspection_findings.close');
|
||
const canReadEvidence = hasPermission('inspection_evidence.read');
|
||
const canCreateEvidence = hasPermission('inspection_evidence.create');
|
||
const canReadCommunications = hasPermission('inspection_communications.read');
|
||
const canCreateCommunications = hasPermission('inspection_communications.create');
|
||
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
setLoading(true);
|
||
setError('');
|
||
getInspectionFinding(id)
|
||
.then(setFinding)
|
||
.catch((requestError) => setError(errorMessage(requestError)))
|
||
.finally(() => setLoading(false));
|
||
}, [id]);
|
||
|
||
if (loading) return <LoadingBlock label="Cargando Hallazgo…" />;
|
||
if (!finding) return <section><Alert>{error || 'Hallazgo no encontrado'}</Alert></section>;
|
||
|
||
const latestVerification = finding.latestVerification;
|
||
const verificationReadyToClose = latestVerification?.outcome === 'RESOLVED'
|
||
&& latestVerification.visitStatus === 'CLOSED';
|
||
const verificationOutcomeLabel = latestVerification?.outcome === 'RESOLVED'
|
||
? 'Solucionado'
|
||
: latestVerification?.outcome === 'NOT_RESOLVED'
|
||
? 'No solucionado'
|
||
: latestVerification?.outcome === 'REQUIRES_NEW_DATE'
|
||
? 'Requiere nueva fecha'
|
||
: 'Sin resultado';
|
||
|
||
const closeFinding = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
if (!closureNotes.trim()) return;
|
||
setBusy(true); setError(''); setSuccess('');
|
||
try {
|
||
const updated = await closeInspectionFinding(finding.id, closureNotes.trim());
|
||
setFinding(updated);
|
||
setClosureNotes('');
|
||
setSuccess('Hallazgo cerrado. La conclusión quedó incorporada al historial sin modificar el Hallazgo original.');
|
||
} catch (requestError) {
|
||
setError(errorMessage(requestError));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return <section>
|
||
<div className="finding-detail-breadcrumb">
|
||
<button type="button" className="text-link" onClick={() => navigate('/hallazgos')}>Hallazgos</button>
|
||
<span>›</span><strong>{finding.code}</strong>
|
||
</div>
|
||
|
||
<div className="page-heading finding-detail-heading">
|
||
<div>
|
||
<span className="eyebrow">{finding.code}</span>
|
||
<h1>{finding.title}</h1>
|
||
<p>{finding.asset.operatorCompany?.name ?? 'Empresa sin asignar'} · {finding.asset.operationalArea?.name ?? 'Área sin asignar'}</p>
|
||
</div>
|
||
<span className={`status-badge large ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>
|
||
{finding.status === 'OPEN' ? 'Abierto' : 'Cerrado'}
|
||
</span>
|
||
</div>
|
||
|
||
{error && <Alert>{error}</Alert>}
|
||
{success && <Alert type="success">{success}</Alert>}
|
||
|
||
<article className="panel">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<span className="eyebrow">CONTENIDO ORIGINAL</span>
|
||
<h2>Descripción</h2>
|
||
<p className="section-copy">Texto libre registrado por el inspector. Una vez bloqueada el Acta, este contenido es inmutable.</p>
|
||
</div>
|
||
</div>
|
||
<p style={{ whiteSpace: 'pre-wrap' }}>{finding.description || 'Sin descripción registrada.'}</p>
|
||
{finding.legalBasis && <p><strong>Base legal / normativa:</strong> {finding.legalBasis}</p>}
|
||
</article>
|
||
|
||
<div className="finding-detail-summary-grid">
|
||
<article>
|
||
<small>Elemento del Inventario</small>
|
||
<Link to={`/inventarios/${finding.assetId}`}>{finding.asset.name}</Link>
|
||
<span>{finding.asset.code} · {finding.asset.typeName}</span>
|
||
</article>
|
||
<article>
|
||
<small>Acta / Inspección</small>
|
||
<Link to={`/inspecciones/actas/${finding.actId}`}>{finding.document.actCode}</Link>
|
||
<span><Link className="text-link" to={`/inspecciones/${finding.document.visitId}`}>{finding.document.visitCode}</Link></span>
|
||
</article>
|
||
<article>
|
||
<small>Gravedad</small>
|
||
<strong>{finding.severity ? `${finding.severity}/10` : 'Sin calificar'}</strong>
|
||
<span>{finding.suggestedSeverity ? `Sugerida por catálogo: ${finding.suggestedSeverity}/10` : finding.catalogItemId ? 'El catálogo no define una sugerencia' : 'Hallazgo OTROS'}</span>
|
||
</article>
|
||
<article>
|
||
<small>Reincidencia</small>
|
||
<strong>{finding.isRecurrence ? 'Sí' : 'No'}</strong>
|
||
<span>{finding.recurrenceOfFindingCode ? `Antecedente: ${finding.recurrenceOfFindingCode}` : finding.isRecurrence ? 'Antecedente vinculado' : 'Sin antecedente marcado'}</span>
|
||
</article>
|
||
<article>
|
||
<small>Próxima verificación</small>
|
||
<strong>{formatDateOnly(finding.nextControlOn)}</strong>
|
||
<span>{finding.verificationVisit ? `Planificada en ${finding.verificationVisit.code}` : finding.nextControlOn ? 'Pendiente de planificación' : 'Sin fecha definida'}</span>
|
||
</article>
|
||
</div>
|
||
|
||
{finding.verificationVisit && <article className="panel verification-linked-visit">
|
||
<div className="panel-heading">
|
||
<div>
|
||
<span className="eyebrow">VERIFICACIÓN PLANIFICADA</span>
|
||
<h2>{finding.verificationVisit.code}</h2>
|
||
<p className="section-copy">Este Hallazgo está incorporado a una Inspección de verificación.</p>
|
||
</div>
|
||
<Link className="button secondary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir Inspección <Icon name="chevron" /></Link>
|
||
</div>
|
||
<div className="finding-two-deadlines">
|
||
<div><span>FECHA OBJETIVO</span><strong>{formatDateOnly(finding.nextControlOn)}</strong><p>Fecha técnica de control del Hallazgo.</p></div>
|
||
<div><span>INSPECCIÓN</span><strong>{formatDate(finding.verificationVisit.plannedStartAt)}</strong><p>{finding.verificationVisit.code}</p></div>
|
||
</div>
|
||
</article>}
|
||
|
||
{latestVerification?.outcome && <article className={`panel verification-result-panel ${latestVerification.outcome === 'RESOLVED' ? 'resolved' : ''}`}>
|
||
<div className="panel-heading">
|
||
<div><span className="eyebrow">ÚLTIMA VERIFICACIÓN DE CAMPO</span><h2>{verificationOutcomeLabel}</h2><p className="section-copy">Resultado registrado durante {latestVerification.visitCode}.</p></div>
|
||
<Link className="button secondary" to={`/inspecciones/${latestVerification.visitId}`}>Abrir verificación <Icon name="chevron" /></Link>
|
||
</div>
|
||
<div className="finding-two-deadlines">
|
||
<div><span>RESULTADO</span><strong>{verificationOutcomeLabel}</strong><p>{latestVerification.resultNotes || 'Sin observaciones adicionales.'}</p></div>
|
||
<div><span>VERIFICADO</span><strong>{formatDate(latestVerification.verifiedAt)}</strong><p>{latestVerification.evidenceCount} evidencia{latestVerification.evidenceCount === 1 ? '' : 's'} de verificación.</p></div>
|
||
{latestVerification.rescheduledControlOn && <div><span>NUEVA FECHA</span><strong>{formatDateOnly(latestVerification.rescheduledControlOn)}</strong><p>El Hallazgo vuelve a planificación de control.</p></div>}
|
||
</div>
|
||
{verificationReadyToClose && finding.status === 'OPEN' && <Alert type="success">La verificación indicó que el Hallazgo está solucionado y quedó listo para cierre técnico.</Alert>}
|
||
{latestVerification.outcome === 'NOT_RESOLVED' && finding.status === 'OPEN' && <Alert>La verificación indicó que el Hallazgo continúa abierto.</Alert>}
|
||
</article>}
|
||
|
||
{verificationReadyToClose && finding.status === 'OPEN' && canClose && <article className="panel" id="cierre-hallazgo">
|
||
<div className="panel-heading"><div><span className="eyebrow">CIERRE TÉCNICO</span><h2>Cerrar Hallazgo</h2><p className="section-copy">El cierre agrega una conclusión al historial; nunca modifica la descripción original.</p></div></div>
|
||
<form onSubmit={closeFinding}>
|
||
<label className="field"><span>Conclusión de cierre</span><textarea value={closureNotes} onChange={(event) => setClosureNotes(event.target.value)} required minLength={3} rows={4} /></label>
|
||
<div className="form-actions"><button className="button primary" disabled={busy || !closureNotes.trim()}>{busy ? 'Cerrando…' : 'Cerrar Hallazgo'}</button></div>
|
||
</form>
|
||
</article>}
|
||
|
||
<div id="documentos-hallazgo">
|
||
<InspectionEvidencePanel
|
||
finding={finding}
|
||
canReadEvidence={canReadEvidence}
|
||
canCreateEvidence={canCreateEvidence}
|
||
canCreateFieldEvidence={false}
|
||
canReadCommunications={canReadCommunications}
|
||
canCreateCommunications={canCreateCommunications}
|
||
refreshKey={evidenceRefreshKey}
|
||
/>
|
||
</div>
|
||
|
||
{finding.status === 'CLOSED' && <article className="closed-seal finding-closed-seal">
|
||
<Icon name="check" />
|
||
<div><span className="eyebrow">HALLAZGO CERRADO</span><strong>{formatDate(finding.closedAt)}</strong><p>{finding.closureNotes}</p></div>
|
||
</article>}
|
||
</section>;
|
||
}
|