F4 findings: center detail on immutable description and verification
This commit is contained in:
@@ -1,30 +1,26 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router';
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
import { FindingOfficeWorkspace } from '../features/inspections/FindingOfficeWorkspace';
|
|
||||||
import { InspectionEvidencePanel } from '../features/inspections/InspectionEvidencePanel';
|
import { InspectionEvidencePanel } from '../features/inspections/InspectionEvidencePanel';
|
||||||
import { getInspectionFinding } from '../lib/api';
|
import { closeInspectionFinding, getInspectionFinding } from '../lib/api';
|
||||||
import type { InspectionFinding } from '../lib/api';
|
import type { InspectionFinding } from '../lib/api';
|
||||||
import { formatDate, formatDateOnly } from '../lib/format';
|
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() {
|
export function FindingDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const [finding, setFinding] = useState<InspectionFinding | null>(null);
|
const [finding, setFinding] = useState<InspectionFinding | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const [closureNotes, setClosureNotes] = useState('');
|
||||||
const [evidenceRefreshKey, setEvidenceRefreshKey] = useState(0);
|
const [evidenceRefreshKey, setEvidenceRefreshKey] = useState(0);
|
||||||
|
|
||||||
const canFollowUp = hasPermission('inspection_findings.follow_up');
|
|
||||||
const canClose = hasPermission('inspection_findings.close');
|
const canClose = hasPermission('inspection_findings.close');
|
||||||
const canReadEvidence = hasPermission('inspection_evidence.read');
|
const canReadEvidence = hasPermission('inspection_evidence.read');
|
||||||
const canCreateEvidence = hasPermission('inspection_evidence.create');
|
const canCreateEvidence = hasPermission('inspection_evidence.create');
|
||||||
@@ -41,14 +37,9 @@ export function FindingDetailPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [id]);
|
}, [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>;
|
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 latestVerification = finding.latestVerification;
|
||||||
const verificationReadyToClose = latestVerification?.outcome === 'RESOLVED'
|
const verificationReadyToClose = latestVerification?.outcome === 'RESOLVED'
|
||||||
&& latestVerification.visitStatus === 'CLOSED';
|
&& latestVerification.visitStatus === 'CLOSED';
|
||||||
@@ -60,48 +51,118 @@ export function FindingDetailPage() {
|
|||||||
? 'Requiere nueva fecha'
|
? 'Requiere nueva fecha'
|
||||||
: 'Sin resultado';
|
: '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>
|
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 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>
|
<div>
|
||||||
<span className={`status-badge large ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : 'Cerrado'}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{error && <Alert>{error}</Alert>}
|
{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">
|
<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>
|
||||||
<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>
|
<small>Elemento del Inventario</small>
|
||||||
<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>
|
<Link to={`/inventarios/${finding.assetId}`}>{finding.asset.name}</Link>
|
||||||
<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>
|
<span>{finding.asset.code} · {finding.asset.typeName}</span>
|
||||||
<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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{finding.verificationVisit && <article className="panel verification-linked-visit">
|
{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="panel-heading">
|
||||||
<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>
|
||||||
|
<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>}
|
</article>}
|
||||||
|
|
||||||
{latestVerification?.outcome && <article className={`panel verification-result-panel ${latestVerification.outcome === 'RESOLVED' ? 'resolved' : ''}`}>
|
{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 className="finding-two-deadlines">
|
||||||
<div><span>RESULTADO</span><strong>{verificationOutcomeLabel}</strong><p>{latestVerification.resultNotes || 'Sin observaciones adicionales.'}</p></div>
|
<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>
|
<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 la planificación operativa.</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>
|
</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>}
|
{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. Debe definirse una nueva fecha de control.</Alert>}
|
{latestVerification.outcome === 'NOT_RESOLVED' && finding.status === 'OPEN' && <Alert>La verificación indicó que el Hallazgo continúa abierto.</Alert>}
|
||||||
</article>}
|
</article>}
|
||||||
|
|
||||||
<FindingOfficeWorkspace
|
{verificationReadyToClose && finding.status === 'OPEN' && canClose && <article className="panel" id="cierre-hallazgo">
|
||||||
finding={finding}
|
<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>
|
||||||
canFollowUp={canFollowUp}
|
<form onSubmit={closeFinding}>
|
||||||
canClose={canClose}
|
<label className="field"><span>Conclusión de cierre</span><textarea value={closureNotes} onChange={(event) => setClosureNotes(event.target.value)} required minLength={3} rows={4} /></label>
|
||||||
canCreateEvidence={canCreateEvidence}
|
<div className="form-actions"><button className="button primary" disabled={busy || !closureNotes.trim()}>{busy ? 'Cerrando…' : 'Cerrar Hallazgo'}</button></div>
|
||||||
canCreateCommunications={canCreateCommunications}
|
</form>
|
||||||
onFindingChanged={setFinding}
|
</article>}
|
||||||
onEvidenceChanged={() => setEvidenceRefreshKey((value) => value + 1)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div id="documentos-hallazgo">
|
<div id="documentos-hallazgo">
|
||||||
<InspectionEvidencePanel
|
<InspectionEvidencePanel
|
||||||
@@ -112,10 +173,12 @@ export function FindingDetailPage() {
|
|||||||
canReadCommunications={canReadCommunications}
|
canReadCommunications={canReadCommunications}
|
||||||
canCreateCommunications={canCreateCommunications}
|
canCreateCommunications={canCreateCommunications}
|
||||||
refreshKey={evidenceRefreshKey}
|
refreshKey={evidenceRefreshKey}
|
||||||
managedCompanyResponse
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user