chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,48 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listFindingCatalogProposals, reviewFindingCatalogProposal } from '../../lib/api';
import type { FindingAdminCatalog, FindingCatalogProposal } from '../../lib/api';
export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdminCatalog }) {
const [items, setItems] = useState<FindingCatalogProposal[]>([]);
const [matches, setMatches] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => listFindingCatalogProposals({ status: 'PENDING' }).then(setItems);
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, []);
const review = async (proposal: FindingCatalogProposal, decision: 'MATCH' | 'REJECT') => {
setBusyId(proposal.id); setError(''); setSuccess('');
try {
if (decision === 'MATCH' && !matches[proposal.id]) {
setError('Seleccioná primero el tipo de hallazgo del catálogo que corresponde.');
return;
}
await reviewFindingCatalogProposal(proposal.id, {
decision,
catalogItemId: decision === 'MATCH' ? matches[proposal.id] : undefined,
notes: decision === 'REJECT' ? 'Propuesta descartada desde administración de catálogo.' : 'Propuesta vinculada a un tipo existente del catálogo.',
});
await load();
setSuccess(decision === 'MATCH' ? 'Propuesta incorporada al criterio de catálogo futuro.' : 'Propuesta descartada sin modificar el hallazgo histórico.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusyId('');
}
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando propuestas OTROS…" /></div>;
return <section className="panel finding-proposals-panel">
<div className="panel-heading"><div><span className="eyebrow">PROPUESTAS DESDE CAMPO</span><h2>Hallazgos cargados como OTROS</h2><p className="section-copy">El hallazgo original queda intacto. Esta bandeja sirve para decidir si conviene sumar esa opción al catálogo para inspecciones futuras.</p></div><span className="count-pill">{items.length} pendientes</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{items.length === 0 ? <EmptyState title="Sin propuestas pendientes" text="Los nuevos hallazgos cargados como OTROS aparecerán acá." /> : <div className="finding-proposal-list">{items.map((proposal) => <article className="finding-proposal-card" key={proposal.id}><div><span className="eyebrow">{proposal.assetTypeName} · {proposal.assetCode}</span><h3>{proposal.proposedTitle}</h3><p>{proposal.description}</p><small><Link to={`/hallazgos/${proposal.findingId}`}>{proposal.findingCode}</Link> · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}</small></div><div className="finding-proposal-actions"><label className="field"><span>Coincide con</span><SearchableSelect value={matches[proposal.id] ?? ''} onChange={(event) => setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}><option value="">Seleccionar tipo existente</option>{catalog.items.filter((item) => item.isActive && item.categoryActive).map((item) => <option value={item.id} key={item.id}>{item.title} · {item.code}</option>)}</SearchableSelect></label><div className="form-actions"><button type="button" className="button secondary" disabled={busyId === proposal.id} onClick={() => review(proposal, 'REJECT')}>Descartar</button><button type="button" className="button primary" disabled={busyId === proposal.id || !matches[proposal.id]} onClick={() => review(proposal, 'MATCH')}><Icon name="check" />Vincular</button></div><small>Si todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.</small></div></article>)}</div>}
</section>;
}
@@ -0,0 +1,95 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getFindingCatalogAssetTypeSelection,
listAssetTypes,
replaceFindingCatalogAssetTypeSelection,
} from '../../lib/api';
import type { AssetType, FindingCatalogAssetTypeSelection } from '../../lib/api';
export function FindingCatalogTypeApplicabilityPanel() {
const [types, setTypes] = useState<AssetType[]>([]);
const [typeId, setTypeId] = useState('');
const [selection, setSelection] = useState<FindingCatalogAssetTypeSelection | null>(null);
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
listAssetTypes()
.then((loaded) => {
const technical = loaded.filter((type) => type.isActive && type.operationalRole === 'GENERIC');
setTypes(technical);
setTypeId(technical[0]?.id ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!typeId) { setSelection(null); return; }
setLoading(true); setError(''); setSuccess('');
getFindingCatalogAssetTypeSelection(typeId)
.then((loaded) => {
setSelection(loaded);
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(loaded.reason ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [typeId]);
const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName]
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? [];
}, [selection, search]);
const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const save = async () => {
if (!selection) return;
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetTypeSelection(selection.assetType.id, {
enabledItemIds: [...enabled],
reason,
});
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(saved.reason ?? reason);
setSuccess(`Aplicabilidad guardada para ${saved.assetType.name}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading && types.length === 0) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (types.length === 0) return null;
return <section className="panel finding-applicability-panel">
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR TIPO TÉCNICO</span><h2>Qué hallazgos verá el inspector</h2><p className="section-copy">Configurá el catálogo base para cada tipo de elemento del Inventario. Después se pueden hacer excepciones por objeto concreto.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar">
<label className="field"><span>Tipo técnico</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}>{types.map((type) => <option value={type.id} key={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Buscar hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
</div>
{selection && !selection.configured && <div className="temporal-notice"><Icon name="alert" /><p><strong>Este tipo todavía no fue configurado.</strong> Para no romper el funcionamiento actual, hoy recibe todo el catálogo activo. Al guardar esta pantalla, sólo quedarán habilitados los seleccionados.</p></div>}
<div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={() => setEnabled(new Set(selection?.items.map((item) => item.id) ?? []))}>Seleccionar todos</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
<label className="field"><span>Motivo de configuración</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: catálogo aplicable a tanques según criterio técnico de Hidrocarburos…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar aplicabilidad'}</button></div>
</section>;
}
@@ -0,0 +1,370 @@
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>;
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listInspectionActs } from '../../lib/api';
import type { InspectionActListItem, InspectionVisit } from '../../lib/api';
import { formatDate } from '../../lib/format';
import { inspectionActStatusClass, inspectionActStatusLabel } from './inspectionActPresentation';
export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
const { hasPermission } = useAuth();
const canRead = hasPermission('inspection_acts.read');
const [acts, setActs] = useState<InspectionActListItem[]>([]);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
useEffect(() => {
if (!canRead) return;
setLoading(true);
setError('');
listInspectionActs(visit.id, { pageSize: 100 })
.then((response) => setActs(response.data))
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [canRead, visit.id]);
if (!canRead) return null;
return <section className="inspection-acts-panel">
<div className="panel-heading">
<div><span className="eyebrow">ACTA DE LA VISITA</span><h2>Acta única</h2><p className="section-copy">Cada visita genera una única acta, con numeración anual oficial y versiones inmutables.</p></div>
<span className="status-badge pending">Sólo lectura</span>
</div>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>El acta se crea en la APK.</strong> El dashboard permite consultar el acta, sus hallazgos, firmas e informe después de la sincronización.</p></div>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0
? <EmptyState title="Sin acta sincronizada" text={visit.status === 'IN_PROGRESS' ? 'El inspector debe crearla desde la APK.' : 'Aparecerá aquí cuando el inspector la genere desde la APK.'} />
: <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Acta de inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div></div>}
</section>;
}
@@ -0,0 +1,85 @@
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getInspectionClosure, getInspectionSignatureBlob } from '../../lib/api';
import type { InspectionAct, InspectionActSignature, InspectionClosure } from '../../lib/api';
import { formatDate } from '../../lib/format';
function signatureStatusLabel(value: InspectionActSignature['status']): string {
if (value === 'SIGNED') return 'Firmada';
if (value === 'REFUSED') return 'Se negó a firmar';
return 'Ausente';
}
function signerTypeLabel(value: InspectionActSignature['signerType']): string {
return value === 'INSPECTOR' ? 'Inspector/a' : 'Responsable de la empresa';
}
export function InspectionClosurePanel({ act }: { act: InspectionAct }) {
const { hasPermission } = useAuth();
const canRead = hasPermission('inspection_closure.read');
const [closure, setClosure] = useState<InspectionClosure | null>(null);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
useEffect(() => {
if (!canRead) return;
setLoading(true);
getInspectionClosure(act.id)
.then(setClosure)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [act.id, canRead]);
const inspectorSignatures = useMemo(
() => closure?.signatures.filter((item) => item.signerType === 'INSPECTOR') ?? [],
[closure],
);
const companyOutcome = useMemo(
() => closure?.signatures.find((item) => item.signerType === 'COMPANY_RESPONSIBLE') ?? null,
[closure],
);
const viewSignature = async (signature: InspectionActSignature) => {
const tab = window.open('about:blank', '_blank');
if (tab) tab.opener = null;
try {
const blob = await getInspectionSignatureBlob(signature.id);
const url = URL.createObjectURL(blob);
if (tab) tab.location.href = url;
else window.open(url, '_blank', 'noopener,noreferrer');
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (requestError) {
tab?.close();
setError(errorMessage(requestError));
}
};
if (!canRead) return null;
if (loading || !closure) return <section className="panel inspection-closure-panel"><LoadingBlock label="Cargando cierre del acta…" /></section>;
const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome);
return <section className="panel inspection-closure-panel">
<div className="panel-heading"><div><span className="eyebrow">CIERRE DE LA INSPECCIÓN · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">La preparación, las firmas y el cierre son operaciones exclusivas de la APK para inspectores.</p></div><span className={`status-badge large ${act.status === 'CLOSED' ? 'active' : act.status === 'READY' ? 'observed' : 'pending'}`}>{act.status === 'CLOSED' ? 'Cierre sellado' : act.status === 'READY' ? 'Esperando firmas' : 'Pendiente en APK'}</span></div>
{error && <Alert>{error}</Alert>}
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Operación exclusiva en APK.</strong> El dashboard muestra las constancias sincronizadas, pero no permite identificar al responsable, firmar, reabrir ni cerrar el acta.</p></div>
<div className="closure-step-grid">
<div className={closure.responsible ? 'complete' : ''}><span>1</span><strong>Responsable</strong><small>{closure.responsible ? 'Registrado' : 'Pendiente'}</small></div>
<div className={closure.closure?.isCurrent ? 'complete' : ''}><span>2</span><strong>Congelado</strong><small>{closure.closure?.isCurrent ? 'SHA-256 listo' : 'Pendiente'}</small></div>
<div className={constanciasCompletas ? 'complete' : ''}><span>3</span><strong>Constancias</strong><small>{inspectorSignatures.length} inspector · {companyOutcome ? 'empresa OK' : 'empresa pendiente'}</small></div>
<div className={act.status === 'CLOSED' ? 'complete' : ''}><span>4</span><strong>Cierre</strong><small>{act.status === 'CLOSED' ? 'Sellado' : 'Pendiente'}</small></div>
</div>
{closure.responsible && <div className="responsible-summary"><div><small>Situación</small><strong>{closure.responsible.attendanceStatus === 'PRESENT' ? 'Presente' : 'Ausente'}</strong></div><div><small>Responsable</small><strong>{closure.responsible.fullName ?? 'No estuvo presente'}</strong></div><div><small>Documento / cargo</small><strong>{closure.responsible.documentNumber ? `${closure.responsible.documentType} ${closure.responsible.documentNumber}` : 'No informado'}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</strong></div><div><small>Contacto</small><strong>{closure.responsible.email ?? closure.responsible.phone ?? 'No informado'}</strong></div></div>}
{closure.closure?.isCurrent && <div className="closure-hash-card"><div><span className="eyebrow">CONTENIDO CONGELADO</span><strong>{closure.closure.schemaVersion}</strong><small>Preparado {formatDate(closure.closure.preparedAt)}</small></div><code>{closure.closure.preparedSha256}</code></div>}
{closure.signatures.length > 0 && <div className="signature-records">{closure.signatures.map((signature) => <article key={signature.id}><div><span className={`status-badge ${signature.status === 'SIGNED' ? 'active' : 'observed'}`}>{signatureStatusLabel(signature.status)}</span><strong>{signature.signerName}</strong><small>{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}</small></div><code title={signature.signaturePayloadSha256}>{signature.signaturePayloadSha256}</code>{signature.status === 'SIGNED' ? <button type="button" className="button secondary" onClick={() => viewSignature(signature)}>Ver firma</button> : <p>{signature.reason}</p>}</article>)}</div>}
{act.status === 'CLOSED' && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA Y VISITA CERRADAS</span><strong>{formatDate(closure.closure.serverClosedAt)}</strong><p>Los hallazgos continúan abiertos para la respuesta de la empresa y el próximo control.</p><code>{closure.closure.finalSha256}</code></div></div>}
</section>;
}
@@ -0,0 +1,330 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
createInspectionFindingCommunication,
getInspectionFindingEvidenceBlob,
listInspectionFindingCommunications,
listInspectionFindingEvidence,
uploadInspectionFindingEvidence,
} from '../../lib/api';
import type {
InspectionEvidenceKind,
InspectionEvidencePurpose,
InspectionFinding,
InspectionFindingCommunication,
InspectionFindingEvidence,
} from '../../lib/api';
import { formatDate } 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 fileSize(value: number): string {
return value >= 1024 * 1024
? `${(value / (1024 * 1024)).toFixed(1)} MB`
: `${Math.max(1, Math.round(value / 1024))} KB`;
}
const purposeLabels: Record<InspectionEvidencePurpose, string> = {
OBSERVATION: 'Evidencia del hallazgo',
VERIFICATION: 'Evidencia de verificación',
COMPANY_RESPONSE: 'PDF de respuesta de la empresa',
COMMUNICATION_ATTACHMENT: 'Adjunto de comunicación',
OTHER_DOCUMENT: 'Otro documento',
};
const typeLabels: Record<InspectionFindingCommunication['type'], string> = {
COMPANY_RESPONSE: 'Respuesta de la empresa',
AUTHORITY_NOTICE: 'Notificación del organismo',
FOLLOW_UP: 'Seguimiento',
OTHER: 'Otra comunicación',
};
const channelLabels: Record<InspectionFindingCommunication['channel'], string> = {
EMAIL: 'Email',
IN_PERSON: 'Presencial',
PHONE: 'Teléfono',
LETTER: 'Nota formal',
SYSTEM: 'Sistema',
OTHER: 'Otro medio',
};
function EvidencePhotoPreview({ evidence }: { evidence: InspectionFindingEvidence }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getInspectionFindingEvidenceBlob(evidence.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [evidence.id]);
return url
? <img src={url} alt={evidence.title || evidence.originalName} />
: <div className="media-preview-loading"><span className="spinner" /></div>;
}
export function InspectionEvidencePanel({
finding,
canReadEvidence,
canCreateEvidence,
canCreateFieldEvidence,
canReadCommunications,
canCreateCommunications,
refreshKey = 0,
managedCompanyResponse = false,
}: {
finding: InspectionFinding;
canReadEvidence: boolean;
canCreateEvidence: boolean;
canCreateFieldEvidence: boolean;
canReadCommunications: boolean;
canCreateCommunications: boolean;
refreshKey?: number;
managedCompanyResponse?: boolean;
}) {
const fileRef = useRef<HTMLInputElement | null>(null);
const [evidence, setEvidence] = useState<InspectionFindingEvidence[]>([]);
const [communications, setCommunications] = useState<InspectionFindingCommunication[]>([]);
const [loading, setLoading] = useState(canReadEvidence || canReadCommunications);
const [busy, setBusy] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [kind, setKind] = useState<InspectionEvidenceKind>(
canCreateFieldEvidence ? 'PHOTO' : 'DOCUMENT',
);
const [purpose, setPurpose] = useState<InspectionEvidencePurpose>(
canCreateFieldEvidence ? 'OBSERVATION' : 'OTHER_DOCUMENT',
);
const [file, setFile] = useState<File | null>(null);
const [communicationId, setCommunicationId] = useState('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [capturedAt, setCapturedAt] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [accuracyM, setAccuracyM] = useState('');
const [communicationType, setCommunicationType] = useState<InspectionFindingCommunication['type']>(managedCompanyResponse ? 'FOLLOW_UP' : 'COMPANY_RESPONSE');
const [direction, setDirection] = useState<InspectionFindingCommunication['direction']>('INBOUND');
const [channel, setChannel] = useState<InspectionFindingCommunication['channel']>('EMAIL');
const [occurredAt, setOccurredAt] = useState(localDateTime(new Date()));
const [subject, setSubject] = useState('');
const [details, setDetails] = useState('');
const [contactName, setContactName] = useState('');
const [contactEmail, setContactEmail] = useState('');
const load = async () => {
const [evidenceValue, communicationValue] = await Promise.all([
canReadEvidence ? listInspectionFindingEvidence(finding.id) : Promise.resolve([]),
canReadCommunications
? listInspectionFindingCommunications(finding.id)
: Promise.resolve([]),
]);
setEvidence(evidenceValue);
setCommunications(communicationValue);
};
useEffect(() => {
if (!canReadEvidence && !canReadCommunications) return;
setLoading(true);
load()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [finding.id, canReadEvidence, canReadCommunications, refreshKey]);
const resetEvidenceForm = () => {
setFile(null); setCommunicationId(''); setTitle(''); setDescription('');
setCapturedAt(''); setLatitude(''); setLongitude(''); setAccuracyM('');
if (fileRef.current) fileRef.current.value = '';
};
const chooseKind = (value: InspectionEvidenceKind) => {
setKind(value);
setPurpose(value === 'PHOTO' ? 'OBSERVATION' : 'OTHER_DOCUMENT');
setCommunicationId(''); setFile(null);
if (fileRef.current) fileRef.current.value = '';
};
const choosePurpose = (value: InspectionEvidencePurpose) => {
setPurpose(value);
if (value === 'COMPANY_RESPONSE') {
const response = communications.find((item) => item.type === 'COMPANY_RESPONSE');
setCommunicationId(response?.id ?? '');
} else if (value !== 'COMMUNICATION_ATTACHMENT') {
setCommunicationId('');
}
};
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setAccuracyM(position.coords.accuracy.toFixed(3));
if (!capturedAt) setCapturedAt(localDateTime(position.timestamp));
setLocating(false);
},
() => {
setError('No fue posible obtener la ubicación del dispositivo.');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
);
};
const upload = async (event: FormEvent) => {
event.preventDefault();
if (!file) return;
setBusy(true); setError(''); setSuccess('');
try {
await uploadInspectionFindingEvidence(finding.id, {
file,
kind,
purpose,
communicationId: communicationId || undefined,
title: title.trim() || undefined,
description: description.trim() || undefined,
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
accuracyM: accuracyM ? Number(accuracyM) : undefined,
deviceLabel: navigator.userAgent.slice(0, 200),
});
await load();
resetEvidenceForm();
setSuccess('Evidencia incorporada y sellada con SHA-256.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const createCommunication = async (event: FormEvent) => {
event.preventDefault();
setBusy(true); setError(''); setSuccess('');
try {
const created = await createInspectionFindingCommunication(finding.id, {
type: communicationType,
direction: communicationType === 'COMPANY_RESPONSE' ? 'INBOUND' : direction,
channel,
occurredAt: new Date(occurredAt).toISOString(),
subject,
details: details.trim() || null,
contactName: contactName.trim() || null,
contactEmail: contactEmail.trim() || null,
});
await load();
setCommunicationId(created.id);
setSubject(''); setDetails(''); setContactName(''); setContactEmail('');
setOccurredAt(localDateTime(new Date()));
setSuccess(
created.type === 'COMPANY_RESPONSE'
? 'Respuesta registrada. Ya podés vincular y subir el PDF recibido.'
: 'Comunicación incorporada al historial inmutable.',
);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const download = async (item: InspectionFindingEvidence) => {
setError('');
try {
const blob = await getInspectionFindingEvidenceBlob(item.id, true);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = item.originalName;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (requestError) {
setError(errorMessage(requestError));
}
};
const preview = async (item: InspectionFindingEvidence) => {
setError('');
try {
const blob = await getInspectionFindingEvidenceBlob(item.id);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (requestError) {
setError(errorMessage(requestError));
}
};
if (!canReadEvidence && !canReadCommunications) return null;
const responseCommunications = communications.filter((item) => item.type === 'COMPANY_RESPONSE');
const requiresCommunication = purpose === 'COMPANY_RESPONSE'
|| purpose === 'COMMUNICATION_ATTACHMENT';
const canUploadSelected = kind === 'PHOTO' ? canCreateFieldEvidence : canCreateEvidence;
return <details className="finding-evidence-panel">
<summary>
<span><strong>{managedCompanyResponse ? 'Documentos y trazabilidad' : 'Evidencias y comunicaciones'}</strong><small>{managedCompanyResponse ? 'Adjuntos adicionales, comunicaciones y respaldo histórico' : 'Fotos, GPS, PDFs y trazabilidad protegida'}</small></span>
<span className="count-pill">{evidence.length + communications.length}</span>
</summary>
<div className="finding-evidence-body">
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{loading ? <LoadingBlock label="Cargando evidencias…" /> : <>
{canCreateCommunications && finding.status === 'OPEN' && <form className="communication-form" onSubmit={createCommunication}>
<div className="subsection-heading"><div><span className="eyebrow">NUEVO REGISTRO</span><h4>{managedCompanyResponse ? 'Comunicación adicional' : 'Comunicación'}</h4></div></div>
<div className="form-grid">
<label className="field"><span>Tipo</span><SearchableSelect value={communicationType} onChange={(event) => { const value = event.target.value as InspectionFindingCommunication['type']; setCommunicationType(value); if (value === 'COMPANY_RESPONSE') setDirection('INBOUND'); }}>{!managedCompanyResponse && <option value="COMPANY_RESPONSE">Respuesta de la empresa</option>}<option value="AUTHORITY_NOTICE">Notificación del organismo</option><option value="FOLLOW_UP">Seguimiento</option><option value="OTHER">Otra comunicación</option></SearchableSelect></label>
<label className="field"><span>Sentido</span><SearchableSelect value={communicationType === 'COMPANY_RESPONSE' ? 'INBOUND' : direction} disabled={communicationType === 'COMPANY_RESPONSE'} onChange={(event) => setDirection(event.target.value as InspectionFindingCommunication['direction'])}><option value="INBOUND">Recibida</option><option value="OUTBOUND">Enviada</option><option value="INTERNAL">Interna</option></SearchableSelect></label>
<label className="field"><span>Medio</span><SearchableSelect value={channel} onChange={(event) => setChannel(event.target.value as InspectionFindingCommunication['channel'])}>{Object.entries(channelLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</SearchableSelect></label>
<label className="field"><span>Fecha y hora</span><input type="datetime-local" required value={occurredAt} onChange={(event) => setOccurredAt(event.target.value)} /></label>
</div>
<label className="field"><span>Asunto</span><input required maxLength={250} value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Ej.: Respuesta al hallazgo y compromiso de corrección" /></label>
<label className="field"><span>Detalle <em>opcional</em></span><textarea rows={3} maxLength={10000} value={details} onChange={(event) => setDetails(event.target.value)} /></label>
<div className="form-grid"><label className="field"><span>Contacto <em>opcional</em></span><input maxLength={200} value={contactName} onChange={(event) => setContactName(event.target.value)} /></label><label className="field"><span>Email <em>opcional</em></span><input type="email" maxLength={320} value={contactEmail} onChange={(event) => setContactEmail(event.target.value)} /></label></div>
<div className="form-actions"><button className="button secondary" disabled={busy || !subject.trim() || !occurredAt}><Icon name="plus" />Registrar comunicación</button></div>
</form>}
{canCreateEvidence && finding.status === 'OPEN' && <form className="evidence-upload-form" onSubmit={upload}>
<div className="subsection-heading"><div><span className="eyebrow">ARCHIVO PROTEGIDO</span><h4>Incorporar evidencia</h4></div></div>
<div className="form-grid">
<label className="field"><span>Clase</span><SearchableSelect value={kind} onChange={(event) => chooseKind(event.target.value as InspectionEvidenceKind)}><option value="PHOTO" disabled={!canCreateFieldEvidence}>Fotografía de campo</option><option value="DOCUMENT">Documento PDF</option></SearchableSelect></label>
<label className="field"><span>Finalidad</span><SearchableSelect value={purpose} onChange={(event) => choosePurpose(event.target.value as InspectionEvidencePurpose)}>{kind === 'PHOTO' ? <option value="OBSERVATION">Evidencia del hallazgo</option> : <><option value="COMPANY_RESPONSE">PDF de respuesta de la empresa</option><option value="COMMUNICATION_ATTACHMENT">Adjunto de comunicación</option>{canCreateFieldEvidence && <option value="OBSERVATION">Documento de campo</option>}<option value="OTHER_DOCUMENT">Otro documento</option></>}</SearchableSelect></label>
</div>
{requiresCommunication && <label className="field"><span>Comunicación vinculada</span><SearchableSelect required value={communicationId} onChange={(event) => setCommunicationId(event.target.value)}><option value="">Seleccionar</option>{(purpose === 'COMPANY_RESPONSE' ? responseCommunications : communications).map((item) => <option key={item.id} value={item.id}>{formatDate(item.occurredAt)} · {item.subject}</option>)}</SearchableSelect><small className="field-help">{purpose === 'COMPANY_RESPONSE' && responseCommunications.length === 0 ? 'Primero registrá arriba la respuesta recibida de la empresa.' : 'El vínculo queda preservado y no puede modificarse.'}</small></label>}
<div className="form-grid"><label className="field"><span>Archivo</span><input ref={fileRef} type="file" accept={kind === 'PHOTO' ? 'image/jpeg,image/png,image/webp' : 'application/pdf'} capture={kind === 'PHOTO' ? 'environment' : undefined} onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label><label className="field"><span>Fecha de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} /></label></div>
<div className="form-grid"><label className="field"><span>Título <em>opcional</em></span><input maxLength={200} value={title} onChange={(event) => setTitle(event.target.value)} /></label><label className="field"><span>Descripción <em>opcional</em></span><input maxLength={4000} value={description} onChange={(event) => setDescription(event.target.value)} /></label></div>
{kind === 'PHOTO' && <div className="media-location-row"><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Capturar GPS actual'}</button>{latitude && longitude ? <span>{latitude}, {longitude}{accuracyM ? ` · ±${accuracyM} m` : ''}</span> : <span>Sin coordenadas</span>}<button type="button" className="button text" onClick={() => { setLatitude(''); setLongitude(''); setAccuracyM(''); }}>Limpiar</button></div>}
<p className="field-help">El original, autor, dispositivo, fecha, ubicación y hash SHA-256 quedan inmutables. Máximo 15 MB.</p>
<div className="form-actions"><button className="button primary" disabled={busy || !file || !canUploadSelected || (requiresCommunication && !communicationId)}><Icon name="plus" />{busy ? 'Subiendo…' : 'Incorporar evidencia'}</button></div>
</form>}
<section className="evidence-list-section"><div className="subsection-heading"><div><span className="eyebrow">ARCHIVOS</span><h4>Evidencias protegidas</h4></div><span>{evidence.length}</span></div>{evidence.length === 0 ? <div className="inline-empty">Este hallazgo todavía no tiene fotos ni documentos.</div> : <div className="finding-evidence-grid">{evidence.map((item) => <article className={`finding-evidence-card ${item.kind.toLowerCase()}`} key={item.id}>{item.kind === 'PHOTO' ? <div className="finding-evidence-preview"><EvidencePhotoPreview evidence={item} /></div> : <span className="asset-symbol"><Icon name="clipboard" /></span>}<div className="finding-evidence-copy"><small>{purposeLabels[item.purpose]}</small><strong>{item.title || item.originalName}</strong><span>{fileSize(item.sizeBytes)} · {formatDate(item.capturedAt || item.createdAt)}</span>{item.description && <p>{item.description}</p>}{item.communication && <span>Vinculado a: {item.communication.subject}</span>}{item.latitude != null && item.longitude != null && <span>GPS {item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}{item.accuracyM != null ? ` · ±${item.accuracyM} m` : ''}</span>}<code>SHA-256 {item.sha256.slice(0, 16)}</code><span>Autor: {item.uploadedByUsername ?? 'sistema'} · {item.deviceLabel ?? item.source}</span></div><div className="media-card-actions"><button type="button" className="button text" onClick={() => preview(item)}>Ver</button><button type="button" className="button text" onClick={() => download(item)}>Descargar</button></div></article>)}</div>}</section>
<section className="communication-timeline"><div className="subsection-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h4>Historial de comunicaciones</h4></div><span>{communications.length}</span></div>{communications.length === 0 ? <div className="inline-empty">Todavía no hay comunicaciones registradas.</div> : <div className="communication-list">{communications.map((item) => <article key={item.id}><span className={`communication-direction ${item.direction.toLowerCase()}`}>{item.direction === 'INBOUND' ? 'Recibida' : item.direction === 'OUTBOUND' ? 'Enviada' : 'Interna'}</span><div><strong>{item.subject}</strong><small>{typeLabels[item.type]} · {channelLabels[item.channel]} · {formatDate(item.occurredAt)}</small>{item.details && <p>{item.details}</p>}{(item.contactName || item.contactEmail) && <span>{item.contactName}{item.contactName && item.contactEmail ? ' · ' : ''}{item.contactEmail}</span>}<span>{item.attachmentCount} adjunto{item.attachmentCount === 1 ? '' : 's'} · Registró {item.createdByUsername ?? 'sistema'}</span></div></article>)}</div>}</section>
</>}
</div>
</details>;
}
@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listInspectionFindings, updateInspectionFindingFollowUp } from '../../lib/api';
import type { InspectionAct, InspectionFinding } from '../../lib/api';
import { formatDateOnly } from '../../lib/format';
import { InspectionEvidencePanel } from './InspectionEvidencePanel';
function todayInput(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
function FindingCard({
finding,
canFollowUp,
canReadEvidence,
canCreateEvidence,
canReadCommunications,
canCreateCommunications,
onSaved,
}: {
finding: InspectionFinding;
canFollowUp: boolean;
canReadEvidence: boolean;
canCreateEvidence: boolean;
canReadCommunications: boolean;
canCreateCommunications: boolean;
onSaved: (value: InspectionFinding) => void;
}) {
const [companyResponse, setCompanyResponse] = useState(finding.companyResponse ?? '');
const [responseReceivedOn, setResponseReceivedOn] = useState(
finding.companyResponseReceivedOn ?? todayInput(),
);
const [committedOn, setCommittedOn] = useState(
finding.companyCommittedCorrectionOn ?? '',
);
const [nextControlOn, setNextControlOn] = useState(finding.nextControlOn ?? '');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
setCompanyResponse(finding.companyResponse ?? '');
setResponseReceivedOn(finding.companyResponseReceivedOn ?? todayInput());
setCommittedOn(finding.companyCommittedCorrectionOn ?? '');
setNextControlOn(finding.nextControlOn ?? '');
}, [finding]);
const saveFollowUp = async (event: FormEvent) => {
event.preventDefault();
setBusy(true); setError(''); setSuccess('');
try {
const hasResponse = Boolean(companyResponse.trim());
const updated = await updateInspectionFindingFollowUp(finding.id, {
companyResponse: hasResponse ? companyResponse : null,
companyResponseReceivedOn: hasResponse ? responseReceivedOn : null,
companyCommittedCorrectionOn: hasResponse && committedOn ? committedOn : null,
nextControlOn: nextControlOn || null,
});
onSaved(updated);
setSuccess('Seguimiento actualizado; el hallazgo continúa abierto hasta su verificación y cierre.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
return <article className="inspection-finding-card">
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.catalog?.categoryName ?? 'Hallazgo personalizado'} · {finding.asset.code} · {finding.asset.name}</p></div><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : 'Cerrado'}</span></div>
<p className="inspection-finding-description">{finding.description}</p>{finding.severity && <span className="tag">Gravedad {finding.severity}/10{finding.suggestedSeverity ? ` · sugerida ${finding.suggestedSeverity}/10` : ''}</span>}
<div className="inspection-finding-dates"><span><small>Vence respuesta empresa</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong></span><span><small>Fecha informada por empresa</small><strong>{formatDateOnly(finding.companyCommittedCorrectionOn)}</strong></span><span className={finding.nextControlOn && finding.nextControlOn < todayInput() ? 'overdue' : ''}><small>Verificación / resolución</small><strong>{formatDateOnly(finding.nextControlOn)}</strong></span><span><small>Versión</small><strong>v{finding.currentVersion}</strong></span></div>
{finding.legalBasis && <details className="finding-detail"><summary>Normativa aplicable</summary><p>{finding.legalBasis}</p>{finding.glossary && <small>{finding.glossary}</small>}</details>}
{finding.companyResponse && <div className="company-response-summary"><small>RESPUESTA RECIBIDA {formatDateOnly(finding.companyResponseReceivedOn)}</small><p>{finding.companyResponse}</p></div>}
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{canFollowUp && finding.status === 'OPEN' && <details className="finding-editor follow-up-editor" open={!finding.nextControlOn}><summary>Respuesta de la empresa y verificación</summary><form onSubmit={saveFollowUp}><label className="field"><span>Respuesta recibida de la empresa</span><textarea rows={4} value={companyResponse} maxLength={20000} onChange={(event) => setCompanyResponse(event.target.value)} placeholder="Compromiso, explicación o acción correctiva informada…" /></label><div className="form-grid finding-follow-up-dates"><label className="field"><span>Respuesta recibida el</span><input type="date" value={responseReceivedOn} disabled={!companyResponse.trim()} onChange={(event) => setResponseReceivedOn(event.target.value)} /></label><label className="field"><span>Fecha informada por la empresa</span><input type="date" value={committedOn} disabled={!companyResponse.trim()} 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 de respuesta es administrativo. La fecha de verificación se usa para planificar el control posterior. Adjuntá la presentación en Evidencias y comunicaciones.</p><div className="form-actions"><button className="button primary" disabled={busy || (Boolean(companyResponse.trim()) && !responseReceivedOn)}><Icon name="check" />Guardar seguimiento</button></div></form></details>}
<InspectionEvidencePanel finding={finding} canReadEvidence={canReadEvidence} canCreateEvidence={canCreateEvidence} canCreateFieldEvidence={false} canReadCommunications={canReadCommunications} canCreateCommunications={canCreateCommunications} />
</article>;
}
export function InspectionFindingsPanel({ act }: { act: InspectionAct }) {
const { hasPermission } = useAuth();
const canRead = hasPermission('inspection_findings.read');
const [findings, setFindings] = useState<InspectionFinding[]>([]);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
const canFollowUp = hasPermission('inspection_findings.follow_up');
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 (!canRead) return;
setLoading(true); setError('');
listInspectionFindings(act.id)
.then(setFindings)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [act.id, canRead]);
const replaceFinding = (value: InspectionFinding) => {
setFindings((current) => current.map((item) => item.id === value.id ? value : item));
};
if (!canRead) return null;
if (loading) return <LoadingBlock label="Cargando hallazgos…" />;
return <section className="inspection-findings-panel panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS DEL ACTA · SÓLO LECTURA DE CAMPO</span><h2>Múltiples hallazgos</h2><p className="section-copy">Permanecen abiertos hasta que una inspección posterior verifique la corrección.</p></div><span className="count-pill">{findings.filter((finding) => finding.status === 'OPEN').length} abiertos</span></div>
{error && <Alert>{error}</Alert>}
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Actas y hallazgos se registran desde la APK.</strong> En el dashboard se consultan y se carga únicamente el seguimiento posterior: respuesta de la empresa, documentación y fecha de verificación.</p></div>
<div className="inspection-finding-list">{findings.length === 0 ? <EmptyState title="Sin hallazgos sincronizados" text="Los hallazgos que registre el inspector en la APK aparecerán aquí." /> : findings.map((finding) => <FindingCard key={finding.id} finding={finding} canFollowUp={canFollowUp} canReadEvidence={canReadEvidence} canCreateEvidence={canCreateEvidence} canReadCommunications={canReadCommunications} canCreateCommunications={canCreateCommunications} onSaved={replaceFinding} />)}</div>
</section>;
}
@@ -0,0 +1,28 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { listInspectionAssignees } from '../../lib/api';
import type { InspectionPerson } from '../../lib/api';
export function OperationalFilters({
inspectorId,
dateFrom,
dateTo,
onChange,
}: {
inspectorId: string;
dateFrom: string;
dateTo: string;
onChange: (key: string, value: string) => void;
}) {
const [inspectors, setInspectors] = useState<InspectionPerson[]>([]);
useEffect(() => {
void listInspectionAssignees().then(setInspectors).catch(() => setInspectors([]));
}, []);
return <>
<label className="select-field"><span>Inspector</span><SearchableSelect value={inspectorId} onChange={(event) => onChange('inspectorId', event.target.value)}><option value="">Todos</option>{inspectors.map((item) => <option key={item.id} value={item.id}>{item.lastName}, {item.firstName}</option>)}</SearchableSelect></label>
<label className="select-field"><span>Desde</span><input type="date" value={dateFrom} onChange={(event) => onChange('dateFrom', event.target.value)} /></label>
<label className="select-field"><span>Hasta</span><input type="date" value={dateTo} onChange={(event) => onChange('dateTo', event.target.value)} /></label>
</>;
}
@@ -0,0 +1,101 @@
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
export interface SignaturePadHandle {
clear: () => void;
isEmpty: () => boolean;
toBlob: () => Promise<Blob>;
}
export const SignaturePad = forwardRef<SignaturePadHandle, { disabled?: boolean }>(
function SignaturePad({ disabled = false }, ref) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef(false);
const [empty, setEmpty] = useState(true);
const point = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = event.currentTarget;
const bounds = canvas.getBoundingClientRect();
return {
x: (event.clientX - bounds.left) * (canvas.width / bounds.width),
y: (event.clientY - bounds.top) * (canvas.height / bounds.height),
};
};
const start = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
const context = event.currentTarget.getContext('2d');
if (!context) return;
event.currentTarget.setPointerCapture(event.pointerId);
const current = point(event);
context.beginPath();
context.moveTo(current.x, current.y);
context.lineCap = 'round';
context.lineJoin = 'round';
context.lineWidth = 5;
context.strokeStyle = '#172033';
drawingRef.current = true;
};
const move = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current || disabled) return;
const context = event.currentTarget.getContext('2d');
if (!context) return;
const current = point(event);
context.lineTo(current.x, current.y);
context.stroke();
setEmpty(false);
};
const stop = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current) return;
drawingRef.current = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const clear = () => {
const canvas = canvasRef.current;
canvas?.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
setEmpty(true);
};
useImperativeHandle(ref, () => ({
clear,
isEmpty: () => empty,
toBlob: () => new Promise<Blob>((resolve, reject) => {
const canvas = canvasRef.current;
if (!canvas || empty) {
reject(new Error('La firma está vacía'));
return;
}
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('No se pudo generar la firma'));
}, 'image/png');
}),
}), [empty]);
return <div className={`signature-pad-shell ${disabled ? 'disabled' : ''}`}>
<canvas
ref={canvasRef}
width={1000}
height={300}
className="signature-pad"
aria-label="Área para firma manuscrita"
onPointerDown={start}
onPointerMove={move}
onPointerUp={stop}
onPointerCancel={stop}
/>
<div className="signature-pad-line" />
<div className="signature-pad-footer">
<span>{empty ? 'Firmá dentro del recuadro' : 'Firma capturada'}</span>
<button type="button" className="button text" onClick={clear} disabled={disabled || empty}>
Limpiar
</button>
</div>
</div>;
},
);
@@ -0,0 +1,33 @@
import type { InspectionActStatus, InspectionActVersionEvent } from '../../lib/api';
const statusLabels: Record<InspectionActStatus, string> = {
DRAFT: 'Borrador',
READY: 'Lista para cerrar',
CLOSED: 'Cerrada',
CANCELLED: 'Cancelada',
RECTIFIED: 'Rectificada',
};
const eventLabels: Record<InspectionActVersionEvent, string> = {
CREATED: 'Creación',
UPDATED: 'Actualización',
READY: 'Preparación para firmas',
REOPENED: 'Vuelta a borrador',
CLOSED: 'Cierre y sellado',
CANCELLED: 'Cancelación',
};
export function inspectionActStatusLabel(value: InspectionActStatus): string {
return statusLabels[value];
}
export function inspectionActStatusClass(value: InspectionActStatus): string {
if (value === 'CLOSED') return 'active';
if (value === 'READY' || value === 'RECTIFIED') return 'observed';
if (value === 'CANCELLED') return 'inactive';
return 'pending';
}
export function inspectionActVersionEventLabel(value: InspectionActVersionEvent): string {
return eventLabels[value];
}
@@ -0,0 +1,23 @@
import type { InspectionVisitStatus } from '../../lib/api';
export const INSPECTION_VISIT_STATUSES: Array<{
value: InspectionVisitStatus;
label: string;
}> = [
{ value: 'DRAFT', label: 'Borrador' },
{ value: 'PLANNED', label: 'Planificada' },
{ value: 'IN_PROGRESS', label: 'En curso' },
{ value: 'CLOSED', label: 'Cerrada' },
{ value: 'CANCELLED', label: 'Cancelada' },
];
export function inspectionVisitStatusLabel(value: InspectionVisitStatus): string {
return INSPECTION_VISIT_STATUSES.find((item) => item.value === value)?.label ?? value;
}
export function inspectionStatusClass(value: InspectionVisitStatus): string {
if (value === 'CLOSED') return 'active';
if (value === 'IN_PROGRESS') return 'observed';
if (value === 'CANCELLED') return 'inactive';
return 'pending';
}