Compare commits

...
Author SHA1 Message Date
admin ba01bc844e test(f7): cover simple inventory detail experience 2026-09-11 22:12:28 -03:00
admin fa6d4bd06a refactor(web): route canonical inventory to simple detail 2026-09-11 22:11:55 -03:00
admin f54e41d9c9 style(web): add simple inventory detail layout 2026-09-11 22:11:46 -03:00
admin 1b769a0ff4 feat(web): simplify canonical inventory details 2026-09-11 22:11:28 -03:00
admin 5695643fa8 fix(inventory): keep operator snapshot audit marker 2026-09-11 21:26:32 -03:00
admin a6e71fa299 test(inventory): move Yacimiento creation contract to F7 Area companies 2026-09-11 21:24:04 -03:00
admin 02091e14e6 test(inventory): align F5 source guards with F7 simple UX 2026-09-11 21:23:47 -03:00
admin 827b172c66 test(inventory): retain historical Area relation invariant 2026-09-11 21:23:28 -03:00
admin 45a1a08b16 test(web): replace obsolete F3 inventory contract with F7 simplicity guard 2026-09-11 21:20:16 -03:00
admin 3b9153f30a fix(inventory): use simple F7 create endpoint 2026-09-11 21:18:46 -03:00
admin 4f90032d00 feat(inventory): register simple inventory service 2026-09-11 21:18:21 -03:00
admin f621677527 feat(inventory): expose simple F7 create endpoint 2026-09-11 21:18:10 -03:00
admin eeffdc441e feat(inventory): add simple F7 create service 2026-09-11 21:17:59 -03:00
admin 351752256e feat(inventory): align Yacimiento context with simple F7 model 2026-09-11 21:17:19 -03:00
admin abe7e40c99 fix(inventory): allow multiple companies per Area 2026-09-11 21:16:20 -03:00
admin 3b7db0a869 docs: define F7 simple inventory model 2026-09-11 21:13:57 -03:00
admin 0213c29465 ux(admin): replace inventory model screen with simple type and field editor 2026-09-11 21:13:39 -03:00
admin fce370b287 ux(inventory): simplify manual create flow 2026-09-11 21:13:00 -03:00
admin 537b0a6f42 refactor(inventory): expose area companies for simple Yacimiento create 2026-09-11 21:12:24 -03:00
15 changed files with 1585 additions and 286 deletions
@@ -24,6 +24,7 @@ import { AssetRegistryService } from './asset-registry.service';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
@@ -59,6 +60,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetTypesService,
AssetsService,
InventoryStructureService,
SimpleInventoryStructureService,
InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
@@ -142,27 +142,8 @@ export class AssetOperationalRelationsService {
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
}
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
const [currentOperator] = (await manager.query(`
SELECT relation.id,company.name AS "companyName"
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND relation.company_id<>$2
ORDER BY relation.valid_from DESC
LIMIT 1
FOR UPDATE OF relation
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
if (currentOperator) {
throw new ConflictException({
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
});
}
}
// F7: un Área puede estar explotada por varias Empresas simultáneamente.
// La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área.
const [row] = (await manager.query(`
INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
@@ -170,9 +151,6 @@ export class AssetOperationalRelationsService {
RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// F5/F6: changing the Area operator is a temporal relation event only.
// Existing Inventory keeps its creation/historical operator snapshot and
// physical hierarchy unchanged. Runtime ownership must resolve this row.
const created = await this.loadRelation(manager, row.id);
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -181,7 +159,7 @@ export class AssetOperationalRelationsService {
entityId: row.id,
afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true }
: undefined,
}, manager);
return created;
@@ -212,7 +190,6 @@ export class AssetOperationalRelationsService {
});
}
// F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
await manager.query(`
UPDATE area_company_relations
@@ -232,6 +209,7 @@ export class AssetOperationalRelationsService {
afterData: this.auditView(updated),
metadata: {
inventoryHierarchyChanged:false,
operatorSnapshotPreserved:true,
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
},
}, manager);
@@ -4,10 +4,14 @@ import { RequirePermissions } from '../authorization/decorators/require-permissi
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
@Controller('inventory-structure')
export class InventoryStructureController {
constructor(private readonly inventoryStructure: InventoryStructureService) {}
constructor(
private readonly inventoryStructure: InventoryStructureService,
private readonly simpleInventoryStructure: SimpleInventoryStructureService,
) {}
@Get()
@RequirePermissions('assets.read')
@@ -24,6 +28,17 @@ export class InventoryStructureController {
return this.inventoryStructure.parents(kind, search);
}
@Post('simple')
@RequirePermissions('assets.create')
createSimple(
@Body() dto: CreateInventoryStructureDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.simpleInventoryStructure.create(dto, principal, request);
}
// Compatibilidad temporal con el alta anterior. La WEB F7 utiliza /simple.
@Post()
@RequirePermissions('assets.create')
create(
@@ -0,0 +1,323 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AssetDataOrigin,
AssetInformationStatus,
AssetOperationalStatus,
AssetVersionChangeType,
AuditAction,
} from '../database/entities';
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string };
type ParentRow = {
id: string;
code: string;
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
};
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area',
YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion',
};
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null,
DEPARTAMENTO: null,
AREA: 'departamento',
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion',
};
@Injectable()
export class SimpleInventoryStructureService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async create(
dto: CreateInventoryStructureDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
try {
return await this.dataSource.transaction(async (manager) => {
const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
let operationalAreaId: string | null = null;
let operatorCompanyId: string | null = null;
if (dto.kind === 'YACIMIENTO') {
if (!parent) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_AREA_REQUIRED', message: 'Seleccioná el Área del Yacimiento' });
if (!dto.operatorCompanyId) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED', message: 'Seleccioná la Empresa operadora del Yacimiento' });
await this.requireAreaCompanyRelation(manager, parent.id, dto.operatorCompanyId);
operationalAreaId = parent.id;
operatorCompanyId = dto.operatorCompanyId;
} else if (dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') {
operationalAreaId = parent?.operationalAreaId ?? null;
operatorCompanyId = parent?.operatorCompanyId ?? null;
if (!operationalAreaId || !operatorCompanyId) {
throw new ConflictException({
code: 'INVENTORY_YACIMIENTO_CONTEXT_MISSING',
message: 'El Yacimiento de origen debe tener Área y Empresa operadora válidas',
});
}
} else if (dto.operatorCompanyId || dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_SIMPLE_CONTEXT_NOT_ALLOWED',
message: 'Empresa operadora sólo corresponde al Yacimiento',
});
}
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const rows = (await manager.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,NULL,$5::uuid,
$6::varchar,$7::varchar,NULL,NULL,$8::asset_information_status,$9::asset_operational_status,
$10::varchar,$11::varchar,$12::varchar,$13::text,$14::uuid,$14::uuid,$14::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
family?.id ?? null,
generatedCode,
dto.name,
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Alta simple de Inventario',
`inventory-simple:${dto.kind.toLowerCase()}`,
family ? `Tipo: ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = rows[0]?.id;
if (!id) throw new Error('No se pudo crear el registro');
if (dto.kind === 'EMPRESA') {
await manager.query(`
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
ON CONFLICT (asset_id) DO UPDATE
SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`, [id, dto.name, principal.userId]);
}
const versionNumber = await this.history.capture(
manager,
id,
AssetVersionChangeType.CREATED,
principal,
request,
);
await manager.query(`
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
`, [
id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
dto.kind === 'YACIMIENTO'
? 'Alta de Yacimiento con Empresa operadora vinculada al Área'
: 'Alta simple de Inventario',
versionNumber,
request.requestId,
principal.userId,
]);
const created = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED,
entityType: 'asset',
entityId: id,
afterData: created as Record<string, unknown>,
metadata: {
versionNumber,
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorCompanyId,
simpleInventoryFlow: true,
},
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS', message: 'Ya existe un registro con ese código' });
}
throw error;
}
}
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const sql = kind === 'EMPRESA'
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind, 'EMPRESA'>]];
const rows = (await manager.query(sql, params)) as StructureTypeRow[];
if (!rows[0]) throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', message: `El nivel ${kind} no está configurado` });
return rows[0];
}
private async requireParent(
manager: EntityManager,
kind: InventoryStructureKind,
parentId: string | null,
): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) {
if (parentId) throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', message: `${kind === 'EMPRESA' ? 'Empresa' : 'Departamento'} no admite padre` });
return null;
}
if (!parentId) throw new BadRequestException({ code: 'INVENTORY_PARENT_REQUIRED', message: `Seleccioná el nivel superior para crear ${kind.toLowerCase()}` });
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,lower(type.code) AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId",
CASE WHEN lower(type.code)='area' THEN asset.id ELSE asset.operational_area_id END AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
FOR KEY SHARE OF asset
`, [parentId])) as ParentRow[];
const parent = rows[0];
if (!parent || parent.typeCode !== expectedType) {
throw new BadRequestException({ code: 'INVENTORY_PARENT_INVALID', message: `El registro padre debe ser ${expectedType}` });
}
return parent;
}
private async requireAreaCompanyRelation(manager: EntityManager, areaId: string, companyId: string): Promise<void> {
const rows = await manager.query(`
SELECT 1
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE relation.area_id=$1::uuid
AND relation.company_id=$2::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND company.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
LIMIT 1
FOR KEY SHARE OF relation
`, [areaId, companyId]);
if (!rows[0]) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA',
message: 'La Empresa seleccionada no está vinculada como explotadora del Área',
});
}
}
private async requireFamily(
manager: EntityManager,
kind: InventoryStructureKind,
familyId: string | null,
parent: ParentRow | null,
): Promise<FamilyRow | null> {
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') {
if (familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel no usa Tipo de Instalación/Subinstalación' });
return null;
}
if (!familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_REQUIRED', message: `Seleccioná el tipo de ${kind === 'INSTALACION' ? 'Instalación' : 'Subinstalación'}` });
const rows = (await manager.query(`
SELECT id,code,name,level::text AS level
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
LIMIT 1
FOR KEY SHARE
`, [familyId])) as FamilyRow[];
const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El tipo seleccionado no existe o está oculto' });
const expected = kind === 'INSTALACION' ? 'INSTALLATION' : 'SUBINSTALLATION';
if (family.level !== expected) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', message: 'El tipo seleccionado no corresponde a este nivel' });
if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({ code: 'INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un tipo configurado' });
const compatible = await manager.query(`
SELECT 1 FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`, [family.id, parent.inventoryFamilyId]);
if (!compatible[0]) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', message: 'Ese tipo de Subinstalación no está habilitado dentro de la Instalación seleccionada' });
}
return family;
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP'
: kind === 'DEPARTAMENTO' ? 'DEP'
: kind === 'AREA' ? 'AREA'
: kind === 'YACIMIENTO' ? 'YAC'
: kind === 'INSTALACION' ? 'INST'
: 'SUB';
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
}
private async loadView(manager: EntityManager, id: string) {
const rows = await manager.query(`
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',family.id,'code',family.code,'name',family.name,'level',family.level) END AS "inventoryFamily",
asset.created_at AS "createdAt",asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN assets company ON company.id=asset.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
return rows[0];
}
}
@@ -0,0 +1,179 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F7SimpleInventoryContext1790110200000 implements MigrationInterface {
name = 'F7SimpleInventoryContext1790110200000';
public async up(q: QueryRunner): Promise<void> {
await q.query(`
UPDATE asset_types
SET description='Yacimiento. Pertenece a un Área, tiene una única Empresa operadora vigente y puede recibir Hallazgos.',
updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='yacimiento'
`);
await q.query(`
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
kind text;
asset_role asset_type_operational_role;
parent_kind text;
parent_area uuid;
parent_company uuid;
parent_family uuid;
company_role asset_type_operational_role;
family_level text;
BEGIN
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
IF asset_role='COMPANY'::asset_type_operational_role THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
ELSIF kind='departamento' THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
INTO parent_kind,parent_area,parent_company,parent_family
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
IF kind='area' THEN
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='yacimiento' THEN
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere una Empresa operadora'; END IF;
SELECT type.operational_role INTO company_role
FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa operadora del Yacimiento no es válida';
END IF;
IF NOT EXISTS(
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=NEW.parent_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa del Yacimiento debe estar vinculada como explotadora del Área';
END IF;
IF NEW.concession_type_id IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de concesión indicado no es válido';
END IF;
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre';
END IF;
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='instalacion' THEN
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Yacimiento debe tener una Empresa operadora'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
ELSIF kind='subinstalacion' THEN
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
IF NOT EXISTS(
SELECT 1 FROM inventory_family_parent_rules rule
WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no está habilitado dentro del Tipo de instalación padre';
END IF;
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación debe pertenecer a un Yacimiento con Empresa operadora'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
END IF;
RETURN NEW;
END $$
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`
UPDATE asset_types
SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',
updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='yacimiento'
`);
await q.query(`
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
kind text;
asset_role asset_type_operational_role;
parent_kind text;
parent_area uuid;
parent_company uuid;
parent_family uuid;
company_role asset_type_operational_role;
family_level text;
BEGIN
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
IF asset_role='COMPANY'::asset_type_operational_role THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
ELSIF kind='departamento' THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
INTO parent_kind,parent_area,parent_company,parent_family
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
IF kind='area' THEN
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='yacimiento' THEN
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF;
SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF;
IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido';
END IF;
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF;
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='instalacion' THEN
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
ELSIF kind='subinstalacion' THEN
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre';
END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
END IF;
RETURN NEW;
END $$
`);
}
}
@@ -32,7 +32,7 @@ test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root
assert.match(migration, /SET can_be_root=false/);
});
test('Authoritative manual creation keeps Area physical and stores Company plus concession on Yacimiento', () => {
test('Historical authoritative endpoint keeps the former Yacimiento Company plus concession contract for compatibility', () => {
const structure = source('src/asset-master/inventory-structure.service.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
@@ -66,27 +66,60 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
});
test('Authoritative Configuration presents exact physical relationships and technical families', () => {
test('F7 Inventory Configuration exposes only simple types, containment and fields', () => {
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
assert.match(configPage, /El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento/);
assert.match(configPage, /Tipo de concesión/);
assert.match(configPage, /Empresa relacionada/);
assert.match(configPage, /Yacimiento → Instalación/);
assert.match(configPage, /Instalación → Subinstalación/);
assert.match(configPage, /installationFamilies\.map/);
assert.match(configPage, /subinstallationFamilies\.map/);
assert.match(configPage, /Hallazgos asociados/);
assert.match(configPage, /Tipos y campos/);
assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
assert.match(configPage, /Un Área puede estar vinculada a varias Empresas/);
assert.match(configPage, /Cada Yacimiento elige una sola Empresa operadora/);
assert.match(configPage, /Tipos de Instalación/);
assert.match(configPage, /Tipos de Subinstalación/);
assert.match(configPage, /Puede estar dentro de/);
assert.match(configPage, /\{ value: 'TEXT', label: 'Texto' \}/);
assert.match(configPage, /\{ value: 'NUMBER', label: 'Número' \}/);
assert.match(configPage, /\{ value: 'DATE', label: 'Fecha' \}/);
assert.match(configPage, /\{ value: 'BOOLEAN', label: 'Sí \/ No' \}/);
assert.doesNotMatch(configPage, /Hallazgos asociados/);
});
test('Authoritative Web creation exposes Departamento as root and Yacimiento as operational owner', () => {
test('F7 Web creation keeps the fixed hierarchy and asks only the required simple context', () => {
const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
assert.match(createPage, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA:'Departamento'/);
assert.match(createPage, /Empresa relacionada/);
assert.match(createPage, /Tipo de concesión/);
assert.match(createPage, /Datos opcionales/);
assert.match(configPage, /<strong>Departamento<\/strong>/);
assert.match(configPage, /<strong>Yacimiento<\/strong>/);
assert.match(createPage, /AREA: 'Departamento'/);
assert.match(createPage, /YACIMIENTO: 'Área'/);
assert.match(createPage, /INSTALACION: 'Yacimiento'/);
assert.match(createPage, /SUBINSTALACION: 'Instalación'/);
assert.match(createPage, /listCompaniesForInventoryArea/);
assert.match(createPage, /Empresa operadora/);
assert.match(createPage, /ALTA RÁPIDA/);
assert.doesNotMatch(createPage, /Tipo de concesión/);
assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
});
test('F7 canonical detail uses one short everyday profile and keeps advanced administration separate', () => {
const detailRoute = source('../web-v2/src/pages/InventoryDetailPage.tsx');
const detail = source('../web-v2/src/pages/SimpleInventoryDetailPage.tsx');
assert.match(detailRoute, /isSimpleInventoryAsset\(asset\) && !advanced/);
assert.match(detailRoute, /<SimpleInventoryDetailPage initialAsset=\{asset\}/);
assert.match(detail, /DEPARTAMENTO/);
assert.match(detail, /AREA/);
assert.match(detail, /YACIMIENTO/);
assert.match(detail, /INSTALACION/);
assert.match(detail, /SUBINSTALACION/);
assert.match(detail, />Resumen</);
assert.match(detail, />Actividad</);
assert.match(detail, />Ubicación</);
assert.match(detail, />Fotos</);
assert.match(detail, />Cambios</);
assert.match(detail, /Empresa operadora/);
assert.match(detail, /Tipo técnico/);
assert.match(detail, /AssetTechnicalDataPanel/);
assert.match(detail, /AssetDossierPanel/);
assert.match(detail, /Administración avanzada/);
assert.doesNotMatch(detail, /Tipo de concesión/);
assert.doesNotMatch(detail, /AssetProvenancePanel/);
});
@@ -88,14 +88,21 @@ test('F6.1 WEB Inventory searches use physical scope and never the operator snap
}
});
test('WEB creation captures Company and concession only while creating a Yacimiento', () => {
test('F7 WEB creation selects one Area-linked Company for Yacimiento and retires concession from the simple flow', () => {
const page = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const structure = source('src/asset-master/inventory-structure.service.ts');
const api = source('../web-v2/src/lib/inventoryStructureApi.ts');
const simpleStructure = source('src/asset-master/simple-inventory-structure.service.ts');
const migration = source('src/database/migrations/1790110200000-f7-simple-inventory-context.ts');
assert.match(page, /requiresYacimientoContext = kind === 'YACIMIENTO'/);
assert.match(page, /Empresa relacionada/);
assert.match(page, /Tipo de concesión/);
assert.match(page, /operatorCompanyId:operatorCompanyId \|\| null/);
assert.match(page, /concessionTypeId:concessionTypeId \|\| null/);
assert.match(structure, /Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica/);
assert.match(page, /const requiresCompany = kind === 'YACIMIENTO'/);
assert.match(page, /listCompaniesForInventoryArea\(parentId\)/);
assert.match(page, /Empresa operadora/);
assert.match(page, /operatorCompanyId: operatorCompanyId \|\| null/);
assert.doesNotMatch(page, /Tipo de concesión/);
assert.doesNotMatch(page, /concessionTypeId/);
assert.match(api, /inventory-structure\/simple/);
assert.match(simpleStructure, /requireAreaCompanyRelation/);
assert.match(simpleStructure, /INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA/);
assert.match(migration, /La Empresa del Yacimiento debe estar vinculada como explotadora del Área/);
assert.match(migration, /NEW\.operational_area_id:=NEW\.parent_id/);
});
+136
View File
@@ -0,0 +1,136 @@
# F7 · Modelo simple de Inventarios
Este documento fija el nuevo eje funcional de Inventarios para DH Inspección V2. Su objetivo es reducir la complejidad visible y orientar toda la experiencia al trabajo real de inspección.
## 1. Jerarquía
La estructura física es única:
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
- Departamento y Área organizan el territorio.
- Yacimiento, Instalación y Subinstalación son niveles válidos para registrar Hallazgos.
- Una Instalación pertenece a un Yacimiento.
- Una Subinstalación pertenece a una Instalación.
## 2. Empresas
- Un Área puede estar explotada por varias Empresas al mismo tiempo.
- Cada Yacimiento tiene una sola Empresa operadora vigente.
- La Empresa del Yacimiento se elige entre las Empresas vinculadas al Área.
- Instalaciones y Subinstalaciones heredan el contexto de Empresa desde su Yacimiento.
- Cambiar la Empresa de un Yacimiento no mueve ni recrea su Inventario físico.
## 3. Datos simples
Todos los registros usan una base común reducida:
- Nombre.
- Código, opcional y autogenerable.
- Ubicación jerárquica.
- Estado.
- GPS cuando corresponda.
- Observaciones sólo cuando aporten valor.
Departamento, Área y Yacimiento no necesitan un constructor técnico complejo.
Instalaciones y Subinstalaciones pueden tener campos específicos configurados por el Superadmin técnico.
Los tipos de campo visibles para la puesta a punto deben mantenerse simples:
- Texto.
- Número.
- Fecha.
- Sí / No.
Teléfono, email o identificadores simples pueden resolverse como Texto. GPS es un dato básico del registro, no un atributo técnico configurable.
## 4. Administración de tipos
Sólo el Superadmin técnico modifica la configuración.
La pantalla administrativa debe permitir únicamente:
1. Crear/ocultar Tipos de Instalación.
2. Crear/ocultar Tipos de Subinstalación.
3. Definir qué Tipos de Subinstalación puede contener cada Tipo de Instalación.
4. Agregar, mostrar/ocultar y marcar como obligatorios los campos simples de cada tipo.
Los términos técnicos internos como `inventory_families`, reglas de compatibilidad o schemas no deben exponerse al usuario.
## 5. Cambio de tipo / función
La identidad física del elemento no cambia cuando cambia su función.
Ejemplo: `TK-57` sigue siendo `TK-57`, aunque hoy funcione como tanque de petróleo y mañana como tanque de agua.
El cambio debe registrarse cronológicamente con:
- tipo / función anterior;
- tipo / función nueva;
- fecha efectiva;
- observación opcional.
No se crea un Inventario nuevo para representar un cambio de función.
## 6. Cronología útil
Para Yacimiento, Instalación y Subinstalación la cronología debe concentrar sólo hechos operativos relevantes:
- Hallazgos.
- Acta asociada.
- Empresa operadora correspondiente a la fecha.
- Cambio de tipo / función.
- Cambio de ubicación cuando corresponda.
- Alta e inactivación.
La experiencia normal no debe obligar a navegar por procedencia, snapshots, registros técnicos o paneles administrativos separados.
## 7. Filosofía de la APK
La APK existe para **inspeccionar, generar Acta y generar Informe**.
El Inspector no administra Inventarios como tarea separada.
La mayoría de las altas reales ocurren en campo mientras se registra un Hallazgo.
Flujo:
1. La Inspección se planifica desde WEB y ya conoce el Yacimiento.
2. El Inspector abre la Inspección al llegar.
3. Pulsa `+ Crear hallazgo`.
4. Decide si el Hallazgo corresponde a Yacimiento, Instalación o Subinstalación.
5. Si la Instalación no existe, puede crearla rápidamente sin abandonar el flujo.
6. Si necesita una Subinstalación que no existe, puede crearla rápidamente dentro de la Instalación seleccionada.
7. El catálogo ayuda, pero `OTRO` siempre está disponible.
8. Con el tiempo, el Inventario se completa naturalmente y disminuye la necesidad de altas nuevas.
## 8. Planificación y recorrido
La planificación WEB define:
- Área.
- Empresa.
- Yacimiento.
- Inspector.
- Fecha/hora.
- Instalaciones/Subinstalaciones elegidas preventivamente.
- pendientes por vencer, mostrados por elemento a controlar.
- pendientes vencidos, mostrados por elemento a controlar.
La APK presenta ese conjunto como recorrido sugerido. No es una ruta rígida: el Inspector puede registrar Hallazgos nuevos en cualquier momento.
## 9. Actas e Informes
- Abrir una Inspección no crea automáticamente un Acta.
- El Inspector pulsa `+ Nueva Acta` cuando empieza a documentar.
- El número definitivo del Acta se genera al cerrarla.
- Una Inspección puede tener varias Actas.
- Al cerrar un Acta se pueden agregar uno o más acompañantes con Nombre + Email.
- El Acta se envía a Empresa, Inspector y acompañantes.
- El Informe se genera automáticamente en Word y se envía únicamente al Inspector que realizó el Acta.
- La firma o validación legal definitiva queda para una etapa posterior; la prioridad actual es la usabilidad.
## 10. Regla de diseño
Si una pantalla, campo o estado necesita explicación técnica para ser usado correctamente, debe simplificarse antes de incorporarse a la experiencia cotidiana.
+33 -11
View File
@@ -4,37 +4,59 @@ set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LAYOUT="$ROOT/web-v2/src/layout/AppLayout.tsx"
APP="$ROOT/web-v2/src/app/App.tsx"
PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx"
CREATE_PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx"
ADMIN_PAGE="$ROOT/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx"
API="$ROOT/web-v2/src/lib/inventoryStructureApi.ts"
for file in "$LAYOUT" "$APP" "$PAGE"; do
for file in "$LAYOUT" "$APP" "$CREATE_PAGE" "$ADMIN_PAGE" "$API"; do
test -f "$file" || {
echo "F4 WEB contract: falta $file"
echo "F7 WEB contract: falta $file"
exit 1
}
done
if grep -Fq "label: 'Relevamientos'" "$LAYOUT"; then
echo "F4 WEB contract: Relevamientos no debe volver al menú principal"
echo "F7 WEB contract: Relevamientos no debe volver al menú principal"
exit 1
fi
if grep -Fq 'path="/relevamiento/*"' "$APP"; then
echo "F4 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas"
echo "F7 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas"
exit 1
fi
grep -Fq 'path="/inventarios/nuevo"' "$APP"
grep -Fq 'InventoryCreatePage' "$APP"
for kind in AREA YACIMIENTO INSTALACION SUBINSTALACION; do
grep -Fq "kind: '$kind'" "$PAGE"
for kind in DEPARTAMENTO AREA YACIMIENTO INSTALACION SUBINSTALACION; do
grep -Fq "kind: '$kind'" "$CREATE_PAGE"
done
if grep -Fq "kind: 'EQUIPO'" "$PAGE"; then
echo "F4 WEB contract: el alta guiada no debe restaurar EQUIPO como quinto nivel estructural"
if grep -Fq "kind: 'EQUIPO'" "$CREATE_PAGE"; then
echo "F7 WEB contract: EQUIPO no debe restaurarse como nivel estructural"
exit 1
fi
grep -Fq 'getInventoryFamilyFindings' "$PAGE"
# F7: Yacimiento elige una Empresa ya vinculada al Área; concesión deja de ser una decisión del alta cotidiana.
grep -Fq 'listCompaniesForInventoryArea' "$CREATE_PAGE"
if grep -Fq 'concessionTypeId' "$CREATE_PAGE"; then
echo "F7 WEB contract: el alta simple no debe exponer Tipo de concesión"
exit 1
fi
echo 'F4 WEB contract: OK'
# El alta normal usa el endpoint simple; el endpoint histórico queda sólo por compatibilidad transitoria.
grep -Fq "'/inventory-structure/simple'" "$API"
# La configuración técnica visible se reduce a tipos, contención y campos sencillos.
grep -Fq 'Tipos y campos' "$ADMIN_PAGE"
grep -Fq 'Tipos de Instalación' "$ADMIN_PAGE"
grep -Fq 'Tipos de Subinstalación' "$ADMIN_PAGE"
grep -Fq '+ Agregar campo' "$ADMIN_PAGE"
# El catálogo de Hallazgos no debe mezclarse con el formulario de alta de Inventario.
if grep -Fq 'getInventoryFamilyFindings' "$CREATE_PAGE"; then
echo "F7 WEB contract: el alta simple no debe cargar catálogo de Hallazgos"
exit 1
fi
echo 'F7 WEB contract: OK'
+6 -2
View File
@@ -22,6 +22,7 @@ export interface InventoryTechnicalValues {
}
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
export interface InventoryStructureOption { id:string; code:string; name:string; }
export interface InventoryAreaCompanyOption extends InventoryStructureOption { commonName?:string|null; typeName?:string; }
export interface InventoryStructureOptions {
independentMasters:InventoryStructureLevel[];
levels:InventoryStructureLevel[];
@@ -45,7 +46,7 @@ export interface CreatedInventoryStructure {
type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
operatorCompany?:null|{id:string;code:string;name:string};
concessionType?:null|{id:string;code:string;name:string};
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels?:string[]};
}
export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');}
@@ -53,6 +54,9 @@ export async function listInventoryStructureParents(kind:InventoryStructureKind,
const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:'';
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data;
}
export async function listCompaniesForInventoryArea(areaId:string){
return (await apiRequest<{data:InventoryAreaCompanyOption[]}>(`/asset-operational-relations/areas/${areaId}/companies`)).data;
}
export function getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);}
@@ -63,4 +67,4 @@ export function updateInventoryFamily(familyId:string,input:{name?:string;parent
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure/simple',{method:'POST',body:JSON.stringify(input)});}
@@ -1,203 +1,305 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { getFindingCatalogAdmin, listAssetTypes } from '../lib/api';
import type { AssetType, FindingAdminCatalog } from '../lib/api';
import { listInventoryFamiliesAdmin } from '../lib/inventoryStructureApi';
import type { InventoryFamily } from '../lib/inventoryStructureApi';
import {
createInventoryFamily,
createInventoryFamilyAttribute,
getInventoryFamilyAttributes,
listInventoryFamiliesAdmin,
updateInventoryFamily,
updateInventoryFamilyAttribute,
} from '../lib/inventoryStructureApi';
import type {
InventoryFamily,
InventoryFamilyAttribute,
InventoryFamilyAttributeDataType,
} from '../lib/inventoryStructureApi';
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type FamilyLevel = 'INSTALLATION' | 'SUBINSTALLATION';
type StructuralField = {
label: string;
detail: string;
required?: boolean;
relation?: boolean;
};
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string; fields: StructuralField[] }> = [
{
kind: 'EMPRESA',
label: 'Empresa',
description: 'Maestro independiente. La Empresa se relaciona directamente con uno o más Yacimientos.',
fields: [{ label: 'Nombre', detail: 'Nombre de la Empresa / Operadora.', required: true }],
},
{
kind: 'DEPARTAMENTO',
label: 'Departamento',
description: 'Raíz territorial. No depende de ningún otro nivel.',
fields: [{ label: 'Nombre', detail: 'Nombre del Departamento.', required: true }],
},
{
kind: 'AREA',
label: 'Área',
description: 'El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento.',
fields: [
{ label: 'Departamento', detail: 'Relación obligatoria Departamento → Área.', required: true, relation: true },
{ label: 'Nombre', detail: 'Nombre del Área.', required: true },
],
},
{
kind: 'YACIMIENTO',
label: 'Yacimiento',
description: 'El Yacimiento concentra el contexto operativo: Área, Tipo de concesión y Empresa relacionada.',
fields: [
{ label: 'Área', detail: 'Relación obligatoria Área → Yacimiento.', required: true, relation: true },
{ label: 'Tipo de concesión', detail: 'Explotación o Exploración, según la fuente.', required: true, relation: true },
{ label: 'Empresa relacionada', detail: 'Empresa / Operadora asociada directamente al Yacimiento.', required: true, relation: true },
{ label: 'Nombre', detail: 'Nombre del Yacimiento. Puede repetirse en otra Área.', required: true },
],
},
{
kind: 'INSTALACION',
label: 'Instalación',
description: 'Cada Instalación pertenece a un Yacimiento y posee una clasificación técnica.',
fields: [
{ label: 'Yacimiento', detail: 'Relación obligatoria Yacimiento → Instalación.', required: true, relation: true },
{ label: 'Tipo de instalación', detail: 'Clasificación técnica tomada del modelo de Instalaciones.', required: true, relation: true },
],
},
{
kind: 'SUBINSTALACION',
label: 'Subinstalación',
description: 'Cada Subinstalación pertenece a una Instalación y usa una clasificación compatible con ella.',
fields: [
{ label: 'Instalación', detail: 'Relación obligatoria Instalación → Subinstalación.', required: true, relation: true },
{ label: 'Tipo de subinstalación', detail: 'Clasificación técnica compatible con el Tipo de instalación padre.', required: true, relation: true },
],
},
const FIELD_TYPES: Array<{ value: InventoryFamilyAttributeDataType; label: string }> = [
{ value: 'TEXT', label: 'Texto' },
{ value: 'NUMBER', label: 'Número' },
{ value: 'DATE', label: 'Fecha' },
{ value: 'BOOLEAN', label: 'Sí / No' },
];
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
function fieldTypeLabel(value: InventoryFamilyAttributeDataType) {
return FIELD_TYPES.find((item) => item.value === value)?.label
?? (value === 'DATETIME' ? 'Fecha y hora' : value === 'SELECT' ? 'Lista' : value);
}
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null;
if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null;
return types.find((type) => type.code.toLowerCase() === kind.toLowerCase() && type.isActive) ?? null;
function fieldCode(name: string) {
return name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 100) || `campo_${Date.now()}`;
}
export function AuthoritativeInventoryConfigPage() {
const [types, setTypes] = useState<AssetType[]>([]);
const { hasPermission } = useAuth();
const canManage = hasPermission('asset_types.manage');
const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('AREA');
const [level, setLevel] = useState<FamilyLevel>('INSTALLATION');
const [selectedFamilyId, setSelectedFamilyId] = useState('');
const [attributes, setAttributes] = useState<InventoryFamilyAttribute[]>([]);
const [newTypeName, setNewTypeName] = useState('');
const [newTypeParents, setNewTypeParents] = useState<string[]>([]);
const [newFieldName, setNewFieldName] = useState('');
const [newFieldType, setNewFieldType] = useState<InventoryFamilyAttributeDataType>('TEXT');
const [newFieldRequired, setNewFieldRequired] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const refreshFamilies = async () => {
const loaded = await listInventoryFamiliesAdmin();
setFamilies(loaded);
return loaded;
};
useEffect(() => {
Promise.all([listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
.then(([loadedTypes, loadedFamilies, loadedCatalog]) => {
setTypes(loadedTypes);
setFamilies(loadedFamilies);
setCatalog(loadedCatalog);
})
refreshFamilies()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
const level = LEVELS.find((item) => item.kind === selectedKind) ?? LEVELS[0]!;
const selectedType = canonicalType(types, selectedKind);
const commonAttributes = useMemo(() => {
const attributes = selectedType?.attributes.filter((attribute) => attribute.isActive) ?? [];
return selectedKind === 'INSTALACION'
? attributes.filter((attribute) => attribute.code !== 'tipo_instalacion')
: attributes;
}, [selectedKind, selectedType]);
const installationFamilies = families.filter((family) => family.level === 'INSTALLATION' && family.isActive !== false);
const subinstallationFamilies = families.filter((family) => family.level === 'SUBINSTALLATION' && family.isActive !== false);
const activeFindings = catalog.items.filter((item) => item.isActive).length;
const installationFamilies = useMemo(
() => families.filter((family) => family.level === 'INSTALLATION'),
[families],
);
const visibleFamilies = useMemo(
() => families.filter((family) => family.level === level),
[families, level],
);
const selectedFamily = families.find((family) => family.id === selectedFamilyId) ?? null;
useEffect(() => {
const first = visibleFamilies.find((family) => family.isActive !== false) ?? visibleFamilies[0] ?? null;
setSelectedFamilyId((current) => visibleFamilies.some((family) => family.id === current) ? current : first?.id ?? '');
}, [level, visibleFamilies]);
useEffect(() => {
if (!selectedFamilyId) {
setAttributes([]);
return;
}
getInventoryFamilyAttributes(selectedFamilyId)
.then((result) => setAttributes(result.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))))
.catch((requestError) => setError(errorMessage(requestError)));
}, [selectedFamilyId]);
const run = async (action: () => Promise<void>, message: string) => {
setSaving(true);
setError('');
setSuccess('');
try {
await action();
setSuccess(message);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const createType = async () => {
if (!newTypeName.trim()) return;
if (level === 'SUBINSTALLATION' && newTypeParents.length === 0) {
setError('Elegí al menos un Tipo de Instalación que pueda contener esta Subinstalación.');
return;
}
await run(async () => {
const created = await createInventoryFamily({
level,
name: newTypeName.trim(),
parentFamilyIds: level === 'SUBINSTALLATION' ? newTypeParents : undefined,
});
await refreshFamilies();
setSelectedFamilyId(created.id);
setNewTypeName('');
setNewTypeParents([]);
}, `${level === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
};
const toggleParent = (parentId: string) => {
setNewTypeParents((current) => current.includes(parentId)
? current.filter((id) => id !== parentId)
: [...current, parentId]);
};
const toggleSelectedParent = async (parentId: string) => {
if (!selectedFamily || selectedFamily.level !== 'SUBINSTALLATION') return;
const next = selectedFamily.parentFamilyIds.includes(parentId)
? selectedFamily.parentFamilyIds.filter((id) => id !== parentId)
: [...selectedFamily.parentFamilyIds, parentId];
if (next.length === 0) {
setError('Una Subinstalación debe quedar habilitada dentro de al menos un Tipo de Instalación.');
return;
}
await run(async () => {
await updateInventoryFamily(selectedFamily.id, { parentFamilyIds: next });
await refreshFamilies();
}, 'Tipos permitidos actualizados.');
};
const createField = async () => {
if (!selectedFamily || !newFieldName.trim()) return;
await run(async () => {
await createInventoryFamilyAttribute(selectedFamily.id, {
code: fieldCode(newFieldName),
name: newFieldName.trim(),
dataType: newFieldType,
isRequired: newFieldRequired,
sortOrder: attributes.length,
});
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
setNewFieldName('');
setNewFieldType('TEXT');
setNewFieldRequired(false);
}, 'Campo agregado.');
};
const toggleField = async (attribute: InventoryFamilyAttribute, key: 'isRequired' | 'isActive') => {
if (!selectedFamily) return;
await run(async () => {
await updateInventoryFamilyAttribute(selectedFamily.id, attribute.id, { [key]: !attribute[key] });
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
}, key === 'isActive' ? 'Visibilidad del campo actualizada.' : 'Obligatoriedad actualizada.');
};
const toggleFamilyActive = async () => {
if (!selectedFamily) return;
await run(async () => {
await updateInventoryFamily(selectedFamily.id, { isActive: selectedFamily.isActive === false });
await refreshFamilies();
}, selectedFamily.isActive === false ? 'Tipo activado.' : 'Tipo desactivado.');
};
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page">
<div className="page-heading">
<div>
<span className="eyebrow">ADMINISTRACIÓN · MODELO AUTORITATIVO</span>
<h1>Configuración de Inventarios</h1>
<p>Relaciones fijas cargadas desde los SQL definitivos. Los vínculos estructurales no son campos de texto editables.</p>
<span className="eyebrow">ADMINISTRACIÓN · INVENTARIOS</span>
<h1>Tipos y campos</h1>
<p>Configuración simple para la puesta a punto. Los usuarios de campo no ven esta pantalla.</p>
</div>
<Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<article className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading">
<div>
<span className="eyebrow">ESTRUCTURA FÍSICA</span>
<h2>Jerarquía obligatoria</h2>
<p className="section-copy">Empresa es un maestro independiente y se asocia al Yacimiento. El árbol físico queda separado y sin ambigüedades.</p>
<span className="eyebrow">ESTRUCTURA FIJA</span>
<h2>Departamento Área Yacimiento Instalación Subinstalación</h2>
<p className="section-copy">Un Área puede estar vinculada a varias Empresas. Cada Yacimiento elige una sola Empresa operadora de las vinculadas a su Área.</p>
</div>
</div>
<div className="asset-browser-levels" aria-label="Jerarquía de Inventarios">
<div><span>1</span><strong>Departamento</strong><small>raíz</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>Área + Empresa + concesión</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>Yacimiento</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>Instalación</small></div>
</div>
<div className="temporal-notice" style={{ marginTop: 14 }}>
<Icon name="users" />
<p><strong>Empresa:</strong> ya no pertenece al Área. La relación canónica es <strong>Yacimiento Empresa relacionada</strong>.</p>
<div className="temporal-notice">
<Icon name="map" />
<p><strong>Campos básicos del sistema:</strong> nombre, código, ubicación jerárquica, estado y GPS. No hace falta configurarlos acá.</p>
</div>
</article>
<article className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading">
<div>
<span className="eyebrow">INFORMACIÓN ESTRUCTURAL</span>
<h2>{level.label}</h2>
<p className="section-copy">{level.description}</p>
</div>
<span className="tag">Modelo SQL</span>
</div>
<div className="quick-view-row" style={{ marginBottom: 18 }}>
{LEVELS.map((item) => <button type="button" key={item.kind} className={selectedKind === item.kind ? 'active' : ''} onClick={() => setSelectedKind(item.kind)}>{item.label}</button>)}
</div>
<div className="attribute-list">
{level.fields.map((field, index) => <div className="attribute-card" key={field.label}>
<span className="attribute-order">{index + 1}</span>
<span>
<strong>{field.label}</strong>
<small>{field.detail}</small>
</span>
<span className="attribute-flags">
{field.relation && <span className="tag">Relación</span>}
{field.required && <span className="tag">Obligatorio</span>}
</span>
</div>)}
{commonAttributes.map((attribute, index) => <div className="attribute-card" key={attribute.id}>
<span className="attribute-order">{level.fields.length + index + 1}</span>
<span>
<strong>{attribute.name}</strong>
<small>{attribute.code} · Campo común del nivel</small>
</span>
<span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}</span>
</div>)}
</div>
</article>
<div className="quick-view-row" style={{ marginBottom: 18 }}>
<button type="button" className={level === 'INSTALLATION' ? 'active' : ''} onClick={() => setLevel('INSTALLATION')}>Tipos de Instalación</button>
<button type="button" className={level === 'SUBINSTALLATION' ? 'active' : ''} onClick={() => setLevel('SUBINSTALLATION')}>Tipos de Subinstalación</button>
</div>
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Instalación</h2><p className="section-copy">{installationFamilies.length} clasificaciones exactas del SQL.</p></div></div>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
{installationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.code}</span></div>)}
<div className="panel-heading">
<div>
<span className="eyebrow">TIPOS</span>
<h2>{level === 'INSTALLATION' ? 'Instalaciones' : 'Subinstalaciones'}</h2>
<p className="section-copy">Seleccioná un tipo para administrar sus campos.</p>
</div>
</div>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
{visibleFamilies.length === 0 && <p className="muted">Todavía no hay tipos configurados.</p>}
{visibleFamilies.map((family) => <button
type="button"
key={family.id}
onClick={() => setSelectedFamilyId(family.id)}
className="attribute-card"
style={{ width: '100%', textAlign: 'left', borderColor: selectedFamilyId === family.id ? 'var(--primary)' : undefined, opacity: family.isActive === false ? .55 : 1 }}
>
<span className="asset-symbol"><Icon name="layers" size={16} /></span>
<span><strong>{family.name}</strong><small>{family.isActive === false ? 'Oculto' : `${family.technicalAttributeCount ?? 0} campos`}</small></span>
<Icon name="chevron" size={16} />
</button>)}
</div>
{canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3>
<label className="field"><span>Nombre</span><input value={newTypeName} onChange={(event) => setNewTypeName(event.target.value)} placeholder={level === 'INSTALLATION' ? 'Ej. Planta de tratamiento' : 'Ej. Bomba centrífuga'} /></label>
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} />{family.name}</label>)}
</div></div>}
<button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button>
</div>}
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Subinstalación</h2><p className="section-copy">{subinstallationFamilies.length} clasificaciones, cada una vinculada a su Tipo de instalación.</p></div></div>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
{subinstallationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin padre'} · {family.findingCount ?? 0} Hallazgos</small></span></div>)}
</div>
{!selectedFamily ? <div className="inline-empty">Seleccioná un tipo para ver sus campos.</div> : <>
<div className="panel-heading">
<div>
<span className="eyebrow">{selectedFamily.level === 'INSTALLATION' ? 'INSTALACIÓN' : 'SUBINSTALACIÓN'}</span>
<h2>{selectedFamily.name}</h2>
<p className="section-copy">Campos sencillos que se muestran al cargar este tipo.</p>
</div>
{canManage && <button type="button" className="button secondary" onClick={() => void toggleFamilyActive()}>{selectedFamily.isActive === false ? 'Activar tipo' : 'Ocultar tipo'}</button>}
</div>
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
<strong>Puede estar dentro de:</strong>
<div style={{ display: 'grid', gap: 8, marginTop: 10 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} />
{family.name}
</label>)}
</div>
</div>}
<div className="attribute-list">
{attributes.length === 0 && <p className="muted">Este tipo no tiene campos específicos. Puede usarse sólo con los datos básicos.</p>}
{attributes.map((attribute) => <div className="attribute-card" key={attribute.id} style={{ opacity: attribute.isActive ? 1 : .5 }}>
<span className="attribute-order">{attribute.sortOrder + 1}</span>
<span>
<strong>{attribute.name}</strong>
<small>{fieldTypeLabel(attribute.dataType)}{attribute.isRequired ? ' · Obligatorio' : ' · Opcional'}{attribute.isActive ? '' : ' · Oculto'}</small>
</span>
{canManage && <span className="attribute-flags" style={{ display: 'flex', gap: 6 }}>
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isRequired')}>{attribute.isRequired ? 'Hacer opcional' : 'Hacer obligatorio'}</button>
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isActive')}>{attribute.isActive ? 'Ocultar' : 'Mostrar'}</button>
</span>}
</div>)}
</div>
{canManage && selectedFamily.isActive !== false && <div style={{ marginTop: 22, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Agregar campo</h3>
<div className="form-grid">
<label className="field"><span>Nombre</span><input value={newFieldName} onChange={(event) => setNewFieldName(event.target.value)} placeholder="Ej. Capacidad" /></label>
<label className="field"><span>Tipo</span><select value={newFieldType} onChange={(event) => setNewFieldType(event.target.value as InventoryFamilyAttributeDataType)}>{FIELD_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}><input type="checkbox" checked={newFieldRequired} onChange={(event) => setNewFieldRequired(event.target.checked)} />Obligatorio</label>
<button type="button" className="button primary" disabled={saving || !newFieldName.trim()} onClick={() => void createField()}><Icon name="plus" />Agregar campo</button>
<p className="muted" style={{ marginBottom: 0, marginTop: 12 }}>Teléfono, email o identificadores simples se cargan como Texto. GPS es un dato básico del registro y no se configura como campo.</p>
</div>}
</>}
</article>
</div>
<article className="panel" style={{ marginTop: 18 }}>
<div className="panel-heading">
<div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo contextual</h2><p className="section-copy">{activeFindings} Hallazgos cargados con sus relaciones exactas a Instalaciones y Subinstalaciones.</p></div>
<Link className="button secondary" to="/admin/finding-catalog">Abrir catálogo <Icon name="chevron" /></Link>
</div>
</article>
</section>;
}
+207 -66
View File
@@ -6,13 +6,13 @@ import { Icon } from '../components/Icon';
import { getAsset } from '../lib/api';
import {
createInventoryStructure,
getInventoryFamilyFindings,
getInventoryStructureOptions,
listCompaniesForInventoryArea,
listInventoryStructureParents,
} from '../lib/inventoryStructureApi';
import type {
InventoryAreaCompanyOption,
InventoryFamily,
InventoryFamilyFindings,
InventoryStructureKind,
InventoryStructureOptions,
InventoryStructureParent,
@@ -26,13 +26,24 @@ const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [
{ kind: 'SUBINSTALACION', label: 'Subinstalación' },
{ kind: 'EMPRESA', label: 'Empresa' },
];
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
departamento: 'AREA', area: 'YACIMIENTO', yacimiento: 'INSTALACION', instalacion: 'SUBINSTALACION',
departamento: 'AREA',
area: 'YACIMIENTO',
yacimiento: 'INSTALACION',
instalacion: 'SUBINSTALACION',
};
const parentLabelByKind: Partial<Record<InventoryStructureKind,string>> = {
AREA:'Departamento', YACIMIENTO:'Área', INSTALACION:'Yacimiento', SUBINSTALACION:'Instalación',
const parentLabelByKind: Partial<Record<InventoryStructureKind, string>> = {
AREA: 'Departamento',
YACIMIENTO: 'Área',
INSTALACION: 'Yacimiento',
SUBINSTALACION: 'Instalación',
};
function kindLabel(kind: InventoryStructureKind) { return KINDS.find((item) => item.kind===kind)?.label ?? kind; }
function kindLabel(kind: InventoryStructureKind) {
return KINDS.find((item) => item.kind === kind)?.label ?? kind;
}
export function InventoryCreatePage() {
const navigate = useNavigate();
@@ -44,102 +55,232 @@ export function InventoryCreatePage() {
const [parentSearch, setParentSearch] = useState('');
const [parentId, setParentId] = useState('');
const [familyId, setFamilyId] = useState('');
const [areaCompanies, setAreaCompanies] = useState<InventoryAreaCompanyOption[]>([]);
const [operatorCompanyId, setOperatorCompanyId] = useState('');
const [concessionTypeId, setConcessionTypeId] = useState('');
const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null);
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [commonName, setCommonName] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
getInventoryStructureOptions().then(async (loaded) => {
setOptions(loaded);
if (contextParentId) {
const parent = await getAsset(contextParentId);
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
if (inferred) { setKind(inferred); setParentId(parent.id); }
}
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
getInventoryStructureOptions()
.then(async (loaded) => {
setOptions(loaded);
if (contextParentId) {
const parent = await getAsset(contextParentId);
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
if (inferred) {
setKind(inferred);
setParentId(parent.id);
}
}
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [contextParentId]);
const requiresParent = Boolean(parentLabelByKind[kind]);
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const requiresYacimientoContext = kind === 'YACIMIENTO';
const requiresCompany = kind === 'YACIMIENTO';
const parentLabel = parentLabelByKind[kind] ?? '';
useEffect(() => {
if (!requiresParent) { setParents([]); setParentId(''); return; }
if (!requiresParent) {
setParents([]);
setParentId('');
return;
}
const timer = window.setTimeout(() => {
listInventoryStructureParents(kind,parentSearch).then((loaded) => {
setParents(loaded);
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
}).catch((requestError) => setError(errorMessage(requestError)));
},150);
listInventoryStructureParents(kind, parentSearch)
.then((loaded) => {
setParents(loaded);
if (contextParentId && loaded.some((item) => item.id === contextParentId)) setParentId(contextParentId);
})
.catch((requestError) => setError(errorMessage(requestError)));
}, 150);
return () => window.clearTimeout(timer);
}, [kind,parentSearch,contextParentId,requiresParent]);
}, [kind, parentSearch, contextParentId, requiresParent]);
const selectedParent = parents.find((item) => item.id===parentId) ?? null;
useEffect(() => {
if (kind !== 'YACIMIENTO' || !parentId) {
setAreaCompanies([]);
setOperatorCompanyId('');
return;
}
setOperatorCompanyId('');
listCompaniesForInventoryArea(parentId)
.then(setAreaCompanies)
.catch((requestError) => setError(errorMessage(requestError)));
}, [kind, parentId]);
const selectedParent = parents.find((item) => item.id === parentId) ?? null;
const families = useMemo(() => {
if (!options) return [] as InventoryFamily[];
if (kind==='INSTALACION') return options.installationFamilies;
if (kind==='SUBINSTALACION') {
const parentFamilyId=selectedParent?.inventoryFamily?.id;
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : [];
if (kind === 'INSTALACION') return options.installationFamilies;
if (kind === 'SUBINSTALACION') {
const parentFamilyId = selectedParent?.inventoryFamily?.id;
return parentFamilyId
? options.subinstallationFamilies.filter((item) => item.parentFamilyIds.includes(parentFamilyId))
: [];
}
return [];
},[options,kind,selectedParent]);
const selectedFamily=families.find((item)=>item.id===familyId) ?? null;
}, [options, kind, selectedParent]);
useEffect(() => {
if (!familyId) { setFamilyFindings(null); return; }
getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError)));
},[familyId]);
useEffect(() => {
if (!requiresFamily) setFamilyId('');
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId('');
},[kind,requiresFamily,families,familyId]);
useEffect(() => {
if (!requiresYacimientoContext) { setOperatorCompanyId(''); setConcessionTypeId(''); }
},[requiresYacimientoContext]);
if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId('');
}, [kind, requiresFamily, families, familyId]);
const changeKind=(next:InventoryStructureKind) => {
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null);
setOperatorCompanyId(''); setConcessionTypeId(''); setError('');
const changeKind = (next: InventoryStructureKind) => {
setKind(next);
setParentId('');
setParentSearch('');
setFamilyId('');
setAreaCompanies([]);
setOperatorCompanyId('');
setError('');
};
const save=async(event:FormEvent) => {
const save = async (event: FormEvent) => {
event.preventDefault();
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; }
if (requiresYacimientoContext && !operatorCompanyId) { setError('Seleccioná la Empresa relacionada del Yacimiento.'); return; }
if (requiresYacimientoContext && !concessionTypeId) { setError('Seleccioná el Tipo de concesión del Yacimiento.'); return; }
if (requiresFamily && !familyId) { setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); return; }
setSaving(true); setError('');
if (requiresParent && !parentId) {
setError(`Seleccioná ${parentLabel}.`);
return;
}
if (requiresCompany && !operatorCompanyId) {
setError('Seleccioná la Empresa que opera este Yacimiento.');
return;
}
if (requiresFamily && !familyId) {
setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`);
return;
}
setSaving(true);
setError('');
try {
const created=await createInventoryStructure({
kind,name:name.trim(),parentId:parentId || null,familyId:familyId || null,
operatorCompanyId:operatorCompanyId || null,concessionTypeId:concessionTypeId || null,
code:code.trim() || null,commonName:commonName.trim() || null,description:description.trim() || null,
const created = await createInventoryStructure({
kind,
name: name.trim(),
parentId: parentId || null,
familyId: familyId || null,
operatorCompanyId: operatorCompanyId || null,
code: code.trim() || null,
});
navigate(`/inventarios/${created.id}`,{replace:true});
} catch(requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
navigate(`/inventarios/${created.id}`, { replace: true });
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading) return <LoadingBlock label="Preparando alta…" />;
const disableSave = saving
|| !name.trim()
|| (requiresParent && !parentId)
|| (requiresCompany && !operatorCompanyId)
|| (requiresFamily && !familyId);
return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario"><Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong></nav>
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Modelo fijo: Departamento Área Yacimiento Instalación Subinstalación. La Empresa y el Tipo de concesión pertenecen al Yacimiento.</p></div></div>
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario">
<Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong>
</nav>
<div className="page-heading asset-editor-heading">
<div>
<span className="eyebrow">ALTA RÁPIDA</span>
<h1>Agregar al Inventario</h1>
<p>Elegí qué querés crear, dónde está y completá sólo los datos necesarios.</p>
</div>
</div>
{error && <Alert>{error}</Alert>}
<form className="panel form-panel" onSubmit={save}>
<div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Área sólo depende de Departamento. Yacimiento define Área, Empresa relacionada y Tipo de concesión.</p></div><label className="field"><span>Tipo</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label></div>
{requiresParent && <div className="form-section"><div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} concreto.</p></div><label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}`} /></label><label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label></div>}
{requiresYacimientoContext && <div className="form-section"><div><h2>3. Contexto del Yacimiento</h2><p className="section-copy">Estas relaciones pertenecen directamente al Yacimiento y no al Área.</p></div><div className="form-grid"><label className="field"><span>Empresa relacionada <em>obligatorio</em></span><select value={operatorCompanyId} onChange={(event)=>setOperatorCompanyId(event.target.value)} required><option value="">Seleccionar</option>{options?.companies.map((company)=><option key={company.id} value={company.id}>{company.name}</option>)}</select></label><label className="field"><span>Tipo de concesión <em>obligatorio</em></span><select value={concessionTypeId} onChange={(event)=>setConcessionTypeId(event.target.value)} required><option value="">Seleccionar</option>{options?.concessionTypes.map((type)=><option key={type.id} value={type.id}>{type.name}</option>)}</select></label></div></div>}
{requiresFamily && <div className="form-section"><div><h2>3. Clasificación técnica</h2><p className="section-copy">Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.</p></div>{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene clasificación técnica.</Alert> : <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && <Alert>No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}.</Alert>}{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}</div>}
<div className="form-section"><div><h2>{requiresYacimientoContext ? '4' : requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">El nombre es el dato propio principal de este nivel. Los datos técnicos se completan después, cuando corresponda.</p></div><label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label><details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejalo vacío para generar automáticamente" /></label><label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label></div><label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label></div></details></div>
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresYacimientoContext && (!operatorCompanyId || !concessionTypeId)) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
<div className="form-section">
<div>
<h2>¿Qué querés crear?</h2>
<p className="section-copy">Departamento Área Yacimiento Instalación Subinstalación.</p>
</div>
<div className="quick-view-row" aria-label="Tipo de registro">
{KINDS.map((item) => <button
type="button"
key={item.kind}
className={kind === item.kind ? 'active' : ''}
onClick={() => changeKind(item.kind)}
>{item.label}</button>)}
</div>
</div>
{requiresParent && <div className="form-section">
<div>
<h2>¿Dónde está?</h2>
<p className="section-copy">Seleccioná el {parentLabel.toLowerCase()} al que pertenece.</p>
</div>
<label className="field">
<span>Buscar {parentLabel.toLowerCase()}</span>
<input value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}`} />
</label>
<label className="field">
<span>{parentLabel} <em>obligatorio</em></span>
<select value={parentId} onChange={(event) => { setParentId(event.target.value); setFamilyId(''); }} required>
<option value="">Seleccionar</option>
{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name}</option>)}
</select>
</label>
</div>}
{requiresCompany && <div className="form-section">
<div>
<h2>Empresa operadora</h2>
<p className="section-copy">Un Área puede tener varias Empresas. Cada Yacimiento queda asociado a una sola de ellas.</p>
</div>
{!parentId ? <Alert>Primero seleccioná el Área.</Alert> : areaCompanies.length === 0 ? <Alert>El Área elegida todavía no tiene Empresas operadoras vinculadas.</Alert> : <label className="field">
<span>Empresa <em>obligatorio</em></span>
<select value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} required>
<option value="">Seleccionar</option>
{areaCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}
</select>
</label>}
</div>}
{requiresFamily && <div className="form-section">
<div>
<h2>{kind === 'INSTALACION' ? 'Tipo de instalación' : 'Tipo / función'}</h2>
<p className="section-copy">{kind === 'SUBINSTALACION' ? 'Sólo aparecen tipos permitidos dentro de la Instalación seleccionada.' : 'Elegí la clasificación simple que corresponda.'}</p>
</div>
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene un tipo configurado.</Alert> : <label className="field">
<span>{kind === 'INSTALACION' ? 'Tipo' : 'Tipo / función'} <em>obligatorio</em></span>
<select value={familyId} onChange={(event) => setFamilyId(event.target.value)} required>
<option value="">Seleccionar</option>
{families.map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}
</select>
</label>}
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && families.length === 0 && <Alert>No hay tipos de Subinstalación configurados para esta Instalación.</Alert>}
</div>}
<div className="form-section">
<div>
<h2>Datos básicos</h2>
<p className="section-copy">El nombre alcanza para identificar el registro. El código puede generarse automáticamente.</p>
</div>
<label className="field">
<span>Nombre <em>obligatorio</em></span>
<input autoFocus value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} />
</label>
<label className="field">
<span>Código <em>opcional</em></span>
<input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Se genera automáticamente si lo dejás vacío" />
</label>
</div>
<div className="form-actions">
<Link className="button secondary" to="/inventarios">Cancelar</Link>
<button className="button primary" disabled={disableSave}><Icon name="check" />{saving ? 'Guardando…' : `Crear ${kindLabel(kind)}`}</button>
</div>
</form>
</section>;
}
+5
View File
@@ -7,6 +7,7 @@ import { getAsset } from '../lib/api';
import type { AssetDetail } from '../lib/api';
import { AssetEditorPage } from './AssetEditorPage';
import { CompanyInventoryPage } from './CompanyInventoryPage';
import { SimpleInventoryDetailPage, isSimpleInventoryAsset } from './SimpleInventoryDetailPage';
import { TerritorialInventoryPage, isTerritorialInventoryAsset } from './TerritorialInventoryPage';
export function InventoryDetailPage() {
@@ -43,6 +44,10 @@ export function InventoryDetailPage() {
return <CompanyInventoryPage initialAsset={asset} />;
}
if (isSimpleInventoryAsset(asset) && !advanced) {
return <SimpleInventoryDetailPage initialAsset={asset} />;
}
if (isTerritorialInventoryAsset(asset) && !advanced) {
return <TerritorialInventoryPage initialAsset={asset} />;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,350 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
import { AssetTechnicalDataPanel } from '../features/assets/AssetTechnicalDataPanel';
import {
ASSET_OPERATIONAL_STATUSES,
ASSET_STATUSES,
assetOperationalStatusLabel,
assetStatusClass,
assetStatusLabel,
} from '../features/assets/assetPresentation';
import {
getAssetLineage,
listAssetTreeChildren,
updateAsset,
updateAssetInformationStatus,
updateAssetOperationalStatus,
} from '../lib/api';
import type {
AssetDetail,
AssetInformationStatus,
AssetLineageItem,
AssetListItem,
AssetOperationalStatus,
} from '../lib/api';
import { getInventoryTechnicalValues } from '../lib/inventoryStructureApi';
import type { InventoryTechnicalValues } from '../lib/inventoryStructureApi';
import './SimpleInventoryDetailPage.css';
const AssetGeometryEditor = lazy(() =>
import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })),
);
export type SimpleInventoryKind =
| 'DEPARTAMENTO'
| 'AREA'
| 'YACIMIENTO'
| 'INSTALACION'
| 'SUBINSTALACION';
type SimpleTab = 'summary' | 'activity' | 'findings' | 'location' | 'photos' | 'history';
function normalized(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[_\s]+/g, '-');
}
export function simpleInventoryKind(asset: AssetDetail): SimpleInventoryKind | null {
const code = normalized(asset.type.code);
const name = normalized(asset.type.name);
const values = new Set([code, name]);
if (values.has('departamento')) return 'DEPARTAMENTO';
if (values.has('area')) return 'AREA';
if (values.has('yacimiento')) return 'YACIMIENTO';
if (values.has('subinstalacion')) return 'SUBINSTALACION';
if (
values.has('instalacion')
|| values.has('instalacion-de-superficie')
|| values.has('instalacion-superficie')
) return 'INSTALACION';
return null;
}
export function isSimpleInventoryAsset(asset: AssetDetail): boolean {
return simpleInventoryKind(asset) !== null;
}
const labels: Record<SimpleInventoryKind, string> = {
DEPARTAMENTO: 'Departamento',
AREA: 'Área',
YACIMIENTO: 'Yacimiento',
INSTALACION: 'Instalación',
SUBINSTALACION: 'Subinstalación',
};
const childLabels: Partial<Record<SimpleInventoryKind, string>> = {
DEPARTAMENTO: 'Área',
AREA: 'Yacimiento',
YACIMIENTO: 'Instalación',
INSTALACION: 'Subinstalación',
};
function lineageLabel(item: AssetLineageItem): string {
return item.commonName ? `${item.name} · ${item.commonName}` : item.name;
}
export function SimpleInventoryDetailPage({ initialAsset }: { initialAsset: AssetDetail }) {
const [params, setParams] = useSearchParams();
const { user, hasPermission } = useAuth();
const kind = simpleInventoryKind(initialAsset);
const [asset, setAsset] = useState(initialAsset);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [childrenHasMore, setChildrenHasMore] = useState(false);
const [technical, setTechnical] = useState<InventoryTechnicalValues | null>(null);
const [loadingRelated, setLoadingRelated] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState(initialAsset.name);
const [commonName, setCommonName] = useState(initialAsset.commonName ?? '');
const [description, setDescription] = useState(initialAsset.description ?? '');
const [status, setStatus] = useState<AssetInformationStatus>(initialAsset.informationStatus);
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>(initialAsset.operationalStatus);
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create');
const canChangeStatus = hasPermission('assets.change_status');
const canChangeOperationalStatus = hasPermission('assets.change_operational_status');
const canReadHistory = hasPermission('assets.read_history');
const canReadMedia = hasPermission('assets.read_media');
const canManageMedia = hasPermission('assets.manage_media');
const canEditGeometry = hasPermission('assets.update_geometry');
const canReadFindingCatalog = hasPermission('finding_catalog.read');
const canManageFindingCatalog = hasPermission('finding_catalog.manage');
const canReadDossier = hasPermission('inspections.read')
&& hasPermission('inspection_acts.read')
&& hasPermission('inspection_findings.read')
&& hasPermission('inspection_evidence.read')
&& hasPermission('inspection_communications.read');
const canAdvanced = hasAdministratorRole(user);
const inspectable = kind === 'YACIMIENTO' || kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const technicalLevel = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const hasChildren = kind !== 'SUBINSTALACION';
const requestedTab = params.get('tab') as SimpleTab | null;
const availableTabs = useMemo(() => new Set<SimpleTab>([
'summary',
...(inspectable && canReadDossier ? ['activity' as const] : []),
...(technicalLevel && canReadFindingCatalog ? ['findings' as const] : []),
'location',
...(canReadMedia ? ['photos' as const] : []),
...(canReadHistory ? ['history' as const] : []),
]), [inspectable, technicalLevel, canReadDossier, canReadFindingCatalog, canReadMedia, canReadHistory]);
const tab: SimpleTab = requestedTab && availableTabs.has(requestedTab) ? requestedTab : 'summary';
const loadRelated = async () => {
setLoadingRelated(true);
try {
const [loadedLineage, childPage, loadedTechnical] = await Promise.all([
getAssetLineage(asset.id),
hasChildren
? listAssetTreeChildren({ parentId: asset.id, limit: 100 })
: Promise.resolve({ data: [] as AssetListItem[], meta: { count: 0, hasMore: false } }),
technicalLevel
? getInventoryTechnicalValues(asset.id).catch(() => null)
: Promise.resolve(null),
]);
setLineage(loadedLineage);
setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore);
setTechnical(loadedTechnical);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoadingRelated(false);
}
};
useEffect(() => { void loadRelated(); }, [asset.id]);
if (!kind) return <Alert>Este registro no pertenece al modelo simple de Inventarios.</Alert>;
const save = async (event: FormEvent) => {
event.preventDefault();
if (!name.trim()) return;
setSaving(true);
setError('');
setSuccess('');
try {
let saved = asset;
if (canEdit) {
saved = await updateAsset(asset.id, {
name: name.trim(),
commonName: commonName.trim() || null,
description: description.trim() || null,
});
}
if (canChangeStatus && saved.informationStatus !== status) {
saved = await updateAssetInformationStatus(asset.id, status);
}
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
saved = await updateAssetOperationalStatus(asset.id, operationalStatus);
}
setAsset(saved);
setName(saved.name);
setCommonName(saved.commonName ?? '');
setDescription(saved.description ?? '');
setStatus(saved.informationStatus);
setOperationalStatus(saved.operationalStatus);
setEditing(false);
setHistoryRefreshKey((current) => current + 1);
setSuccess('Registro actualizado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const cancelEdit = () => {
setEditing(false);
setName(asset.name);
setCommonName(asset.commonName ?? '');
setDescription(asset.description ?? '');
setStatus(asset.informationStatus);
setOperationalStatus(asset.operationalStatus);
};
const setTab = (next: SimpleTab) => {
const nextParams = new URLSearchParams(params);
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
nextParams.delete('advanced');
setParams(nextParams);
};
const childLabel = childLabels[kind];
const parentLabel = kind === 'AREA'
? 'Departamento'
: kind === 'YACIMIENTO'
? 'Área'
: kind === 'INSTALACION'
? 'Yacimiento'
: kind === 'SUBINSTALACION'
? 'Instalación'
: null;
return <section className="narrow-section simple-inventory-page">
<nav className="breadcrumb simple-inventory-breadcrumb" aria-label="Ruta de Inventario">
<Link to="/inventarios">Inventarios</Link>
{lineage.filter((item) => item.id !== asset.id).map((item) => <span className="simple-inventory-breadcrumb-part" key={item.id}><span></span><Link to={`/inventarios/${item.id}`}>{lineageLabel(item)}</Link></span>)}
<span></span><strong>{asset.name}</strong>
</nav>
<header className="simple-inventory-hero">
<div>
<span className="eyebrow">{labels[kind].toUpperCase()}</span>
<h1>{asset.name}</h1>
<div className="simple-inventory-hero-meta"><strong>{asset.code}</strong>{asset.commonName && <span>{asset.commonName}</span>}</div>
</div>
<div className="simple-inventory-hero-actions">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
{tab === 'summary' && (canEdit || canChangeStatus || canChangeOperationalStatus) && <button type="button" className="button secondary" onClick={() => editing ? cancelEdit() : setEditing(true)}><Icon name="edit" />{editing ? 'Cancelar' : 'Editar'}</button>}
{tab === 'summary' && canCreate && childLabel && <Link className="button primary" to={`/inventarios/nuevo?parentId=${asset.id}`}><Icon name="plus" />Agregar {childLabel.toLowerCase()}</Link>}
</div>
</header>
<div className="simple-inventory-purpose">
<Icon name={inspectable ? 'clipboard' : 'layers'} />
<span>{inspectable
? 'Ficha operativa: identidad, ubicación y actividad de inspección. La información administrativa avanzada queda fuera de la vista cotidiana.'
: 'Este nivel organiza la estructura del Inventario. La vista cotidiana muestra sólo los datos necesarios para ubicar y navegar.'}</span>
</div>
<nav className="simple-inventory-tabs" aria-label="Secciones del Inventario">
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
{inspectable && canReadDossier && <button type="button" className={tab === 'activity' ? 'active' : ''} onClick={() => setTab('activity')}>Actividad</button>}
{technicalLevel && canReadFindingCatalog && <button type="button" className={tab === 'findings' ? 'active' : ''} onClick={() => setTab('findings')}>Hallazgos</button>}
<button type="button" className={tab === 'location' ? 'active' : ''} onClick={() => setTab('location')}>Ubicación</button>
{canReadMedia && <button type="button" className={tab === 'photos' ? 'active' : ''} onClick={() => setTab('photos')}>Fotos</button>}
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Cambios</button>}
</nav>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{tab === 'summary' && <div className="simple-inventory-stack">
<article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">DATOS PRINCIPALES</span><h2>Identificación</h2></div></div>
{editing ? <form className="simple-inventory-form" onSubmit={save}>
<div className="form-grid two">
<label className="field"><span>Nombre <em>obligatorio</em></span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} /></label>
<label className="field"><span>Nombre habitual <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} /></label>
</div>
<details className="simple-inventory-details">
<summary>Más datos</summary>
<label className="field"><span>Descripción <em>opcional</em></span><textarea rows={3} value={description} onChange={(event) => setDescription(event.target.value)} maxLength={4000} /></label>
</details>
{(canChangeStatus || canChangeOperationalStatus) && <details className="simple-inventory-details">
<summary>Estado y opciones</summary>
<div className="form-grid two">
{canChangeStatus && <label className="field"><span>Estado del dato</span><select value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>}
{canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><select value={operationalStatus} onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>}
</div>
</details>}
<div className="form-actions"><button type="button" className="button secondary" onClick={cancelEdit}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar cambios'}</button></div>
</form> : <div className="simple-inventory-data-grid">
<div><small>Nombre</small><strong>{asset.name}</strong></div>
<div><small>Código DH</small><strong>{asset.code}</strong></div>
{asset.commonName && <div><small>Nombre habitual</small><strong>{asset.commonName}</strong></div>}
<div><small>Estado operativo</small><strong>{assetOperationalStatusLabel(asset.operationalStatus)}</strong></div>
</div>}
</article>
<article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">UBICACIÓN ACTUAL</span><h2>Dentro del Inventario</h2></div></div>
<div className="simple-inventory-context-grid">
{parentLabel && <div className="simple-inventory-context-card"><small>{parentLabel}</small>{asset.parent ? <Link to={`/inventarios/${asset.parent.id}`}><strong>{asset.parent.name}</strong><span>{asset.parent.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{kind === 'YACIMIENTO' && <div className="simple-inventory-context-card"><small>Empresa operadora</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="simple-inventory-context-card"><small>Empresa del Yacimiento</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{technicalLevel && <div className="simple-inventory-context-card"><small>Tipo técnico</small><strong>{technical?.family.name ?? 'Sin clasificación técnica'}</strong>{technical?.family.code && <span>{technical.family.code}</span>}</div>}
</div>
</article>
{hasChildren && <article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">CONTENIDO</span><h2>{childLabel ? `${childLabel}s dentro de ${asset.name}` : `Contenido de ${asset.name}`}</h2></div><span className="count-pill">{children.length}{childrenHasMore ? '+' : ''}</span></div>
{loadingRelated ? <LoadingBlock label="Cargando contenido…" /> : children.length === 0 ? <div className="inline-empty">Todavía no hay registros dentro de {asset.name}.</div> : <div className="simple-inventory-child-list">{children.map((child) => <Link key={child.id} to={`/inventarios/${child.id}`} className="simple-inventory-child-row"><span className="asset-symbol"><Icon name="layers" /></span><span><strong>{child.name}</strong><small>{child.type.name} · {child.code}</small></span><span className={`status-badge ${assetStatusClass(child.informationStatus)}`}>{assetStatusLabel(child.informationStatus)}</span><Icon name="chevron" size={16} /></Link>)}</div>}
</article>}
{technicalLevel && <details className="panel simple-inventory-card simple-inventory-technical-details">
<summary><span><small>DATOS TÉCNICOS</small><strong>{technical?.family.name ?? 'Información técnica'}</strong></span><span>Ver / editar</span></summary>
<AssetTechnicalDataPanel assetId={asset.id} canEdit={canEdit} />
</details>}
{!editing && asset.description && <details className="panel simple-inventory-card simple-inventory-extra-details">
<summary>Más datos</summary>
<p>{asset.description}</p>
</details>}
{!editing && <details className="panel simple-inventory-card simple-inventory-extra-details">
<summary>Estado y opciones</summary>
<div className="simple-inventory-data-grid">
<div><small>Estado del dato</small><strong>{assetStatusLabel(asset.informationStatus)}</strong></div>
<div><small>Estado operativo</small><strong>{assetOperationalStatusLabel(asset.operationalStatus)}</strong></div>
</div>
{canAdvanced && <div className="simple-inventory-advanced"><span>Las herramientas históricas y administrativas quedan separadas de esta ficha.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>}
</details>}
</div>}
{tab === 'activity' && inspectable && canReadDossier && <AssetDossierPanel assetId={asset.id} />}
{tab === 'findings' && technicalLevel && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={asset.id} canManage={canManageFindingCatalog} />}
{tab === 'location' && <Suspense fallback={<LoadingBlock label="Cargando mapa…" />}><AssetGeometryEditor assetId={asset.id} assetName={asset.name} canEdit={canEditGeometry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} /></Suspense>}
{tab === 'photos' && canReadMedia && <AssetMediaPanel assetId={asset.id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} refreshKey={historyRefreshKey} />}
</section>;
}