Files
dh-inspeccion-v2/web-v2/src/features/inspections/FindingOfficeWorkspace.tsx
T

371 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router';
import { Alert, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
closeInspectionFinding,
createInspectionFindingCommunication,
updateInspectionFindingFollowUp,
uploadInspectionFindingEvidence,
} from '../../lib/api';
import type {
InspectionFinding,
InspectionFindingCommunication,
InspectionFindingOfficeWorkflow,
} from '../../lib/api';
import { formatDate, formatDateOnly } from '../../lib/format';
function localDateTime(value: string | number | Date): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function workflowFallback(finding: InspectionFinding): InspectionFindingOfficeWorkflow {
if (finding.status === 'CLOSED') {
return { stage: 'CLOSED', label: 'Hallazgo cerrado', action: 'El seguimiento está finalizado.', dueOn: null, overdue: false };
}
if (!finding.companyResponseReceivedOn) {
return { stage: 'WAITING_COMPANY', label: 'Esperando respuesta de empresa', action: 'Registrar la respuesta cuando sea recibida.', dueOn: finding.correctionDueOn, overdue: false };
}
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') {
return { stage: 'READY_TO_CLOSE', label: 'Listo para cierre', action: 'Revisar la evidencia y cerrar administrativamente.', dueOn: null, overdue: false };
}
if (!finding.nextControlOn) {
return { stage: 'DEFINE_VERIFICATION', label: 'Definir fecha de verificación', action: 'Establecer cuándo debe volver a controlarse.', dueOn: null, overdue: false };
}
if (finding.verificationVisit) {
return { stage: 'VERIFICATION_PLANNED', label: 'Verificación planificada', action: 'Consultar la visita vinculada.', dueOn: finding.nextControlOn, overdue: false };
}
return { stage: 'PLAN_VERIFICATION', label: 'Verificación pendiente de planificación', action: 'Crear la visita de verificación.', dueOn: finding.nextControlOn, overdue: false };
}
function verificationEventLabel(eventType: NonNullable<InspectionFinding['verificationHistory']>[number]['eventType']): string {
if (eventType === 'CONTROL_DATE_DEFINED') return 'Fecha de verificación definida';
if (eventType === 'CONTROL_DATE_CHANGED') return 'Fecha de verificación reprogramada';
if (eventType === 'CONTROL_DATE_CLEARED') return 'Fecha de verificación consumida o retirada';
if (eventType === 'VISIT_PLANNED') return 'Visita de verificación planificada';
return 'Resultado de verificación registrado';
}
function verificationOutcomeLabel(outcome: NonNullable<InspectionFinding['latestVerification']>['outcome']): string {
if (outcome === 'RESOLVED') return 'Solucionado';
if (outcome === 'NOT_RESOLVED') return 'No solucionado';
if (outcome === 'REQUIRES_NEW_DATE') return 'Requiere nueva fecha';
return '';
}
export function FindingOfficeWorkspace({
finding,
canFollowUp,
canClose,
canCreateEvidence,
canCreateCommunications,
onFindingChanged,
onEvidenceChanged,
}: {
finding: InspectionFinding;
canFollowUp: boolean;
canClose: boolean;
canCreateEvidence: boolean;
canCreateCommunications: boolean;
onFindingChanged: (finding: InspectionFinding) => void;
onEvidenceChanged: () => void;
}) {
const responseFileRef = useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [responseText, setResponseText] = useState('');
const [responseOccurredAt, setResponseOccurredAt] = useState(localDateTime(new Date()));
const [responseChannel, setResponseChannel] = useState<InspectionFindingCommunication['channel']>('EMAIL');
const [responseContactName, setResponseContactName] = useState('');
const [responseContactEmail, setResponseContactEmail] = useState('');
const [responseFile, setResponseFile] = useState<File | null>(null);
const [responseDueBasis, setResponseDueBasis] = useState<'FINDING_DATE' | 'REPORT_NOTIFICATION' | ''>(finding.responseDueBasis ?? '');
const [responseDueDays, setResponseDueDays] = useState(finding.responseDueDays == null ? '' : String(finding.responseDueDays));
const [reportNotifiedOn, setReportNotifiedOn] = useState(finding.reportNotifiedOn ?? '');
const [committedOn, setCommittedOn] = useState(finding.companyCommittedCorrectionOn ?? '');
const [nextControlOn, setNextControlOn] = useState(finding.nextControlOn ?? '');
const [closureNotes, setClosureNotes] = useState('');
useEffect(() => {
setResponseText('');
setResponseDueBasis(finding.responseDueBasis ?? '');
setResponseDueDays(finding.responseDueDays == null ? '' : String(finding.responseDueDays));
setReportNotifiedOn(finding.reportNotifiedOn ?? '');
setCommittedOn(finding.companyCommittedCorrectionOn ?? '');
setNextControlOn(finding.nextControlOn ?? '');
}, [finding.id, finding.responseDueBasis, finding.responseDueDays, finding.reportNotifiedOn, finding.companyCommittedCorrectionOn, finding.nextControlOn]);
const workflow = finding.officeWorkflow ?? workflowFallback(finding);
const hasResponse = Boolean(finding.companyResponseReceivedOn);
const verificationDone = Boolean(finding.latestVerification?.outcome);
const closed = finding.status === 'CLOSED';
const verificationReadyToClose = finding.latestVerification?.outcome === 'RESOLVED'
&& finding.latestVerification.visitStatus === 'CLOSED';
const verificationActive = Boolean(finding.verificationVisit);
const verificationHistory = finding.verificationHistory ?? [];
const saveResponseDeadline = async (event: FormEvent) => {
event.preventDefault();
if (!responseDueBasis || responseDueDays === '') return;
if (responseDueBasis === 'REPORT_NOTIFICATION' && !reportNotifiedOn) return;
setBusy(true);
setError('');
setSuccess('');
try {
const updated = await updateInspectionFindingFollowUp(finding.id, {
responseDueBasis,
responseDueDays: Number(responseDueDays),
reportNotifiedOn: responseDueBasis === 'REPORT_NOTIFICATION' ? reportNotifiedOn : null,
});
onFindingChanged(updated);
setSuccess('Plazo administrativo calculado y guardado con su fecha base.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const registerResponse = async (event: FormEvent) => {
event.preventDefault();
if (!responseText.trim() || !responseOccurredAt) return;
const receivedOn = responseOccurredAt.slice(0, 10);
const firstPresentation = !hasResponse;
setBusy(true);
setError('');
setSuccess('');
try {
let updated = finding;
if (firstPresentation) {
updated = await updateInspectionFindingFollowUp(finding.id, {
companyResponse: responseText.trim(),
companyResponseReceivedOn: receivedOn,
companyCommittedCorrectionOn: committedOn || null,
nextControlOn: nextControlOn || null,
});
onFindingChanged(updated);
} else if (
committedOn !== (finding.companyCommittedCorrectionOn ?? '')
|| nextControlOn !== (finding.nextControlOn ?? '')
) {
updated = await updateInspectionFindingFollowUp(finding.id, {
companyCommittedCorrectionOn: committedOn || null,
nextControlOn: nextControlOn || null,
});
onFindingChanged(updated);
}
let communicationId = '';
let traceWarning = '';
if (canCreateCommunications) {
try {
const communication = await createInspectionFindingCommunication(finding.id, {
type: 'COMPANY_RESPONSE',
direction: 'INBOUND',
channel: responseChannel,
occurredAt: new Date(responseOccurredAt).toISOString(),
subject: `${firstPresentation ? 'Respuesta' : 'Nueva presentación'} de empresa · ${finding.code}`,
details: responseText.trim(),
contactName: responseContactName.trim() || null,
contactEmail: responseContactEmail.trim() || null,
});
communicationId = communication.id;
onEvidenceChanged();
} catch (requestError) {
traceWarning = `${firstPresentation ? 'La primera respuesta' : 'La actualización de seguimiento'} quedó registrada, pero la presentación histórica no pudo incorporarse: ${errorMessage(requestError)}`;
}
} else {
traceWarning = 'No tenés permiso para incorporar la presentación a la trazabilidad histórica.';
}
if (responseFile && canCreateEvidence) {
if (!communicationId) {
traceWarning = traceWarning || 'El documento debe incorporarse desde Documentos y trazabilidad porque no existe una presentación histórica vinculada.';
} else {
try {
await uploadInspectionFindingEvidence(finding.id, {
file: responseFile,
kind: 'DOCUMENT',
purpose: 'COMPANY_RESPONSE',
communicationId,
title: responseFile.name,
description: `Documento presentado por la empresa para ${finding.code}`,
capturedAt: new Date(responseOccurredAt).toISOString(),
deviceLabel: navigator.userAgent.slice(0, 200),
});
onEvidenceChanged();
} catch (requestError) {
traceWarning = `La presentación quedó registrada, pero el documento no pudo incorporarse: ${errorMessage(requestError)}`;
}
}
}
setResponseText('');
setResponseContactName('');
setResponseContactEmail('');
setResponseOccurredAt(localDateTime(new Date()));
setResponseFile(null);
if (responseFileRef.current) responseFileRef.current.value = '';
setSuccess(firstPresentation
? 'Primera respuesta registrada. Las presentaciones posteriores se agregarán sin modificar esta respuesta original.'
: 'Nueva presentación agregada al historial del hallazgo. La respuesta original permanece inmutable.');
if (traceWarning) setError(traceWarning);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const saveOperationalFollowUp = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError('');
setSuccess('');
try {
const updated = await updateInspectionFindingFollowUp(finding.id, {
companyCommittedCorrectionOn: committedOn || null,
nextControlOn: nextControlOn || null,
});
onFindingChanged(updated);
setSuccess('Seguimiento operativo actualizado. El cambio de fecha quedó agregado al historial sin borrar los controles anteriores.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const closeFinding = async (event: FormEvent) => {
event.preventDefault();
if (!closureNotes.trim()) return;
setBusy(true);
setError('');
setSuccess('');
try {
const updated = await closeInspectionFinding(finding.id, closureNotes.trim());
onFindingChanged(updated);
setClosureNotes('');
setSuccess('Hallazgo cerrado. La conclusión quedó incorporada al expediente histórico.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
return <article className="panel finding-office-workspace">
<div className="panel-heading">
<div><span className="eyebrow">EXPEDIENTE OPERATIVO</span><h2>Dar curso al hallazgo</h2><p className="section-copy">Respuesta, documentación, vencimientos, verificación y cierre desde una sola ficha.</p></div>
<span className={`status-badge large ${workflow.overdue ? 'observed' : workflow.stage === 'CLOSED' ? 'active' : 'pending'}`}>{workflow.label}</span>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<div className="finding-case-progress" aria-label="Etapas del seguimiento">
<div className="done"><span>1</span><strong>Hallazgo</strong><small>Registrado</small></div>
<i></i>
<div className={hasResponse ? 'done' : 'current'}><span>2</span><strong>Empresa</strong><small>{hasResponse ? 'Respuesta recibida' : 'Esperando respuesta'}</small></div>
<i></i>
<div className={verificationDone ? 'done' : hasResponse ? 'current' : 'pending'}><span>3</span><strong>Verificación</strong><small>{verificationDone ? 'Resultado registrado' : finding.verificationVisit ? 'Visita planificada' : finding.nextControlOn ? 'Fecha definida' : 'Pendiente'}</small></div>
<i></i>
<div className={closed ? 'done' : verificationReadyToClose ? 'current' : 'pending'}><span>4</span><strong>Cierre</strong><small>{closed ? 'Finalizado' : verificationReadyToClose ? 'Listo para cerrar' : 'Pendiente'}</small></div>
</div>
{!closed && <div className={`finding-recommended-action ${workflow.overdue ? 'overdue' : ''}`}>
<div><span className="eyebrow">ACCIÓN RECOMENDADA</span><strong>{workflow.label}</strong><p>{workflow.action}{workflow.dueOn ? ` Fecha: ${formatDateOnly(workflow.dueOn)}.` : ''}</p></div>
<div className="form-actions">
{(workflow.stage === 'WAITING_COMPANY' || workflow.stage === 'COMPANY_OVERDUE') && <a className="button primary" href="#respuesta-empresa">Registrar respuesta</a>}
{(workflow.stage === 'DEFINE_VERIFICATION' || workflow.stage === 'RESCHEDULE_VERIFICATION') && <a className="button primary" href="#seguimiento-operativo">Definir fecha</a>}
{workflow.stage === 'PLAN_VERIFICATION' && <Link className="button primary" to="/hallazgos/planificacion">Planificar visita <Icon name="chevron" /></Link>}
{workflow.stage === 'VERIFICATION_PLANNED' && finding.verificationVisit && <Link className="button primary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir verificación <Icon name="chevron" /></Link>}
{workflow.stage === 'READY_TO_CLOSE' && <a className="button primary" href="#cierre-hallazgo">Revisar y cerrar</a>}
</div>
</div>}
<div className="finding-office-sections">
<section id="respuesta-empresa" className={`finding-office-step ${hasResponse ? 'complete' : ''}`}>
<div className="subsection-heading"><div><span className="eyebrow">1 · ADMINISTRATIVO</span><h3>Respuesta de la empresa</h3></div><span className={`status-badge ${hasResponse ? 'active' : workflow.stage === 'COMPANY_OVERDUE' ? 'observed' : 'pending'}`}>{hasResponse ? 'Recibida' : workflow.stage === 'COMPANY_OVERDUE' ? 'Vencida' : 'Pendiente'}</span></div>
{!hasResponse && canFollowUp && <form className="finding-office-form finding-deadline-form" onSubmit={saveResponseDeadline}>
<div className="form-grid">
<label className="field"><span>Inicio del plazo de respuesta</span><SearchableSelect value={responseDueBasis} onChange={(event) => setResponseDueBasis(event.target.value as 'FINDING_DATE' | 'REPORT_NOTIFICATION' | '')}><option value="">Seleccionar</option><option value="FINDING_DATE">Desde el día del hallazgo</option><option value="REPORT_NOTIFICATION">Desde la notificación del informe</option></SearchableSelect></label>
<label className="field"><span>Cantidad de días</span><input type="number" min="0" max="3650" value={responseDueDays} onChange={(event) => setResponseDueDays(event.target.value)} placeholder="Ej.: 5" /></label>
{responseDueBasis === 'REPORT_NOTIFICATION' && <label className="field"><span>Informe notificado el</span><input type="date" value={reportNotifiedOn} onChange={(event) => setReportNotifiedOn(event.target.value)} /></label>}
</div>
<p className="field-help">DH guarda la regla, la fecha base y el vencimiento calculado. Para casos críticos puede computarse desde el hallazgo; para los demás, desde la notificación del informe.</p>
<div className="form-actions"><button className="button secondary" disabled={busy || !responseDueBasis || responseDueDays === '' || (responseDueBasis === 'REPORT_NOTIFICATION' && !reportNotifiedOn)}><Icon name="calendar" />Calcular vencimiento</button></div>
</form>}
<div className="finding-office-deadline-row"><div><small>Vencimiento empresa</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong>{finding.responseDueBasis && <span>{finding.responseDueBasis === 'FINDING_DATE' ? 'Desde hallazgo' : 'Desde notificación'} · {finding.responseDueDays ?? 0} día{finding.responseDueDays === 1 ? '' : 's'} · base {formatDateOnly(finding.responseDueBaseOn)}</span>}</div>{hasResponse && <div><small>Respuesta recibida</small><strong>{formatDateOnly(finding.companyResponseReceivedOn)}</strong></div>}</div>
{!closed && canFollowUp && <form className="finding-office-form" onSubmit={registerResponse}>
<label className="field"><span>{hasResponse ? 'Nueva presentación / ampliación de la empresa' : 'Respuesta o descargo recibido'}</span><textarea required rows={5} maxLength={20000} value={responseText} onChange={(event) => setResponseText(event.target.value)} placeholder={hasResponse ? 'Registrar sólo la nueva presentación; la respuesta original no se modifica…' : 'Resumir la presentación, acción correctiva o descargo recibido…'} /></label>
<div className="form-grid finding-response-grid">
<label className="field"><span>Recibida el</span><input type="datetime-local" required value={responseOccurredAt} onChange={(event) => setResponseOccurredAt(event.target.value)} /></label>
<label className="field"><span>Medio</span><SearchableSelect value={responseChannel} onChange={(event) => setResponseChannel(event.target.value as InspectionFindingCommunication['channel'])}><option value="EMAIL">Email</option><option value="LETTER">Nota formal</option><option value="IN_PERSON">Presencial</option><option value="PHONE">Teléfono</option><option value="SYSTEM">Sistema</option><option value="OTHER">Otro medio</option></SearchableSelect></label>
<label className="field"><span>Fecha informada por la empresa <em>opcional</em></span><input type="date" value={committedOn} onChange={(event) => setCommittedOn(event.target.value)} /></label>
<label className="field"><span>Fecha de verificación <em>opcional</em></span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label>
</div>
{canCreateCommunications && <div className="form-grid"><label className="field"><span>Contacto <em>opcional</em></span><input maxLength={200} value={responseContactName} onChange={(event) => setResponseContactName(event.target.value)} /></label><label className="field"><span>Email de contacto <em>opcional</em></span><input type="email" maxLength={320} value={responseContactEmail} onChange={(event) => setResponseContactEmail(event.target.value)} /></label></div>}
{canCreateCommunications && canCreateEvidence && <label className="field"><span>Documento presentado por la empresa <em>opcional</em></span><input ref={responseFileRef} type="file" accept="application/pdf" onChange={(event) => setResponseFile(event.target.files?.[0] ?? null)} /><small className="field-help">Si adjuntás un PDF, queda vinculado a la respuesta recibida y protegido con SHA-256.</small></label>}
<p className="field-help">Registrar la respuesta cumple el vencimiento administrativo. La fecha informada por la empresa es sólo un compromiso declarado y la fecha de verificación sigue siendo una decisión operativa de DH.</p>
<div className="form-actions"><button className="button primary" disabled={busy || !responseText.trim() || !responseOccurredAt}><Icon name="check" />{busy ? 'Registrando…' : hasResponse ? 'Agregar presentación' : 'Registrar respuesta'}</button></div>
</form>}
{hasResponse && <div className="finding-response-record">
<div><small>Primera respuesta registrada · inmutable</small><p>{finding.companyResponse}</p></div>
<div><small>Fecha informada por la empresa</small><strong>{formatDateOnly(finding.companyCommittedCorrectionOn)}</strong></div>
</div>}
</section>
<section id="seguimiento-operativo" className={`finding-office-step ${finding.nextControlOn || verificationDone ? 'complete' : ''}`}>
<div className="subsection-heading"><div><span className="eyebrow">2 · OPERATIVO</span><h3>Verificación del hallazgo</h3></div><span className={`status-badge ${verificationDone ? 'active' : finding.nextControlOn ? 'pending' : 'draft'}`}>{verificationDone ? 'Verificado' : finding.nextControlOn ? 'Fecha definida' : 'Sin fecha'}</span></div>
{hasResponse && canFollowUp && !closed && <form className="finding-office-form" onSubmit={saveOperationalFollowUp}>
<div className="form-grid finding-follow-up-dates">
<label className="field"><span>Fecha informada por la empresa</span><input type="date" value={committedOn} onChange={(event) => setCommittedOn(event.target.value)} /></label>
<label className="field"><span>Fecha de verificación / resolución</span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label>
</div>
<p className="field-help">El vencimiento administrativo original permanece intacto. Esta segunda fecha alimenta la planificación de verificaciones e inspecciones.</p>
<div className="form-actions"><button className="button secondary" disabled={busy}><Icon name="check" />Guardar seguimiento</button>{nextControlOn && !finding.verificationVisit && finding.latestVerification?.outcome !== 'RESOLVED' && <Link className="button primary" to="/hallazgos/planificacion">Planificar verificación <Icon name="chevron" /></Link>}{finding.verificationVisit && <Link className="button secondary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir visita <Icon name="chevron" /></Link>}</div>
</form>}
{!hasResponse && <div className="inline-empty">La planificación operativa se habilita cuando se registra la respuesta de la empresa.</div>}
{finding.latestVerification?.outcome && <div className="finding-verification-record"><small>Último resultado de campo</small><strong>{verificationOutcomeLabel(finding.latestVerification.outcome)}</strong><span>{formatDate(finding.latestVerification.verifiedAt)} · {finding.latestVerification.visitCode}</span><p>{finding.latestVerification.resultNotes || 'Sin observaciones adicionales.'}</p></div>}
{verificationHistory.length > 0 && <div className="finding-verification-history">
<div className="finding-verification-history-heading"><div><small>TRAZABILIDAD APPEND-ONLY</small><strong>Historial completo de verificaciones</strong></div><span>{verificationHistory.length} evento{verificationHistory.length === 1 ? '' : 's'}</span></div>
<div className="finding-verification-timeline">{verificationHistory.map((event) => <div className="finding-verification-event" key={event.id}>
<div className="finding-verification-event-dot" />
<div className="finding-verification-event-body">
<div className="finding-verification-event-title"><strong>{verificationEventLabel(event.eventType)}</strong><span>{formatDate(event.occurredAt)}</span></div>
{event.outcome && <span className="status-badge pending">{verificationOutcomeLabel(event.outcome)}</span>}
{event.eventType.startsWith('CONTROL_DATE_') && (event.previousControlOn || event.nextControlOn) && <div className="finding-verification-date-change"><small>Fecha anterior</small><strong>{formatDateOnly(event.previousControlOn)}</strong><i></i><small>Nueva fecha</small><strong>{formatDateOnly(event.nextControlOn)}</strong></div>}
{(event.eventType === 'VISIT_PLANNED' || event.eventType === 'RESULT_RECORDED') && event.targetControlOn && <div className="finding-verification-target-date"><small>{event.eventType === 'VISIT_PLANNED' ? 'Control previsto' : 'Fecha que se verificó'}</small><strong>{formatDateOnly(event.targetControlOn)}</strong></div>}
{event.verificationVisit && <Link className="finding-verification-visit-link" to={`/inspecciones/${event.verificationVisit.id}`}><span>{event.verificationVisit.code}</span><Icon name="chevron" /></Link>}
{event.notes && <p>{event.notes}</p>}
<div className="finding-verification-event-meta"><span>{event.actorUsername || 'Sistema'}</span>{event.evidenceCount > 0 && <span>{event.evidenceCount} evidencia{event.evidenceCount === 1 ? '' : 's'}</span>}</div>
</div>
</div>)}</div>
</div>}
</section>
<section id="cierre-hallazgo" className={`finding-office-step ${closed ? 'complete' : ''}`}>
<div className="subsection-heading"><div><span className="eyebrow">3 · CIERRE</span><h3>{closed ? 'Hallazgo cerrado' : verificationReadyToClose ? 'Listo para cierre' : 'Decisión administrativa'}</h3></div><span className={`status-badge ${closed ? 'active' : verificationReadyToClose ? 'active' : 'draft'}`}>{closed ? 'Cerrado' : verificationReadyToClose ? 'Revisar' : 'Pendiente'}</span></div>
{closed ? <div className="finding-response-record"><div><small>Cerrado el</small><strong>{formatDate(finding.closedAt)}</strong><p>{finding.closureNotes}</p></div></div> : canClose ? <form className="finding-office-form" onSubmit={closeFinding}>
{verificationReadyToClose && <Alert type="success">La verificación de campo indicó que el hallazgo está solucionado. Revisá fotos y documentos antes de registrar el cierre.</Alert>}
<label className="field"><span>Conclusión de cierre</span><textarea rows={4} maxLength={5000} value={closureNotes} onChange={(event) => setClosureNotes(event.target.value)} placeholder="Indicar qué se revisó, qué respaldo existe y por qué corresponde cerrar el hallazgo…" /></label>
<p className="field-help">{verificationActive ? 'Existe una visita de verificación activa. Debe finalizar antes del cierre administrativo.' : 'El cierre queda versionado y auditado. Los documentos y fotos continúan disponibles en el expediente.'}</p>
<div className="form-actions"><a className="button text" href="#documentos-hallazgo">Revisar documentos</a><button className="button danger-outline" disabled={busy || verificationActive || closureNotes.trim().length < 3}><Icon name="check" />Cerrar hallazgo</button></div>
</form> : <div className="inline-empty">El cierre está reservado a Inspector, Supervisor o Director.</div>}
</section>
</div>
{hasResponse && <div className="finding-office-footnote"><Icon name="history" /><span>Respuesta recibida {formatDateOnly(finding.companyResponseReceivedOn)} · Las presentaciones sucesivas, medios y documentos quedan en la trazabilidad sin modificar la respuesta original. {finding.companyCommittedCorrectionOn ? `La empresa informó ${formatDateOnly(finding.companyCommittedCorrectionOn)}.` : 'La empresa no informó una fecha de corrección.'}</span></div>}
</article>;
}