feat(f6.9): consolidate act and report documents and simplify follow-up
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m24s
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m22s

This commit is contained in:
DH V2
2026-09-15 18:48:12 -03:00
parent 079728aa6d
commit 5cf99442a8
39 changed files with 1604 additions and 566 deletions
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetMediaBlob,
getInspectionFindingEvidenceBlob,
@@ -8,142 +7,70 @@ import {
listInspectionFindingEvidence,
listInspectionFindings,
} from '../../lib/api';
import type { InspectionActFieldMedia, InspectionFindingEvidence } from '../../lib/api';
import type { InspectionActFieldMedia, InspectionFinding, InspectionFindingEvidence } from '../../lib/api';
import { formatDate } from '../../lib/format';
type ActPhoto = {
findingId: string;
findingCode: string;
findingTitle: string;
evidence: InspectionFindingEvidence;
};
type FindingWithPhotos = { finding: InspectionFinding; photos: InspectionFindingEvidence[] };
type AssetPhoto = InspectionActFieldMedia;
function PhotoThumb({ photo }: { photo: ActPhoto }) {
function Photo({ id, title, caption, load }: { id: string; title: string; caption: string; load: (id: string) => Promise<Blob> }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getInspectionFindingEvidenceBlob(photo.evidence.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [photo.evidence.id]);
return <article className="act-photo-card">
<button
type="button"
className="act-photo-preview"
onClick={() => url && window.open(url, '_blank', 'noopener,noreferrer')}
aria-label={`Ver foto ${photo.findingCode}`}
>
{url
? <img src={url} alt={photo.evidence.title || `${photo.findingCode} · ${photo.findingTitle}`} />
: <span className="media-preview-loading"><span className="spinner" /></span>}
</button>
<div className="act-photo-copy">
<span className="eyebrow">{photo.findingCode}</span>
<strong>{photo.findingTitle}</strong>
<small>{formatDate(photo.evidence.capturedAt || photo.evidence.createdAt)}</small>
{photo.evidence.latitude != null && photo.evidence.longitude != null && <small>
GPS {photo.evidence.latitude.toFixed(6)}, {photo.evidence.longitude.toFixed(6)}
</small>}
</div>
</article>;
load(id).then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
}).catch(() => undefined);
return () => { active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [id, load]);
return <figure className="act-finding-photo">
{url ? <a href={url} target="_blank" rel="noopener noreferrer" aria-label={`Ver foto ${title}`}><img src={url} alt={title} /></a> : <span className="media-preview-loading">Cargando foto</span>}
<figcaption>{caption}</figcaption>
</figure>;
}
function AssetPhotoThumb({ photo }: { photo: AssetPhoto }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getAssetMediaBlob(photo.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [photo.id]);
return <article className="act-photo-card">
<button type="button" className="act-photo-preview" onClick={() => url && window.open(url, '_blank', 'noopener,noreferrer')} aria-label={`Ver foto ${photo.assetCode}`}>
{url ? <img src={url} alt={photo.title || photo.assetName} /> : <span className="media-preview-loading"><span className="spinner" /></span>}
</button>
<div className="act-photo-copy">
<span className="eyebrow">INVENTARIO · {photo.assetCode}</span>
<strong>{photo.assetName}</strong>
<small>{formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)}</small>
{photo.latitude != null && photo.longitude != null && <small>GPS {photo.latitude.toFixed(6)}, {photo.longitude.toFixed(6)}</small>}
</div>
function Finding({ item, assetPhotos }: { item: FindingWithPhotos; assetPhotos: InspectionActFieldMedia[] }) {
const { finding, photos } = item;
return <article className="act-finding-record">
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.asset.name} · {finding.asset.code}</p></div></div>
<p className="inspection-finding-description">{finding.description}</p>
{finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>}
{finding.severity != null && <p>Gravedad {finding.severity}/10</p>}
{(photos.length > 0 || assetPhotos.length > 0) && <div className="act-finding-photos">
{photos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.title} caption={`Hallazgo · ${formatDate(photo.capturedAt || photo.createdAt)}${photo.latitude != null && photo.longitude != null ? ` · GPS ${photo.latitude.toFixed(6)}, ${photo.longitude.toFixed(6)}` : ''}`} load={getInspectionFindingEvidenceBlob} />)}
{assetPhotos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.asset.name} caption={`Inventario · ${formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)}`} load={getAssetMediaBlob} />)}
</div>}
{photos.length === 0 && assetPhotos.length === 0 && <small className="muted">Sin fotografías vinculadas.</small>}
</article>;
}
export function InspectionActMediaPanel({ actId }: { actId: string }) {
const [photos, setPhotos] = useState<ActPhoto[]>([]);
const [assetPhotos, setAssetPhotos] = useState<AssetPhoto[]>([]);
const [findingCount, setFindingCount] = useState(0);
const [items, setItems] = useState<FindingWithPhotos[]>([]);
const [assetPhotos, setAssetPhotos] = useState<InspectionActFieldMedia[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
setLoading(true);
setError('');
Promise.all([
listInspectionFindings(actId),
listInspectionActFieldMedia(actId).catch(() => []),
])
.then(async ([findings, fieldMedia]) => {
const evidenceByFinding = await Promise.all(
findings.map(async (finding) => ({
finding,
evidence: await listInspectionFindingEvidence(finding.id),
})),
);
setLoading(true); setError('');
Promise.all([listInspectionFindings(actId), listInspectionActFieldMedia(actId).catch(() => [])])
.then(async ([findings, media]) => {
const records = await Promise.all(findings.map(async (finding) => ({
finding, photos: (await listInspectionFindingEvidence(finding.id)).filter((evidence) => evidence.kind === 'PHOTO' && evidence.purpose === 'OBSERVATION'),
})));
if (!active) return;
setFindingCount(findings.length);
setPhotos(evidenceByFinding.flatMap(({ finding, evidence }) =>
evidence.filter((item) => item.kind === 'PHOTO').map((item) => ({
findingId: finding.id, findingCode: finding.code, findingTitle: finding.title, evidence: item,
})),
));
setAssetPhotos(fieldMedia.filter((item) => item.kind === 'PHOTO'));
setItems(records);
setAssetPhotos(media.filter((photo) => photo.kind === 'PHOTO'));
})
.catch((requestError) => active && setError(errorMessage(requestError)))
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [actId]);
return <section className="panel act-media-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">REGISTRO DE CAMPO</span>
<h2>Fotos y Hallazgos</h2>
<p className="section-copy">Las fotografías del Acta se muestran directamente, vinculadas al Hallazgo que documentan.</p>
</div>
<span className="count-pill">{photos.length + assetPhotos.length} foto{photos.length + assetPhotos.length === 1 ? '' : 's'} · {findingCount} hallazgo{findingCount === 1 ? '' : 's'}</span>
</div>
<div className="panel-heading"><div><h2>Hallazgos del Acta</h2><p className="section-copy">Cada hallazgo reúne su descripción y las fotos tomadas en campo.</p></div><span className="count-pill">{items.length}</span></div>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando registro fotográfico…" /> : <>
{photos.length === 0 && assetPhotos.length === 0 && <EmptyState title="Sin fotografías" text="Esta Acta todavía no tiene fotografías sincronizadas." />}
{photos.length > 0 && <div className="act-media-group"><div className="subsection-heading"><div><span className="eyebrow">HALLAZGOS</span><h4>Evidencia fotográfica</h4></div><span>{photos.length}</span></div><div className="act-photo-grid">{photos.map((photo) => <PhotoThumb key={photo.evidence.id} photo={photo} />)}</div></div>}
{assetPhotos.length > 0 && <div className="act-media-group"><div className="subsection-heading"><div><span className="eyebrow">INVENTARIO DE LA INSPECCIÓN</span><h4>Fotos tomadas durante esta inspección</h4></div><span>{assetPhotos.length}</span></div><div className="act-photo-grid">{assetPhotos.map((photo) => <AssetPhotoThumb key={photo.id} photo={photo} />)}</div></div>}
</>}
{(photos.length > 0 || assetPhotos.length > 0) && <div className="act-media-footnote"><Icon name="camera" /><span>Seleccioná una foto para verla a tamaño completo.</span></div>}
{loading ? <LoadingBlock label="Cargando hallazgos y fotografías…" /> : items.length ?
<div className="act-finding-list">{items.map((item) => <Finding key={item.finding.id} item={item} assetPhotos={assetPhotos.filter((photo) => photo.assetId === item.finding.asset.id)} />)}</div> :
<EmptyState title="Sin hallazgos" text="Esta Acta no contiene hallazgos sincronizados." />}
</section>;
}
@@ -1,24 +1,17 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { useAuth } from '../../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getInspectionSignatureBlob } from '../../lib/api';
import type { InspectionActSignature } from '../../lib/api';
import {
getInspectionClosureF4,
type InspectionActF4,
type InspectionClosureF4,
} from '../../lib/inspectionActF4Api';
import { getInspectionClosureF4, type InspectionActF4, type InspectionClosureF4 } from '../../lib/inspectionActF4Api';
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';
function signatureOutcome(signature: InspectionActSignature): string {
if (signature.status === 'REFUSED') return `Se negó a firmar${signature.reason ? ` · ${signature.reason}` : ''}`;
if (signature.status !== 'SIGNED') return 'No firmó';
if (signature.signerType === 'INSPECTOR') return 'Firmó como inspector/a';
if (signature.companyManifestation === 'DISSENT') return `Firmó en disconformidad${signature.companyStatement ? ` · ${signature.companyStatement}` : ''}`;
return 'Firmó en conformidad';
}
export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
@@ -27,25 +20,11 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
const [closure, setClosure] = useState<InspectionClosureF4 | null>(null);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
useEffect(() => {
if (!canRead) return;
setLoading(true);
getInspectionClosureF4(act.id)
.then(setClosure)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
getInspectionClosureF4(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;
@@ -55,53 +34,19 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
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));
}
} 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);
const isSealed = act.status === 'SEALED' || act.status === 'CLOSED';
const isLocked = act.status === 'LOCKED' || act.status === 'READY';
const lifecycleLabel = isSealed
? 'Acta sellada'
: isLocked
? 'Esperando manifestación'
: act.status === 'CANCELLED'
? 'Acta cancelada'
: 'Borrador en campo';
const lifecycleClass = isSealed ? 'active' : isLocked ? 'observed' : act.status === 'CANCELLED' ? 'inactive' : 'pending';
return <section className="panel inspection-closure-panel">
<div className="panel-heading"><div><span className="eyebrow">CIERRE DEL ACTA · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">Al bloquearse, el contenido del Acta queda inmutable. La Inspección puede continuar y generar otras Actas.</p></div><span className={`status-badge large ${lifecycleClass}`}>{lifecycleLabel}</span></div>
if (loading || !closure) return <section className="panel"><LoadingBlock label="Cargando firmas…" /></section>;
return <section className="panel act-signatures-panel">
<div className="panel-heading"><div><h2>Participantes y firmas</h2></div></div>
{error && <Alert>{error}</Alert>}
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>El dashboard no modifica el Acta.</strong> El bloqueo y la firma del Inspector se realizan desde la APK. La manifestación de la empresa puede completarse en campo o mediante el enlace seguro posterior.</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>Bloqueo</strong><small>{closure.closure?.isCurrent ? 'Contenido inmutable' : 'Pendiente'}</small></div>
<div className={constanciasCompletas ? 'complete' : ''}><span>3</span><strong>Manifestaciones</strong><small>{inspectorSignatures.length} inspector · {companyOutcome ? 'empresa resuelta' : 'empresa pendiente'}</small></div>
<div className={isSealed ? 'complete' : ''}><span>4</span><strong>Sellado</strong><small>{isSealed ? 'Definitivo' : 'Pendiente'}</small></div>
</div>
<div className="responsible-summary">
<div><small>Urgencia</small><strong>{closure.act.urgency === 'URGENT' ? 'Urgente' : closure.act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
<div><small>Plazo configurado</small><strong>{closure.act.deadlineDays ? `${closure.act.deadlineDays} días ${closure.act.deadlineDayType === 'BUSINESS' ? 'hábiles' : 'corridos'}` : 'Pendiente'}</strong></div>
<div><small>Inicio del plazo</small><strong>{closure.act.deadlineBaseAt ? formatDate(closure.act.deadlineBaseAt) : 'Pendiente de evento válido'}</strong></div>
<div><small>Vencimiento</small><strong>{closure.act.deadlineAt ? formatDate(closure.act.deadlineAt) : 'Todavía no iniciado'}</strong></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 BLOQUEADO</span><strong>{closure.closure.schemaVersion}</strong><small>Bloqueado {formatDate(closure.act.lockedAt ?? closure.closure.preparedAt)}</small></div><code>{closure.act.lockedSha256 ?? 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>{signature.signerType === 'COMPANY_RESPONSIBLE' && <p><strong>{signature.companyManifestation === 'DISSENT' ? 'Firma en disidencia' : 'Firma en conformidad'}</strong>{signature.companyStatement ? ` · ${signature.companyStatement}` : ''}</p>}</> : <p>{signature.reason}</p>}</article>)}</div>}
{isSealed && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA SELLADA</span><strong>{formatDate(closure.act.sealedAt ?? closure.closure.serverClosedAt)}</strong><p>El Acta quedó sellada e inmutable. La Inspección y sus demás Actas continúan con ciclo independiente.</p><code>{closure.closure.finalSha256}</code></div></div>}
{closure.responsible && <p><strong>Representante de la empresa:</strong> {closure.responsible.fullName ?? 'No estuvo presente'}{closure.responsible.documentNumber ? ` · ${closure.responsible.documentType} ${closure.responsible.documentNumber}` : ''}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</p>}
{closure.signatures.length ? <div className="act-signer-list">{closure.signatures.map((signature) => <div key={signature.id} className="act-signer-row">
<div><strong>{signature.signerName}</strong><small>{signature.signerType === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa'} · {formatDate(signature.signedAt ?? signature.createdAt)}</small><p>{signatureOutcome(signature)}</p></div>
{signature.status === 'SIGNED' && <button type="button" className="button secondary" onClick={() => void viewSignature(signature)}>Ver firma</button>}
</div>)}</div> : <p className="muted">Las firmas todavía no se registraron.</p>}
{(act.status === 'SEALED' || act.status === 'CLOSED') && <p className="act-closed-date">Acta firmada y cerrada {formatDate(closure.act.sealedAt ?? closure.act.closedAt)}</p>}
{closure.closure?.finalSha256 && <details className="act-integrity-details"><summary>Verificar integridad del Acta</summary><p>SHA-256 del cierre: <code>{closure.closure.finalSha256}</code></p></details>}
</section>;
}
@@ -1,11 +1,11 @@
import type { InspectionActVersionEvent } from '../../lib/api';
const statusLabels: Record<string, string> = {
DRAFT: 'Borrador',
LOCKED: 'Bloqueada · esperando manifestación',
SEALED: 'Sellada',
DRAFT: 'En elaboración',
LOCKED: 'Para firmar',
SEALED: 'Firmada y cerrada',
READY: 'Lista para cerrar · legado',
CLOSED: 'Cerrada · legado',
CLOSED: 'Firmada y cerrada',
CANCELLED: 'Cancelada',
RECTIFIED: 'Rectificada · legado',
};