Compare commits

...
Author SHA1 Message Date
admin 29208f4e1f release(android): prepare client demo 0.19.12
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 36s
Production dependency audit / API · production dependencies (push) Successful in 9s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m33s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m3s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
2026-09-16 00:42:29 -03:00
admin 4523aef932 fix(map): serve MapLibre module worker as JavaScript
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
Production dependency audit / API · production dependencies (push) Successful in 49s
DH V2 CI / Docker / migrations / production images (push) Successful in 50s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m9s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
2026-09-16 00:32:03 -03:00
admin 011b3e7fcb fix(web): include map worker build script in image
Production dependency audit / API · production dependencies (push) Successful in 48s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m10s
DH V2 CI / Docker / migrations / production images (push) Successful in 59s
DH V2 CI / Promote verified main to deploy (push) Successful in 2s
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
2026-09-16 00:23:57 -03:00
admin 9b7d67bea2 test(web): align F6.13 presentation phase
DH V2 CI / WEB · typecheck, build (push) Successful in 23s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
Production dependency audit / API · production dependencies (push) Successful in 49s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m11s
DH V2 CI / Docker / migrations / production images (push) Failing after 1m1s
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 00:18:53 -03:00
admin f0b92c00e1 fix(map): publish MapLibre worker
DH V2 CI / WEB · typecheck, build (push) Successful in 21s
DH V2 CI / API · typecheck, tests, build (push) Failing after 46s
DH V2 CI / Docker / migrations / production images (push) Skipped
DH V2 CI / Promote verified main to deploy (push) Skipped
2026-09-16 00:14:17 -03:00
admin cab9cd7c01 fix: restore map geometries and SMTP configuration
DH V2 CI / API · typecheck, tests, build (push) Successful in 38s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / WEB · typecheck, build (push) Successful in 1m36s
DH V2 CI / Docker / migrations / production images (push) Successful in 1m45s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 4m5s
2026-09-15 23:58:49 -03:00
admin c9575cc520 feat(informes): manage manual GEDO responses and shared due date
DH V2 CI / WEB · typecheck, build (push) Successful in 27s
Production dependency audit / API · production dependencies (push) Successful in 15s
Production dependency audit / WEB · production dependencies (push) Successful in 15s
DH V2 CI / API · typecheck, tests, build (push) Successful in 2m3s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m58s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m55s
2026-09-15 23:35:49 -03:00
35 changed files with 606 additions and 190 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
name: Android CI / RC name: Android CI / RC
# F6.8 offline/document barrier: lint + real tests + debug artifact + release compile. # F6.13 client-demo barrier: lint + real tests + debug artifact + release compile.
on: on:
push: push:
@@ -111,7 +111,7 @@ jobs:
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk" cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256" sha256sum "$apk" > "${apk}.sha256"
{ {
echo "phase=F6.8" echo "phase=F6.13"
echo "version=$version" echo "version=$version"
echo "versionCode=$code" echo "versionCode=$code"
echo "commit=$GITHUB_SHA" echo "commit=$GITHUB_SHA"
+3 -3
View File
@@ -1,9 +1,9 @@
# DH Inspección Android · release de campo 0.19.11 # DH Inspección Android · release de campo 0.19.12
## Candidata vigente ## Candidata vigente
- `versionName`: **0.19.11**; `versionCode`: **39**. - `versionName`: **0.19.12**; `versionCode`: **40**.
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-9 / WEB 0.23.0-6. - API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-13 / WEB 0.23.0-10.
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado. - Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión. - La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
- Un acta anterior, ya sellada, conserva el texto originalmente registrado. - Un acta anterior, ya sellada, conserva el texto originalmente registrado.
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 39 versionCode = 40
versionName = "0.19.11" versionName = "0.19.12"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test @Test
fun debugBuildKeepsSeparateApplicationIdentity() { fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(39, BuildConfig.VERSION_CODE) assertEquals(40, BuildConfig.VERSION_CODE)
assertEquals("0.19.11-debug", BuildConfig.VERSION_NAME) assertEquals("0.19.12-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-11", "version": "0.29.0-13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-11", "version": "0.29.0-13",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-11", "version": "0.29.0-13",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -18,5 +18,6 @@ import { FieldBriefingService } from './field-briefing.service';
FieldBriefingController, FieldBriefingController,
], ],
providers: [ActAdministrationService, FieldBriefingService], providers: [ActAdministrationService, FieldBriefingService],
exports: [ActAdministrationService],
}) })
export class ActAdministrationModule {} export class ActAdministrationModule {}
@@ -114,17 +114,46 @@ export class ActAdministrationService {
return act; return act;
} }
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) { async setDeadline(
actId: string,
dto: SetActResponseDeadlineDto,
principal: AuthPrincipal,
request: RequestWithContext,
reportId: string | null = null,
) {
const act = await this.ensureClosedAct(actId); const act = await this.ensureClosedAct(actId);
const rows = await this.dataSource.query( return this.dataSource.transaction(async (manager) => {
`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`, const rows = await manager.query(
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId], `INSERT INTO inspection_act_deadline_events (id, act_id, report_id, response_due_on, reason, created_by)
); VALUES ($1,$2,$3,$4,$5,$6)
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } }); RETURNING id, report_id AS "reportId", response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
return rows[0]; [randomUUID(), actId, reportId, dto.responseDueOn, dto.reason, principal.userId],
);
const projected = await manager.query(
`UPDATE inspection_findings
SET correction_due_on=$2, updated_by=$3, updated_at=CURRENT_TIMESTAMP
WHERE act_id=$1 AND status<>'VOIDED'
RETURNING id`,
[actId, dto.responseDueOn, principal.userId],
) as Array<{ id: string }>;
await this.audit.record({
actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET',
entityType: 'inspection_act', entityId: actId, requestId: request.requestId,
afterData: { actCode: act.code, reportId, responseDueOn: dto.responseDueOn, reason: dto.reason },
metadata: { reportId, projectedFindingCount: projected.length, sharedDeadline: true },
}, manager);
return { ...rows[0], projectedFindingCount: projected.length };
});
} }
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) { async addResponse(
actId: string,
dto: CreateActCompanyResponseDto,
file: UploadedActResponseFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
reportId: string | null = null,
) {
const act = await this.ensureClosedAct(actId); const act = await this.ensureClosedAct(actId);
let storedName: string | null = null; let storedName: string | null = null;
let sha256: string | null = null; let sha256: string | null = null;
@@ -138,12 +167,12 @@ export class ActAdministrationService {
} }
try { try {
const rows = await this.dataSource.query( const rows = await this.dataSource.query(
`INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by) `INSERT INTO inspection_act_company_responses (id, act_id, report_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`, RETURNING id, report_id AS "reportId", received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
[randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId], [randomUUID(), actId, reportId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
); );
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } }); await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, reportId, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 }, metadata: { reportId } });
return rows[0]; return rows[0];
} catch (error) { } catch (error) {
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined); if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
@@ -151,8 +180,13 @@ export class ActAdministrationService {
} }
} }
async responseContent(responseId: string) { async responseContent(responseId: string, reportId?: string) {
const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]); const rows = await this.dataSource.query(
`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes"
FROM inspection_act_company_responses
WHERE id=$1 AND stored_name IS NOT NULL AND ($2::uuid IS NULL OR report_id=$2::uuid)`,
[responseId, reportId ?? null],
);
const row = rows[0]; const row = rows[0];
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' }); if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
const filePath = join(STORAGE, row.storedName); const filePath = join(STORAGE, row.storedName);
@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F611ReportResponseWorkflow1790139000000 implements MigrationInterface {
name = 'F611ReportResponseWorkflow1790139000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid`);
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD CONSTRAINT fk_act_deadline_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD CONSTRAINT fk_act_response_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_report_created ON inspection_act_deadline_events(report_id, created_at DESC)`);
await queryRunner.query(`CREATE INDEX idx_act_company_responses_report_received ON inspection_act_company_responses(report_id, received_on DESC, created_at DESC)`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION validate_report_act_relation()
RETURNS trigger AS $$
BEGIN
IF NEW.report_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM inspection_reports report
WHERE report.id=NEW.report_id AND report.act_id=NEW.act_id
) THEN
RAISE EXCEPTION 'El Informe no corresponde al Acta indicada';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql
`);
await queryRunner.query(`CREATE TRIGGER trg_act_deadline_report_relation BEFORE INSERT ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
await queryRunner.query(`CREATE TRIGGER trg_act_response_report_relation BEFORE INSERT ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_response_report_relation ON inspection_act_company_responses`);
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_report_relation ON inspection_act_deadline_events`);
await queryRunner.query(`DROP FUNCTION IF EXISTS validate_report_act_relation()`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_company_responses_report_received`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_deadline_events_report_created`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP CONSTRAINT IF EXISTS fk_act_response_report`);
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP CONSTRAINT IF EXISTS fk_act_deadline_report`);
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP COLUMN IF EXISTS report_id`);
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP COLUMN IF EXISTS report_id`);
}
}
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F612AdminReportPermission1790142600000 implements MigrationInterface {
name = 'F612AdminReportPermission1790142600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO role_permissions(role_id, permission_id)
SELECT role.id, permission.id
FROM roles role
JOIN permissions permission ON permission.code='inspection_reports.generate'
WHERE role.code='admin'
ON CONFLICT(role_id, permission_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions role_permission
USING roles role, permissions permission
WHERE role_permission.role_id=role.id
AND role_permission.permission_id=permission.id
AND role.code='admin'
AND permission.code='inspection_reports.generate'
`);
}
}
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsDateString, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SetInspectionReportResponseDeadlineDto {
@IsDateString()
responseDueOn!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MinLength(3)
@MaxLength(1000)
reason?: string | null;
}
@@ -11,6 +11,8 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit'; import { administrationAuditContext } from '../administration/common/administration-audit';
import { ActAdministrationService, type UploadedActResponseFile } from '../act-administration/act-administration.service';
import type { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { import {
@@ -20,6 +22,7 @@ import {
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto'; import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import type { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024; export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
@@ -56,6 +59,7 @@ export class InspectionReportWorkflowService {
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly administration: ActAdministrationService,
config: ConfigService, config: ConfigService,
) { ) {
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT') const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
@@ -220,6 +224,42 @@ export class InspectionReportWorkflowService {
} }
} }
async setResponseDeadline(
reportId: string,
dto: SetInspectionReportResponseDeadlineDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const report = await this.requireOfficializedReport(reportId);
const reason = dto.reason ?? `Vencimiento general de respuestas definido desde el Informe ${report.code}`;
await this.administration.setDeadline(
report.actId,
{ responseDueOn: dto.responseDueOn, reason },
principal,
request,
report.id,
);
return this.getWorkflowView(this.dataSource.manager, reportId);
}
async addCompanyResponse(
reportId: string,
dto: CreateActCompanyResponseDto,
file: UploadedActResponseFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const report = await this.requireOfficializedReport(reportId);
await this.administration.addResponse(report.actId, dto, file, principal, request, report.id);
return this.getWorkflowView(this.dataSource.manager, reportId);
}
async companyResponseContent(reportId: string, responseId: string) {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
return this.administration.responseContent(responseId, reportId);
}
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(` const [row] = await this.dataSource.query(`
SELECT SELECT
@@ -300,6 +340,15 @@ export class InspectionReportWorkflowService {
try { try {
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId); const report = await this.lockReport(manager, reportId);
if (
(dto.type === 'COMPANY_NOTE' || dto.type === 'COMPANY_DOCUMENT')
&& report.status !== InspectionReportStatus.OFFICIALIZED
) {
throw new ConflictException({
code: 'INSPECTION_REPORT_GEDO_REQUIRED_FOR_COMPANY_RESPONSE',
message: 'Las respuestas de la empresa se registran después de cargar el PDF oficial de GEDO',
});
}
const occurredAt = new Date(dto.occurredAt); const occurredAt = new Date(dto.occurredAt);
await manager.query(` await manager.query(`
INSERT INTO inspection_report_follow_ups ( INSERT INTO inspection_report_follow_ups (
@@ -407,6 +456,18 @@ export class InspectionReportWorkflowService {
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : ''; return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
} }
private async requireOfficializedReport(reportId: string): Promise<ReportRow> {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
if (report.status !== InspectionReportStatus.OFFICIALIZED) {
throw new ConflictException({
code: 'INSPECTION_REPORT_GEDO_REQUIRED',
message: 'Primero debe cargarse manualmente el PDF oficial de GEDO',
});
}
return report;
}
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> { private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
const [row] = await manager.query(` const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
@@ -15,6 +15,8 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
import { MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from '../act-administration/act-administration.service';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { Response } from 'express'; import type { Response } from 'express';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
@@ -22,6 +24,7 @@ import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-repor
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto'; import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
import { import {
InspectionReportWorkflowService, InspectionReportWorkflowService,
MAX_INSPECTION_REPORT_FILE_BYTES, MAX_INSPECTION_REPORT_FILE_BYTES,
@@ -136,6 +139,49 @@ export class InspectionReportsController {
return this.workflow.officialize(id, dto, file, principal, request); return this.workflow.officialize(id, dto, file, principal, request);
} }
@Patch(':id/response-deadline')
@RequirePermissions('inspection_reports.generate')
setResponseDeadline(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: SetInspectionReportResponseDeadlineDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.setResponseDeadline(id, dto, principal, request);
}
@Post(':id/company-responses')
@RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 },
}))
addCompanyResponse(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CreateActCompanyResponseDto,
@UploadedFile() file: UploadedActResponseFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.addCompanyResponse(id, dto, file, principal, request);
}
@Get(':id/company-responses/:responseId/content')
@RequirePermissions('inspection_reports.read')
async companyResponseContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string,
@Res() response: Response,
): Promise<void> {
const item = await this.workflow.companyResponseContent(id, responseId);
const safeName = item.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
response.setHeader('Content-Type', 'application/pdf');
response.setHeader('Content-Length', String(item.sizeBytes));
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
}
@Get(':id/follow-ups') @Get(':id/follow-ups')
@RequirePermissions('inspection_reports.read') @RequirePermissions('inspection_reports.read')
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { ActAdministrationModule } from '../act-administration/act-administration.module';
import { DocumentDeliveryController } from './document-delivery.controller'; import { DocumentDeliveryController } from './document-delivery.controller';
import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller'; import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
@@ -12,7 +13,7 @@ import { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service'; import { SmtpDeliveryService } from './smtp-delivery.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule, ActAdministrationModule],
controllers: [ controllers: [
InspectionReportsController, InspectionReportsController,
InspectionActReportController, InspectionActReportController,
@@ -64,6 +64,16 @@ export interface InspectionReportListItem {
export interface InspectionReportView extends InspectionReportListItem { export interface InspectionReportView extends InspectionReportListItem {
frozenSnapshot: Record<string, unknown>; frozenSnapshot: Record<string, unknown>;
responseDueOn: string | null;
deadlineReason: string | null;
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: Date }>;
companyResponses: Array<{
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: Date;
}>;
findings: Array<{
id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null;
}>;
} }
export interface PendingInspectionReportItem { export interface PendingInspectionReportItem {
@@ -491,7 +501,38 @@ export class InspectionReportsService {
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1', 'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
[id], [id],
)) as Array<{ frozenSnapshot: Record<string, unknown> }>; )) as Array<{ frozenSnapshot: Record<string, unknown> }>;
return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; const deadlines = await manager.query(`
SELECT id,report_id AS "reportId",response_due_on AS "responseDueOn",reason,created_at AS "createdAt"
FROM inspection_act_deadline_events
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
ORDER BY created_at DESC,id DESC
`, [report.actId, report.id]) as InspectionReportView['deadlines'];
const currentDeadline = deadlines[0] ?? null;
const companyResponses = await manager.query(`
SELECT id,report_id AS "reportId",received_on AS "receivedOn",details,
committed_correction_on AS "committedCorrectionOn",contact_name AS "contactName",contact_email AS "contactEmail",
original_name AS "originalName",size_bytes::integer AS "sizeBytes",sha256,created_at AS "createdAt"
FROM inspection_act_company_responses
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
ORDER BY received_on DESC,created_at DESC,id DESC
`, [report.actId, report.id]) as InspectionReportView['companyResponses'];
const findings = await manager.query(`
SELECT finding.id,finding.code,finding.title,finding.status,asset.code AS "assetCode",asset.name AS "assetName",
$2::date AS "responseDueOn"
FROM inspection_findings finding
JOIN assets asset ON asset.id=finding.asset_id
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
ORDER BY finding.finding_number,finding.id
`, [report.actId, currentDeadline?.responseDueOn ?? null]) as InspectionReportView['findings'];
return {
...report,
frozenSnapshot: snapshot.frozenSnapshot,
responseDueOn: currentDeadline?.responseDueOn ?? null,
deadlineReason: currentDeadline?.reason ?? null,
deadlines,
companyResponses,
findings,
};
} }
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> { private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
@@ -1,7 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
import { connect as connectNet, Socket } from 'node:net'; import { connect as connectNet, Socket } from 'node:net';
import { connect as connectTls, TLSSocket } from 'node:tls'; import { connect as connectTls, TLSSocket } from 'node:tls';
import { Injectable } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { SmtpSecurityMode } from '../database/entities'; import { SmtpSecurityMode } from '../database/entities';
@@ -250,9 +250,9 @@ export class SmtpDeliveryService {
private encryptionKey():Buffer{ private encryptionKey():Buffer{
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY'); const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada'); if(!raw)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED', message:'La clave maestra para proteger credenciales SMTP no está configurada en el servidor' });
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64'); const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes'); if(key.length!==32)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_INVALID', message:'La clave maestra SMTP del servidor tiene un formato inválido' });
return key; return key;
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-11'; export const API_VERSION = '0.29.0-13';
export const API_PHASE = 'F6.10'; export const API_PHASE = 'F6.12';
+3 -3
View File
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.10 release', () => { test('health metadata reports the current F6.12 release', () => {
assert.equal(API_PHASE, 'F6.10'); assert.equal(API_PHASE, 'F6.12');
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
assert.equal(API_VERSION, pkg.version); assert.equal(API_VERSION, pkg.version);
assert.equal(API_VERSION, '0.29.0-11'); assert.equal(API_VERSION, '0.29.0-13');
}); });
+2 -2
View File
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => { test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 39/); assert.match(gradle, /versionCode = 40/);
assert.match(gradle, /versionName = "0\.19\.11"/); assert.match(gradle, /versionName = "0\.19\.12"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); assert.match(gradle, /applicationIdSuffix = "\.debug"/);
}); });
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1]; const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version); assert.equal(visibleVersion, pkg.version);
assert.match(version, /APP_PHASE\s*=\s*'F6\.9/); assert.match(version, /APP_PHASE\s*=\s*'F6\.13/);
}); });
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', path), 'utf8');
test('F6.11 keeps GEDO officialization manual and does not activate response deadlines automatically', () => {
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
const page = web('pages/ReportDetailPage.tsx');
assert.match(workflow, /GEDO oficializa el INF, pero no equivale por sí solo a la notificación/);
assert.doesNotMatch(workflow.slice(workflow.indexOf(' async officialize('), workflow.indexOf(' async setResponseDeadline(')), /setDeadline\(/);
assert.match(page, /GEDO no se consulta automáticamente/);
assert.match(page, /no crea respuestas ni vencimientos automáticamente/);
});
test('F6.11 stores new deadlines and company responses with both Report and Act relation', () => {
const migration = api('database/migrations/1790139000000-f6-11-report-response-workflow.ts');
const administration = api('act-administration/act-administration.service.ts');
assert.match(migration, /inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid/);
assert.match(migration, /inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid/);
assert.match(migration, /report\.id=NEW\.report_id AND report\.act_id=NEW\.act_id/);
assert.doesNotMatch(migration, /UPDATE inspection_act_deadline_events|UPDATE inspection_act_company_responses/);
assert.match(administration, /INSERT INTO inspection_act_deadline_events \(id, act_id, report_id/);
assert.match(administration, /INSERT INTO inspection_act_company_responses \(id, act_id, report_id/);
});
test('F6.11 projects one Act response deadline to every non-voided finding', () => {
const administration = api('act-administration/act-administration.service.ts');
const reports = api('inspection-reports/inspection-reports.service.ts');
assert.match(administration, /UPDATE inspection_findings[\s\S]*SET correction_due_on=\$2[\s\S]*WHERE act_id=\$1 AND status<>'VOIDED'/);
assert.match(administration, /sharedDeadline: true/);
assert.match(reports, /\$2::date AS "responseDueOn"/);
assert.match(reports, /currentDeadline\?\.responseDueOn/);
});
test('F6.11 enables formal responses only after the official GEDO PDF exists', () => {
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
const controller = api('inspection-reports/inspection-reports.controller.ts');
const page = web('pages/ReportDetailPage.tsx');
assert.match(workflow, /requireOfficializedReport\(reportId\)/);
assert.match(workflow, /Primero debe cargarse manualmente el PDF oficial de GEDO/);
assert.match(controller, /@Post\(':id\/company-responses'\)/);
assert.match(controller, /@Patch\(':id\/response-deadline'\)/);
assert.match(page, /Las respuestas se habilitan después de cargar el PDF oficial de GEDO/);
assert.match(page, /Vencimiento común del Acta/);
});
test('F6.11 separates formal company responses from internal report follow-up notes', () => {
const page = web('pages/ReportDetailPage.tsx');
assert.match(page, /RESPUESTAS DE EMPRESA/);
assert.match(page, /No usar este bloque para respuestas formales de empresa/);
assert.match(page, /<option value="INTERNAL_NOTE">Nota interna<\/option>/);
assert.doesNotMatch(page.slice(page.indexOf('Agregar otro antecedente')), /option value="COMPANY_NOTE"/);
});
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
test('F6.12 uses standard GeoJSON names in WEB and converts only at the API boundary', () => {
const client = web('src/lib/api.ts');
const map = web('src/features/map/DhMap.tsx');
const editor = web('src/features/map/AssetGeometryEditor.tsx');
assert.match(client, /type: 'Point'/);
assert.match(client, /type: 'LineString'/);
assert.match(client, /type: 'Polygon'/);
assert.match(client, /geometryPayload/);
assert.match(map, /feature\.geometry\.type === 'Point'/);
assert.match(editor, /setType\(value\.geometryType\)/);
});
test('F6.12 reports missing SMTP encryption configuration explicitly', () => {
const smtp = api('src/inspection-reports/smtp-delivery.service.ts');
assert.match(smtp, /SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED/);
assert.match(smtp, /ServiceUnavailableException/);
});
test('F6.12 grants report management to the system administrator role', () => {
const migration = api('src/database/migrations/1790142600000-f6-12-admin-report-permission.ts');
assert.match(migration, /role\.code='admin'/);
assert.match(migration, /inspection_reports\.generate/);
assert.match(migration, /ON CONFLICT\(role_id, permission_id\) DO NOTHING/);
});
+19
View File
@@ -0,0 +1,19 @@
# F6.11 · GEDO y respuestas de Informes
## Regla funcional
GEDO no está integrado como respuesta automática. El Informe permanece en preparación hasta que un usuario carga manualmente el identificador IF, la fecha y el PDF oficial emitido por GEDO.
La oficialización documental no crea una respuesta de empresa ni activa un vencimiento por sí sola.
## Flujo
1. El Acta sellada origina un INF editable.
2. El usuario carga manualmente IF + PDF oficial GEDO.
3. El Informe queda oficializado e inmutable en su contenido técnico.
4. Desde el Informe se define un único vencimiento de respuesta para su Acta.
5. Ese vencimiento se proyecta a todos los Hallazgos no anulados del Acta.
6. Las respuestas de empresa se registran después de la oficialización y quedan relacionadas con `report_id` + `act_id`.
7. Cada respuesta puede incluir fecha de recepción, detalle, compromiso, contacto y PDF.
Los vencimientos y respuestas históricas permanecen append-only y no se reescriben para completar relaciones nuevas.
+7
View File
@@ -0,0 +1,7 @@
# F6.12 · Geometrías y correo SMTP
- Corrige el contrato GeoJSON entre PostGIS, WEB y MapLibre: `Point`, `LineString` y `Polygon` se usan para render; `POINT`, `LINESTRING` y `POLYGON` quedan como tipos técnicos al persistir.
- El editor de geometrías vuelve a cargar correctamente geometrías existentes y conserva el tipo técnico separado del GeoJSON.
- La vista general del mapa vuelve a calcular bounds sobre GeoJSON real.
- El SMTP administrable requiere una clave maestra AES-256-GCM persistente; si falta o es inválida, el API devuelve un error de configuración explícito en vez de un 500 genérico.
- El rol Administrador recibe `inspection_reports.generate`, coherente con su definición de administración total.
+1
View File
@@ -4,6 +4,7 @@ COPY package*.json ./
RUN npm ci RUN npm ci
COPY tsconfig*.json vite.config.ts index.html ./ COPY tsconfig*.json vite.config.ts index.html ./
COPY public ./public COPY public ./public
COPY scripts ./scripts
COPY src ./src COPY src ./src
RUN npm run build RUN npm run build
+5
View File
@@ -4,6 +4,11 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location = /assets/maplibre-gl-worker.mjs {
default_type application/javascript;
try_files $uri =404;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-7", "version": "0.23.0-10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-7", "version": "0.23.0-10",
"dependencies": { "dependencies": {
"maplibre-gl": "6.4.1", "maplibre-gl": "6.4.1",
"react": "^19.0.0", "react": "^19.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-7", "version": "0.23.0-10",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -8,7 +8,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build && node scripts/copy-maplibre-worker.mjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
+11
View File
@@ -0,0 +1,11 @@
import { chmodSync, copyFileSync, mkdirSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const source = resolve('node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs');
const target = resolve('dist/assets/maplibre-gl-worker.mjs');
mkdirSync(dirname(target), { recursive: true });
copyFileSync(source, target);
chmodSync(target, 0o644);
const bytes = statSync(target).size;
if (bytes < 1000) throw new Error(`MapLibre worker inválido: ${bytes} bytes`);
console.log(`MapLibre worker: ${target} (${bytes} bytes)`);
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-7'; export const APP_VERSION = '0.23.0-10';
export const APP_PHASE = 'F6.9 · Actas e informes consolidados'; export const APP_PHASE = 'F6.13 · Mapa operativo';
@@ -41,22 +41,22 @@ function localDateTime(value: string | number | Date): string {
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] { function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
if (!geometry) return []; if (!geometry) return [];
if (geometry.type === 'POINT') return [geometry.coordinates]; if (geometry.type === 'Point') return [geometry.coordinates];
if (geometry.type === 'LINESTRING') return geometry.coordinates; if (geometry.type === 'LineString') return geometry.coordinates;
return geometry.coordinates[0]?.slice(0, -1) ?? []; return geometry.coordinates[0]?.slice(0, -1) ?? [];
} }
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null { function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null; if (type === 'POINT') return vertices[0] ? { type: 'Point', coordinates: vertices[0] } : null;
if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null; if (type === 'LINESTRING') return vertices.length >= 2 ? { type: 'LineString', coordinates: vertices } : null;
if (vertices.length < 3) return null; if (vertices.length < 3) return null;
return { type, coordinates: [[...vertices, vertices[0]!]] }; return { type: 'Polygon', coordinates: [[...vertices, vertices[0]!]] };
} }
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) { function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
const features: unknown[] = []; const features: unknown[] = [];
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry }); if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
if (geometry?.type !== 'POINT') { if (geometry?.type !== 'Point') {
vertices.forEach((coordinates, index) => features.push({ vertices.forEach((coordinates, index) => features.push({
type: 'Feature', properties: { kind: 'vertex', index: index + 1 }, type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
geometry: { type: 'Point', coordinates }, geometry: { type: 'Point', coordinates },
@@ -187,7 +187,7 @@ export function AssetGeometryEditor({
const applyStored = (value: AssetGeometry | null) => { const applyStored = (value: AssetGeometry | null) => {
setStored(value); setStored(value);
if (value) { if (value) {
setType(value.geometry.type); setType(value.geometryType);
setVertices(geometryVertices(value.geometry)); setVertices(geometryVertices(value.geometry));
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM)); setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : ''); setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
+3 -3
View File
@@ -25,9 +25,9 @@ const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons'];
function boundsFromFeatures(collection: MapAssetFeatureCollection) { function boundsFromFeatures(collection: MapAssetFeatureCollection) {
const positions: Array<[number, number]> = []; const positions: Array<[number, number]> = [];
collection.features.forEach((feature) => { collection.features.forEach((feature) => {
if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates); if (feature.geometry.type === 'Point') positions.push(feature.geometry.coordinates);
if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates); if (feature.geometry.type === 'LineString') positions.push(...feature.geometry.coordinates);
if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring)); if (feature.geometry.type === 'Polygon') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
}); });
if (!positions.length) return null; if (!positions.length) return null;
return positions.reduce<[number, number, number, number]>((result, point) => [ return positions.reduce<[number, number, number, number]>((result, point) => [
+13 -4
View File
@@ -536,9 +536,9 @@ export type Position = [number, number];
export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON'; export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON';
export type GeoJsonGeometry = export type GeoJsonGeometry =
| { type: 'POINT'; coordinates: Position } | { type: 'Point'; coordinates: Position }
| { type: 'LINESTRING'; coordinates: Position[] } | { type: 'LineString'; coordinates: Position[] }
| { type: 'POLYGON'; coordinates: Position[][] }; | { type: 'Polygon'; coordinates: Position[][] };
export interface AssetGeometry { export interface AssetGeometry {
assetId: string; assetId: string;
@@ -2162,6 +2162,15 @@ export async function getAssetGeometry(assetId: string) {
return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data; return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data;
} }
function geometryPayload(geometry: GeoJsonGeometry) {
const type: AssetGeometryType = geometry.type === 'Point'
? 'POINT'
: geometry.type === 'LineString'
? 'LINESTRING'
: 'POLYGON';
return { type, coordinates: geometry.coordinates };
}
export function upsertAssetGeometry(assetId: string, input: { export function upsertAssetGeometry(assetId: string, input: {
geometry: GeoJsonGeometry; geometry: GeoJsonGeometry;
accuracyM?: number | null; accuracyM?: number | null;
@@ -2169,7 +2178,7 @@ export function upsertAssetGeometry(assetId: string, input: {
deviceLabel?: string | null; deviceLabel?: string | null;
}) { }) {
return apiRequest<AssetGeometry>(`/assets/${assetId}/geometry`, { return apiRequest<AssetGeometry>(`/assets/${assetId}/geometry`, {
method: 'PUT', body: JSON.stringify(input), method: 'PUT', body: JSON.stringify({ ...input, geometry: geometryPayload(input.geometry) }),
}); });
} }
+36
View File
@@ -64,6 +64,14 @@ export interface InspectionReportDetailF4 {
companies: Array<{ id: string; code: string; name: string }>; companies: Array<{ id: string; code: string; name: string }>;
areas: Array<{ id: string; code: string; name: string }>; areas: Array<{ id: string; code: string; name: string }>;
findingCount: number; findingCount: number;
responseDueOn: string | null;
deadlineReason: string | null;
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: string }>;
findings: Array<{ id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null }>;
companyResponses: Array<{
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: string;
}>;
} }
export interface PendingInspectionReportF4 { export interface PendingInspectionReportF4 {
@@ -176,6 +184,30 @@ export function officializeInspectionReport(
}); });
} }
export function setInspectionReportResponseDeadline(
id: string,
input: { responseDueOn: string; reason?: string | null },
) {
return apiRequest<InspectionReportWorkflowView>(`/inspection-reports/${id}/response-deadline`, {
method: 'PATCH',
body: JSON.stringify(input),
});
}
export function addInspectionReportCompanyResponse(
id: string,
input: { receivedOn: string; details?: string; committedCorrectionOn?: string; contactName?: string; contactEmail?: string; file?: File | null },
) {
const body = new FormData();
body.set('receivedOn', input.receivedOn);
if (input.details?.trim()) body.set('details', input.details.trim());
if (input.committedCorrectionOn) body.set('committedCorrectionOn', input.committedCorrectionOn);
if (input.contactName?.trim()) body.set('contactName', input.contactName.trim());
if (input.contactEmail?.trim()) body.set('contactEmail', input.contactEmail.trim());
if (input.file) body.set('file', input.file);
return apiRequest<InspectionReportWorkflowView>(`/inspection-reports/${id}/company-responses`, { method: 'POST', body });
}
export function addInspectionReportFollowUp( export function addInspectionReportFollowUp(
id: string, id: string,
input: { input: {
@@ -206,6 +238,10 @@ export function inspectionReportGedoPdfDownloadUrl(id: string) {
return `/api/v3/inspection-reports/${id}/gedo-pdf`; return `/api/v3/inspection-reports/${id}/gedo-pdf`;
} }
export function inspectionReportCompanyResponseDownloadUrl(reportId: string, responseId: string) {
return `/api/v3/inspection-reports/${reportId}/company-responses/${responseId}/content`;
}
export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) { export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) {
return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`; return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`;
} }
+102 -130
View File
@@ -2,21 +2,22 @@ import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api';
import type { ActAdministrationDetail } from '../lib/api';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { formatDate } from '../lib/format'; import { formatDate, formatDateOnly } from '../lib/format';
import { import {
addInspectionReportCompanyResponse,
addInspectionReportFollowUp, addInspectionReportFollowUp,
getInspectionReportF4, getInspectionReportF4,
inspectionReportGedoPdfDownloadUrl, inspectionReportCompanyResponseDownloadUrl,
inspectionReportFollowUpDownloadUrl,
inspectionReportConsolidatedWordDownloadUrl, inspectionReportConsolidatedWordDownloadUrl,
inspectionReportFollowUpDownloadUrl,
inspectionReportGedoPdfDownloadUrl,
inspectionReportWordDownloadUrl, inspectionReportWordDownloadUrl,
listInspectionReportFollowUps, listInspectionReportFollowUps,
officializeInspectionReport, officializeInspectionReport,
setInspectionReportResponseDeadline,
updateInspectionReportNarrative, updateInspectionReportNarrative,
} from '../lib/reportWorkflowApi'; } from '../lib/reportWorkflowApi';
import type { import type {
@@ -34,6 +35,11 @@ function localDateTime(value: Date): string {
return local.toISOString().slice(0, 16); return local.toISOString().slice(0, 16);
} }
function localDate(value = new Date()): string {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
function reportStatusLabel(status: InspectionReportDetailF4['status']): string { function reportStatusLabel(status: InspectionReportDetailF4['status']): string {
if (status === 'WORKING') return 'En preparación'; if (status === 'WORKING') return 'En preparación';
if (status === 'OFFICIALIZED') return 'Oficializado en GEDO'; if (status === 'OFFICIALIZED') return 'Oficializado en GEDO';
@@ -66,10 +72,8 @@ export function ReportDetailPage() {
const { id } = useParams(); const { id } = useParams();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canManage = hasPermission('inspection_reports.generate'); const canManage = hasPermission('inspection_reports.generate');
const canReadActHistory = hasPermission('inspection_acts.read');
const [report, setReport] = useState<InspectionReportDetailF4 | null>(null); const [report, setReport] = useState<InspectionReportDetailF4 | null>(null);
const [followUps, setFollowUps] = useState<InspectionReportFollowUp[]>([]); const [followUps, setFollowUps] = useState<InspectionReportFollowUp[]>([]);
const [legacy, setLegacy] = useState<ActAdministrationDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -77,12 +81,21 @@ export function ReportDetailPage() {
const [executiveSummary, setExecutiveSummary] = useState(''); const [executiveSummary, setExecutiveSummary] = useState('');
const [reportDescription, setReportDescription] = useState(''); const [reportDescription, setReportDescription] = useState('');
const [gedoIfIdentifier, setGedoIfIdentifier] = useState(''); const [gedoIfIdentifier, setGedoIfIdentifier] = useState('');
const [gedoOfficializedAt, setGedoOfficializedAt] = useState(localDateTime(new Date())); const [gedoOfficializedAt, setGedoOfficializedAt] = useState(localDateTime(new Date()));
const [gedoFile, setGedoFile] = useState<File | null>(null); const [gedoFile, setGedoFile] = useState<File | null>(null);
const [followUpType, setFollowUpType] = useState<InspectionReportFollowUpType>('COMPANY_NOTE'); const [responseDueOn, setResponseDueOn] = useState('');
const [deadlineReason, setDeadlineReason] = useState('');
const [companyReceivedOn, setCompanyReceivedOn] = useState(localDate());
const [companyDetails, setCompanyDetails] = useState('');
const [companyCommittedOn, setCompanyCommittedOn] = useState('');
const [companyContactName, setCompanyContactName] = useState('');
const [companyContactEmail, setCompanyContactEmail] = useState('');
const [companyResponseFile, setCompanyResponseFile] = useState<File | null>(null);
const [followUpType, setFollowUpType] = useState<InspectionReportFollowUpType>('INTERNAL_NOTE');
const [followUpOccurredAt, setFollowUpOccurredAt] = useState(localDateTime(new Date())); const [followUpOccurredAt, setFollowUpOccurredAt] = useState(localDateTime(new Date()));
const [followUpReference, setFollowUpReference] = useState(''); const [followUpReference, setFollowUpReference] = useState('');
const [followUpDescription, setFollowUpDescription] = useState(''); const [followUpDescription, setFollowUpDescription] = useState('');
@@ -96,73 +109,80 @@ export function ReportDetailPage() {
]); ]);
setReport(nextReport); setReport(nextReport);
setFollowUps(nextFollowUps); setFollowUps(nextFollowUps);
if (canReadActHistory) setLegacy(await getActAdministration(nextReport.actId).catch(() => null));
setExecutiveSummary(nextReport.executiveSummary ?? ''); setExecutiveSummary(nextReport.executiveSummary ?? '');
setReportDescription(nextReport.reportDescription ?? ''); setReportDescription(nextReport.reportDescription ?? '');
setGedoIfIdentifier(nextReport.gedoIfIdentifier ?? ''); setGedoIfIdentifier(nextReport.gedoIfIdentifier ?? '');
if (nextReport.gedoOfficializedAt) { if (nextReport.gedoOfficializedAt) setGedoOfficializedAt(localDateTime(new Date(nextReport.gedoOfficializedAt)));
setGedoOfficializedAt(localDateTime(new Date(nextReport.gedoOfficializedAt))); setResponseDueOn(nextReport.responseDueOn?.slice(0, 10) ?? '');
}
}; };
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
setLoading(true); setLoading(true);
setError(''); setError('');
reload() reload().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [id]); }, [id]);
const saveNarrative = async (event: FormEvent) => { const saveNarrative = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!id || report?.status !== 'WORKING') return; if (!id || report?.status !== 'WORKING') return;
setWorking(true); setWorking(true); setError(''); setSuccess('');
setError('');
setSuccess('');
try { try {
await updateInspectionReportNarrative(id, { await updateInspectionReportNarrative(id, { executiveSummary: executiveSummary.trim() || null, description: reportDescription.trim() || null });
executiveSummary: executiveSummary.trim() || null,
description: reportDescription.trim() || null,
});
await reload(); await reload();
setSuccess('Contenido editable del INF actualizado. El Acta fuente y sus Hallazgos no fueron modificados.'); setSuccess('Contenido editable del INF actualizado. El Acta fuente y sus Hallazgos no fueron modificados.');
} catch (requestError) { } catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
setError(errorMessage(requestError));
} finally {
setWorking(false);
}
}; };
const officialize = async (event: FormEvent) => { const officialize = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!id || !gedoFile || !gedoIfIdentifier.trim() || !gedoOfficializedAt) return; if (!id || !gedoFile || !gedoIfIdentifier.trim() || !gedoOfficializedAt) return;
setWorking(true); setWorking(true); setError(''); setSuccess('');
setError('');
setSuccess('');
try { try {
await officializeInspectionReport(id, { await officializeInspectionReport(id, { gedoIfIdentifier: gedoIfIdentifier.trim(), gedoOfficializedAt: new Date(gedoOfficializedAt).toISOString(), file: gedoFile });
gedoIfIdentifier: gedoIfIdentifier.trim(),
gedoOfficializedAt: new Date(gedoOfficializedAt).toISOString(),
file: gedoFile,
});
setGedoFile(null); setGedoFile(null);
await reload(); await reload();
setSuccess('IF oficial de GEDO registrado. El PDF y su hash quedaron fijados de forma inmutable.'); setSuccess('PDF oficial e identificador IF de GEDO cargados manualmente. El Informe quedó oficializado.');
} catch (requestError) { } catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
setError(errorMessage(requestError)); };
} finally {
setWorking(false); const saveDeadline = async (event: FormEvent) => {
} event.preventDefault();
if (!id || report?.status !== 'OFFICIALIZED' || !responseDueOn) return;
setWorking(true); setError(''); setSuccess('');
try {
await setInspectionReportResponseDeadline(id, { responseDueOn, reason: deadlineReason.trim() || null });
setDeadlineReason('');
await reload();
setSuccess(`Vencimiento ${formatDateOnly(responseDueOn)} aplicado a todos los Hallazgos del Acta ${report.act.code}.`);
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
};
const addCompanyResponse = async (event: FormEvent) => {
event.preventDefault();
if (!id || report?.status !== 'OFFICIALIZED' || !companyReceivedOn) return;
if (!companyDetails.trim() && !companyResponseFile) return;
setWorking(true); setError(''); setSuccess('');
try {
await addInspectionReportCompanyResponse(id, {
receivedOn: companyReceivedOn,
details: companyDetails.trim() || undefined,
committedCorrectionOn: companyCommittedOn || undefined,
contactName: companyContactName.trim() || undefined,
contactEmail: companyContactEmail.trim() || undefined,
file: companyResponseFile,
});
setCompanyDetails(''); setCompanyCommittedOn(''); setCompanyContactName(''); setCompanyContactEmail(''); setCompanyResponseFile(null); setCompanyReceivedOn(localDate());
await reload();
setSuccess(`Respuesta de empresa registrada y vinculada al Informe ${report.code} y al Acta ${report.act.code}.`);
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
}; };
const addFollowUp = async (event: FormEvent) => { const addFollowUp = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!id || !followUpOccurredAt) return; if (!id || !followUpOccurredAt) return;
if (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile) return; if (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile) return;
setWorking(true); setWorking(true); setError(''); setSuccess('');
setError('');
setSuccess('');
try { try {
const next = await addInspectionReportFollowUp(id, { const next = await addInspectionReportFollowUp(id, {
type: followUpType, type: followUpType,
@@ -171,17 +191,9 @@ export function ReportDetailPage() {
description: followUpDescription.trim() || null, description: followUpDescription.trim() || null,
file: followUpFile, file: followUpFile,
}); });
setFollowUps(next); setFollowUps(next); setFollowUpReference(''); setFollowUpDescription(''); setFollowUpFile(null); setFollowUpOccurredAt(localDateTime(new Date()));
setFollowUpReference(''); setSuccess('Antecedente agregado al historial del Informe.');
setFollowUpDescription(''); } catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
setFollowUpFile(null);
setFollowUpOccurredAt(localDateTime(new Date()));
setSuccess('Antecedente agregado al seguimiento del INF. Los registros anteriores permanecen intactos.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setWorking(false);
}
}; };
if (loading) return <LoadingBlock label="Cargando informe…" />; if (loading) return <LoadingBlock label="Cargando informe…" />;
@@ -190,105 +202,65 @@ export function ReportDetailPage() {
{ 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}` }, { 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}` }] : []), ...(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' }, { 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 ?? '' }] : []), ...(report.gedoOfficializedAt ? [{ id: 'gedo', date: report.gedoOfficializedAt, title: 'PDF oficial de GEDO cargado', description: report.gedoIfIdentifier ?? '' }] : []),
...report.deadlines.map((item) => ({ id: `deadline-${item.id}`, date: item.createdAt, title: 'Vencimiento general del Acta', description: `${formatDateOnly(item.responseDueOn)} · ${item.reason}` })),
...report.companyResponses.map((item) => ({ id: `response-${item.id}`, date: `${item.receivedOn}T12:00:00`, title: `Respuesta de empresa · Acta ${report.act.code}`, description: item.details ?? item.originalName ?? 'Respuesta registrada', href: item.originalName ? inspectionReportCompanyResponseDownloadUrl(report.id, item.id) : undefined, fileName: item.originalName ?? undefined })),
...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 })), ...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)) : []; ].sort((a, b) => b.date.localeCompare(a.date)) : [];
return <section> return <section>
<div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div> <div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div>
<div className="page-heading survey-editor-heading"> <div className="page-heading survey-editor-heading">
<div><span className="eyebrow">INFORME DE INSPECCIÓN</span><h1>{report?.code ?? 'Informe'}</h1><p>{report ? `Acta ${report.act.code} · generado ${formatDate(report.generatedAt)}` : 'Consulta del informe.'}</p></div> <div><span className="eyebrow">INFORME DE INSPECCIÓN</span><h1>{report?.code ?? 'Informe'}</h1><p>{report ? `Acta ${report.act.code} · generado ${formatDate(report.generatedAt)}` : 'Consulta del informe.'}</p></div>
{report && <div className="report-status-stack"> {report && <div className="report-status-stack"><span className={`status-badge large ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span><span className={`status-badge large ${reportStatusClass(report.status)}`}>{reportStatusLabel(report.status)}</span></div>}
<span className={`status-badge large ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span>
<span className={`status-badge large ${reportStatusClass(report.status)}`}>{reportStatusLabel(report.status)}</span>
</div>}
</div> </div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>} {success && <Alert type="success">{success}</Alert>}
{report && <> {report && <>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> Una Inspección puede contener varias Actas y, por lo tanto, varios INF independientes. El Word puede editarse durante la preparación; el Acta sellada y sus Hallazgos permanecen inmutables.</p></div> <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un Informe corresponde a una sola Acta.</strong> GEDO no se consulta automáticamente: la oficialización se registra manualmente cargando el IF y su PDF oficial. Esa carga no genera una respuesta de empresa ni define un vencimiento por sola.</p></div>
<section className="panel report-summary-panel"> <section className="panel report-summary-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div> <div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
<div className="responsible-summary"> <div className="responsible-summary"><div><small>Empresa</small><strong>{names(report.companies, 'Sin asignar')}</strong></div><div><small>Área / Yacimiento</small><strong>{names(report.areas, 'Sin asignar')}</strong></div><div><small>Hallazgos</small><strong>{report.findingCount}</strong></div><div><small>Generado por</small><strong>{report.generatedBy.firstName} {report.generatedBy.lastName}</strong></div></div>
<div><small>Empresa</small><strong>{names(report.companies, 'Sin asignar')}</strong></div> <div className="report-linked-documents"><Link to={`/inspecciones/${report.visitId}`}><span>Inspección</span><strong>{report.visit.code}</strong><Icon name="chevron" /></Link><Link to={`/inspecciones/actas/${report.actId}`}><span>Acta fuente</span><strong>{report.act.code}</strong><small>Contenido inmutable</small><Icon name="chevron" /></Link><Link to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}><span>Hallazgos del Acta</span><strong>{report.findingCount}</strong><small>Seguimiento técnico</small><Icon name="chevron" /></Link></div>
<div><small>Área / Yacimiento</small><strong>{names(report.areas, 'Sin asignar')}</strong></div>
<div><small>Hallazgos</small><strong>{report.findingCount}</strong></div>
<div><small>Generado por</small><strong>{report.generatedBy.firstName} {report.generatedBy.lastName}</strong></div>
</div>
<div className="report-linked-documents">
<Link to={`/inspecciones/${report.visitId}`}><span>Inspección</span><strong>{report.visit.code}</strong><Icon name="chevron" /></Link>
<Link to={`/inspecciones/actas/${report.actId}`}><span>Acta fuente</span><strong>{report.act.code}</strong><small>Contenido inmutable</small><Icon name="chevron" /></Link>
<Link to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}><span>Hallazgos del Acta</span><strong>{report.findingCount}</strong><small>Seguimiento técnico</small><Icon name="chevron" /></Link>
</div>
</section>
<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><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>
{canManage && report.status === 'WORKING' && <div className="form-actions"><button className="button primary" disabled={working}>{working ? 'Guardando…' : 'Guardar contenido del INF'}</button></div>}
{report.status !== 'WORKING' && <Alert type="info">El contenido editable se cerró al registrar el IF oficial de GEDO.</Alert>}
</form>
</section> </section>
<section className="panel"> <section className="panel">
<div className="panel-heading"><div><span className="eyebrow">GEDO</span><h2>Oficialización del Informe</h2><p className="section-copy">Cuando GEDO devuelve el IF y el PDF oficial, ambos se registran en el sistema y pasan a ser la referencia documental institucional.</p></div></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 antes de enviarlo 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>
{report.status === 'OFFICIALIZED' ? <> <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'} /></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'} /></label>{canManage && report.status === 'WORKING' && <div className="form-actions"><button className="button primary" disabled={working}>{working ? 'Guardando…' : 'Guardar contenido del INF'}</button></div>}{report.status !== 'WORKING' && <Alert type="info">El contenido editable se cerró al registrar manualmente el PDF oficial de GEDO.</Alert>}</form>
<div className="responsible-summary">
<div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div>
<div><small>Oficializado</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div>
<div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div>
<div><small>Vencimiento del Acta</small><strong>{formatDate(report.act.deadlineAt)}</strong></div>
</div>
{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}
{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}
</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}>
<div className="form-grid">
<label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label>
<label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label>
</div>
<label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label>
<Alert>Esta acción cierra la edición del INF. El IF, la fecha y el hash del PDF oficial quedarán registrados como trazabilidad institucional.</Alert>
<div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Registrar IF y PDF oficial'}</button></div>
</form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.</Alert>}
</section> </section>
<section className="panel"> <section className="panel">
<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="panel-heading"><div><span className="eyebrow">GEDO</span><h2>Oficialización del Informe</h2><p className="section-copy">No existe una respuesta automática de GEDO. Cuando recibas el identificador IF y el PDF oficial, cargalos manualmente aquí.</p></div></div>
<div className="dossier-link-list">{timeline.map((item) => <div key={item.id}> {report.status === 'OFFICIALIZED' ? <><div className="responsible-summary"><div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div><div><small>Fecha GEDO</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div><div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div><div><small>Vencimiento de respuestas</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div></div>{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}><div className="form-grid"><label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label><label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label></div><label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label><Alert>Esta carga es manual. Registra la referencia institucional del Informe, pero no crea respuestas ni vencimientos automáticamente.</Alert><div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Cargar IF y PDF oficial'}</button></div></form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.{report.status === 'WORKING' && !canManage ? ' Tu usuario no tiene permiso para gestionar u oficializar Informes.' : ''}</Alert>}
<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>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>
<label className="field"><span>Referencia externa <em>opcional</em></span><input value={followUpReference} onChange={(event) => setFollowUpReference(event.target.value)} maxLength={255} placeholder="GEDO, expediente, nota, ticket…" /></label>
</div>
<label className="field"><span>Descripción</span><textarea rows={4} maxLength={20000} value={followUpDescription} onChange={(event) => setFollowUpDescription(event.target.value)} placeholder="Contenido o resumen de la presentación…" /></label>
<label className="field"><span>Archivo <em>opcional</em></span><input type="file" onChange={(event) => setFollowUpFile(event.target.files?.[0] ?? null)} /></label>
<div className="form-actions"><button className="button primary" disabled={working || (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile)}>{working ? 'Agregando…' : 'Agregar al historial'}</button></div>
</form>}
</section> </section>
<section className="panel report-integrity-panel"> <section className="panel">
<div className="panel-heading"><div><span className="eyebrow">INTEGRIDAD</span><h2>Fuente inmutable</h2><p className="section-copy">El INF conserva una copia verificable del Acta sellada que le dio origen.</p></div></div> <div className="panel-heading"><div><span className="eyebrow">PLAZO DE RESPUESTA</span><h2>Vencimiento común del Acta</h2><p className="section-copy">La fecha se define una sola vez para el Acta {report.act.code} y se proyecta sobre todos sus Hallazgos.</p></div></div>
<dl className="report-integrity-list"> {report.status !== 'OFFICIALIZED' ? <Alert type="info">Este paso se habilita después de cargar el PDF oficial de GEDO.</Alert> : <>
<div><dt>Acta fuente</dt><dd>{report.act.code}</dd></div> <div className="responsible-summary"><div><small>Acta relacionada</small><strong>{report.act.code}</strong></div><div><small>Vencimiento vigente</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div><div><small>Hallazgos alcanzados</small><strong>{report.findings.length}</strong></div><div><small>Motivo / referencia</small><strong>{report.deadlineReason ?? 'Sin definir'}</strong></div></div>
<div><dt>Hash de cierre del Acta</dt><dd>{report.actClosureSha256}</dd></div> {canManage && <form className="form-section" onSubmit={saveDeadline}><div className="form-grid"><label className="field"><span>Fecha de vencimiento</span><input type="date" value={responseDueOn} onChange={(event) => setResponseDueOn(event.target.value)} required /></label><label className="field"><span>Motivo / referencia <em>opcional</em></span><input value={deadlineReason} onChange={(event) => setDeadlineReason(event.target.value)} maxLength={1000} placeholder={`Vencimiento general del Acta ${report.act.code}`} /></label></div><div className="form-actions"><button className="button primary" disabled={working || !responseDueOn}>{report.responseDueOn ? 'Registrar nuevo vencimiento' : 'Definir vencimiento'}</button></div></form>}
<div><dt>Hash de la fuente del INF</dt><dd>{report.frozenSha256}</dd></div> </>}
<div><dt>Estado del INF</dt><dd>{reportStatusLabel(report.status)}</dd></div> <div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Elemento</th><th>Estado</th><th>Vencimiento</th></tr></thead><tbody>{report.findings.map((finding) => <tr key={finding.id}><td><Link className="text-link" to={`/hallazgos/${finding.id}`}>{finding.code}</Link><small className="block-muted">{finding.title}</small></td><td><strong>{finding.assetName}</strong><small className="block-muted">{finding.assetCode}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : finding.status}</span></td><td><strong>{formatDateOnly(finding.responseDueOn)}</strong></td></tr>)}</tbody></table></div>
</dl>
</section> </section>
<section className="panel">
<div className="panel-heading"><div><span className="eyebrow">RESPUESTAS DE EMPRESA</span><h2>Presentaciones vinculadas al Acta {report.act.code}</h2><p className="section-copy">Cada respuesta queda vinculada simultáneamente a este Informe y a su Acta fuente. Puede incluir un PDF recibido de la empresa.</p></div></div>
{report.status !== 'OFFICIALIZED' ? <Alert type="info">Las respuestas se habilitan después de cargar el PDF oficial de GEDO.</Alert> : <>
{report.companyResponses.length === 0 ? <Alert type="info">Todavía no se registraron respuestas de la empresa.</Alert> : <div className="dossier-link-list">{report.companyResponses.map((item) => <div key={item.id}><div><strong>Respuesta recibida {formatDateOnly(item.receivedOn)}</strong><small>{item.details ?? 'Sin detalle'}{item.committedCorrectionOn ? ` · Compromiso: ${formatDateOnly(item.committedCorrectionOn)}` : ''}</small>{item.contactName && <small>{item.contactName}{item.contactEmail ? ` · ${item.contactEmail}` : ''}</small>}{item.originalName && <a className="text-link" href={inspectionReportCompanyResponseDownloadUrl(report.id, item.id)}>Descargar {item.originalName} {fileSize(item.sizeBytes) && `· ${fileSize(item.sizeBytes)}`}</a>}</div><span>Acta {report.act.code}</span></div>)}</div>}
{canManage && <form className="form-section" onSubmit={addCompanyResponse}><div><h3>Registrar respuesta</h3><p className="section-copy">La respuesta se registra sobre el Informe {report.code} y queda relacionada con el Acta {report.act.code}.</p></div><div className="form-grid"><label className="field"><span>Fecha de recepción</span><input type="date" value={companyReceivedOn} onChange={(event) => setCompanyReceivedOn(event.target.value)} required /></label><label className="field"><span>Fecha comprometida por la empresa <em>opcional</em></span><input type="date" value={companyCommittedOn} onChange={(event) => setCompanyCommittedOn(event.target.value)} /></label><label className="field"><span>Contacto <em>opcional</em></span><input value={companyContactName} onChange={(event) => setCompanyContactName(event.target.value)} maxLength={200} /></label><label className="field"><span>Email <em>opcional</em></span><input type="email" value={companyContactEmail} onChange={(event) => setCompanyContactEmail(event.target.value)} maxLength={320} /></label></div><label className="field"><span>Detalle de la respuesta</span><textarea rows={5} maxLength={8000} value={companyDetails} onChange={(event) => setCompanyDetails(event.target.value)} placeholder="Respuesta, descargo, compromiso o documentación presentada…" /></label><label className="field"><span>PDF de respuesta <em>opcional</em></span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setCompanyResponseFile(event.target.files?.[0] ?? null)} /></label><div className="form-actions"><button className="button primary" disabled={working || (!companyDetails.trim() && !companyResponseFile)}>Registrar respuesta</button></div></form>}
</>}
</section>
<section className="panel">
<div className="panel-heading"><div><span className="eyebrow">HISTORIA DEL INFORME</span><h2>Otros antecedentes</h2><p className="section-copy">Cronología documental completa. Las respuestas formales de empresa se cargan en el bloque anterior; aquí quedan notas internas, verificaciones y otros antecedentes.</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 otro antecedente</h3><p className="section-copy">No usar este bloque para respuestas formales de empresa.</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="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><label className="field"><span>Referencia externa <em>opcional</em></span><input value={followUpReference} onChange={(event) => setFollowUpReference(event.target.value)} maxLength={255} /></label></div><label className="field"><span>Descripción</span><textarea rows={4} maxLength={20000} value={followUpDescription} onChange={(event) => setFollowUpDescription(event.target.value)} /></label><label className="field"><span>Archivo <em>opcional</em></span><input type="file" onChange={(event) => setFollowUpFile(event.target.files?.[0] ?? null)} /></label><div className="form-actions"><button className="button primary" disabled={working || (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile)}>Agregar al historial</button></div></form>}
</section>
<section className="panel report-integrity-panel"><div className="panel-heading"><div><span className="eyebrow">INTEGRIDAD</span><h2>Fuente inmutable</h2><p className="section-copy">El INF conserva una copia verificable del Acta sellada que le dio origen.</p></div></div><dl className="report-integrity-list"><div><dt>Acta fuente</dt><dd>{report.act.code}</dd></div><div><dt>Hash de cierre del Acta</dt><dd>{report.actClosureSha256}</dd></div><div><dt>Hash de la fuente del INF</dt><dd>{report.frozenSha256}</dd></div><div><dt>Estado del INF</dt><dd>{reportStatusLabel(report.status)}</dd></div></dl></section>
</>} </>}
</section>; </section>;
} }