F4 findings: center detail on immutable description and verification

This commit is contained in:
2026-09-07 22:16:05 -03:00
parent 747ad21262
commit 30f543ad08
+104 -41
View File
@@ -1,30 +1,26 @@
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 { FindingOfficeWorkspace } from '../features/inspections/FindingOfficeWorkspace';
import { InspectionEvidencePanel } from '../features/inspections/InspectionEvidencePanel';
import { getInspectionFinding } from '../lib/api';
import { closeInspectionFinding, getInspectionFinding } from '../lib/api';
import type { InspectionFinding } from '../lib/api';
import { formatDate, formatDateOnly } from '../lib/format';
function todayInput(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
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 canFollowUp = hasPermission('inspection_findings.follow_up');
const canClose = hasPermission('inspection_findings.close');
const canReadEvidence = hasPermission('inspection_evidence.read');
const canCreateEvidence = hasPermission('inspection_evidence.create');
@@ -41,14 +37,9 @@ export function FindingDetailPage() {
.finally(() => setLoading(false));
}, [id]);
if (loading) return <LoadingBlock label="Cargando hallazgo…" />;
if (loading) return <LoadingBlock label="Cargando Hallazgo…" />;
if (!finding) return <section><Alert>{error || 'Hallazgo no encontrado'}</Alert></section>;
const responseOverdue = finding.status === 'OPEN'
&& !finding.companyResponseReceivedOn
&& Boolean(finding.correctionDueOn && finding.correctionDueOn < todayInput());
const verificationOverdue = finding.status === 'OPEN'
&& Boolean(finding.companyResponseReceivedOn && finding.nextControlOn && finding.nextControlOn < todayInput());
const latestVerification = finding.latestVerification;
const verificationReadyToClose = latestVerification?.outcome === 'RESOLVED'
&& latestVerification.visitStatus === 'CLOSED';
@@ -60,48 +51,118 @@ export function FindingDetailPage() {
? '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="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>
<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 className={responseOverdue ? 'overdue' : ''}><small>Vencimiento administrativo</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong><span>{finding.companyResponseReceivedOn ? `Respondido ${formatDateOnly(finding.companyResponseReceivedOn)}` : 'Esperando respuesta de empresa'}</span></article>
<article className={verificationOverdue ? 'overdue' : ''}><small>Fecha de verificación</small><strong>{formatDateOnly(finding.nextControlOn)}</strong><span>{finding.nextControlOn ? 'Planificación de inspección o control' : 'Todavía sin definir'}</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>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 ya está incorporado a una visita de verificación.</p></div><Link className="button secondary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir visita <Icon name="chevron" /></Link></div>
<div className="finding-two-deadlines"><div><span>FECHA OBJETIVO</span><strong>{formatDateOnly(finding.nextControlOn)}</strong><p>Fecha definida en el seguimiento del hallazgo.</p></div><div><span>VISITA</span><strong>{formatDate(finding.verificationVisit.plannedStartAt)}</strong><p>{finding.verificationVisit.code}</p></div></div>
<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 por el inspector durante {latestVerification.visitCode}.</p></div><Link className="button secondary" to={`/inspecciones/${latestVerification.visitId}`}>Abrir verificación <Icon name="chevron" /></Link></div>
<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} foto{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 la planificación operativa.</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 de campo indicó que el hallazgo está solucionado y quedó listo para revisión de oficina.</Alert>}
{latestVerification.outcome === 'NOT_RESOLVED' && finding.status === 'OPEN' && <Alert>La verificación indicó que el hallazgo continúa abierto. Debe definirse una nueva fecha de control.</Alert>}
{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>}
<FindingOfficeWorkspace
finding={finding}
canFollowUp={canFollowUp}
canClose={canClose}
canCreateEvidence={canCreateEvidence}
canCreateCommunications={canCreateCommunications}
onFindingChanged={setFinding}
onEvidenceChanged={() => setEvidenceRefreshKey((value) => value + 1)}
/>
{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
@@ -112,10 +173,12 @@ export function FindingDetailPage() {
canReadCommunications={canReadCommunications}
canCreateCommunications={canCreateCommunications}
refreshKey={evidenceRefreshKey}
managedCompanyResponse
/>
</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>}
{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>;
}