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
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:
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.23.0-5';
|
||||
export const APP_PHASE = 'F6.8 · Offline seguro y Acta documental';
|
||||
export const APP_VERSION = '0.23.0-6';
|
||||
export const APP_PHASE = 'F6.9 · Actas e informes consolidados';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -18,13 +18,12 @@ const operational: NavItem[] = [
|
||||
];
|
||||
|
||||
const followUp: NavItem[] = [
|
||||
{ to: '/seguimiento-actas', label: 'Seguimiento de actas', icon: 'clipboard', permission: 'inspection_acts.read' },
|
||||
{ to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' },
|
||||
];
|
||||
|
||||
const documents: NavItem[] = [
|
||||
{ to: '/actas', label: 'Actas', icon: 'clipboard', permission: 'inspection_acts.read' },
|
||||
{ to: '/informes', label: 'Informes', icon: 'audit', permission: 'inspection_reports.read' },
|
||||
{ to: '/informes', label: 'Informes y respuestas', icon: 'audit', permission: 'inspection_reports.read' },
|
||||
];
|
||||
|
||||
const master: NavItem[] = [
|
||||
|
||||
@@ -2707,6 +2707,10 @@ export function getInspectionAct(id: string) {
|
||||
return apiRequest<InspectionAct>(`/inspection-acts/${id}`);
|
||||
}
|
||||
|
||||
export function getInspectionActConsolidatedPdfBlob(actId: string) {
|
||||
return apiBlobRequest(`/inspection-acts/${actId}/consolidated-pdf`);
|
||||
}
|
||||
|
||||
export function getInspectionActPdfBlob(actId: string) {
|
||||
return apiBlobRequest(`/inspection-acts/${actId}/pdf`);
|
||||
}
|
||||
|
||||
@@ -205,3 +205,11 @@ export function inspectionReportWordDownloadUrl(id: string) {
|
||||
export function inspectionReportGedoPdfDownloadUrl(id: string) {
|
||||
return `/api/v3/inspection-reports/${id}/gedo-pdf`;
|
||||
}
|
||||
|
||||
export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) {
|
||||
return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`;
|
||||
}
|
||||
|
||||
export function inspectionReportConsolidatedWordDownloadUrl(reportId: string) {
|
||||
return `/api/v3/inspection-reports/${reportId}/consolidated-word`;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { addActCompanyResponse, actCompanyResponseContentUrl, getActAdministration, setActResponseDeadline } from '../lib/api';
|
||||
import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api';
|
||||
import type { ActAdministrationDetail } from '../lib/api';
|
||||
import { getInspectionActF4 } from '../lib/inspectionActF4Api';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
export function ActAdministrationDetailPage(){
|
||||
const {actId=''}=useParams(); const [data,setData]=useState<ActAdministrationDetail|null>(null); const [error,setError]=useState(''); const [busy,setBusy]=useState(false); const [due,setDue]=useState(''); const [reason,setReason]=useState('Plazo administrativo otorgado a la empresa'); const [received,setReceived]=useState(new Date().toISOString().slice(0,10)); const [details,setDetails]=useState(''); const [commitment,setCommitment]=useState(''); const [file,setFile]=useState<File|undefined>();
|
||||
const load=()=>getActAdministration(actId).then(setData).catch(e=>setError(errorMessage(e))); useEffect(()=>{void load()},[actId]);
|
||||
const deadline=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await setActResponseDeadline(actId,{responseDueOn:due,reason});setDue('');await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}};
|
||||
const response=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await addActCompanyResponse(actId,{receivedOn:received,details:details||undefined,committedCorrectionOn:commitment||undefined,file});setDetails('');setCommitment('');setFile(undefined);await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}};
|
||||
if(!data&&!error)return <LoadingBlock label="Cargando expediente administrativo…"/>;
|
||||
return <section>{error&&<Alert>{error}</Alert>}{data&&<><div className="page-heading"><div><span className="eyebrow">EXPEDIENTE ADMINISTRATIVO</span><h1>{data.act.actCode}</h1><p>{data.act.areaName??'Área sin asignar'} · {data.act.companyName??'Empresa sin asignar'} · {data.act.openFindingCount} hallazgos abiertos</p></div><Link className="button secondary" to={`/inspecciones/actas/${data.act.actId}`}>Ver Acta</Link></div>
|
||||
<div className="detail-grid"><div className="card"><h2>Plazo de respuesta</h2><p>El plazo aplica al conjunto completo de hallazgos del Acta.</p><form className="form-grid" onSubmit={deadline}><label><span>Vencimiento *</span><input type="date" required value={due} onChange={e=>setDue(e.target.value)}/></label><label className="full"><span>Motivo *</span><textarea required minLength={3} value={reason} onChange={e=>setReason(e.target.value)}/></label><button className="button primary" disabled={busy}>Registrar nuevo plazo</button></form>{data.deadlines.length>0&&<div className="stack-list">{data.deadlines.map(d=><div key={d.id}><strong>{formatDate(d.responseDueOn)}</strong><small>{d.reason}</small></div>)}</div>}</div>
|
||||
<div className="card"><h2>Respuesta de la empresa</h2><form className="form-grid" onSubmit={response}><label><span>Recibida el *</span><input type="date" required value={received} onChange={e=>setReceived(e.target.value)}/></label><label><span>Fecha comprometida</span><input type="date" value={commitment} onChange={e=>setCommitment(e.target.value)}/></label><label className="full"><span>Detalle</span><textarea value={details} onChange={e=>setDetails(e.target.value)} placeholder="Resumen de la presentación de la empresa"/></label><label className="full"><span>PDF presentado</span><input type="file" accept="application/pdf,.pdf" onChange={e=>setFile(e.target.files?.[0])}/></label><button className="button primary" disabled={busy}>Registrar respuesta</button></form>{data.responses.length>0&&<div className="stack-list">{data.responses.map(r=><div key={r.id}><strong>{formatDate(r.receivedOn)}</strong><small>{r.details??'Sin detalle'}{r.committedCorrectionOn?` · compromiso ${formatDate(r.committedCorrectionOn)}`:''}</small>{r.originalName&&<a className="text-link" href={actCompanyResponseContentUrl(r.id)} target="_blank" rel="noreferrer">Abrir PDF</a>}</div>)}</div>}</div></div>
|
||||
<div className="card"><h2>Hallazgos del Acta</h2><div className="table-scroll"><table><thead><tr><th>Código</th><th>Hallazgo</th><th>Estado</th><th>Próximo control</th></tr></thead><tbody>{data.findings.map(f=><tr key={f.id}><td><Link className="text-link" to={`/hallazgos/${f.id}`}>{f.code}</Link></td><td>{f.title}</td><td>{f.status}</td><td>{f.nextControlOn?formatDate(f.nextControlOn):'Sin programar'}</td></tr>)}</tbody></table></div></div></>}</section>;
|
||||
export function ActAdministrationDetailPage() {
|
||||
const { actId = '' } = useParams();
|
||||
const [data, setData] = useState<ActAdministrationDetail | null>(null);
|
||||
const [reportId, setReportId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
Promise.all([getActAdministration(actId), getInspectionActF4(actId)])
|
||||
.then(([detail, act]) => { setData(detail); setReportId(act.report?.id ?? null); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)));
|
||||
}, [actId]);
|
||||
if (!data && !error) return <LoadingBlock label="Cargando histórico…" />;
|
||||
return <section>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{data && <>
|
||||
<div className="page-heading"><div><span className="eyebrow">HISTÓRICO ADMINISTRATIVO</span><h1>{data.act.actCode}</h1><p>Registro previo de plazos y respuestas asociados al Acta.</p></div><Link className="button secondary" to={`/inspecciones/actas/${actId}`}>Ver Acta</Link></div>
|
||||
{reportId && <p>Las nuevas respuestas y verificaciones se registran en el <Link to={`/informes/${reportId}`}>Informe relacionado</Link>.</p>}
|
||||
<section className="panel"><h2>Antecedentes conservados</h2>
|
||||
{!data.deadlines.length && !data.responses.length && <p>No hay antecedentes administrativos anteriores.</p>}
|
||||
{[...data.deadlines.map((item) => ({ id: item.id, date: item.createdAt, title: 'Plazo registrado', description: `${formatDate(item.responseDueOn)} · ${item.reason}`, fileId: null as string | null, fileName: null as string | null })), ...data.responses.map((item) => ({ id: item.id, date: item.receivedOn, title: 'Respuesta de la empresa', description: item.details ?? 'Sin descripción', fileId: item.id, fileName: item.originalName }))].sort((a, b) => b.date.localeCompare(a.date)).map((item) => <div key={item.id} className="act-signer-row"><div><strong>{item.title}</strong><small>{formatDate(item.date)}</small><p>{item.description}</p></div>{item.fileName && item.fileId && <a className="button secondary" href={actCompanyResponseContentUrl(item.fileId)} target="_blank" rel="noreferrer">Abrir {item.fileName}</a>}</div>)}
|
||||
</section>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import { formatDate } from '../lib/format';
|
||||
|
||||
const statuses: Array<{ value: InspectionActStatusF4 | ''; label: string }> = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'DRAFT', label: 'Borrador' },
|
||||
{ value: 'LOCKED', label: 'Bloqueada · esperando manifestación' },
|
||||
{ value: 'SEALED', label: 'Sellada' },
|
||||
{ value: 'DRAFT', label: 'En elaboración' },
|
||||
{ value: 'LOCKED', label: 'Para firmar' },
|
||||
{ value: 'SEALED', label: 'Firmada y cerrada' },
|
||||
{ value: 'CANCELLED', label: 'Cancelada' },
|
||||
{ value: 'READY', label: 'Lista para cerrar · legado' },
|
||||
{ value: 'CLOSED', label: 'Cerrada · legado' },
|
||||
@@ -112,7 +112,7 @@ export function ActsPage() {
|
||||
{loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table">
|
||||
<div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}>
|
||||
<td><div className="document-primary"><strong>{act.code}</strong><small>{act.title} · {formatDate(act.occurredAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{act.code}</strong><small>{formatDate(act.occurredAt)}</small></div></td>
|
||||
<td><div className="document-primary"><strong>{contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
|
||||
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
|
||||
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td>
|
||||
|
||||
@@ -5,13 +5,10 @@ import { Icon } from '../components/Icon';
|
||||
import {
|
||||
inspectionActStatusClass,
|
||||
inspectionActStatusLabel,
|
||||
inspectionActVersionEventLabel,
|
||||
} from '../features/inspections/inspectionActPresentation';
|
||||
import { InspectionClosurePanel } from '../features/inspections/InspectionClosurePanel';
|
||||
import { InspectionFindingsPanel } from '../features/inspections/InspectionFindingsPanel';
|
||||
import { InspectionActMediaPanel } from '../features/inspections/InspectionActMediaPanel';
|
||||
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
|
||||
import { getInspectionActPdfBlob, getInspectionVisit } from '../lib/api';
|
||||
import { getInspectionActConsolidatedPdfBlob, getInspectionVisit } from '../lib/api';
|
||||
import type { InspectionVisit } from '../lib/api';
|
||||
import { getInspectionActF4, type InspectionActF4 } from '../lib/inspectionActF4Api';
|
||||
import { formatDate } from '../lib/format';
|
||||
@@ -49,12 +46,13 @@ export function InspectionActEditorPage() {
|
||||
if (loading) return <LoadingBlock label="Cargando acta…" />;
|
||||
|
||||
const isSealed = act?.status === 'SEALED' || act?.status === 'CLOSED';
|
||||
const meaningfulSummary = act?.summary && !act.summary.startsWith('Acta de inspección en curso. Los Hallazgos') ? act.summary : null;
|
||||
|
||||
const openActPdf = async (download: boolean) => {
|
||||
if (!act) return;
|
||||
setPdfBusy(true); setError('');
|
||||
try {
|
||||
const blob = await getInspectionActPdfBlob(act.id);
|
||||
const blob = await getInspectionActConsolidatedPdfBlob(act.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (download) {
|
||||
const link = document.createElement('a');
|
||||
@@ -75,38 +73,31 @@ export function InspectionActEditorPage() {
|
||||
|
||||
return <section className="survey-editor inspection-act-editor">
|
||||
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span>{visit && <><Link to={`/inspecciones/${visit.id}`}>{visit.code}</Link><span>/</span></>}<span>{act?.code ?? 'Acta'}</span></div>
|
||||
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">ACTA DE INSPECCIÓN</span><h1>{act?.code ?? 'Acta'}</h1><p>{visit ? `${visit.code} · ${visit.scopeAsset?.name ?? visit.operationalArea?.name ?? 'Inspección'} · versión ${act?.currentVersion ?? ''}` : 'Consulta del documento sincronizado desde la APK.'}</p></div>{act && <span className={`status-badge large ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span>}</div>
|
||||
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">ACTA DE INSPECCIÓN</span><h1>{act?.code ?? 'Acta'}</h1><p>{visit ? `${visit.code} · ${visit.scopeAsset?.name ?? visit.operationalArea?.name ?? 'Inspección'}` : 'Consulta del Acta.'}</p></div>{act && <span className={`status-badge large ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span>}</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{act && <section className="panel act-document-primary">
|
||||
<div className="act-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'PDF del Acta disponible' : 'PDF pendiente de cierre'}</h2><p>{isSealed ? 'Abrí o descargá el Acta completa firmada y sellada.' : 'El PDF definitivo se genera cuando el Acta queda firmada y cerrada.'}</p></div></div>
|
||||
<div className="act-document-primary-copy"><span className="asset-symbol"><Icon name="clipboard" /></span><div><span className="eyebrow">DOCUMENTO DEL ACTA</span><h2>{isSealed ? 'Acta consolidada disponible' : 'Acta en preparación'}</h2><p>{isSealed ? 'Abrí o descargá el Acta firmada, con sus Hallazgos y constancias de integridad.' : 'El documento definitivo se genera al firmar y cerrar el Acta.'}</p></div></div>
|
||||
<div className="act-primary-actions">{isSealed ? <><button className="button primary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(false)}>{pdfBusy ? 'Preparando…' : 'Abrir PDF del Acta'}</button><button className="button secondary" type="button" disabled={pdfBusy} onClick={() => void openActPdf(true)}>Descargar PDF</button></> : <span className="status-badge pending">{inspectionActStatusLabel(act.status)}</span>}</div>
|
||||
</section>}
|
||||
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? ' El informe está disponible.' : ' El INF continúa en preparación.'}</p></div>}
|
||||
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? ' Disponible.' : ' En preparación.'}</p></div>}
|
||||
{isSealed && act && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>INF pendiente de emisión.</strong> El Acta ya está sellada y disponible como documento fuente.</p></div>}
|
||||
{act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>}
|
||||
|
||||
{act && <section className="panel inspection-act-form">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CONTENIDO SINCRONIZADO</span><h2>{act.code}</h2></div><small className="muted">Actualizado {formatDate(act.updatedAt)}</small></div>
|
||||
<div className="responsible-summary">
|
||||
<div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div>
|
||||
<div><small>Estado documental</small><strong>{inspectionActStatusLabel(act.status)}</strong></div>
|
||||
<div><small>Urgencia</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
|
||||
<div><small>Fecha de inspección</small><strong>{formatDate(act.occurredAt)}</strong></div>
|
||||
<div><small>Estado</small><strong>{inspectionActStatusLabel(act.status)}</strong></div>
|
||||
<div><small>Urgencia del Acta</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : act.urgency === 'NON_URGENT' ? 'No urgente' : 'Se define al cerrar el contenido'}</strong></div>
|
||||
<div><small>Hallazgos</small><strong>{act.findingCount}</strong></div>
|
||||
</div>
|
||||
<div className="closure-section"><span className="eyebrow">DESCRIPCIÓN DE LO ACTUADO</span><p>{act.summary}</p>{act.observations && <><span className="eyebrow">OBSERVACIONES</span><p>{act.observations}</p></>}</div>
|
||||
<div className="inspection-act-assets"><div><span className="eyebrow">INVENTARIO DEL ACTA</span><h3>Referencias congeladas</h3></div><span className="count-pill">{act.assets.length}</span></div>
|
||||
<div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div>
|
||||
{meaningfulSummary && <div><h2>Lo actuado</h2><p>{meaningfulSummary}</p></div>}
|
||||
{act.observations && <div><h3>Observaciones</h3><p>{act.observations}</p></div>}
|
||||
{act.assets.length > 0 && <div><h3>Instalaciones inspeccionadas</h3><p>{act.assets.map((asset) => `${asset.name} (${asset.code})`).join(' · ')}</p></div>}
|
||||
</section>}
|
||||
|
||||
{act && <InspectionActMediaPanel actId={act.id} />}
|
||||
{act && <details className="legacy-projection-details"><summary>Ver estructura documental de referencia</summary><DocumentPdfProjection kind="act" /></details>}
|
||||
{act && <InspectionFindingsPanel act={act} />}
|
||||
{act && <InspectionClosurePanel act={act} />}
|
||||
|
||||
{act && <section className="panel survey-report-history">
|
||||
<div className="panel-heading"><div><span className="eyebrow">VERSIONES INMUTABLES</span><h2>Historial exacto del acta</h2><p className="section-copy">Cada transición conserva el contenido y las versiones de los registros referenciados.</p></div><span className="count-pill">{act.versions.length}</span></div>
|
||||
<div className="survey-version-list">{act.versions.map((version) => <details key={version.id}><summary><span className="count-pill">v{version.versionNumber}</span><strong>{inspectionActVersionEventLabel(version.event)}</strong><small>{formatDate(version.createdAt)} · {version.actorUsername ?? 'sistema'}</small></summary><pre>{JSON.stringify(version.snapshot, null, 2)}</pre></details>)}</div>
|
||||
</section>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
|
||||
import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api';
|
||||
import type { ActAdministrationDetail } from '../lib/api';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
addInspectionReportFollowUp,
|
||||
getInspectionReportF4,
|
||||
inspectionReportGedoPdfDownloadUrl,
|
||||
inspectionReportFollowUpDownloadUrl,
|
||||
inspectionReportConsolidatedWordDownloadUrl,
|
||||
inspectionReportWordDownloadUrl,
|
||||
listInspectionReportFollowUps,
|
||||
officializeInspectionReport,
|
||||
@@ -63,8 +66,10 @@ export function ReportDetailPage() {
|
||||
const { id } = useParams();
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('inspection_reports.generate');
|
||||
const canReadActHistory = hasPermission('inspection_acts.read');
|
||||
const [report, setReport] = useState<InspectionReportDetailF4 | null>(null);
|
||||
const [followUps, setFollowUps] = useState<InspectionReportFollowUp[]>([]);
|
||||
const [legacy, setLegacy] = useState<ActAdministrationDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -91,6 +96,7 @@ export function ReportDetailPage() {
|
||||
]);
|
||||
setReport(nextReport);
|
||||
setFollowUps(nextFollowUps);
|
||||
if (canReadActHistory) setLegacy(await getActAdministration(nextReport.actId).catch(() => null));
|
||||
setExecutiveSummary(nextReport.executiveSummary ?? '');
|
||||
setReportDescription(nextReport.reportDescription ?? '');
|
||||
setGedoIfIdentifier(nextReport.gedoIfIdentifier ?? '');
|
||||
@@ -180,6 +186,16 @@ export function ReportDetailPage() {
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando informe…" />;
|
||||
|
||||
const timeline: Array<{ id: string; date: string; title: string; description: string; href?: string; fileName?: string }> = report ? [
|
||||
{ id: 'act-start', date: report.act.occurredAt, title: `Inspección y Acta ${report.act.code}`, description: `${report.findingCount} hallazgo${report.findingCount === 1 ? '' : 's'} registrados`, href: `/inspecciones/actas/${report.actId}` },
|
||||
...(report.act.sealedAt ? [{ id: 'act-sealed', date: report.act.sealedAt, title: 'Acta firmada y cerrada', description: report.act.code, href: `/inspecciones/actas/${report.actId}` }] : []),
|
||||
{ id: 'report-issued', date: report.generatedAt, title: `Informe ${report.code} preparado`, description: 'Documento técnico vinculado al Acta' },
|
||||
...(report.gedoOfficializedAt ? [{ id: 'gedo', date: report.gedoOfficializedAt, title: 'Informe oficializado en GEDO', description: report.gedoIfIdentifier ?? '' }] : []),
|
||||
...followUps.map((item) => ({ id: item.id, date: item.occurredAt, title: followUpLabel(item.type), description: item.description || item.externalReference || item.originalName || 'Antecedente registrado', href: item.originalName ? inspectionReportFollowUpDownloadUrl(report.id, item.id) : undefined, fileName: item.originalName ?? undefined })),
|
||||
...(legacy?.responses.map((item) => ({ id: `legacy-${item.id}`, date: item.receivedOn, title: 'Respuesta de empresa registrada previamente', description: item.details ?? 'Sin detalle', href: item.originalName ? actCompanyResponseContentUrl(item.id) : undefined, fileName: item.originalName ?? undefined })) ?? []),
|
||||
...(legacy?.deadlines.map((item) => ({ id: `deadline-${item.id}`, date: item.createdAt, title: 'Plazo administrativo registrado previamente', description: `${formatDate(item.responseDueOn)} · ${item.reason}` })) ?? []),
|
||||
].sort((a, b) => b.date.localeCompare(a.date)) : [];
|
||||
|
||||
return <section>
|
||||
<div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div>
|
||||
<div className="page-heading survey-editor-heading">
|
||||
@@ -211,10 +227,9 @@ export function ReportDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DocumentPdfProjection kind="report" />
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Descargar Word</a>}</div>
|
||||
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div><div className="act-primary-actions"><a className="button secondary" href={inspectionReportConsolidatedWordDownloadUrl(report.id)}>Descargar Word del informe</a>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Word anterior</a>}</div></div>
|
||||
<form className="form-section" onSubmit={saveNarrative}>
|
||||
<label className="field"><span>Resumen ejecutivo</span><textarea rows={4} maxLength={20000} value={executiveSummary} onChange={(event) => setExecutiveSummary(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Resumen ejecutivo del Informe…" /></label>
|
||||
<label className="field"><span>Descripción / análisis técnico</span><textarea rows={8} maxLength={50000} value={reportDescription} onChange={(event) => setReportDescription(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Descripción técnica, análisis y consideraciones del Inspector…" /></label>
|
||||
@@ -246,21 +261,14 @@ export function ReportDetailPage() {
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">SEGUIMIENTO DEL INF</span><h2>Presentaciones y antecedentes</h2><p className="section-copy">Las respuestas de empresa, documentos, notas internas y verificaciones se agregan cronológicamente. Nunca reemplazan un antecedente anterior.</p></div><span className="count-pill">{followUps.length}</span></div>
|
||||
|
||||
{followUps.length === 0 ? <div className="inline-empty">Todavía no hay antecedentes posteriores registrados.</div> : <div className="dossier-link-list">
|
||||
{[...followUps].reverse().map((item) => <div key={item.id}>
|
||||
<div>
|
||||
<strong>{followUpLabel(item.type)}</strong>
|
||||
<small>{item.description || item.externalReference || item.originalName || 'Sin descripción'}</small>
|
||||
{item.originalName && <small>Archivo: {item.originalName}{item.sizeBytes ? ` · ${fileSize(item.sizeBytes)}` : ''}</small>}
|
||||
</div>
|
||||
<span>{formatDate(item.occurredAt)}{item.externalReference ? ` · ${item.externalReference}` : ''}</span>
|
||||
</div>)}
|
||||
</div>}
|
||||
<div className="panel-heading"><div><span className="eyebrow">INFORME Y RESPUESTAS</span><h2>Historia y presentaciones</h2><p className="section-copy">La historia del Acta y las respuestas posteriores se leen en orden. Las nuevas respuestas se registran en este Informe.</p></div></div>
|
||||
<div className="dossier-link-list">{timeline.map((item) => <div key={item.id}>
|
||||
<div><strong>{item.title}</strong><small>{item.description}</small>{item.href && (item.fileName ? <a className="text-link" href={item.href}>Descargar {item.fileName}</a> : <Link className="text-link" to={item.href}>Ver Acta</Link>)}</div>
|
||||
<span>{formatDate(item.date)}</span>
|
||||
</div>)}</div>
|
||||
|
||||
{canManage && <form className="form-section" onSubmit={addFollowUp}>
|
||||
<div><h3>Agregar antecedente</h3><p className="section-copy">Usá este bloque para registrar una nueva presentación sin modificar las anteriores.</p></div>
|
||||
<div><h3>Registrar respuesta o antecedente</h3><p className="section-copy">La respuesta queda asociada a este Informe y conserva los registros anteriores.</p></div>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Tipo</span><SearchableSelect value={followUpType} onChange={(event) => setFollowUpType(event.target.value as InspectionReportFollowUpType)}><option value="COMPANY_NOTE">Presentación / nota de empresa</option><option value="COMPANY_DOCUMENT">Documento de empresa</option><option value="INTERNAL_NOTE">Nota interna</option><option value="VERIFICATION">Verificación</option><option value="OTHER">Otro antecedente</option></SearchableSelect></label>
|
||||
<label className="field"><span>Fecha</span><input type="datetime-local" value={followUpOccurredAt} onChange={(event) => setFollowUpOccurredAt(event.target.value)} required /></label>
|
||||
|
||||
@@ -1416,3 +1416,20 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
|
||||
.legacy-projection-details > summary::-webkit-details-marker { display: none; }
|
||||
.legacy-projection-details[open] > summary { border-bottom: 1px solid var(--line); }
|
||||
.legacy-projection-details > .document-pdf-projection { margin: 12px; }
|
||||
|
||||
/* Lectura consolidada del Acta */
|
||||
.act-finding-list { display: grid; gap: 22px; }
|
||||
.act-finding-record { border-top: 1px solid var(--border); padding-top: 20px; display: grid; gap: 8px; }
|
||||
.act-finding-record:first-child { border-top: 0; padding-top: 0; }
|
||||
.act-finding-record p { margin: 0; line-height: 1.55; }
|
||||
.act-finding-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-top: 8px; }
|
||||
.act-finding-photo { margin: 0; overflow: hidden; border: 1px solid var(--border); border-radius: 10px; }
|
||||
.act-finding-photo img { display: block; width: 100%; height: 190px; object-fit: cover; }
|
||||
.act-finding-photo figcaption { padding: 10px; color: var(--muted); font-size: 11px; }
|
||||
.act-signer-list { display: grid; gap: 10px; }
|
||||
.act-signer-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.act-signer-row small { display: block; margin-top: 3px; color: var(--muted); }
|
||||
.act-signer-row p { margin: 5px 0 0; }
|
||||
.act-closed-date { font-weight: 700; }
|
||||
.act-integrity-details { border-top: 1px solid var(--border); padding-top: 10px; }
|
||||
.act-integrity-details code { overflow-wrap: anywhere; }
|
||||
|
||||
Reference in New Issue
Block a user