F1.1 · Inspección multi-Acta y firma diferida

Implementa el nuevo núcleo operativo: múltiples Actas por Inspección, un Informe por Acta, cierre de campo independiente y firma de empresa diferida con conformidad/disidencia.
This commit is contained in:
2026-09-05 14:46:26 -03:00
committed by GitHub
parent 8e2d0dfe19
commit cd24736df8
34 changed files with 452 additions and 85 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.19.6-4';
export const APP_PHASE = 'Fase D5.6.4 · Inspecciones simples y combobox buscables';
export const APP_VERSION = '0.20.0-1';
export const APP_PHASE = 'Fase F1.1 · Inspección multi-Acta y firma diferida';
@@ -29,13 +29,13 @@ export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
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>
<div><span className="eyebrow">ACTAS DE LA INSPECCIÓN</span><h2>Actas generadas</h2><p className="section-copy">Una inspección puede generar múltiples actas. Cada acta agrupa sus propios hallazgos y tendrá un único informe.</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>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Las actas se crean en la APK.</strong> El dashboard permite consultar cada 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>}
? <EmptyState title="Sin actas sincronizadas" text={visit.status === 'IN_PROGRESS' ? 'El inspector puede crear la primera acta desde la APK.' : 'Aparecerán aquí a medida que el inspector genere actas desde la APK.'} />
: <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Actas de la 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>;
}
@@ -62,7 +62,7 @@ export function InspectionClosurePanel({ act }: { act: InspectionAct }) {
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>
<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">La preparación y firma del acta se realizan desde la APK. La inspección puede continuar con otras actas.</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>
@@ -78,8 +78,8 @@ export function InspectionClosurePanel({ act }: { act: InspectionAct }) {
{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>}
{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>}
{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>}
{act.status === 'CLOSED' && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA CERRADA</span><strong>{formatDate(closure.closure.serverClosedAt)}</strong><p>El acta quedó sellada. La inspección y las demás actas tienen un ciclo independiente.</p><code>{closure.closure.finalSha256}</code></div></div>}
</section>;
}
+2
View File
@@ -1129,6 +1129,8 @@ export interface InspectionActSignature {
position: string | null;
status: InspectionActSignatureStatus;
reason: string | null;
companyManifestation: 'CONFORMITY' | 'DISSENT' | null;
companyStatement: string | null;
mimeType: string | null;
sizeBytes: number | null;
imageSha256: string | null;
+1 -1
View File
@@ -76,7 +76,7 @@ const actions: Record<string, string> = {
INSPECTION_ACT_REOPENED: 'Acta devuelta a borrador',
INSPECTION_ACT_SIGNATURE_RECORDED: 'Firma manuscrita incorporada al acta',
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED: 'Resultado de recepción empresarial registrado',
INSPECTION_ACT_CLOSED: 'Acta y visita cerradas',
INSPECTION_ACT_CLOSED: 'Acta cerrada',
INSPECTION_REPORT_GENERATED: 'Informe de inspección generado',
INSPECTION_VERIFICATION_PLANNED: 'Visita de verificación preparada',
INSPECTION_FINDING_CREATED: 'Hallazgo abierto',
@@ -404,7 +404,7 @@ export function InspectionVisitEditorPage() {
{visit && <div className="survey-status-actions panel"><div><strong>Flujo de la inspección</strong><p>Oficina planifica y asigna. El inspector responsable inicia la inspección desde la APK; toda la ejecución de campo sigue siendo exclusiva del dispositivo móvil.</p></div><div>{canManage && visit.status === 'DRAFT' && <button type="button" className="button secondary" onClick={() => void changeStatus('PLANNED')} disabled={busy}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" onClick={() => void changeStatus('DRAFT')} disabled={busy}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" onClick={() => void changeStatus('CANCELLED')} disabled={busy}>Cancelar planificación</button>}</div></div>}
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El inspector asignado debe iniciar la inspección desde la APK. El dashboard no dispone de acción de inicio.</p></div>}
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección iniciada {formatDate(visit.actualStartedAt)}.</strong> El acta, hallazgos, evidencias y cierre se registran desde la APK; aquí se consultan.</p></div>}
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección iniciada {formatDate(visit.actualStartedAt)}.</strong> Las actas, hallazgos, evidencias y el cierre de la inspección se registran desde la APK; aquí se consultan.</p></div>}
{visit?.status === 'CANCELLED' && <Alert>Cancelada: {visit.cancellationReason}</Alert>}
</section>;
}
+1 -1
View File
@@ -100,7 +100,7 @@ export function InspectionVisitsPage() {
{statusTabs.map((item) => <Link key={item.value || 'all'} className={status === item.value ? 'active' : ''} to={statusHref(item.value)}>{item.label}</Link>)}
</nav>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un solo ciclo.</strong> La planificación, ejecución, Acta, Hallazgos e Informe pertenecen a la misma inspección. El inicio en campo sigue siendo exclusivo de la APK.</p></div>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un recorrido, varias actas.</strong> La inspección representa la salida a un Área/Operadora y puede generar múltiples actas durante el recorrido. El inicio y cierre en campo siguen siendo exclusivos de la APK.</p></div>
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código" /><button>Buscar</button></label>