chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user