Compare commits

..
Author SHA1 Message Date
admin a5d8022720 fix(f5): make catalog SQL text parameter types explicit 2026-09-08 23:12:47 -03:00
admin bfa9110c77 fix(f5): type relation backup markers explicitly 2026-09-08 23:07:25 -03:00
admin 915a2ee299 ci(f5): include canonical hierarchy migration in rollback gate 2026-09-08 23:02:37 -03:00
admin 91884b567e fix(f5): guarantee canonical inventory hierarchy before territory preload 2026-09-08 23:02:08 -03:00
admin 9dad6888b4 fix(f5): make territory SQL parameter types explicit 2026-09-08 22:56:13 -03:00
admin 1afef0f1f3 ci(f5): bypass irreversible historical reset in clean rehearsal 2026-09-08 22:48:08 -03:00
admin 3cfd3090aa ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal 2026-09-08 22:44:30 -03:00
admin 8e2ed6e38a test(f5): model normalized yacimiento collision explicitly 2026-09-08 22:38:40 -03:00
admin e92b1623c8 test(f5): distinguish literal and normalized yacimiento counts 2026-09-08 22:35:53 -03:00
admin 78e1d5cbf6 test(f5): make merge compatibility area-based 2026-09-08 22:35:26 -03:00
admin bd110b22f3 test(f5): align operator lifecycle with area-owned inventory 2026-09-08 22:35:15 -03:00
admin a53306b9d7 test(f5): align operational navigation contract 2026-09-08 22:35:05 -03:00
admin ec535233fa ci: prove F5 migrations revert and reapply cleanly 2026-09-08 22:08:54 -03:00
admin 35fa968128 ci: rehearse all migrations on clean PostGIS before merge 2026-09-08 22:08:08 -03:00
admin ef781abaca test(dashboard): route F5 summary query mocks explicitly 2026-09-08 22:05:58 -03:00
admin 3752b78f58 test(dashboard): expect inspector activity and act follow-up metrics 2026-09-08 22:02:39 -03:00
admin 923258f5bc chore(release): expose F5 WEB phase 2026-09-08 22:00:30 -03:00
admin 00065d55a6 chore(release): align WEB package with F5 inventory cut 2026-09-08 22:00:21 -03:00
admin e92ff01981 test(release): expect F5 health metadata 2026-09-08 22:00:09 -03:00
admin 7a66d67296 chore(release): bump API for F5 inventory model 2026-09-08 21:58:20 -03:00
admin 99ea1614ff chore(release): identify F5 inventory model 2026-09-08 21:58:07 -03:00
admin 8a9f81b8de fix(web): redefine inventory configuration around hierarchy classifications and columns 2026-09-08 21:56:30 -03:00
admin 741b2f2d8e fix(web): configure finding applicability by inventory classification 2026-09-08 21:54:29 -03:00
admin afa11918a6 feat(web): add inventory classification administration API 2026-09-08 21:53:59 -03:00
admin e2bb122d9e feat(inventory): expose family classification administration 2026-09-08 21:53:31 -03:00
admin 21c51bf672 feat(inventory): administer installation and subinstallation classifications 2026-09-08 21:53:17 -03:00
admin 90a85a7c1f feat(inventory): add family administration DTOs 2026-09-08 21:52:25 -03:00
admin fde562e2b4 test(inventory): lock F5 authoritative model and merge invariants 2026-09-08 21:51:04 -03:00
admin 5e26b4a899 fix(inventory): merge by physical area and preserve sealed documents 2026-09-08 21:50:21 -03:00
admin b4d81cff68 feat(web): create companies independently from physical inventory hierarchy 2026-09-08 21:47:12 -03:00
admin 53c7ca45f1 feat(web): expose independent company master in inventory setup 2026-09-08 21:46:29 -03:00
admin 0e320a9395 fix(inventory): make guided creation area-owned and support companies 2026-09-08 21:46:12 -03:00
admin 791137eb65 feat(inventory): allow independent company master creation 2026-09-08 21:45:23 -03:00
admin bb4a4eeb17 fix(inventory): make authoritative territory preload safely reversible 2026-09-08 21:40:41 -03:00
admin 39ca9dc35f fix(inventory): harden authoritative catalog migration checks 2026-09-08 21:38:00 -03:00
admin c651ef9616 fix(inventory): preserve custom catalog and apply authoritative universal findings 2026-09-08 21:34:52 -03:00
admin a3dd705190 fix(inventory): resolve field inventory by area hierarchy, not company ownership 2026-09-08 21:31:37 -03:00
admin f6564194b6 fix(inventory): treat company on asset as non-owning creation snapshot 2026-09-08 21:30:21 -03:00
admin dcb6b9fdce fix(inventory): apply area-owned operational guard on F5 up 2026-09-08 21:26:07 -03:00
admin 4e93b29edf fix(web): load F5 flow styles 2026-09-08 20:06:14 -03:00
admin ccc5226f72 fix(web): style act follow-up tabs and F5 inventory context 2026-09-08 20:06:05 -03:00
admin 4bc54f67fe fix(web): show only real inventory instances 2026-09-08 20:05:04 -03:00
admin 3f0148e4b7 refactor(web): make inventory hierarchy area-owned 2026-09-08 20:04:35 -03:00
admin 1a6f6f5c68 feat(web): add real inventory list client 2026-09-08 20:03:32 -03:00
admin 06aaacfd3f feat(inventory): expose real inventory list 2026-09-08 20:03:13 -03:00
admin 0548115516 feat(inventory): add real inventory list endpoint logic 2026-09-08 20:02:59 -03:00
admin 7945d1a729 feat(inventory): extend inventory browser filters 2026-09-08 20:02:24 -03:00
admin 8ea8a35829 feat(web): add inventory browser API client 2026-09-08 20:01:55 -03:00
admin 31a3a207d6 fix(inventory): make operator relation temporal and non-owning 2026-09-08 20:01:17 -03:00
admin bd6d91d7ac refactor(inventory): remove function catalog and add inventory browser 2026-09-08 20:00:09 -03:00
admin 598c987031 feat(inventory): expose area-owned inventory browser 2026-09-08 19:59:50 -03:00
admin 84217b67d8 feat(inventory): add area-owned inventory browser 2026-09-08 19:59:39 -03:00
admin 9e29c9d69c feat(inventory): add inventory browser query contract 2026-09-08 19:59:18 -03:00
admin 4bbe32ba93 feat(inventory): add inventory-only tree filter 2026-09-08 19:58:18 -03:00
admin ff518ee916 feat(inventory): add inventory-only asset filter 2026-09-08 19:57:58 -03:00
admin f4cd931d51 fix(inventory): preserve non-hierarchical operator snapshot compatibility 2026-09-08 19:57:18 -03:00
admin 436b57a3da fix(findings): use only authoritative F5 family catalog 2026-09-08 19:55:04 -03:00
admin 60acadd24e feat(inventory): preload authoritative technical catalog 2026-09-08 19:54:44 -03:00
admin 218f792f13 feat(inventory): preload authoritative territory model 2026-09-08 19:54:00 -03:00
admin 91e346d4c4 feat(inventory): add authoritative F5 source snapshot 2026-09-08 19:46:59 -03:00
admin afbece7d70 fix(inventory): count only installation and subinstallation instances 2026-09-08 19:07:50 -03:00
admin 88d43b7414 feat(inventory): classify future concrete instances at database level 2026-09-08 18:23:16 -03:00
admin 6b5b522be1 fix(inventory): keep field yacimientos structural 2026-09-08 18:22:47 -03:00
admin 637e9167b4 feat(inventory): map physical instance flag on asset entity 2026-09-08 18:22:15 -03:00
admin e082f5d062 feat(inventory): mark field-created records as real instances 2026-09-08 18:08:19 -03:00
admin ead1beb9d7 fix(web): clarify dashboard act and report concepts 2026-09-08 18:07:55 -03:00
admin 8389991b2c fix(web): align dashboard summary contract 2026-09-08 18:07:26 -03:00
admin 79258836b1 fix(dashboard): align inventory and act follow-up metrics 2026-09-08 18:07:17 -03:00
admin 11052c83aa feat(inventory): distinguish physical instances from structural records 2026-09-08 18:06:11 -03:00
admin a27f17d642 fix(web): remove redundant inspections lifecycle legend 2026-09-08 18:05:44 -03:00
admin 9083883bf2 fix(web): remove legacy imports and function catalog routes 2026-09-08 18:05:16 -03:00
admin fffd1ddcc2 fix(web): simplify inventory administration menu 2026-09-08 18:04:55 -03:00
41 changed files with 1088 additions and 2141 deletions
+3 -7
View File
@@ -1,11 +1,9 @@
name: Android APK name: Android APK
# F6: genera una APK debug verificable contra la API productiva F6. # F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
on: on:
push: push:
branches: branches:
- 'main'
- 'release/f5-android-test'
- 'feature/f2-2*' - 'feature/f2-2*'
- 'feature/f2-3*' - 'feature/f2-3*'
- 'feature/f2-4*' - 'feature/f2-4*'
@@ -18,8 +16,6 @@ on:
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/auth/**' - 'api-v3/src/auth/**'
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/inspection-acts/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
workflow_dispatch: workflow_dispatch:
@@ -62,7 +58,7 @@ jobs:
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: DH-Inspeccion-F6-0.14.0-debug name: DH-Inspeccion-F3.2-0.12.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 14
+63 -64
View File
@@ -80,9 +80,12 @@ jobs:
docker compose --env-file .env.example up -d db docker compose --env-file .env.example up -d db
# Historical production reset is a one-shot migration that expects the # The historical production reset is a one-shot operational migration,
# production admin. Prove the clean chain reaches that exact guard, # not a bootstrap migration: it requires production data/configuration
# mark only that historical reset as applied, then continue the chain. # that cannot exist at its timestamp in a database rebuilt from zero.
# Prove the clean chain reaches that exact guard, then mark only that
# one-shot migration as already applied and continue the reproducible
# schema chain. The historical migration itself remains untouched.
bootstrap_log="$(mktemp)" bootstrap_log="$(mktemp)"
set +e set +e
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log" docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
@@ -101,14 +104,17 @@ jobs:
docker compose --env-file .env.example exec -T db \ docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$ DO $$
DECLARE reset_rows integer; DECLARE
reset_rows integer;
BEGIN BEGIN
SELECT COUNT(*) INTO reset_rows SELECT COUNT(*) INTO reset_rows
FROM typeorm_migrations FROM typeorm_migrations
WHERE name = 'ResetProductionOperationalData1788652800000'; WHERE name = 'ResetProductionOperationalData1788652800000';
IF reset_rows <> 0 THEN IF reset_rows <> 0 THEN
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows; RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
END IF; END IF;
INSERT INTO typeorm_migrations ("timestamp", name) INSERT INTO typeorm_migrations ("timestamp", name)
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000'); VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
END $$; END $$;
@@ -116,23 +122,17 @@ jobs:
docker compose --env-file .env.example --profile tools run --rm migrate docker compose --env-file .env.example --profile tools run --rm migrate
# F5.1 intentionally ends with zero operational/domain instances. The
# technical family and finding masters remain, but territory preload,
# imports, applicability links and old audits are deliberately gone.
docker compose --env-file .env.example exec -T db \ docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$ DO $$
DECLARE DECLARE
f5_migrations integer; f5_migrations integer;
domain_assets integer; real_inventory integer;
audits integer; source_areas integer;
applicability integer; source_yacimientos integer;
territory_sources integer;
source_installations integer; source_installations integer;
source_subinstallations integer; source_subinstallations integer;
source_findings integer; source_findings integer;
department_types integer;
area_department_rules integer;
BEGIN BEGIN
SELECT COUNT(*) INTO f5_migrations SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations FROM typeorm_migrations
@@ -141,33 +141,28 @@ jobs:
'F5CanonicalInventoryHierarchy1790087150000', 'F5CanonicalInventoryHierarchy1790087150000',
'F5AuthoritativeTerritory1790087200000', 'F5AuthoritativeTerritory1790087200000',
'F5OperationalContextCompatibility1790087250000', 'F5OperationalContextCompatibility1790087250000',
'F5AuthoritativeInventoryCatalog1790087300000', 'F5AuthoritativeInventoryCatalog1790087300000'
'F51CleanManualInventory1790087400000'
); );
IF f5_migrations <> 6 THEN IF f5_migrations <> 5 THEN
RAISE EXCEPTION 'Expected 6 F5/F5.1 migrations, got %', f5_migrations; RAISE EXCEPTION 'Expected 5 F5 migrations, got %', f5_migrations;
END IF; END IF;
SELECT COUNT(*) INTO domain_assets FROM assets; SELECT COUNT(*) INTO real_inventory
IF domain_assets <> 0 THEN FROM assets WHERE is_inventory_instance=true AND information_status<>'INACTIVE';
RAISE EXCEPTION 'F5.1 clean start must contain 0 Assets, got %', domain_assets; IF real_inventory <> 0 THEN
RAISE EXCEPTION 'Fresh F5 database must start with 0 real Inventory instances, got %', real_inventory;
END IF; END IF;
SELECT COUNT(*) INTO audits FROM audit_events; SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA'),
IF audits <> 0 THEN COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')
RAISE EXCEPTION 'F5.1 clean start must contain 0 audit events, got %', audits; INTO source_areas,source_yacimientos
END IF; FROM source_documents document
JOIN asset_source_documents link ON link.document_id=document.id
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families; JOIN assets asset ON asset.id=link.asset_id
IF applicability <> 0 THEN JOIN asset_types type ON type.id=asset.asset_type_id
RAISE EXCEPTION 'F5.1 clean start must contain 0 finding applicability links, got %', applicability; WHERE document.document_number='DH-F5-TERRITORY';
END IF; IF source_areas <> 64 OR source_yacimientos <> 230 THEN
RAISE EXCEPTION 'F5 territory preload mismatch: areas %, yacimientos %', source_areas,source_yacimientos;
SELECT COUNT(*) INTO territory_sources
FROM source_documents
WHERE document_number='DH-F5-TERRITORY';
IF territory_sources <> 0 THEN
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
END IF; END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'), SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
@@ -176,7 +171,7 @@ jobs:
FROM inventory_families FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%'; WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'F5.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations; RAISE EXCEPTION 'F5 family preload mismatch: installations %, subinstallations %', source_installations,source_subinstallations;
END IF; END IF;
SELECT COUNT(*) INTO source_findings SELECT COUNT(*) INTO source_findings
@@ -184,38 +179,42 @@ jobs:
JOIN finding_categories category ON category.id=item.category_id JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='f5model' AND item.is_active=true; WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN IF source_findings <> 177 THEN
RAISE EXCEPTION 'F5.1 must preserve finding master catalog, got %', source_findings; RAISE EXCEPTION 'F5 finding preload mismatch: %', source_findings;
END IF;
SELECT COUNT(*) INTO department_types
FROM asset_types
WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true;
IF department_types <> 1 THEN
RAISE EXCEPTION 'Expected one active root Departamento type, got %', department_types;
END IF;
SELECT COUNT(*) INTO area_department_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='area' AND lower(parent.code)='departamento';
IF area_department_rules <> 1 THEN
RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules;
END IF; END IF;
END $$; END $$;
SQL SQL
# F5.1 is intentionally one-way: production rollback is the PRE database # Prove the five F5 migrations are actually reversible on a clean state.
# backup, not migration:revert. Prove instead that the completed chain is for _ in 1 2 3 4 5; do
# idempotent and has no pending migration on a second run. docker compose --env-file .env.example --profile tools run --rm migrate npm run migration:revert
rerun_log="$(mktemp)" done
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || { docker compose --env-file .env.example exec -T db \
echo "ERROR: F5.1 migration chain is not idempotent." >&2 psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
cat "$rerun_log" >&2 DO $$
exit 1 DECLARE f5_migrations integer; instance_column integer;
} BEGIN
rm -f "$rerun_log" SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
WHERE name LIKE 'F5%1790087%';
IF f5_migrations <> 0 THEN
RAISE EXCEPTION 'F5 rollback left % migration rows behind', f5_migrations;
END IF;
SELECT COUNT(*) INTO instance_column
FROM information_schema.columns
WHERE table_schema='public' AND table_name='assets' AND column_name='is_inventory_instance';
IF instance_column <> 0 THEN
RAISE EXCEPTION 'F5 rollback left is_inventory_instance behind';
END IF;
END $$;
SQL
# Reapply them once more. Each F5 migration performs its own source/cardinality checks.
docker compose --env-file .env.example --profile tools run --rm migrate
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 -Atc \
"SELECT CASE WHEN COUNT(*)=5 THEN 'F5_REAPPLY_OK' ELSE 'F5_REAPPLY_FAILED:'||COUNT(*) END FROM typeorm_migrations WHERE name IN ('F5InventoryPhysicalInstance1790087100000','F5CanonicalInventoryHierarchy1790087150000','F5AuthoritativeTerritory1790087200000','F5OperationalContextCompatibility1790087250000','F5AuthoritativeInventoryCatalog1790087300000');" \
| grep -Fx 'F5_REAPPLY_OK'
- name: VPS-equivalent isolated API preflight - name: VPS-equivalent isolated API preflight
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
+3 -3
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 21 versionCode = 19
versionName = "0.14.0" versionName = "0.12.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -77,4 +77,4 @@ dependencies {
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
} }
@@ -49,8 +49,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList()) var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
private set private set
var inventoryParentId: String? by mutableStateOf(null)
private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList()) var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
@@ -98,7 +96,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
visits = emptyList() visits = emptyList()
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearActState() clearActState()
@@ -115,7 +112,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openVisit(id: String) = launchBusy { fun openVisit(id: String) = launchBusy {
visit = repository.visit(id) visit = repository.visit(id)
inventory = emptyList() inventory = emptyList()
inventoryParentId = visit?.operationalArea?.id
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearFindingState() clearFindingState()
@@ -125,7 +121,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeVisitView() { fun closeVisitView() {
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearActState() clearActState()
@@ -137,7 +132,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val id = visit?.id ?: return val id = visit?.id ?: return
launchBusy { launchBusy {
visit = repository.startVisit(id) visit = repository.startVisit(id)
inventoryParentId = visit?.operationalArea?.id
notice = "Inspección iniciada." notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true) loadActsInternal(id, selectDraft = true)
loadVisitsInternal() loadVisitsInternal()
@@ -195,34 +189,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return val id = visit?.id ?: return
launchBusy { launchBusy {
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id inventory = repository.fieldInventory(id, search, parentId).data
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
} }
} }
fun loadFieldTypes(parentId: String? = null) { fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return val id = visit?.id ?: return
launchBusy { launchBusy {
val effectiveParentId = parentId ?: currentVisit.operationalArea?.id fieldTypes = repository.fieldTypes(id, parentId).data
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
} }
} }
private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
fun selectExisting(item: FieldInventoryItem) { fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección." notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId) inventory = repository.fieldInventory(visitId, null, null).data
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id) selectedAct = actsRepository.ensureAsset(draft.id, item.id)
@@ -251,7 +236,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
if (type.familyRequired && familyId == null) { if (type.familyRequired && familyId == null) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado." error = "Elegí una familia técnica o la opción Otro / no catalogado."
return return
} }
launchBusy { launchBusy {
@@ -269,7 +254,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) )
selectedFieldAsset = repository.createFieldAsset(visitId, request) selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar." notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
reloadCurrentInventory(visitId) inventory = repository.fieldInventory(visitId, null, null).data
} }
} }
@@ -293,7 +278,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) )
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id) selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable." notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
reloadCurrentInventory(visitId) inventory = repository.fieldInventory(visitId, null, null).data
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id) selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
@@ -326,7 +311,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
"Fotografía registrada." "Fotografía registrada."
} }
reloadCurrentInventory(visitId) inventory = repository.fieldInventory(visitId, null, null).data
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) { if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id) selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.28.0-1", "version": "0.26.0-1",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -43,4 +43,4 @@
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.0" "typescript": "^5.9.0"
} }
} }
@@ -26,9 +26,6 @@ import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service'; import { InventoryStructureService } from './inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller'; import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
@@ -42,7 +39,6 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetsController, AssetsController,
InventoryStructureController, InventoryStructureController,
InventoryFamilyCatalogController, InventoryFamilyCatalogController,
InventoryTechnicalValuesController,
InventoryBrowserController, InventoryBrowserController,
InventoryMergeController, InventoryMergeController,
FieldInventoryMergeController, FieldInventoryMergeController,
@@ -60,8 +56,6 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetsService, AssetsService,
InventoryStructureService, InventoryStructureService,
InventoryFamilyCatalogService, InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
InventoryBrowserService, InventoryBrowserService,
InventoryMergeService, InventoryMergeService,
MergedInventoryDossierService, MergedInventoryDossierService,
@@ -11,7 +11,6 @@ import {
export const INVENTORY_STRUCTURE_KINDS = [ export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA', 'EMPRESA',
'DEPARTAMENTO',
'AREA', 'AREA',
'YACIMIENTO', 'YACIMIENTO',
'INSTALACION', 'INSTALACION',
@@ -22,10 +22,8 @@ export class CreateInventoryFamilyDto {
name!: string; name!: string;
@IsOptional() @IsOptional()
@IsArray() @IsUUID('4')
@ArrayMaxSize(200) parentFamilyId?: string | null;
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@@ -44,10 +42,8 @@ export class UpdateInventoryFamilyDto {
name?: string; name?: string;
@IsOptional() @IsOptional()
@IsArray() @IsUUID('4')
@ArrayMaxSize(200) parentFamilyId?: string | null;
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@@ -1,103 +0,0 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const DATA_TYPES = ['TEXT','NUMBER','BOOLEAN','DATE','DATETIME','SELECT'] as const;
export type InventoryFamilyAttributeDataType = typeof DATA_TYPES[number];
export class CreateInventoryFamilyAttributeDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsString()
@Matches(/^[a-z][a-z0-9_]*$/)
@MaxLength(80)
code!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(160)
name!: string;
@IsIn(DATA_TYPES)
dataType!: InventoryFamilyAttributeDataType;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@ValidateIf((_object, value) => value !== null && value !== undefined)
@IsString()
@MaxLength(40)
unit?: string | null;
@ValidateIf((object) => object.dataType === 'SELECT')
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(160, { each: true })
options?: string[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder?: number;
}
export class UpdateInventoryFamilyAttributeDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(160)
name?: string;
@IsOptional()
@IsIn(DATA_TYPES)
dataType?: InventoryFamilyAttributeDataType;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@ValidateIf((_object, value) => value !== null && value !== undefined)
@IsString()
@MaxLength(40)
unit?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(160, { each: true })
options?: string[] | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder?: number;
}
@@ -1,6 +0,0 @@
import { IsObject } from 'class-validator';
export class UpdateInventoryTechnicalValuesDto {
@IsObject()
values!: Record<string, unknown>;
}
@@ -13,16 +13,6 @@ export class InventoryBrowserController {
return this.inventoryBrowser.items(query); return this.inventoryBrowser.items(query);
} }
@Get('departments')
departments(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.departments(query);
}
@Get('companies')
companies(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.companies(query);
}
@Get('areas') @Get('areas')
areas(@Query() query: InventoryBrowserQueryDto) { areas(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.areas(query); return this.inventoryBrowser.areas(query);
@@ -16,9 +16,9 @@ export class InventoryBrowserService {
async items(query: InventoryBrowserQueryDto) { async items(query: InventoryBrowserQueryDto) {
const params: unknown[] = []; const params: unknown[] = [];
const conditions = [ const conditions = [
'asset.is_inventory_instance=true',
"asset.information_status<>'INACTIVE'", "asset.information_status<>'INACTIVE'",
'type.is_active=true', 'type.is_active=true',
"(type.operational_role='COMPANY' OR lower(type.code) IN ('departamento','area','yacimiento','instalacion','subinstalacion'))",
]; ];
const add = (value: unknown): string => { const add = (value: unknown): string => {
params.push(value); params.push(value);
@@ -36,21 +36,15 @@ export class InventoryBrowserService {
if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'"); if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'");
if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.operationalAreaId) { if (query.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::uuid`);
const area = add(query.operationalAreaId);
conditions.push(`(asset.id=${area}::uuid OR asset.operational_area_id=${area}::uuid)`);
}
if (query.operatorCompanyId) { if (query.operatorCompanyId) {
const company = add(query.operatorCompanyId); const company = add(query.operatorCompanyId);
conditions.push(`( conditions.push(`EXISTS (
asset.id=${company}::uuid SELECT 1 FROM area_company_relations relation
OR EXISTS ( WHERE relation.area_id=asset.operational_area_id
SELECT 1 FROM area_company_relations relation AND relation.company_id=${company}::uuid
WHERE (relation.area_id=asset.operational_area_id OR relation.area_id=asset.id) AND relation.relation_role='OPERATOR'
AND relation.company_id=${company}::uuid AND relation.valid_until IS NULL
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)
)`); )`);
} }
@@ -82,8 +76,7 @@ export class InventoryBrowserService {
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=COALESCE(asset.operational_area_id, WHERE relation.area_id=asset.operational_area_id
CASE WHEN type.operational_role='AREA' THEN asset.id ELSE NULL END)
AND relation.relation_role='OPERATOR' AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC ORDER BY relation.valid_from DESC,relation.created_at DESC
@@ -91,8 +84,7 @@ export class InventoryBrowserService {
) AS "operatorCompany", ) AS "operatorCompany",
asset.information_status AS "informationStatus", asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus", asset.operational_status AS "operationalStatus",
(SELECT COUNT(*)::integer FROM assets child 0::integer AS "childrenCount",
WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry", EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType", CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType",
(SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount", (SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount",
@@ -112,14 +104,7 @@ export class InventoryBrowserService {
LIMIT 1 LIMIT 1
) geometry_type ON true ) geometry_type ON true
WHERE ${where} WHERE ${where}
ORDER BY CASE ORDER BY asset.name,asset.code
WHEN lower(type.code)='departamento' THEN 0
WHEN type.operational_role='COMPANY' THEN 1
WHEN lower(type.code)='area' THEN 2
WHEN lower(type.code)='yacimiento' THEN 3
WHEN lower(type.code)='instalacion' THEN 4
WHEN lower(type.code)='subinstalacion' THEN 5 ELSE 9 END,
asset.name,asset.code
LIMIT ${limit} OFFSET ${offsetParam} LIMIT ${limit} OFFSET ${offsetParam}
`,params); `,params);
@@ -134,30 +119,28 @@ export class InventoryBrowserService {
}; };
} }
async departments(query: InventoryBrowserQueryDto) { async areas(query: InventoryBrowserQueryDto) {
const params: unknown[] = []; const params: unknown[] = [];
const conditions = [ const conditions = [
"lower(type.code)='departamento'", "type.operational_role='AREA'",
"department.information_status<>'INACTIVE'", "area.information_status<>'INACTIVE'",
'type.is_active=true', 'type.is_active=true',
]; ];
const add = (value: unknown): string => { const add = (value: unknown): string => {
params.push(value); params.push(value);
return `$${params.length}`; return `$${params.length}`;
}; };
if (query.search?.trim()) { if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`); const p = add(`%${query.search.trim()}%`);
conditions.push(`(department.code ILIKE ${p} OR department.name ILIKE ${p} OR COALESCE(department.common_name,'') ILIKE ${p})`); conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`);
} }
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) { if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId); const p = add(query.operatorCompanyId);
conditions.push(`EXISTS ( conditions.push(`EXISTS (
SELECT 1 SELECT 1 FROM area_company_relations relation
FROM assets area WHERE relation.area_id=area.id
JOIN asset_types atype ON atype.id=area.asset_type_id
JOIN area_company_relations relation ON relation.area_id=area.id
WHERE area.parent_id=department.id
AND lower(atype.code)='area'
AND relation.company_id=${p}::uuid AND relation.company_id=${p}::uuid
AND relation.relation_role='OPERATOR' AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL AND relation.valid_until IS NULL
@@ -166,86 +149,41 @@ export class InventoryBrowserService {
const data = await this.dataSource.query(` const data = await this.dataSource.query(`
SELECT SELECT
department.id,department.code,department.name,department.common_name AS "commonName", area.id,area.code,area.name,area.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
department.information_status AS "informationStatus", area.information_status AS "informationStatus",
department.operational_status AS "operationalStatus", area.operational_status AS "operationalStatus",
(SELECT COUNT(*)::integer FROM assets area (
JOIN asset_types atype ON atype.id=area.asset_type_id SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
WHERE area.parent_id=department.id AND lower(atype.code)='area' FROM area_company_relations relation
AND area.information_status<>'INACTIVE') AS "areaCount", JOIN assets company ON company.id=relation.company_id
(SELECT COUNT(*)::integer FROM assets child WHERE relation.area_id=area.id
WHERE child.parent_id=department.id AND child.information_status<>'INACTIVE') AS "childrenCount" AND relation.relation_role='OPERATOR'
FROM assets department AND relation.valid_until IS NULL
JOIN asset_types type ON type.id=department.asset_type_id ORDER BY relation.valid_from DESC,relation.created_at DESC
WHERE ${conditions.join(' AND ')} LIMIT 1
ORDER BY department.name,department.code ) AS "currentOperator",
`, params); (
return { data, meta: { count: data.length } }; SELECT COUNT(*)::integer
} FROM assets yacimiento
JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
async companies(query: InventoryBrowserQueryDto) { WHERE yacimiento.parent_id=area.id
const params: unknown[] = []; AND lower(ytype.code)='yacimiento'
const conditions = [ AND yacimiento.information_status<>'INACTIVE'
"type.operational_role='COMPANY'", ) AS "yacimientoCount",
"company.information_status<>'INACTIVE'", (
'type.is_active=true', SELECT COUNT(*)::integer
]; FROM assets inventory
if (query.search?.trim()) { WHERE inventory.is_inventory_instance=true
params.push(`%${query.search.trim()}%`); AND inventory.information_status<>'INACTIVE'
conditions.push(`(company.code ILIKE $1 OR company.name ILIKE $1 OR COALESCE(company.common_name,'') ILIKE $1)`); AND inventory.operational_area_id=area.id
} ) AS "inventoryCount"
const data = await this.dataSource.query(` FROM assets area
SELECT company.id,company.code,company.name,company.common_name AS "commonName", JOIN asset_types type ON type.id=area.asset_type_id
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
company.information_status AS "informationStatus",
(SELECT COUNT(*)::integer FROM area_company_relations relation
WHERE relation.company_id=company.id AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL) AS "areaCount"
FROM assets company
JOIN asset_types type ON type.id=company.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY company.name,company.code
`,params);
return { data,meta:{count:data.length} };
}
async areas(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"type.operational_role='AREA'",
"area.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => { params.push(value); return `$${params.length}`; };
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`);
}
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId);
conditions.push(`EXISTS (SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=area.id AND relation.company_id=${p}::uuid
AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL)`);
}
const data = await this.dataSource.query(`
SELECT area.id,area.code,area.name,area.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
area.information_status AS "informationStatus",area.operational_status AS "operationalStatus",
(SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=area.id AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC LIMIT 1) AS "currentOperator",
(SELECT COUNT(*)::integer FROM assets yacimiento JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=area.id AND lower(ytype.code)='yacimiento'
AND yacimiento.information_status<>'INACTIVE') AS "yacimientoCount",
(SELECT COUNT(*)::integer FROM assets inventory
WHERE inventory.information_status<>'INACTIVE' AND inventory.operational_area_id=area.id) AS "inventoryCount"
FROM assets area JOIN asset_types type ON type.id=area.asset_type_id
WHERE ${conditions.join(' AND ')} WHERE ${conditions.join(' AND ')}
ORDER BY area.name,area.code ORDER BY area.name,area.code
`, params); `, params);
return { data, meta: { count: data.length } }; return { data, meta: { count: data.length } };
} }
@@ -261,6 +199,9 @@ export class InventoryBrowserService {
"asset.information_status<>'INACTIVE'", "asset.information_status<>'INACTIVE'",
'type.is_active=true', 'type.is_active=true',
]; ];
if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') {
conditions.push('asset.is_inventory_instance=true');
}
if (query.search?.trim()) { if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`); params.push(`%${query.search.trim()}%`);
const p = `$${params.length}`; const p = `$${params.length}`;
@@ -282,12 +223,20 @@ export class InventoryBrowserService {
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level 'id',family.id,'code',family.code,'name',family.name,'level',family.level
) END AS "inventoryFamily", ) END AS "inventoryFamily",
(SELECT COUNT(*)::integer FROM assets child (
WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount", SELECT COUNT(*)::integer
CASE WHEN family.id IS NULL THEN 0 ELSE ( FROM assets child
SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE child.parent_id=asset.id
WHERE mapping.inventory_family_id=family.id AND child.information_status<>'INACTIVE'
) END AS "findingCount", AND (
lower(type.code)='area'
OR child.is_inventory_instance=true
OR EXISTS (
SELECT 1 FROM asset_types child_type
WHERE child_type.id=child.asset_type_id AND lower(child_type.code)='yacimiento'
)
)
) AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry", EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
asset.updated_at AS "updatedAt" asset.updated_at AS "updatedAt"
FROM assets asset FROM assets asset
@@ -314,10 +263,10 @@ export class InventoryBrowserService {
if (!parent) { if (!parent) {
throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' }); throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' });
} }
if (!['departamento','area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) { if (!['area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
throw new BadRequestException({ throw new BadRequestException({
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID', code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
message:'La navegación de Inventarios admite Departamento → Área → Yacimiento → Instalación → Subinstalación', message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación',
}); });
} }
return parent; return parent;
@@ -325,7 +274,6 @@ export class InventoryBrowserService {
private allowedChildType(typeCode: string): string | null { private allowedChildType(typeCode: string): string | null {
switch (typeCode.toLowerCase()) { switch (typeCode.toLowerCase()) {
case 'departamento': return 'area';
case 'area': return 'yacimiento'; case 'area': return 'yacimiento';
case 'yacimiento': return 'instalacion'; case 'yacimiento': return 'instalacion';
case 'instalacion': return 'subinstalacion'; case 'instalacion': return 'subinstalacion';
@@ -17,10 +17,6 @@ import {
ReplaceInventoryFamilyFindingsDto, ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto, UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto'; } from './dto/inventory-family-admin.dto';
import {
CreateInventoryFamilyAttributeDto,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families') @Controller('inventory-families')
@@ -54,35 +50,6 @@ export class InventoryFamilyCatalogController {
return this.families.update(familyId,dto,principal,request); return this.families.update(familyId,dto,principal,request);
} }
@Get(':familyId/attributes')
@RequirePermissions('asset_types.read')
attributes(@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string) {
return this.families.attributes(familyId);
}
@Post(':familyId/attributes')
@RequirePermissions('asset_types.manage')
createAttribute(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:CreateInventoryFamilyAttributeDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.createAttribute(familyId,dto,principal,request);
}
@Patch(':familyId/attributes/:attributeId')
@RequirePermissions('asset_types.manage')
updateAttribute(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Param('attributeId',new ParseUUIDPipe({version:'4'})) attributeId:string,
@Body() dto:UpdateInventoryFamilyAttributeDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.updateAttribute(familyId,attributeId,dto,principal,request);
}
@Put(':familyId/findings') @Put(':familyId/findings')
@RequirePermissions('finding_catalog.manage') @RequirePermissions('finding_catalog.manage')
replaceFindings( replaceFindings(
@@ -15,13 +15,7 @@ import type {
ReplaceInventoryFamilyFindingsDto, ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto, UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto'; } from './dto/inventory-family-admin.dto';
import type {
CreateInventoryFamilyAttributeDto,
InventoryFamilyAttributeDataType,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = { type FamilyRow = {
id: string; id: string;
code: string; code: string;
@@ -30,24 +24,12 @@ type FamilyRow = {
informationLabels: string[]; informationLabels: string[];
sourceReference: string | null; sourceReference: string | null;
isActive: boolean; isActive: boolean;
parentFamilyIds: string[]; parentFamilyId: string | null;
parentFamilies: FamilyParent[]; parentFamilyCode: string | null;
parentFamilyName: string | null;
assetCount: number; assetCount: number;
findingCount: number; findingCount: number;
findingItemIds: string[]; findingItemIds: string[];
technicalAttributeCount: number;
};
type AttributeRow = {
id: string;
inventoryFamilyId: string;
code: string;
name: string;
dataType: InventoryFamilyAttributeDataType;
isRequired: boolean;
isActive: boolean;
unit: string | null;
options: string[] | null;
sortOrder: number;
}; };
@Injectable() @Injectable()
@@ -58,7 +40,27 @@ export class InventoryFamilyCatalogService {
) {} ) {}
async listAdmin() { async listAdmin() {
const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[]; const data = await this.dataSource.query(`
SELECT
family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((
SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id)
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
WHERE mapping.inventory_family_id=family.id
),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
ORDER BY family.level,family.is_active DESC,
COALESCE(parent.name,''),family.name,family.code
`) as FamilyRow[];
return { data }; return { data };
} }
@@ -78,28 +80,27 @@ export class InventoryFamilyCatalogService {
return { family, items, count: items.length }; return { family, items, count: items.length };
} }
async attributes(familyId: string) {
const family = await this.family(familyId, false);
const items = await this.attributeRows(this.dataSource.manager, familyId);
return { family, items, count: items.length };
}
async create( async create(
dto: CreateInventoryFamilyDto, dto: CreateInventoryFamilyDto,
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
) { ) {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const parentIds = await this.validateParents(manager, dto.level, dto.parentFamilyIds ?? [], null); const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null);
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`; const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
const [inserted] = (await manager.query(` const [inserted] = (await manager.query(`
INSERT INTO inventory_families( INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true) ) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true)
RETURNING id RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>; `,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario'); if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
await this.replaceParents(manager, inserted.id, parentIds); if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[inserted.id,parentId]);
}
const created = await this.family(inserted.id,false,manager); const created = await this.family(inserted.id,false,manager);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal,request), ...administrationAuditContext(principal,request),
@@ -107,7 +108,7 @@ export class InventoryFamilyCatalogService {
entityType: 'inventory_family', entityType: 'inventory_family',
entityId: inserted.id, entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>, afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F6' }, metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' },
},manager); },manager);
return created; return created;
}); });
@@ -121,13 +122,10 @@ export class InventoryFamilyCatalogService {
) { ) {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true); const before = await this.family(familyId,false,manager,true);
const nextParentIds = dto.parentFamilyIds === undefined const nextParentId = dto.parentFamilyId === undefined
? before.parentFamilyIds ? before.parentFamilyId
: dto.parentFamilyIds; : dto.parentFamilyId;
const parentIds = await this.validateParents(manager,before.level,nextParentIds,familyId); const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId);
if (before.level === 'SUBINSTALLATION' && dto.parentFamilyIds !== undefined) {
await this.assertRemovedCompatibilitiesUnused(manager, familyId, parentIds);
}
await manager.query(` await manager.query(`
UPDATE inventory_families SET UPDATE inventory_families SET
name=COALESCE($2::varchar,name), name=COALESCE($2::varchar,name),
@@ -141,7 +139,15 @@ export class InventoryFamilyCatalogService {
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)), dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null, dto.isActive ?? null,
]); ]);
if (dto.parentFamilyIds !== undefined) await this.replaceParents(manager, familyId, parentIds); if (before.level==='SUBINSTALLATION') {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[familyId,parentId]);
}
}
const after = await this.family(familyId,false,manager); const after = await this.family(familyId,false,manager);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal,request), ...administrationAuditContext(principal,request),
@@ -156,85 +162,6 @@ export class InventoryFamilyCatalogService {
}); });
} }
async createAttribute(
familyId: string,
dto: CreateInventoryFamilyAttributeDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
await this.family(familyId,false,manager,true);
const options = this.attributeOptions(dto.dataType,dto.options);
try {
const [created] = (await manager.query(`
INSERT INTO inventory_family_attribute_definitions(
inventory_family_id,code,name,data_type,is_required,is_active,unit,options,sort_order,created_by,updated_by
) VALUES ($1::uuid,$2,$3,$4::asset_attribute_data_type,$5,true,$6,$7::jsonb,$8,$9::uuid,$9::uuid)
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
`,[familyId,dto.code,dto.name,dto.dataType,dto.isRequired ?? false,dto.unit ?? null,
options === null ? null : JSON.stringify(options),dto.sortOrder ?? 0,principal.userId])) as AttributeRow[];
if (!created) throw new Error('No se pudo crear el campo técnico');
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_attribute',entityId:created.id,
afterData:created as unknown as Record<string,unknown>,
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_CREATED',inventoryFamilyId:familyId},
},manager);
return created;
} catch (error) {
if (this.isUniqueViolation(error)) throw new ConflictException({
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
message:'Ya existe un campo técnico con ese código en la clasificación',
});
throw error;
}
});
}
async updateAttribute(
familyId: string,
attributeId: string,
dto: UpdateInventoryFamilyAttributeDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
await this.family(familyId,false,manager,true);
const before = await this.attribute(manager,familyId,attributeId,true);
const nextType = dto.dataType ?? before.dataType;
const nextOptions = dto.options === undefined
? this.attributeOptions(nextType,before.options ?? undefined)
: this.attributeOptions(nextType,dto.options ?? undefined);
const [after] = (await manager.query(`
UPDATE inventory_family_attribute_definitions SET
name=COALESCE($3::varchar,name),
data_type=COALESCE($4::asset_attribute_data_type,data_type),
is_required=COALESCE($5::boolean,is_required),
is_active=COALESCE($6::boolean,is_active),
unit=CASE WHEN $7::boolean THEN $8::varchar ELSE unit END,
options=$9::jsonb,
sort_order=COALESCE($10::integer,sort_order),
updated_by=$11::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
`,[attributeId,familyId,dto.name ?? null,dto.dataType ?? null,dto.isRequired ?? null,dto.isActive ?? null,
dto.unit !== undefined,dto.unit ?? null,nextOptions === null ? null : JSON.stringify(nextOptions),
dto.sortOrder ?? null,principal.userId])) as AttributeRow[];
if (!after) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_attribute',entityId:attributeId,
beforeData:before as unknown as Record<string,unknown>,afterData:after as unknown as Record<string,unknown>,
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_UPDATED',inventoryFamilyId:familyId},
},manager);
return after;
});
}
async replaceFindings( async replaceFindings(
familyId: string, familyId: string,
dto: ReplaceInventoryFamilyFindingsDto, dto: ReplaceInventoryFamilyFindingsDto,
@@ -251,10 +178,12 @@ export class InventoryFamilyCatalogService {
JOIN finding_categories category ON category.id=item.category_id JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true
`,[uniqueIds])) as Array<{total:number}>; `,[uniqueIds])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==uniqueIds.length) throw new BadRequestException({ if (Number(count?.total ?? 0)!==uniqueIds.length) {
code:'INVENTORY_FAMILY_FINDING_INVALID', throw new BadRequestException({
message:'Uno o más Hallazgos elegidos no están activos en el catálogo', code:'INVENTORY_FAMILY_FINDING_INVALID',
}); message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
} }
const beforeIds=family.findingItemIds; const beforeIds=family.findingItemIds;
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]); await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
@@ -269,9 +198,11 @@ export class InventoryFamilyCatalogService {
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal,request), ...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED, action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_findings',entityId:familyId, entityType:'inventory_family_findings',
beforeData:{itemIds:beforeIds},afterData:{itemIds:after.findingItemIds}, entityId:familyId,
metadata:{operation:'INVENTORY_FAMILY_FINDINGS_REPLACED',reason:dto.reason}, beforeData:{ itemIds:beforeIds },
afterData:{ itemIds:after.findingItemIds },
metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason },
},manager); },manager);
return this.findingsWithManager(manager,familyId); return this.findingsWithManager(manager,familyId);
}); });
@@ -286,150 +217,77 @@ export class InventoryFamilyCatalogService {
FROM finding_catalog_item_inventory_families mapping FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid AND item.is_active=true AND category.is_active=true WHERE mapping.inventory_family_id=$1::uuid
AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title ORDER BY category.sort_order,item.source_number,item.title
`,[familyId]); `,[familyId]);
return {family,items,count:items.length}; return {family,items,count:items.length};
} }
private familySelect(suffix:string) {
return `
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds",
(SELECT COUNT(*)::integer FROM inventory_family_attribute_definitions definition
WHERE definition.inventory_family_id=family.id AND definition.is_active=true) AS "technicalAttributeCount"
FROM inventory_families family
${suffix}
`;
}
private async family( private async family(
familyId:string, familyId:string,
activeOnly:boolean, activeOnly:boolean,
manager:EntityManager=this.dataSource.manager, manager:EntityManager=this.dataSource.manager,
lock=false, lock=false,
):Promise<FamilyRow> { ):Promise<FamilyRow> {
const lockClause=lock ? 'FOR UPDATE OF family' : ''; const rows=(await manager.query(`
const rows=(await manager.query(this.familySelect(` SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''} WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lockClause} ${lock ? 'FOR UPDATE OF family' : ''}
`),[familyId])) as FamilyRow[]; `,[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({ if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe', code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
}); });
return rows[0]; return rows[0];
} }
private async validateParents( private async validateParent(
manager:EntityManager, manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION', level:'INSTALLATION'|'SUBINSTALLATION',
requestedIds:string[], parentFamilyId:string|null,
ownId:string|null, ownId:string|null,
):Promise<string[]> { ):Promise<string|null> {
const ids=[...new Set(requestedIds)];
if (level==='INSTALLATION') { if (level==='INSTALLATION') {
if (ids.length) throw new BadRequestException({ if (parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED', code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no lleva compatibilidades padre', message:'Una clasificación de Instalación no tiene clasificación padre',
}); });
return []; return null;
} }
if (!ids.length) throw new BadRequestException({ if (!parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED', code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación', message:'Una Subinstalación debe pertenecer a un tipo de Instalación',
}); });
if (ownId && ids.includes(ownId)) throw new BadRequestException({ if (parentFamilyId===ownId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma', code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
}); });
const [count]=(await manager.query(`
SELECT COUNT(*)::integer AS total FROM inventory_families
WHERE id=ANY($1::uuid[]) AND level='INSTALLATION' AND is_active=true
`,[ids])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_INVALID',
message:'Todas las compatibilidades deben ser clasificaciones de Instalación activas',
});
return ids;
}
private async replaceParents(manager:EntityManager,familyId:string,parentIds:string[]) {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentIds.length) await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
SELECT $1::uuid,parent_id FROM UNNEST($2::uuid[]) AS selected(parent_id)
ON CONFLICT (child_family_id,parent_family_id) DO NOTHING
`,[familyId,parentIds]);
}
private async assertRemovedCompatibilitiesUnused(manager:EntityManager,childFamilyId:string,nextParentIds:string[]) {
const rows=await manager.query(`
SELECT child.name AS "childName",parent.name AS "parentName",COUNT(*)::integer AS total
FROM assets child
JOIN assets parent ON parent.id=child.parent_id
WHERE child.inventory_family_id=$1::uuid
AND parent.inventory_family_id IS NOT NULL
AND NOT (parent.inventory_family_id=ANY($2::uuid[]))
GROUP BY child.name,parent.name
ORDER BY total DESC
LIMIT 1
`,[childFamilyId,nextParentIds]);
if (rows[0]) throw new ConflictException({
code:'INVENTORY_FAMILY_COMPATIBILITY_IN_USE',
message:`No podés quitar esa compatibilidad: ya existen Subinstalaciones de este tipo dentro de ${rows[0].parentName}`,
});
}
private async attributeRows(manager:EntityManager,familyId:string):Promise<AttributeRow[]> {
return manager.query(`
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE inventory_family_id=$1::uuid
ORDER BY is_active DESC,sort_order,name,code
`,[familyId]) as Promise<AttributeRow[]>;
}
private async attribute(manager:EntityManager,familyId:string,attributeId:string,lock=false):Promise<AttributeRow> {
const rows=(await manager.query(` const rows=(await manager.query(`
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType", SELECT id FROM inventory_families
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder" WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true
FROM inventory_family_attribute_definitions LIMIT 1
WHERE id=$1::uuid AND inventory_family_id=$2::uuid `,[parentFamilyId])) as Array<{id:string}>;
${lock ? 'FOR UPDATE' : ''} if (!rows[0]) throw new BadRequestException({
`,[attributeId,familyId])) as AttributeRow[]; code:'INVENTORY_FAMILY_PARENT_INVALID',
if (!rows[0]) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'}); message:'La Subinstalación debe vincularse a una clasificación de Instalación activa',
return rows[0];
}
private attributeOptions(type:InventoryFamilyAttributeDataType,raw:string[]|undefined):string[]|null {
if (type!=='SELECT') return null;
const values=[...new Map((raw ?? []).map((value) => {
const clean=value.trim(); return [clean.toLocaleLowerCase('es-AR'),clean] as const;
}).filter(([,value]) => Boolean(value))).values()];
if (!values.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_ATTRIBUTE_OPTIONS_REQUIRED',
message:'Un campo de lista necesita al menos una opción',
}); });
return values; return parentFamilyId;
} }
private cleanLabels(labels:string[]):string[] { private cleanLabels(labels:string[]):string[] {
const unique=new Map<string,string>(); const unique=new Map<string,string>();
for (const raw of labels) { for (const raw of labels) {
const clean=raw.trim(); if (!clean) continue; const clean=raw.trim();
if (!clean) continue;
const identity=clean.toLocaleLowerCase('es-AR'); const identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean); if (!unique.has(identity)) unique.set(identity,clean);
} }
@@ -438,8 +296,4 @@ export class InventoryFamilyCatalogService {
}); });
return [...unique.values()]; return [...unique.values()];
} }
private isUniqueViolation(error:unknown) {
return Boolean(error && typeof error==='object' && 'code' in error && (error as {code?:string}).code==='23505');
}
} }
@@ -20,7 +20,6 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
import { AssetHistoryService } from './asset-history.service'; import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string }; type StructureTypeRow = { id: string; code: string; name: string };
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = { type FamilyRow = {
id: string; id: string;
code: string; code: string;
@@ -28,8 +27,9 @@ type FamilyRow = {
level: 'INSTALLATION' | 'SUBINSTALLATION'; level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null; legacyTypeCode: string | null;
informationLabels: string[]; informationLabels: string[];
parentFamilyIds: string[]; parentFamilyId: string | null;
parentFamilies: FamilyParent[]; parentFamilyCode: string | null;
parentFamilyName: string | null;
}; };
type ParentRow = { type ParentRow = {
id: string; id: string;
@@ -38,10 +38,8 @@ type ParentRow = {
typeCode: string; typeCode: string;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
}; };
type IdRow = { id: string };
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = { const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area', AREA: 'area',
YACIMIENTO: 'yacimiento', YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion', INSTALACION: 'instalacion',
@@ -49,8 +47,7 @@ const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, stri
}; };
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = { const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null, EMPRESA: null,
DEPARTAMENTO: null, AREA: null,
AREA: 'departamento',
YACIMIENTO: 'area', YACIMIENTO: 'area',
INSTALACION: 'yacimiento', INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion', SUBINSTALACION: 'instalacion',
@@ -73,24 +70,22 @@ export class InventoryStructureService {
SELECT id,code,name SELECT id,code,name
FROM asset_types FROM asset_types
WHERE ( WHERE (
lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion') lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
OR operational_role='COMPANY' OR operational_role='COMPANY'
) AND is_active=true ) AND is_active=true
ORDER BY CASE ORDER BY CASE
WHEN operational_role='COMPANY' THEN 0 WHEN operational_role='COMPANY' THEN 0
WHEN lower(code)='departamento' THEN 1 WHEN lower(code)='area' THEN 1
WHEN lower(code)='area' THEN 2 WHEN lower(code)='yacimiento' THEN 2
WHEN lower(code)='yacimiento' THEN 3 WHEN lower(code)='instalacion' THEN 3
WHEN lower(code)='instalacion' THEN 4 WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END
WHEN lower(code)='subinstalacion' THEN 5 ELSE 9 END
`)) as StructureTypeRow[]; `)) as StructureTypeRow[];
const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase())); const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
const departamento = types.find((item) => item.code.toLowerCase()==='departamento');
const area = types.find((item) => item.code.toLowerCase()==='area'); const area = types.find((item) => item.code.toLowerCase()==='area');
const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento'); const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion'); const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion'); const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion');
if (!company || !departamento || !area || !yacimiento || !instalacion || !subinstalacion) { if (!company || !area || !yacimiento || !instalacion || !subinstalacion) {
throw new ConflictException({ throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE', code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La configuración maestra de Empresa e Inventario todavía no está completa', message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
@@ -101,15 +96,10 @@ export class InventoryStructureService {
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode", family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels", family.information_labels AS "informationLabels",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code) parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.is_active=true WHERE family.is_active=true
ORDER BY family.level,family.name,family.code ORDER BY family.level,family.name,family.code
`)) as FamilyRow[]; `)) as FamilyRow[];
@@ -119,8 +109,7 @@ export class InventoryStructureService {
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false }, { kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
], ],
levels: [ levels: [
{ kind: 'DEPARTAMENTO', label: 'Departamento', type: departamento, parentKind: null, requiresFamily: false }, { kind: 'AREA', label: 'Área', type: area, parentKind: null, requiresFamily: false },
{ kind: 'AREA', label: 'Área', type: area, parentKind: 'DEPARTAMENTO', requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false }, { kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true }, { kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true }, { kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
@@ -132,7 +121,7 @@ export class InventoryStructureService {
async parents(kindValue: string, search?: string) { async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind; const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'DEPARTAMENTO' || kind === 'EMPRESA') { if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') {
throw new BadRequestException({ throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID', code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre', message: 'El nivel indicado no requiere un registro padre',
@@ -164,7 +153,7 @@ export class InventoryStructureService {
AND asset.information_status<>'INACTIVE' AND asset.information_status<>'INACTIVE'
${searchSql} ${searchSql}
ORDER BY asset.name,asset.code ORDER BY asset.name,asset.code
LIMIT 100 LIMIT 80
`, parameters); `, parameters);
return { data: rows }; return { data: rows };
} }
@@ -180,9 +169,7 @@ export class InventoryStructureService {
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null); const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent); const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind) const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null;
? await this.resolveAreaId(manager, parent)
: null;
const inserted = (await manager.query(` const inserted = (await manager.query(`
INSERT INTO assets ( INSERT INTO assets (
@@ -206,9 +193,9 @@ export class InventoryStructureService {
AssetInformationStatus.DRAFT, AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN, AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL, AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F6' : 'Estructura manual de Inventario F6', dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`, dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family ? `Clasificación técnica: ${family.code} · ${family.name}` : null, family ? `Familia técnica: ${family.code} · ${family.name}` : null,
principal.userId, principal.userId,
])) as Array<{ id: string }>; ])) as Array<{ id: string }>;
const id = inserted[0]?.id; const id = inserted[0]?.id;
@@ -223,7 +210,11 @@ export class InventoryStructureService {
} }
const versionNumber = await this.history.capture( const versionNumber = await this.history.capture(
manager,id,AssetVersionChangeType.CREATED,principal,request, manager,
id,
AssetVersionChangeType.CREATED,
principal,
request,
); );
await manager.query(` await manager.query(`
INSERT INTO asset_context_history ( INSERT INTO asset_context_history (
@@ -231,28 +222,38 @@ export class InventoryStructureService {
change_reason,asset_version_number,source,request_id,created_by change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7) ) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [ `, [
id,parent?.id ?? null,operationalAreaId, id,
dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F6' : 'Alta manual de estructura de Inventario F6', parent?.id ?? null,
versionNumber,request.requestId,principal.userId, operationalAreaId,
dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5',
versionNumber,
request.requestId,
principal.userId,
]); ]);
const created = await this.loadView(manager, id); const created = await this.loadView(manager, id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED, action: AuditAction.ASSET_CREATED,
entityType: 'asset',entityId: id, entityType: 'asset',
entityId: id,
afterData: created as unknown as Record<string, unknown>, afterData: created as unknown as Record<string, unknown>,
metadata: { metadata: {
versionNumber,inventoryStructureKind: dto.kind, versionNumber,
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null, inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorOwnership: false, operatorOwnership: false,
}, },
}, manager); }, manager);
return created; return created;
}); });
} catch (error) { } catch (error) {
if (isUniqueViolation(error)) throw new ConflictException({ if (isUniqueViolation(error)) {
code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código', throw new ConflictException({
}); code: 'ASSET_CODE_ALREADY_EXISTS',
message: 'Ya existe un registro con ese código',
});
}
throw error; throw error;
} }
} }
@@ -263,9 +264,12 @@ export class InventoryStructureService {
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true 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 params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
const rows = (await manager.query(sql,params)) as StructureTypeRow[]; const rows = (await manager.query(sql,params)) as StructureTypeRow[];
if (!rows[0]) throw new ConflictException({ if (!rows[0]) {
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',message: `El nivel ${kind} no está configurado`, throw new ConflictException({
}); code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
message: `El nivel ${kind} no está configurado`,
});
}
return rows[0]; return rows[0];
} }
@@ -276,18 +280,22 @@ export class InventoryStructureService {
): Promise<ParentRow | null> { ): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind]; const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) { if (!expectedType) {
if (parentId) throw new BadRequestException({ if (parentId) {
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', throw new BadRequestException({
message: kind === 'EMPRESA' code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
? 'Una Empresa es un maestro independiente y no puede tener padre' message: kind === 'EMPRESA'
: 'Un Departamento es un registro raíz y no puede tener padre', ? 'Una Empresa es un maestro independiente y no puede tener padre'
}); : 'Un Área es un registro raíz y no puede tener padre',
});
}
return null; return null;
} }
if (!parentId) throw new BadRequestException({ if (!parentId) {
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED', throw new BadRequestException({
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`, code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
}); message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
});
}
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode", SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId" asset.inventory_family_id AS "inventoryFamilyId"
@@ -298,10 +306,12 @@ export class InventoryStructureService {
`, [parentId])) as ParentRow[]; `, [parentId])) as ParentRow[];
const parent = rows[0]; const parent = rows[0];
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' }); if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
if (parent.typeCode.toLowerCase() !== expectedType) throw new BadRequestException({ if (parent.typeCode.toLowerCase() !== expectedType) {
code: 'INVENTORY_STRUCTURE_PARENT_INVALID', throw new BadRequestException({
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación', code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
}); message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación',
});
}
return parent; return parent;
} }
@@ -320,10 +330,12 @@ export class InventoryStructureService {
LIMIT 1 LIMIT 1
`,[parent.id])) as IdRow[]; `,[parent.id])) as IdRow[];
const areaId=rows[0]?.id; const areaId=rows[0]?.id;
if (!areaId) throw new ConflictException({ if (!areaId) {
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING', throw new ConflictException({
message:'La ubicación seleccionada no pertenece a un Área válida', code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
}); message:'La ubicación seleccionada no pertenece a un Área válida',
});
}
return areaId; return areaId;
} }
@@ -337,59 +349,49 @@ export class InventoryStructureService {
if (!expectedLevel) { if (!expectedLevel) {
if (familyId) throw new BadRequestException({ if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica', message: 'Empresa, Área y Yacimiento no llevan familia técnica',
}); });
return null; return null;
} }
if (!familyId) throw new BadRequestException({ if (!familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED', code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`, message: `Elegí la familia técnica de la ${kind.toLowerCase()}`,
}); });
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels", family.legacy_type_code AS "legacyTypeCode",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id) family.information_labels AS "informationLabels",
FROM inventory_family_parent_rules rule WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds", parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid AND family.is_active=true WHERE family.id=$1::uuid AND family.is_active=true
LIMIT 1 LIMIT 1
`, [familyId])) as FamilyRow[]; `, [familyId])) as FamilyRow[];
const family = rows[0]; const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' }); if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' });
if (family.level !== expectedLevel) throw new BadRequestException({ if (family.level !== expectedLevel) throw new BadRequestException({
code: 'INVENTORY_FAMILY_LEVEL_INVALID', code: 'INVENTORY_FAMILY_LEVEL_INVALID',
message: 'La clasificación técnica no corresponde al nivel seleccionado', message: 'La familia técnica no corresponde al nivel seleccionado',
}); });
if (kind === 'SUBINSTALACION') { if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) {
if (!parent?.inventoryFamilyId) throw new BadRequestException({ throw new BadRequestException({
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
message:'La Instalación padre debe tener una clasificación técnica válida',
});
const [compatible]=(await manager.query(`
SELECT 1 AS ok FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
if (!compatible) throw new BadRequestException({
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada', message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada',
}); });
} }
return family; return family;
} }
private generatedCode(kind: InventoryStructureKind, name: string): string { private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP' const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
: kind === 'DEPARTAMENTO' ? 'DEP' const readable = name
: kind === 'AREA' ? 'AREA' .normalize('NFD')
: kind === 'YACIMIENTO' ? 'YAC' .replace(/[\u0300-\u036f]/g, '')
: kind === 'INSTALACION' ? 'INST' .toUpperCase()
: 'SUB'; .replace(/[^A-Z0-9]+/g, '-')
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase() .replace(/^-+|-+$/g, '')
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO'; .slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120); return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
} }
@@ -415,3 +417,5 @@ export class InventoryStructureService {
return rows[0]; return rows[0];
} }
} }
type IdRow = { id: string };
@@ -1,28 +0,0 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
@Controller('assets/:assetId/technical-values')
export class InventoryTechnicalValuesController {
constructor(private readonly technical:InventoryTechnicalValuesService) {}
@Get()
@RequirePermissions('assets.read')
get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string) {
return this.technical.get(assetId);
}
@Put()
@RequirePermissions('assets.update')
replace(
@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,
@Body() dto:UpdateInventoryTechnicalValuesDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.technical.replace(assetId,dto,principal,request);
}
}
@@ -1,118 +0,0 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import type { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
type DefinitionRow = {
id:string; code:string; name:string; dataType:'TEXT'|'NUMBER'|'BOOLEAN'|'DATE'|'DATETIME'|'SELECT';
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
};
type AssetRow = { id:string; inventoryFamilyId:string|null; familyCode:string|null; familyName:string|null; familyLevel:string|null };
@Injectable()
export class InventoryTechnicalValuesService {
constructor(private readonly dataSource:DataSource,private readonly audit:AuditService) {}
async get(assetId:string) {
return this.load(this.dataSource.manager,assetId);
}
async replace(assetId:string,dto:UpdateInventoryTechnicalValuesDto,principal:AuthPrincipal,request:RequestWithContext) {
return this.dataSource.transaction(async(manager) => {
const before=await this.load(manager,assetId,true);
const definitions=before.definitions as DefinitionRow[];
const definitionById=new Map(definitions.map((definition)=>[definition.id,definition]));
const normalized:Record<string,unknown>={};
for (const [definitionId,raw] of Object.entries(dto.values)) {
const definition=definitionById.get(definitionId);
if (!definition || !definition.isActive) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual',
});
const value=this.normalize(definition,raw);
if (value!==undefined) normalized[definitionId]=value;
}
for (const definition of definitions.filter((item)=>item.isActive && item.isRequired)) {
if (!(definition.id in normalized)) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FIELD_REQUIRED',message:`Completá el campo técnico obligatorio: ${definition.name}`,
});
}
await manager.query('DELETE FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid',[assetId]);
const entries=Object.entries(normalized);
if (entries.length) await manager.query(`
INSERT INTO asset_inventory_attribute_values(asset_id,definition_id,value,updated_by)
SELECT $1::uuid,item.definition_id,item.value,$3::uuid
FROM JSONB_TO_RECORDSET($2::jsonb) AS item(definition_id uuid,value jsonb)
`,[assetId,JSON.stringify(entries.map(([definition_id,value])=>({definition_id,value}))),principal.userId]);
const after=await this.load(manager,assetId);
await this.audit.record({
...administrationAuditContext(principal,request),action:AuditAction.ASSET_UPDATED,
entityType:'asset_inventory_technical_values',entityId:assetId,
beforeData:{values:before.values},afterData:{values:after.values},
metadata:{operation:'INVENTORY_TECHNICAL_VALUES_REPLACED',inventoryFamilyId:after.family.id},
},manager);
return after;
});
}
private async load(manager:EntityManager,assetId:string,lock=false) {
const rows=(await manager.query(`
SELECT asset.id,asset.inventory_family_id AS "inventoryFamilyId",
family.code AS "familyCode",family.name AS "familyName",family.level::text AS "familyLevel"
FROM assets asset LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid ${lock ? 'FOR UPDATE OF asset' : ''}
`,[assetId])) as AssetRow[];
const asset=rows[0];
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro',
});
const definitions=(await manager.query(`
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE inventory_family_id=$1::uuid
ORDER BY is_active DESC,sort_order,name,code
`,[asset.inventoryFamilyId])) as DefinitionRow[];
const valueRows=await manager.query(`
SELECT definition_id AS id,value FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid
`,[assetId]) as Array<{id:string;value:unknown}>;
return {
assetId,
family:{id:asset.inventoryFamilyId,code:asset.familyCode,name:asset.familyName,level:asset.familyLevel},
definitions,
values:Object.fromEntries(valueRows.map((row)=>[row.id,row.value])),
};
}
private normalize(definition:DefinitionRow,raw:unknown):unknown|undefined {
if (raw===null || raw===undefined || raw==='') return undefined;
switch(definition.dataType) {
case 'TEXT': {
if (typeof raw!=='string') return this.invalid(definition);
const value=raw.trim(); if (!value) return undefined; if (value.length>4000) return this.invalid(definition); return value;
}
case 'NUMBER': {
const value=typeof raw==='number' ? raw : typeof raw==='string' ? Number(raw) : Number.NaN;
if (!Number.isFinite(value)) return this.invalid(definition); return value;
}
case 'BOOLEAN': if (typeof raw!=='boolean') return this.invalid(definition); return raw;
case 'DATE': {
if (typeof raw!=='string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(`${raw}T00:00:00Z`))) return this.invalid(definition);
return raw;
}
case 'DATETIME': {
if (typeof raw!=='string' || Number.isNaN(Date.parse(raw))) return this.invalid(definition); return new Date(raw).toISOString();
}
case 'SELECT': {
if (typeof raw!=='string' || !definition.options?.includes(raw)) return this.invalid(definition); return raw;
}
}
}
private invalid(definition:DefinitionRow):never {
throw new BadRequestException({code:'INVENTORY_TECHNICAL_VALUE_INVALID',message:`Valor inválido para ${definition.name}`});
}
}
@@ -1,168 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F51CleanManualInventory1790087400000 implements MigrationInterface {
name = 'F51CleanManualInventory1790087400000';
public async up(queryRunner: QueryRunner): Promise<void> {
// F5.1 is an intentional clean-start cut. The deployment process creates a
// full database backup before migrations, so old domain data is recovered
// from that backup rather than by pretending a destructive migration can
// reconstruct historical rows.
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
// Remove all operational/domain instances and every table that depends on
// them (visits, acts, findings, reports, versions, media, relations, etc.).
// Users/roles/permissions and technical configuration are deliberately not
// part of this TRUNCATE.
await queryRunner.query('TRUNCATE TABLE assets CASCADE');
// Imported territorial/source material must not silently repopulate or
// influence the new manually curated structure.
await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE');
await queryRunner.query('TRUNCATE TABLE source_documents CASCADE');
// Start the classification ↔ finding applicability review from zero while
// preserving both master catalogs themselves.
await queryRunner.query('TRUNCATE TABLE finding_catalog_item_inventory_families');
// Explicitly clear audit history, including authentication/admin events
// accumulated during development. New events continue to be recorded after
// this migration.
await queryRunner.query('TRUNCATE TABLE audit_events');
// Import/reconciliation tables can contain rows not connected to a current
// Asset. Clear every asset_import_* data table without coupling this cut to
// one historical import implementation.
await queryRunner.query(`
DO $$
DECLARE table_name text;
BEGIN
FOR table_name IN
SELECT tablename
FROM pg_tables
WHERE schemaname = current_schema()
AND tablename LIKE 'asset_import_%'
LOOP
EXECUTE format('TRUNCATE TABLE %I CASCADE', table_name);
END LOOP;
END $$;
`);
// F5.1 decouples the physical Inventory tree from Empresa. A structural
// Asset may therefore inherit an Area while no operator has been assigned
// yet. Keep the useful invariant that an operator can never exist without
// an Area, but remove the old all-or-nothing pair requirement.
await queryRunner.query(`
ALTER TABLE asset_context_history
DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair
`);
await queryRunner.query(`
ALTER TABLE asset_context_history
ADD CONSTRAINT chk_asset_context_history_context_pair
CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL)
`);
// Departamento becomes the real root of the physical Inventory tree.
await queryRunner.query(`
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
SELECT 'departamento','Departamento','Departamento administrativo que contiene Áreas.',true,true,'GENERIC'
WHERE NOT EXISTS (
SELECT 1 FROM asset_types WHERE lower(code)='departamento'
)
`);
await queryRunner.query(`
UPDATE asset_types
SET name='Departamento',
description='Departamento administrativo que contiene Áreas.',
can_be_root=true,
is_active=true,
operational_role='GENERIC',
updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='departamento'
`);
await queryRunner.query(`
UPDATE asset_types
SET can_be_root=false,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='area'
`);
// Area has exactly one canonical structural parent kind: Departamento.
await queryRunner.query(`
DELETE FROM asset_type_parent_rules rule
USING asset_types child
WHERE rule.child_type_id=child.id AND lower(child.code)='area'
`);
await queryRunner.query(`
INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id)
SELECT child.id,parent.id
FROM asset_types child CROSS JOIN asset_types parent
WHERE lower(child.code)='area' AND lower(parent.code)='departamento'
ON CONFLICT (child_type_id,parent_type_id) DO NOTHING
`);
// Database-level guard: UI/API bugs cannot create an invalid physical tree.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_code text; parent_code text;
BEGIN
SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id;
IF child_code IN ('empresa','organizacion','departamento') THEN
IF NEW.parent_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Departamento son maestros raíz independientes';
END IF;
RETURN NEW;
END IF;
IF child_code NOT IN ('area','yacimiento','instalacion','subinstalacion') THEN
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La estructura requiere Departamento → Área → Yacimiento → Instalación → Subinstalación';
END IF;
SELECT lower(type.code) INTO parent_code
FROM assets parent
JOIN asset_types type ON type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id;
IF (child_code='area' AND parent_code<>'departamento')
OR (child_code='yacimiento' AND parent_code<>'area')
OR (child_code='instalacion' AND parent_code<>'yacimiento')
OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía inválida: Departamento → Área → Yacimiento → Instalación → Subinstalación';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE TRIGGER trg_f5_canonical_asset_hierarchy
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy()
`);
const rows = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::integer FROM assets) AS assets,
(SELECT COUNT(*)::integer FROM audit_events) AS audits,
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families) AS applicability,
(SELECT COUNT(*)::integer FROM asset_types WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true) AS departments,
(SELECT COUNT(*)::integer
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='area' AND lower(parent.code)='departamento') AS area_rules
`)) as Array<{ assets: number; audits: number; applicability: number; departments: number; area_rules: number }>;
const check = rows[0];
if (!check || Number(check.assets) !== 0 || Number(check.audits) !== 0 || Number(check.applicability) !== 0
|| Number(check.departments) !== 1 || Number(check.area_rules) !== 1) {
throw new Error(`F5.1 clean-start verification failed: ${JSON.stringify(check ?? {})}`);
}
}
public async down(): Promise<void> {
throw new Error('F5.1 is an intentional destructive clean-start migration. Restore the pre-deploy database backup to recover previous data.');
}
}
@@ -1,226 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F6SolidInventoryModel1790091000000 implements MigrationInterface {
name = 'F6SolidInventoryModel1790091000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// A technical Subinstallation family may be valid under several Installation
// families. F3.1 used child_family_id as the PK, which made this relation
// accidentally one-to-one from the child's point of view.
await queryRunner.query(`
DO $$
DECLARE constraint_name text;
BEGIN
SELECT con.conname INTO constraint_name
FROM pg_constraint con
JOIN pg_class rel ON rel.oid=con.conrelid
WHERE rel.relname='inventory_family_parent_rules' AND con.contype='p'
LIMIT 1;
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT %I',constraint_name);
END IF;
END $$;
`);
await queryRunner.query(`
ALTER TABLE inventory_family_parent_rules
ADD CONSTRAINT pk_inventory_family_parent_rules
PRIMARY KEY (child_family_id,parent_family_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inventory_family_parent_rules_parent
ON inventory_family_parent_rules(parent_family_id,child_family_id)
`);
// Protect compatibility semantics at database level.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_inventory_family_compatibility_rule()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_level text; parent_level text;
BEGIN
SELECT level::text INTO child_level FROM inventory_families WHERE id=NEW.child_family_id;
SELECT level::text INTO parent_level FROM inventory_families WHERE id=NEW.parent_family_id;
IF child_level IS DISTINCT FROM 'SUBINSTALLATION' OR parent_level IS DISTINCT FROM 'INSTALLATION' THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La compatibilidad debe vincular Subinstalación con Instalación';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
await queryRunner.query(`
CREATE TRIGGER trg_inventory_family_compatibility_rule
BEFORE INSERT OR UPDATE OF child_family_id,parent_family_id ON inventory_family_parent_rules
FOR EACH ROW EXECUTE FUNCTION enforce_inventory_family_compatibility_rule()
`);
// Family-specific technical fields. Generic asset type attributes remain for
// data genuinely shared by a whole structural level.
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_family_id uuid NOT NULL REFERENCES inventory_families(id) ON DELETE CASCADE,
code varchar(80) NOT NULL,
name varchar(160) NOT NULL,
data_type asset_attribute_data_type NOT NULL,
is_required boolean NOT NULL DEFAULT false,
is_active boolean NOT NULL DEFAULT true,
unit varchar(40) NULL,
options jsonb NULL,
sort_order integer NOT NULL DEFAULT 0,
created_by uuid NULL,
updated_by uuid NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inventory_family_attribute_code UNIQUE(inventory_family_id,code),
CONSTRAINT chk_inventory_family_attribute_code CHECK (code ~ '^[a-z][a-z0-9_]*$'),
CONSTRAINT chk_inventory_family_attribute_options CHECK (
(data_type='SELECT' AND options IS NOT NULL AND jsonb_typeof(options)='array')
OR (data_type<>'SELECT')
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inventory_family_attributes_family
ON inventory_family_attribute_definitions(inventory_family_id,is_active,sort_order,name)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values (
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
definition_id uuid NOT NULL REFERENCES inventory_family_attribute_definitions(id) ON DELETE CASCADE,
value jsonb NOT NULL,
updated_by uuid NULL,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(asset_id,definition_id)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_inventory_attribute_values_definition
ON asset_inventory_attribute_values(definition_id,asset_id)
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_inventory_attribute_family()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE asset_family uuid; definition_family uuid;
BEGIN
SELECT inventory_family_id INTO asset_family FROM assets WHERE id=NEW.asset_id;
SELECT inventory_family_id INTO definition_family
FROM inventory_family_attribute_definitions WHERE id=NEW.definition_id AND is_active=true;
IF asset_family IS NULL OR definition_family IS NULL OR asset_family<>definition_family THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El campo técnico no pertenece a la clasificación del Inventario';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
await queryRunner.query(`
CREATE TRIGGER trg_asset_inventory_attribute_family
BEFORE INSERT OR UPDATE OF asset_id,definition_id ON asset_inventory_attribute_values
FOR EACH ROW EXECUTE FUNCTION enforce_asset_inventory_attribute_family()
`);
// Enforce classification level and Subinstallation compatibility on every
// physical Inventory write, not just through the current WEB/API.
// A FIELD_SURVEY+DRAFT row may exist momentarily without family because the
// field-discovery transaction creates the provisional record before the F6
// structure service assigns the family selected by the inspector. Such a row
// remains unusable for findings until classification is present.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_f6_asset_family_contract()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_type text; family_level text; parent_family uuid;
BEGIN
SELECT lower(code) INTO child_type FROM asset_types WHERE id=NEW.asset_type_id;
IF child_type IN ('departamento','area','yacimiento') THEN
IF NEW.inventory_family_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento, Área y Yacimiento no llevan clasificación técnica';
END IF;
RETURN NEW;
END IF;
IF child_type='instalacion' THEN
IF NEW.inventory_family_id IS NULL THEN
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
RETURN NEW;
END IF;
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación requiere clasificación técnica';
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='La clasificación elegida no corresponde a una Instalación';
END IF;
RETURN NEW;
END IF;
IF child_type='subinstalacion' THEN
IF NEW.parent_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere una Instalación padre';
END IF;
IF NEW.inventory_family_id IS NULL THEN
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
RETURN NEW;
END IF;
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere clasificación técnica';
END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW.parent_id;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Subinstalación';
END IF;
IF parent_family IS NULL OR 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='La clasificación de Subinstalación no es compatible con la Instalación padre';
END IF;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
await queryRunner.query(`
CREATE TRIGGER trg_f6_asset_family_contract
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,inventory_family_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_f6_asset_family_contract()
`);
const [check] = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::integer FROM pg_constraint c JOIN pg_class r ON r.oid=c.conrelid
WHERE r.relname='inventory_family_parent_rules' AND c.contype='p'
AND pg_get_constraintdef(c.oid) LIKE '%child_family_id, parent_family_id%') AS composite_pk,
to_regclass('inventory_family_attribute_definitions') IS NOT NULL AS family_attributes,
to_regclass('asset_inventory_attribute_values') IS NOT NULL AS family_values
`)) as Array<{ composite_pk:number; family_attributes:boolean; family_values:boolean }>;
if (!check || Number(check.composite_pk)!==1 || !check.family_attributes || !check.family_values) {
throw new Error(`F6 inventory model verification failed: ${JSON.stringify(check ?? {})}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f6_asset_family_contract()');
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_asset_inventory_attribute_family()');
await queryRunner.query('DROP TABLE IF EXISTS asset_inventory_attribute_values');
await queryRunner.query('DROP TABLE IF EXISTS inventory_family_attribute_definitions');
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_inventory_family_compatibility_rule()');
await queryRunner.query('DROP INDEX IF EXISTS idx_inventory_family_parent_rules_parent');
// Old schema allowed only one Installation family per Subinstallation family.
// Keep a deterministic first relation if F6 data must be rolled back.
await queryRunner.query(`
DELETE FROM inventory_family_parent_rules rule
USING inventory_family_parent_rules keep
WHERE rule.child_family_id=keep.child_family_id
AND rule.parent_family_id>keep.parent_family_id
`);
await queryRunner.query('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT IF EXISTS pk_inventory_family_parent_rules');
await queryRunner.query(`
ALTER TABLE inventory_family_parent_rules
ADD CONSTRAINT inventory_family_parent_rules_pkey PRIMARY KEY(child_family_id)
`);
}
}
@@ -36,17 +36,19 @@ export class F3FindingCatalogResolverService {
enabled: true, enabled: true,
code: 'OTHER', code: 'OTHER',
label: 'OTROS', label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista entre los asociados a esta clasificación. Quedará registrado para revisión en oficina.', help: 'Usalo cuando el Hallazgo no exista en el catálogo aplicable. Se enviará una propuesta a revisión de oficina.',
}; };
// F5: no se mezclan catálogos históricos. Yacimiento no posee familia técnica
// en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS.
if (!asset.familyId || asset.familyActive !== true) { if (!asset.familyId || asset.familyActive !== true) {
return { return {
asset: { id: asset.id, code: asset.code, name: asset.name }, asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null, inventoryFamily: null,
typeConfigured: false, typeConfigured: false,
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento' configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
? 'El Yacimiento es contexto estructural y no posee clasificación técnica. Usá OTROS sólo si necesitás registrar una observación excepcional.' ? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.'
: 'El elemento todavía no tiene una clasificación técnica activa.', : 'F5 · El elemento todavía no tiene una clasificación técnica activa.',
categories: [], categories: [],
items: [], items: [],
other, other,
@@ -57,6 +59,7 @@ export class F3FindingCatalogResolverService {
'mapping.inventory_family_id=$1::uuid', 'mapping.inventory_family_id=$1::uuid',
'item.is_active=true', 'item.is_active=true',
'category.is_active=true', 'category.is_active=true',
"lower(category.code)='f5model'",
'merge_record.source_item_id IS NULL', 'merge_record.source_item_id IS NULL',
]; ];
const params: unknown[] = [asset.familyId]; const params: unknown[] = [asset.familyId];
@@ -104,7 +107,7 @@ export class F3FindingCatalogResolverService {
level: asset.familyLevel, level: asset.familyLevel,
}, },
typeConfigured: true, typeConfigured: true,
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(), configurationReason: `F5 · Catálogo final_modelov2.xlsx asociado a ${asset.familyName ?? 'la clasificación técnica'}`,
categories, categories,
items, items,
other, other,
@@ -44,8 +44,6 @@ const STRUCTURAL_CHILD: Record<string, string | undefined> = {
instalacion: 'subinstalacion', instalacion: 'subinstalacion',
}; };
const OTHER_FAMILY_SQL = "source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR source_reference LIKE 'F5:SYSTEM:OTHER:%'";
@Injectable() @Injectable()
export class F3FieldInventoryStructureService { export class F3FieldInventoryStructureService {
constructor( constructor(
@@ -204,10 +202,10 @@ export class F3FieldInventoryStructureService {
return this.dataSource.query(` return this.dataSource.query(`
SELECT id,code,name,level,information_labels AS "informationLabels", SELECT id,code,name,level,information_labels AS "informationLabels",
source_reference AS "sourceReference", source_reference AS "sourceReference",
(${OTHER_FAMILY_SQL}) AS "isOther" (source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
FROM inventory_families FROM inventory_families
WHERE level='INSTALLATION' AND is_active=true WHERE level='INSTALLATION' AND is_active=true
ORDER BY (${OTHER_FAMILY_SQL}) ASC,name,code ORDER BY (source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,name,code
`) as Promise<FamilyRow[]>; `) as Promise<FamilyRow[]>;
} }
if (expectedTypeCode === 'subinstalacion') { if (expectedTypeCode === 'subinstalacion') {
@@ -221,13 +219,13 @@ export class F3FieldInventoryStructureService {
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels", family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference", family.source_reference AS "sourceReference",
(family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') AS "isOther" (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
FROM inventory_family_parent_rules rule FROM inventory_family_parent_rules rule
JOIN inventory_families family ON family.id=rule.child_family_id JOIN inventory_families family ON family.id=rule.child_family_id
WHERE rule.parent_family_id=$1::uuid WHERE rule.parent_family_id=$1::uuid
AND family.level='SUBINSTALLATION' AND family.level='SUBINSTALLATION'
AND family.is_active=true AND family.is_active=true
ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') ASC,family.name,family.code ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,family.name,family.code
`, [parent.inventoryFamilyId]) as Promise<FamilyRow[]>; `, [parent.inventoryFamilyId]) as Promise<FamilyRow[]>;
} }
return []; return [];
@@ -138,31 +138,8 @@ export class FieldFindingsService {
asset.id AS "assetId", asset.id AS "assetId",
asset_type.code AS "assetTypeCode", asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId", asset.inventory_family_id AS "inventoryFamilyId",
( asset.operational_area_id AS "assetAreaId",
asset.operational_area_id=visit.operational_area_id asset.operator_company_id AS "assetCompanyId",
OR asset.id=visit.operational_area_id
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1
)
) AS "insideArea",
EXISTS (
SELECT 1
FROM area_company_relations relation
WHERE relation.area_id=visit.operational_area_id
AND relation.company_id=visit.operator_company_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
AND (
relation.valid_until IS NULL
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
)
) AS "operatorRelationValid",
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM inspection_visit_members member FROM inspection_visit_members member
@@ -222,8 +199,8 @@ export class FieldFindingsService {
assetId: string | null; assetId: string | null;
assetTypeCode: string | null; assetTypeCode: string | null;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
insideArea: boolean; assetAreaId: string | null;
operatorRelationValid: boolean; assetCompanyId: string | null;
assigned: boolean; assigned: boolean;
selected: boolean; selected: boolean;
captureRequired: boolean; captureRequired: boolean;
@@ -253,12 +230,6 @@ export class FieldFindingsService {
message: 'La inspección no tiene Área y Operadora definidas', message: 'La inspección no tiene Área y Operadora definidas',
}); });
} }
if (!row.operatorRelationValid) {
throw new ConflictException({
code: 'FIELD_FINDING_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
});
}
if (!row.assetId) { if (!row.assetId) {
throw new NotFoundException({ throw new NotFoundException({
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND', code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
@@ -274,13 +245,13 @@ export class FieldFindingsService {
if (!row.inventoryFamilyId) { if (!row.inventoryFamilyId) {
throw new ConflictException({ throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED', code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos', message: 'La Instalación/Subinstalación debe tener una familia técnica u Otro antes de registrar Hallazgos',
}); });
} }
if (!row.insideArea) { if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT', code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
message: 'El Inventario no pertenece al Área de esta inspección', message: 'El Inventario no pertenece al Área y Operadora de esta inspección',
}); });
} }
if (!row.selected) { if (!row.selected) {
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.28.0-1'; export const API_VERSION = '0.26.0-1';
export const API_PHASE = 'F6'; export const API_PHASE = 'F5';
+4 -4
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6 release', () => { test('health metadata reports the current F5 release', () => {
assert.equal(API_PHASE, 'F6'); assert.equal(API_PHASE, 'F5');
assert.equal(API_VERSION, '0.28.0-1'); assert.equal(API_VERSION, '0.26.0-1');
}); });
@@ -1,86 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F5.1 clean start deletes domain instances and audit history but preserves master configuration by contract', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
assert.match(migration, /TRUNCATE TABLE assets CASCADE/);
assert.match(migration, /TRUNCATE TABLE audit_events/);
assert.match(migration, /TRUNCATE TABLE administrative_departments CASCADE/);
assert.match(migration, /TRUNCATE TABLE finding_catalog_item_inventory_families/);
assert.doesNotMatch(migration, /TRUNCATE TABLE inventory_families/);
assert.doesNotMatch(migration, /TRUNCATE TABLE finding_catalog_items/);
assert.doesNotMatch(migration, /TRUNCATE TABLE users/);
});
test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
assert.match(dto, /'DEPARTAMENTO'/);
assert.match(structure, /DEPARTAMENTO: 'departamento'/);
assert.match(structure, /AREA: 'departamento'/);
assert.match(structure, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
assert.match(migration, /child_code='area' AND parent_code<>'departamento'/);
assert.match(migration, /WHERE lower\(code\)='area'/);
assert.match(migration, /SET can_be_root=false/);
});
test('F5.1 manual creation permits Area context without requiring an operator', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
assert.match(migration, /DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair/);
assert.match(migration, /CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
assert.match(structure, /operator_company_id,inventory_family_id/);
assert.match(structure, /asset_context_history/);
assert.match(structure, /operational_area_id,operator_company_id/);
});
test('F5.1 Inventory browser exposes every canonical level instead of filtering by is_inventory_instance', () => {
const browser = source('src/asset-master/inventory-browser.service.ts');
const controller = source('src/asset-master/inventory-browser.controller.ts');
assert.match(browser, /'departamento','area','yacimiento','instalacion','subinstalacion'/);
assert.match(browser, /case 'departamento': return 'area'/);
assert.doesNotMatch(browser, /['"]asset\.is_inventory_instance=true['"]/);
assert.match(controller, /@Get\('departments'\)/);
assert.match(controller, /@Get\('companies'\)/);
});
test('F5.1 Finding Catalog defaults to associated findings and exposes all items only for linking', () => {
const panel = source('../web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx');
assert.match(panel, /type ViewMode = 'ASSOCIATED' \| 'ALL'/);
assert.match(panel, /useState<ViewMode>\('ASSOCIATED'\)/);
assert.match(panel, /available\.filter\(\(item\) => savedIds\.has\(item\.id\)\)/);
assert.match(panel, /Buscar dentro de \{viewMode === 'ASSOCIATED' \? 'los asociados' : 'todo el Catálogo'\}/);
assert.match(panel, /Todos para vincular/);
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
});
test('F5.1/F6 Configuration manages findings inline and filters Subinstallations by compatible Installation classifications', () => {
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(configPage, /replaceInventoryFamilyFindings/);
assert.match(configPage, /Agregar o quitar/);
assert.match(configPage, /familyFindingIds/);
assert.match(configPage, /selectedInstallationFamilyId/);
assert.match(configPage, /family\.parentFamilyIds\.includes\(selectedInstallationFamilyId\)/);
assert.match(configPage, /Filtrar por tipo de Instalación/);
});
test('F5.1 Web creation and configuration expose Departamento as the physical root', () => {
const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(createPage, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA:'Departamento'/);
assert.match(createPage, /Datos opcionales/);
assert.match(configPage, /<strong>Departamento<\/strong>/);
assert.match(configPage, /familyId=\$\{familyEditor\.id\}/);
});
@@ -1,28 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 21/);
assert.match(gradle, /versionName = "0\.14\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
test('F5/F6 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8',
);
assert.match(service, /F5:SYSTEM:OTHER:%/);
assert.match(service, /AS "isOther"/);
assert.match(service, /isOtherFamily: family\.isOther/);
});
@@ -1,31 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
import { MODULE_METADATA } from '@nestjs/common/constants';
import { AssetMasterModule } from '../../src/asset-master/asset-master.module';
import { InventoryFunctionService } from '../../src/asset-master/inventory-function.service';
test('F5 AssetMasterModule keeps dossier-only function dependency injectable without reopening its controller', () => {
const providers = (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AssetMasterModule) ?? []) as unknown[];
const controllers = (Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AssetMasterModule) ?? []) as Array<{ name?: string }>;
const dossierSource = readFileSync(
resolve(process.cwd(), 'src/asset-master/merged-inventory-dossier.service.ts'),
'utf8',
);
assert.match(
dossierSource,
/private readonly functions: InventoryFunctionService/,
'MergedInventoryDossierService must still depend on InventoryFunctionService for historical dossier data',
);
assert.ok(
providers.includes(InventoryFunctionService),
'AssetMasterModule must provide InventoryFunctionService so production Nest bootstrap can resolve the dossier',
);
assert.equal(
controllers.some((controller) => controller?.name === 'InventoryFunctionController'),
false,
'F5 must not reopen the retired Inventory Function controller',
);
});
@@ -1,90 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6 models concrete Installation 1→N Subinstallations while each child keeps exactly one physical parent', () => {
const asset = source('src/database/entities/asset.entity.ts');
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
assert.match(asset, /@Column\(\{ name: 'parent_id', type: 'uuid', nullable: true \}\)/);
assert.match(asset, /parentId!: string \| null/);
assert.match(migration, /IF child_type='subinstalacion' THEN/);
assert.match(migration, /IF NEW\.parent_id IS NULL THEN/);
assert.doesNotMatch(asset, /parentIds!/);
});
test('F6 makes technical Installation↔Subinstallation compatibility truly many-to-many', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const catalog = source('src/asset-master/inventory-family-catalog.service.ts');
assert.match(migration, /PRIMARY KEY \(child_family_id,parent_family_id\)/);
assert.match(catalog, /parentFamilyIds: string\[\]/);
assert.match(catalog, /requestedIds:string\[\]/);
assert.match(catalog, /INSERT INTO inventory_family_parent_rules\(child_family_id,parent_family_id\)/);
assert.match(catalog, /UNNEST\(\$2::uuid\[\]\)/);
assert.match(catalog, /ON CONFLICT \(child_family_id,parent_family_id\) DO NOTHING/);
});
test('F6 database rejects a Subinstallation classification incompatible with its concrete Installation parent', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
assert.match(migration, /SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW\.parent_id/);
assert.match(migration, /rule\.child_family_id=NEW\.inventory_family_id AND rule\.parent_family_id=parent_family/);
assert.match(migration, /La clasificación de Subinstalación no es compatible con la Instalación padre/);
});
test('F6 technical values are defined by classification and cannot leak between families', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const service = source('src/asset-master/inventory-technical-values.service.ts');
assert.match(migration, /CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions/);
assert.match(migration, /CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values/);
assert.match(migration, /El campo técnico no pertenece a la clasificación del Inventario/);
assert.match(service, /inventory_family_attribute_definitions/);
assert.match(service, /asset_inventory_attribute_values/);
});
test('F6 field findings resolve by exact technical classification and keep OTROS available', () => {
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
assert.match(resolver, /mapping\.inventory_family_id=\$1::uuid/);
assert.match(resolver, /asset\.inventory_family_id AS "familyId"/);
assert.doesNotMatch(resolver, /category\.code='f5model'/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
});
test('F6 mobile finding gate uses Area ancestry and temporal operator relation instead of copying company ownership onto each object', () => {
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/);
assert.match(fieldFindings, /FROM area_company_relations relation/);
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
});
test('F6 field-created technical Inventory may be provisional only until the selected family is assigned', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const fieldStructure = source('src/inspection-visits/f3-field-inventory-structure.service.ts');
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(migration, /NEW\.data_origin='FIELD_SURVEY' AND NEW\.information_status='DRAFT'/);
assert.match(fieldStructure, /SET inventory_family_id=\$2::uuid/);
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
});
test('F6 Android keeps search and reloads scoped to the current physical parent', () => {
const viewModel = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/);
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
});
-1
View File
@@ -1 +0,0 @@
# F6 · Modelo definitivo de Inventarios
-7
View File
@@ -1,7 +0,0 @@
# F6 · Modelo definitivo de Inventarios
La jerarquía física canónica es fija:
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
-7
View File
@@ -1,7 +0,0 @@
# F6 · Modelo definitivo de Inventarios
La jerarquía física canónica es fija:
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-1", "version": "0.21.0-1",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -24,4 +24,4 @@
"typescript": "^5.9.0", "typescript": "^5.9.0",
"vite": "^7.0.0" "vite": "^7.0.0"
} }
} }
@@ -1,98 +1,59 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon'; import { Icon } from '../../components/Icon';
import { import { getFindingCatalogAssetSelection, replaceFindingCatalogAssetSelection } from '../../lib/api';
getInventoryFamilyFindings, import type { FindingCatalogAssetSelection } from '../../lib/api';
getInventoryTechnicalValues,
} from '../../lib/inventoryStructureApi';
import type {
InventoryFamilyFinding,
InventoryTechnicalValues,
} from '../../lib/inventoryStructureApi';
import { AssetTechnicalDataPanel } from './AssetTechnicalDataPanel';
export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) { export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) {
const { hasPermission } = useAuth(); const [selection, setSelection] = useState<FindingCatalogAssetSelection | null>(null);
const canEditTechnical = hasPermission('assets.update'); const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [technical, setTechnical] = useState<InventoryTechnicalValues | null>(null); const [reason, setReason] = useState('');
const [items, setItems] = useState<InventoryFamilyFinding[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [unsupported, setUnsupported] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => { const load = () => getFindingCatalogAssetSelection(assetId).then((loaded) => {
let active = true; setSelection(loaded);
setLoading(true); setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
setUnsupported(false); });
setError('');
getInventoryTechnicalValues(assetId) useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId]);
.then(async (loaded) => {
const findings = await getInventoryFamilyFindings(loaded.family.id);
if (!active) return;
setTechnical(loaded);
setItems(findings.items);
})
.catch((requestError) => {
if (!active) return;
const message = errorMessage(requestError);
if (message.includes('no tiene clasificación técnica')) setUnsupported(true);
else setError(message);
})
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [assetId]);
const visible = useMemo(() => { const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase(); const needle = search.trim().toLocaleLowerCase();
if (!needle) return items; return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName]
return items.filter((item) => [item.title, item.code, item.categoryName, item.legalBasis ?? ''] .some((value) => value.toLocaleLowerCase().includes(needle))) ?? [];
.some((value) => value.toLocaleLowerCase().includes(needle))); }, [selection, search]);
}, [items, search]);
if (loading) return <div className="panel"><LoadingBlock label="Cargando clasificación y Hallazgos aplicables…" /></div>; const toggle = (id: string) => setEnabled((current) => {
if (error) return <Alert>{error}</Alert>; const next = new Set(current);
if (unsupported || !technical) return <section className="panel asset-finding-catalog-panel"> if (next.has(id)) next.delete(id); else next.add(id);
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>Sin clasificación técnica</h2><p className="section-copy">Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones clasificadas.</p></div></div> return next;
});
const save = async () => {
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetSelection(assetId, { enabledItemIds: [...enabled], reason });
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason('');
setSuccess('Subconjunto de hallazgos actualizado para este objeto.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando hallazgos aplicables…" /></div>;
if (!selection) return <Alert>{error || 'No se pudo cargar la configuración.'}</Alert>;
return <section className="panel asset-finding-catalog-panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>{selection.asset.name}</h2><p className="section-copy">Base: {selection.asset.assetTypeName}. Las excepciones de esta pantalla afectan sólo a este objeto del Inventario.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{!selection.typeConfigured && <div className="temporal-notice"><Icon name="alert" /><p><strong>El tipo técnico todavía no tiene un catálogo restringido.</strong> Por compatibilidad, su base actual incluye todo el catálogo activo. Podés configurar primero el tipo general en Catálogo de hallazgos.</p></div>}
<label className="search-field"><Icon name="search" /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar hallazgo aplicable…" /></label>
<div className="finding-selection-list">{visible.map((item) => <label className={`finding-selection-row ${item.assetOverride !== null ? 'override' : ''}`} key={item.id}><input type="checkbox" disabled={!canManage} checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code} · base del tipo: {item.typeDefaultEnabled ? 'sí' : 'no'}{item.assetOverride !== null ? ' · excepción de este objeto' : ''}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
{canManage && <><label className="field"><span>Motivo de la excepción</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Explicá por qué este objeto usa un subconjunto distinto…" /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar excepciones'}</button></div></>}
</section>; </section>;
}
return <div className="asset-tab-stack">
<AssetTechnicalDataPanel assetId={assetId} canEdit={canEditTechnical} />
<section className="panel asset-finding-catalog-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">HALLAZGOS APLICABLES</span>
<h2>{technical.family.name}</h2>
<p className="section-copy">Se muestran únicamente los Hallazgos asociados a esta clasificación técnica. Éste es el mismo catálogo que consume la APK para este objeto.</p>
</div>
<span className="count-pill">{items.length} asociados</span>
</div>
{canManage && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>¿Falta un Hallazgo?</strong> Administrá la clasificación en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo técnico.</p>
<Link className="button secondary" to="/admin/asset-types">Configurar Inventarios</Link>
</div>}
{items.length > 0 && <label className="search-field">
<Icon name="search" />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={`Buscar entre los ${items.length} Hallazgos asociados…`} />
</label>}
{items.length === 0
? <div className="inline-empty">Esta clasificación todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.</div>
: visible.length === 0
? <div className="inline-empty">No hay Hallazgos asociados que coincidan con la búsqueda.</div>
: <div className="finding-selection-list">{visible.map((item) => <div className="finding-selection-row" key={item.id}>
<span className="asset-symbol"><Icon name="alert" size={15} /></span>
<span>
<strong>{item.title}</strong>
<small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small>
{item.legalBasis && <small>{item.legalBasis}</small>}
</span>
</div>)}</div>}
</section>
</div>;
}
@@ -6,13 +6,11 @@ import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api'; import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem } from '../../lib/api'; import type { AssetLineageItem } from '../../lib/api';
import { import {
listInventoryCompanies, listInventoryAreas,
listInventoryDepartments,
listInventoryChildren, listInventoryChildren,
} from '../../lib/inventoryBrowserApi'; } from '../../lib/inventoryBrowserApi';
import type { import type {
InventoryBrowserCompany, InventoryBrowserArea,
InventoryBrowserDepartment,
InventoryBrowserItem, InventoryBrowserItem,
InventoryQuery, InventoryQuery,
} from '../../lib/inventoryBrowserApi'; } from '../../lib/inventoryBrowserApi';
@@ -28,30 +26,28 @@ function navigationHref(base: URLSearchParams, parentId?: string) {
return `/inventarios${params.size ? `?${params}` : ''}`; return `/inventarios${params.size ? `?${params}` : ''}`;
} }
function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) { function AreaCard({ area, href }: { area: InventoryBrowserArea; href: string }) {
return <Link className="asset-browser-item" to={href}> return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="map" size={17} /></span> <span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
<span className="asset-browser-item-main"> <span className="asset-browser-item-main">
<strong>{department.name}</strong> <strong>{area.name}</strong>
<small>{department.code} · {department.areaCount} Área{department.areaCount === 1 ? '' : 's'}</small> <small>
{area.code} · {area.yacimientoCount} yacimiento{area.yacimientoCount === 1 ? '' : 's'}
{area.currentOperator ? ` · Operadora vigente: ${area.currentOperator.name}` : ' · Sin operadora vigente'}
</small>
</span>
<span className="asset-browser-item-status">
<strong>{area.inventoryCount}</strong>
<small>instancia{area.inventoryCount === 1 ? '' : 's'} real{area.inventoryCount === 1 ? '' : 'es'}</small>
</span> </span>
<span className="asset-browser-item-status"><span className={`status-badge ${assetStatusClass(department.informationStatus)}`}>{assetStatusLabel(department.informationStatus)}</span></span>
<Icon name="chevron" size={16} />
</Link>;
}
function CompanyCard({ company }: { company: InventoryBrowserCompany }) {
return <Link className="asset-browser-item" to={`/inventarios/${company.id}`}>
<span className="asset-browser-item-icon"><Icon name="users" size={17} /></span>
<span className="asset-browser-item-main"><strong>{company.name}</strong><small>{company.code} · {company.areaCount} Área{company.areaCount === 1 ? '' : 's'} operada{company.areaCount === 1 ? '' : 's'}</small></span>
<span className="asset-browser-item-status"><span className={`status-badge ${assetStatusClass(company.informationStatus)}`}>{assetStatusLabel(company.informationStatus)}</span></span>
<Icon name="chevron" size={16} /> <Icon name="chevron" size={16} />
</Link>; </Link>;
} }
function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) { function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) {
const structural = !item.isInventoryInstance;
return <Link className="asset-browser-item" to={href}> return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name={['departamento','area','yacimiento'].includes(item.type.code.toLowerCase()) ? 'map' : 'layers'} size={17} /></span> <span className="asset-browser-item-icon"><Icon name={structural ? 'map' : 'layers'} size={17} /></span>
<span className="asset-browser-item-main"> <span className="asset-browser-item-main">
<strong>{item.name}</strong> <strong>{item.name}</strong>
<small> <small>
@@ -61,10 +57,9 @@ function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: strin
</small> </small>
</span> </span>
<span className="asset-browser-item-status"> <span className="asset-browser-item-status">
<span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span> {structural
{item.inventoryFamily ? <><span className="tag">Contexto</span><small>{item.childrenCount} nivel{item.childrenCount === 1 ? '' : 'es'} inferior{item.childrenCount === 1 ? '' : 'es'}</small></>
? <small>{item.findingCount} Hallazgo{item.findingCount === 1 ? '' : 's'} asociado{item.findingCount === 1 ? '' : 's'}</small> : <><span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span><small>{assetOperationalStatusLabel(item.operationalStatus)}</small></>}
: <small>{item.childrenCount} registro{item.childrenCount === 1 ? '' : 's'} inferior{item.childrenCount === 1 ? '' : 'es'}</small>}
</span> </span>
<Icon name="chevron" size={16} /> <Icon name="chevron" size={16} />
</Link>; </Link>;
@@ -72,7 +67,6 @@ function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: strin
function nextLevelLabel(typeCode: string | undefined) { function nextLevelLabel(typeCode: string | undefined) {
switch (typeCode?.toLowerCase()) { switch (typeCode?.toLowerCase()) {
case 'departamento': return 'Áreas';
case 'area': return 'Yacimientos'; case 'area': return 'Yacimientos';
case 'yacimiento': return 'Instalaciones'; case 'yacimiento': return 'Instalaciones';
case 'instalacion': return 'Subinstalaciones'; case 'instalacion': return 'Subinstalaciones';
@@ -85,8 +79,7 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
const canCreate = hasPermission('assets.create'); const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const parentId = searchParams.get('parentId') ?? ''; const parentId = searchParams.get('parentId') ?? '';
const [departments, setDepartments] = useState<InventoryBrowserDepartment[]>([]); const [areas, setAreas] = useState<InventoryBrowserArea[]>([]);
const [companies, setCompanies] = useState<InventoryBrowserCompany[]>([]);
const [children, setChildren] = useState<InventoryBrowserItem[]>([]); const [children, setChildren] = useState<InventoryBrowserItem[]>([]);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]); const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
@@ -95,53 +88,66 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true); setError(''); setDepartments([]); setCompanies([]); setChildren([]); setLineage([]); setHasMore(false); setLoading(true);
setError('');
setAreas([]);
setChildren([]);
setLineage([]);
setHasMore(false);
const run = async () => { const run = async () => {
if (!parentId) { if (!parentId) {
const [loadedDepartments, loadedCompanies] = await Promise.all([ const response = await listInventoryAreas({
listInventoryDepartments({ search: filters.search, operatorCompanyId: filters.operatorCompanyId }), search: filters.search,
listInventoryCompanies({ search: filters.search }), operationalAreaId: filters.operationalAreaId,
]); operatorCompanyId: filters.operatorCompanyId,
if (active) { setDepartments(loadedDepartments.data); setCompanies(loadedCompanies.data); } });
if (active) setAreas(response.data);
return; return;
} }
const [loadedLineage, response] = await Promise.all([ const [loadedLineage, response] = await Promise.all([
getAssetLineage(parentId), getAssetLineage(parentId),
listInventoryChildren(parentId, { search: filters.search }), listInventoryChildren(parentId, { search: filters.search }),
]); ]);
if (!active) return; if (!active) return;
setLineage(loadedLineage.filter((item) => ['departamento','area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase()))); setLineage(loadedLineage.filter((item) => ['area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase())));
setChildren(response.data); setHasMore(response.meta.hasMore); setChildren(response.data);
setHasMore(response.meta.hasMore);
}; };
run().catch((requestError) => active && setError(errorMessage(requestError))).finally(() => active && setLoading(false));
return () => { active = false; };
}, [parentId, filters.search, filters.operatorCompanyId]);
if (loading) return <LoadingBlock label="Cargando Inventarios…" />; run()
.catch((requestError) => active && setError(errorMessage(requestError)))
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [parentId, filters.search, filters.operationalAreaId, filters.operatorCompanyId]);
if (loading) return <LoadingBlock label="Cargando inventarios…" />;
if (!parentId) { if (!parentId) {
const realTotal = areas.reduce((sum, area) => sum + Number(area.inventoryCount ?? 0), 0);
return <div className="asset-browser-panel"> return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"> <div className="asset-browser-section-heading">
<div><span className="eyebrow">INVENTARIO COMPLETO</span><h2>Departamentos</h2><p>Entrá por Departamento y navegá Área Yacimiento Instalación Subinstalación hasta llegar a su clasificación y Hallazgos asociados.</p></div> <div>
<div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div> <span className="eyebrow">ESTRUCTURA TERRITORIAL</span>
<h2>Áreas</h2>
<p>Las Áreas y Yacimientos son contexto de navegación. El Inventario real comienza en las Instalaciones/Subinstalaciones efectivamente registradas.</p>
</div>
<div className="asset-browser-current-actions">
<span className="count-pill">{realTotal} Inventario real</span>
<span className="count-pill">{areas.length} Áreas</span>
</div>
</div> </div>
{departments.length === 0 {areas.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." /> ? <EmptyState title="No hay Áreas para mostrar" text="Probá con otra búsqueda o revisá el contexto seleccionado." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>} : <div className="asset-browser-list">{areas.map((area) => <AreaCard key={area.id} area={area} href={navigationHref(searchParams, area.id)} />)}</div>}
<div className="asset-browser-levels" aria-label="Estructura de Inventarios"> <div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i> <div><span>1</span><strong>Área</strong><small>Ancla territorial</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i> <div><span>2</span><strong>Yacimiento</strong><small>Contexto dentro del Área</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i> <div><span>3</span><strong>Instalación</strong><small>Inventario real</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i> <div><span>4</span><strong>Subinstalación</strong><small>Inventario real</small></div>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
</div> </div>
<section className="asset-browser-group" style={{ marginTop: 20 }}>
<div className="asset-browser-group-heading"><div><h3>Empresas</h3><p>Maestro independiente. La Empresa se vincula al Área como operadora sin alterar la estructura física.</p></div><span>{companies.length}</span></div>
{companies.length === 0 ? <div className="inline-empty">Todavía no hay Empresas cargadas.</div> : <div className="asset-browser-list">{companies.map((company) => <CompanyCard key={company.id} company={company} />)}</div>}
</section>
</div>; </div>;
} }
@@ -150,7 +156,10 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
<Link to="/inventarios">Inventarios</Link> <Link to="/inventarios">Inventarios</Link>
{lineage.map((item,index) => { {lineage.map((item,index) => {
const isLast=index===lineage.length-1; const isLast=index===lineage.length-1;
return <span className="asset-browser-crumb-part" key={item.id}><span></span>{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams,item.id)}>{item.name}</Link>}</span>; return <span className="asset-browser-crumb-part" key={item.id}>
<span></span>
{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams,item.id)}>{item.name}</Link>}
</span>;
})} })}
</nav>; </nav>;
@@ -159,17 +168,31 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda para acotar el resultado.</Alert>} {hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda para acotar el resultado.</Alert>}
<div className="asset-browser-current-heading"> <div className="asset-browser-current-heading">
<div><span className="eyebrow">{current?.type.name ?? 'INVENTARIO'}</span><h2>{current?.name ?? 'Nivel de Inventario'}</h2><p>{current?.code ?? ''}</p></div> <div>
<span className="eyebrow">{current?.type.name ?? 'INVENTARIO'}</span>
<h2>{current?.name ?? 'Nivel de Inventario'}</h2>
<p>{current?.code ?? ''}</p>
</div>
<div className="asset-browser-current-actions"> <div className="asset-browser-current-actions">
{current && <Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha{['instalacion','subinstalacion'].includes(current.type.code.toLowerCase()) ? ' y Hallazgos' : ''}</Link>} {current && <Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha</Link>}
{canCreate && current?.type.code.toLowerCase() !== 'subinstalacion' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${parentId}`}><Icon name="plus" />Agregar aquí</Link>} {canCreate && current?.type.code.toLowerCase() !== 'subinstalacion' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${parentId}`}><Icon name="plus" />Agregar aquí</Link>}
</div> </div>
</div> </div>
<section className="asset-browser-group"> <section className="asset-browser-group">
<div className="asset-browser-group-heading"><div><h3>{nextLevelLabel(current?.type.code)}</h3><p>Jerarquía: Departamento Área Yacimiento Instalación Subinstalación.</p></div><span>{children.length}</span></div> <div className="asset-browser-group-heading">
<div><h3>{nextLevelLabel(current?.type.code)}</h3><p>La jerarquía permitida es Área Yacimiento Instalación Subinstalación.</p></div>
<span>{children.length}</span>
</div>
{children.length === 0 {children.length === 0
? <EmptyState title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'} text={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Abrí la ficha para ver su clasificación y Hallazgos asociados.' : 'Agregá el primer registro de este nivel o revisá la búsqueda actual.'} /> ? <EmptyState
title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'}
text={current?.type.code.toLowerCase() === 'yacimiento'
? 'Todavía no hay Instalaciones reales registradas en este Yacimiento.'
: current?.type.code.toLowerCase() === 'instalacion'
? 'Todavía no hay Subinstalaciones registradas en esta Instalación.'
: 'No hay registros que coincidan con la búsqueda actual.'}
/>
: <div className="asset-browser-list">{children.map((item) => <InventoryCard key={item.id} item={item} href={navigationHref(searchParams,item.id)} />)}</div>} : <div className="asset-browser-list">{children.map((item) => <InventoryCard key={item.id} item={item} href={navigationHref(searchParams,item.id)} />)}</div>}
</section> </section>
</div>; </div>;
@@ -1,62 +0,0 @@
import { useEffect, useState } from 'react';
import { SearchableSelect } from '../../components/SearchableSelect';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getInventoryTechnicalValues, replaceInventoryTechnicalValues } from '../../lib/inventoryStructureApi';
import type { InventoryFamilyAttribute, InventoryTechnicalValues } from '../../lib/inventoryStructureApi';
function localDateTime(value:unknown){
if(typeof value!=='string'||!value)return '';
const date=new Date(value); if(Number.isNaN(date.getTime()))return value.slice(0,16);
const local=new Date(date.getTime()-date.getTimezoneOffset()*60000); return local.toISOString().slice(0,16);
}
function displayValue(definition:InventoryFamilyAttribute,value:unknown){
if(value===undefined||value===null||value==='')return 'Sin completar';
if(definition.dataType==='BOOLEAN')return value?'Sí':'No';
if(definition.dataType==='DATETIME'&&typeof value==='string')return new Date(value).toLocaleString('es-AR');
return `${String(value)}${definition.unit?` ${definition.unit}`:''}`;
}
export function AssetTechnicalDataPanel({assetId,canEdit}:{assetId:string;canEdit:boolean}){
const [data,setData]=useState<InventoryTechnicalValues|null>(null);
const [values,setValues]=useState<Record<string,unknown>>({});
const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false);
const [unsupported,setUnsupported]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState('');
useEffect(()=>{
let active=true; setLoading(true); setUnsupported(false); setError('');
getInventoryTechnicalValues(assetId).then((loaded)=>{if(!active)return;setData(loaded);setValues(Object.fromEntries(loaded.definitions.map((definition)=>{
const raw=loaded.values[definition.id]; return [definition.id,definition.dataType==='DATETIME'?localDateTime(raw):raw??''];
})))}).catch((requestError)=>{if(!active)return;const message=errorMessage(requestError);if(message.includes('no tiene clasificación técnica'))setUnsupported(true);else setError(message);}).finally(()=>active&&setLoading(false));
return()=>{active=false};
},[assetId]);
if(loading)return <div className="form-section"><LoadingBlock label="Cargando datos técnicos…" /></div>;
if(unsupported)return null;
if(error&&!data)return <div className="form-section"><Alert>{error}</Alert></div>;
if(!data)return null;
const activeDefinitions=data.definitions.filter((item)=>item.isActive);
const save=async()=>{
setSaving(true);setError('');setSuccess('');
try{
const normalized:Record<string,unknown>={};
for(const definition of activeDefinitions){const raw=values[definition.id];if(raw===''||raw===null||raw===undefined)continue;
if(definition.dataType==='NUMBER')normalized[definition.id]=Number(raw);
else if(definition.dataType==='BOOLEAN')normalized[definition.id]=Boolean(raw);
else if(definition.dataType==='DATETIME')normalized[definition.id]=new Date(String(raw)).toISOString();
else normalized[definition.id]=raw;
}
const saved=await replaceInventoryTechnicalValues(assetId,normalized);setData(saved);setSuccess('Datos técnicos guardados.');
}catch(requestError){setError(errorMessage(requestError));}finally{setSaving(false)}
};
return <div className="form-section">
<div><h2>Información técnica · {data.family.name}</h2><p className="section-copy">Estos campos pertenecen a la clasificación concreta del elemento, no al nivel genérico.</p></div>
{error&&<Alert>{error}</Alert>}{success&&<Alert type="success">{success}</Alert>}
{activeDefinitions.length===0?<div className="inline-empty">Esta clasificación todavía no tiene campos técnicos configurados.</div>:canEdit?<div className="dynamic-attributes">{activeDefinitions.map((definition)=>{
const value=values[definition.id];const label=<span>{definition.name}{definition.unit?` (${definition.unit})`:''}{definition.isRequired?<em>obligatorio</em>:<em>opcional</em>}</span>;
if(definition.dataType==='BOOLEAN')return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.checked}))}/><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>;
if(definition.dataType==='SELECT')return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value??'')} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option)=><option key={option} value={option}>{option}</option>)}</SearchableSelect></label>;
const inputType=definition.dataType==='NUMBER'?'number':definition.dataType==='DATE'?'date':definition.dataType==='DATETIME'?'datetime-local':'text';
return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value??'')} step={definition.dataType==='NUMBER'?'any':undefined} required={definition.isRequired} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))}/></label>;
})}</div>:<div className="attribute-list">{activeDefinitions.map((definition)=><div className="attribute-card" key={definition.id}><span className="asset-symbol"><Icon name="layers" size={15}/></span><span><strong>{definition.name}</strong><small>{definition.code}</small></span><span>{displayValue(definition,data.values[definition.id])}</span></div>)}</div>}
{canEdit&&activeDefinitions.length>0&&<div className="form-actions"><button type="button" className="button primary" disabled={saving} onClick={()=>void save()}><Icon name="check" />{saving?'Guardando…':'Guardar datos técnicos'}</button></div>}
</div>;
}
@@ -1,6 +1,5 @@
import { SearchableSelect } from '../../components/SearchableSelect'; import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon'; import { Icon } from '../../components/Icon';
import { getFindingCatalogAdmin } from '../../lib/api'; import { getFindingCatalogAdmin } from '../../lib/api';
@@ -12,80 +11,122 @@ import {
import type { InventoryFamily } from '../../lib/inventoryStructureApi'; import type { InventoryFamily } from '../../lib/inventoryStructureApi';
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] }; const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
type ViewMode = 'ASSOCIATED' | 'ALL';
function familyContext(family: InventoryFamily) {
if (family.level === 'INSTALLATION') return `Instalación · ${family.name}`;
const parents = family.parentFamilies.map((parent) => parent.name).join(', ');
return `Subinstalación · ${family.name}${parents ? ` · compatible con ${parents}` : ''}`;
}
export function FindingCatalogTypeApplicabilityPanel() { export function FindingCatalogTypeApplicabilityPanel() {
const [searchParams] = useSearchParams();
const requestedFamilyId = searchParams.get('familyId') ?? '';
const [families, setFamilies] = useState<InventoryFamily[]>([]); const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG); const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [familyId, setFamilyId] = useState(''); const [familyId, setFamilyId] = useState('');
const [enabled, setEnabled] = useState<Set<string>>(new Set()); const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('Actualización de Hallazgos asociados a la clasificación de Inventario'); const [reason, setReason] = useState('Actualización de aplicabilidad por clasificación de Inventario');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('ASSOCIATED');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const selectedFamily = families.find((family) => family.id === familyId) ?? null; const selectedFamily = families.find((family) => family.id === familyId) ?? null;
const savedIds = useMemo(() => new Set(selectedFamily?.findingItemIds ?? []), [selectedFamily]);
useEffect(() => { useEffect(() => {
Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()]) Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
.then(([loadedFamilies, loadedCatalog]) => { .then(([loadedFamilies, loadedCatalog]) => {
const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false); const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
setFamilies(activeFamilies); setCatalog(loadedCatalog); setFamilies(activeFamilies);
setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId) ? requestedFamilyId : activeFamilies[0]?.id ?? ''); setCatalog(loadedCatalog);
setFamilyId(activeFamilies[0]?.id ?? '');
}) })
.catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); .catch((requestError) => setError(errorMessage(requestError)))
}, [requestedFamilyId]); .finally(() => setLoading(false));
}, []);
useEffect(() => { useEffect(() => {
const selected = families.find((family) => family.id === familyId); const selected = families.find((family) => family.id === familyId);
setEnabled(new Set(selected?.findingItemIds ?? [])); setViewMode('ASSOCIATED'); setSearch(''); setSuccess(''); setError(''); setEnabled(new Set(selected?.findingItemIds ?? []));
setSuccess('');
setError('');
}, [familyId, families]); }, [familyId, families]);
const activeCategoryIds = useMemo(() => new Set(catalog.categories.filter((category) => category.isActive).map((category) => category.id)), [catalog.categories]); const activeCategoryIds = useMemo(() => new Set(
const categoryName = useMemo(() => new Map(catalog.categories.map((category) => [category.id, category.name])), [catalog.categories]); catalog.categories.filter((category) => category.isActive).map((category) => category.id),
const available = useMemo(() => { ), [catalog.categories]);
const categoryName = useMemo(() => new Map(
catalog.categories.map((category) => [category.id, category.name]),
), [catalog.categories]);
const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase('es-AR'); const needle = search.trim().toLocaleLowerCase('es-AR');
return catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId) return catalog.items.filter((item) =>
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? ''].some((value) => value.toLocaleLowerCase('es-AR').includes(needle)))); item.isActive
&& activeCategoryIds.has(item.categoryId)
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? '']
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle))),
);
}, [catalog.items, search, activeCategoryIds, categoryName]); }, [catalog.items, search, activeCategoryIds, categoryName]);
const visible = useMemo(() => viewMode === 'ASSOCIATED' ? available.filter((item) => savedIds.has(item.id)) : available, [available, savedIds, viewMode]);
const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const toggle = (id: string) => setEnabled((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
const selectVisible = () => setEnabled((current) => { const next = new Set(current); visible.forEach((item) => next.add(item.id)); return next; });
const save = async () => { const save = async () => {
if (!selectedFamily) return; if (!selectedFamily) return;
setSaving(true); setError(''); setSuccess(''); setSaving(true); setError(''); setSuccess('');
try { try {
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, { itemIds: [...enabled], reason }); const saved = await replaceInventoryFamilyFindings(selectedFamily.id, {
const itemIds = saved.items.map((item) => item.id); setEnabled(new Set(itemIds)); itemIds: [...enabled],
setFamilies((current) => current.map((family) => family.id === selectedFamily.id ? { ...family, findingItemIds: itemIds, findingCount: itemIds.length } : family)); reason,
setViewMode('ASSOCIATED'); setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`); });
} catch (requestError) { setError(errorMessage(requestError)); } const itemIds = saved.items.map((item) => item.id);
finally { setSaving(false); } setEnabled(new Set(itemIds));
setFamilies((current) => current.map((family) => family.id === selectedFamily.id
? { ...family, findingItemIds: itemIds, findingCount: itemIds.length }
: family));
setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
}; };
if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>; if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>; if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>;
return <section className="panel finding-applicability-panel"> return <section className="panel finding-applicability-panel">
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR CLASIFICACIÓN</span><h2>Qué Hallazgos verá el inspector</h2><p className="section-copy">Al elegir una clasificación se muestran sólo sus Hallazgos asociados. Todos para vincular se usa exclusivamente para modificar la relación.</p></div><span className="count-pill">{savedIds.size} vinculados</span></div> <div className="panel-heading">
<div>
<span className="eyebrow">APLICABILIDAD POR INSTALACIÓN / SUBINSTALACIÓN</span>
<h2>Qué Hallazgos verá el inspector</h2>
<p className="section-copy">Los Hallazgos se vinculan a la clasificación concreta del elemento, no a una función. La opción OTROS permanece siempre disponible en la APK.</p>
</div>
<span className="count-pill">{enabled.size} vinculados</span>
</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>} {error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar"><label className="field"><span>Clasificación de Inventario</span><SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>{families.map((family) => <option value={family.id} key={family.id}>{familyContext(family)}</option>)}</SearchableSelect></label><label className="field"><span>Buscar dentro de {viewMode === 'ASSOCIATED' ? 'los asociados' : 'todo el Catálogo'}</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label></div> <div className="form-grid finding-applicability-toolbar">
{selectedFamily && <div className="temporal-notice"><Icon name="layers" /><p><strong>{selectedFamily.name}:</strong> {selectedFamily.level === 'SUBINSTALLATION' && selectedFamily.parentFamilies.length ? `compatible con ${selectedFamily.parentFamilies.map((parent) => parent.name).join(', ')}. ` : ''}Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.</p></div>} <label className="field">
<div className="quick-view-row" style={{ marginBottom: 12 }}><button type="button" className={viewMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => { setViewMode('ASSOCIATED'); setSearch(''); }}>Asociados ({savedIds.size})</button><button type="button" className={viewMode === 'ALL' ? 'active' : ''} onClick={() => { setViewMode('ALL'); setSearch(''); }}>Todos para vincular ({catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)).length})</button></div> <span>Clasificación de Inventario</span>
{viewMode === 'ASSOCIATED' && visible.length === 0 ? <div className="inline-empty"><strong>{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Esta clasificación todavía no tiene Hallazgos asociados.'}</strong><br />{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}</div> : <>{viewMode === 'ALL' && <div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={selectVisible}>Seleccionar visibles</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>}<div className="finding-selection-list">{visible.map((item) => viewMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></div>)}</div></>} <SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>
{viewMode === 'ALL' && <><label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : `Guardar ${enabled.size} asociados`}</button></div></>} {families.map((family) => <option value={family.id} key={family.id}>
{family.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'} · {family.parentFamilyName ? `${family.parentFamilyName}` : ''}{family.name}
</option>)}
</SearchableSelect>
</label>
<label className="field"><span>Buscar Hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
</div>
{selectedFamily && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>{selectedFamily.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'}:</strong> {selectedFamily.parentFamilyName ? `${selectedFamily.parentFamilyName}` : ''}{selectedFamily.name}. Actualmente tiene {selectedFamily.findingCount ?? enabled.size} Hallazgo{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'} asociado{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'}.</p>
</div>}
<div className="catalog-selection-actions">
<button type="button" className="button secondary" onClick={() => setEnabled(new Set(visible.map((item) => item.id)))}>Seleccionar visibles</button>
<button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button>
</div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}>
<input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} />
<span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span>
</label>)}</div>
<label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: Hallazgos aplicables a esta Subinstalación según criterio técnico…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar Hallazgos vinculados'}</button></div>
</section>; </section>;
} }
+1 -39
View File
@@ -6,28 +6,6 @@ import type {
PageMeta, PageMeta,
} from './api'; } from './api';
export interface InventoryBrowserDepartment {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
informationStatus: AssetInformationStatus;
operationalStatus: AssetOperationalStatus;
areaCount: number;
childrenCount: number;
}
export interface InventoryBrowserCompany {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
informationStatus: AssetInformationStatus;
areaCount: number;
}
export interface InventoryBrowserArea { export interface InventoryBrowserArea {
id: string; id: string;
code: string; code: string;
@@ -53,7 +31,6 @@ export interface InventoryBrowserItem {
isInventoryInstance: boolean; isInventoryInstance: boolean;
inventoryFamily: { id: string; code: string; name: string; level: string } | null; inventoryFamily: { id: string; code: string; name: string; level: string } | null;
childrenCount: number; childrenCount: number;
findingCount: number;
hasGeometry: boolean; hasGeometry: boolean;
updatedAt: string; updatedAt: string;
} }
@@ -108,27 +85,12 @@ function queryString(params: InventoryQuery): string {
return query.size ? `?${query}` : ''; return query.size ? `?${query}` : '';
} }
export function listInventory(params: InventoryQuery = {}) { export function listRealInventory(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryListItem[]; meta: PageMeta }>( return apiRequest<{ data: InventoryListItem[]; meta: PageMeta }>(
`/inventory-browser/items${queryString(params)}`, `/inventory-browser/items${queryString(params)}`,
); );
} }
// Compatibility alias while old call sites are removed.
export const listRealInventory = listInventory;
export function listInventoryDepartments(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserDepartment[]; meta: { count: number } }>(
`/inventory-browser/departments${queryString(params)}`,
);
}
export function listInventoryCompanies(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserCompany[]; meta: { count: number } }>(
`/inventory-browser/companies${queryString(params)}`,
);
}
export function listInventoryAreas(params: InventoryQuery = {}) { export function listInventoryAreas(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserArea[]; meta: { count: number } }>( return apiRequest<{ data: InventoryBrowserArea[]; meta: { count: number } }>(
`/inventory-browser/areas${queryString(params)}`, `/inventory-browser/areas${queryString(params)}`,
+156 -49
View File
@@ -1,56 +1,163 @@
import { apiRequest } from './api'; import { apiRequest } from './api';
export type InventoryStructureKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION'; export type InventoryStructureKind = 'EMPRESA' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
export type InventoryFamilyAttributeDataType = 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'DATETIME' | 'SELECT';
export interface InventoryFamilyParent { id:string; code:string; name:string; }
export interface InventoryFamily { export interface InventoryFamily {
id:string; code:string; name:string; level:'INSTALLATION'|'SUBINSTALLATION'; legacyTypeCode?:string|null; id: string;
informationLabels:string[]; sourceReference?:string|null; isActive?:boolean; parentFamilyIds:string[]; code: string;
parentFamilies:InventoryFamilyParent[]; assetCount?:number; findingCount?:number; findingItemIds?:string[]; technicalAttributeCount?:number; name: string;
} level: 'INSTALLATION' | 'SUBINSTALLATION';
export interface InventoryFamilyAttribute { legacyTypeCode?: string | null;
id:string; inventoryFamilyId:string; code:string; name:string; dataType:InventoryFamilyAttributeDataType; informationLabels: string[];
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number; sourceReference?: string | null;
} isActive?: boolean;
export interface InventoryFamilyAttributes { family:InventoryFamily; items:InventoryFamilyAttribute[]; count:number; } parentFamilyId: string | null;
export interface InventoryTechnicalValues { parentFamilyCode: string | null;
assetId:string; parentFamilyName: string | null;
family:{id:string;code:string;name:string;level:string}; assetCount?: number;
definitions:InventoryFamilyAttribute[]; findingCount?: number;
values:Record<string,unknown>; findingItemIds?: string[];
}
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
export interface InventoryStructureOptions { independentMasters:InventoryStructureLevel[]; levels:InventoryStructureLevel[]; installationFamilies:InventoryFamily[]; subinstallationFamilies:InventoryFamily[]; }
export interface InventoryStructureParent {
id:string; code:string; name:string; commonName:string|null; type:{id:string;code:string;name:string};
inventoryFamily:null|{id:string;code:string;name:string;level:'INSTALLATION'|'SUBINSTALLATION';informationLabels:string[]};
parent:null|{id:string;code:string;name:string};
}
export interface InventoryFamilyFinding {
id:string; code:string; sourceNumber:number; title:string; legalBasis:string|null; glossary:string|null;
suggestedSeverity:number|null; categoryId:string; categoryCode:string; categoryName:string;
}
export interface InventoryFamilyFindings { family:InventoryFamily; items:InventoryFamilyFinding[]; count:number; }
export interface CreatedInventoryStructure {
id:string; code:string; name:string; commonName:string|null; description:string|null; informationStatus:string; operationalStatus:string;
type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
} }
export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');} export interface InventoryStructureLevel {
export async function listInventoryStructureParents(kind:InventoryStructureKind,search=''){ kind: InventoryStructureKind;
const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:''; label: string;
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data; type: { id: string; code: string; name: string };
parentKind: InventoryStructureKind | null;
requiresFamily: boolean;
}
export interface InventoryStructureOptions {
independentMasters: InventoryStructureLevel[];
levels: InventoryStructureLevel[];
installationFamilies: InventoryFamily[];
subinstallationFamilies: InventoryFamily[];
}
export interface InventoryStructureParent {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
inventoryFamily: null | {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
};
parent: null | { id: string; code: string; name: string };
}
export interface InventoryFamilyFinding {
id: string;
code: string;
sourceNumber: number;
title: string;
legalBasis: string | null;
glossary: string | null;
suggestedSeverity: number | null;
categoryId: string;
categoryCode: string;
categoryName: string;
}
export interface InventoryFamilyFindings {
family: {
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
};
items: InventoryFamilyFinding[];
count: number;
}
export interface CreatedInventoryStructure {
id: string;
code: string;
name: string;
commonName: string | null;
description: string | null;
informationStatus: string;
operationalStatus: string;
type: { id: string; code: string; name: string };
parent: null | { id: string; code: string; name: string };
operationalArea?: null | { id: string; code: string; name: string };
inventoryFamily: null | {
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
};
}
export function getInventoryStructureOptions() {
return apiRequest<InventoryStructureOptions>('/inventory-structure');
}
export async function listInventoryStructureParents(kind: InventoryStructureKind, search = '') {
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 function getInventoryFamilyFindings(familyId: string) {
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);
}
export async function listInventoryFamiliesAdmin() {
return (await apiRequest<{ data: InventoryFamily[] }>('/inventory-families/admin')).data;
}
export function createInventoryFamily(input: {
level: 'INSTALLATION' | 'SUBINSTALLATION';
name: string;
parentFamilyId?: string | null;
informationLabels?: string[];
}) {
return apiRequest<InventoryFamily>('/inventory-families', {
method: 'POST', body: JSON.stringify(input),
});
}
export function updateInventoryFamily(familyId: string, input: {
name?: string;
parentFamilyId?: string | null;
informationLabels?: string[];
isActive?: boolean;
}) {
return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`, {
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;
description?: string | null;
}) {
return apiRequest<CreatedInventoryStructure>('/inventory-structure', {
method: 'POST',
body: JSON.stringify(input),
});
} }
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`);}
export function replaceInventoryTechnicalValues(assetId:string,values:Record<string,unknown>){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`,{method:'PUT',body:JSON.stringify({values})});}
export async function listInventoryFamiliesAdmin(){return (await apiRequest<{data:InventoryFamily[]}>('/inventory-families/admin')).data;}
export function createInventoryFamily(input:{level:'INSTALLATION'|'SUBINSTALLATION';name:string;parentFamilyIds?:string[];informationLabels?:string[]}){return apiRequest<InventoryFamily>('/inventory-families',{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamily(familyId:string,input:{name?:string;parentFamilyIds?:string[];informationLabels?:string[];isActive?:boolean}){return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`,{method:'PATCH',body:JSON.stringify(input)});}
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;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
+102 -158
View File
@@ -7,7 +7,6 @@ import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { import {
createAssetAttribute, createAssetAttribute,
getFindingCatalogAdmin,
listAssetTypes, listAssetTypes,
updateAssetAttribute, updateAssetAttribute,
} from '../lib/api'; } from '../lib/api';
@@ -15,22 +14,13 @@ import type {
AssetAttributeDataType, AssetAttributeDataType,
AssetAttributeDefinition, AssetAttributeDefinition,
AssetType, AssetType,
FindingAdminCatalog,
} from '../lib/api'; } from '../lib/api';
import { import {
createInventoryFamily, createInventoryFamily,
createInventoryFamilyAttribute,
getInventoryFamilyAttributes,
listInventoryFamiliesAdmin, listInventoryFamiliesAdmin,
replaceInventoryFamilyFindings,
updateInventoryFamily, updateInventoryFamily,
updateInventoryFamilyAttribute,
} from '../lib/inventoryStructureApi';
import type {
InventoryFamily,
InventoryFamilyAttribute,
InventoryFamilyAttributeDataType,
} from '../lib/inventoryStructureApi'; } from '../lib/inventoryStructureApi';
import type { InventoryFamily } from '../lib/inventoryStructureApi';
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
{ value: 'TEXT', label: 'Texto' }, { value: 'TEXT', label: 'Texto' },
@@ -40,20 +30,16 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
{ value: 'DATETIME', label: 'Fecha y hora' }, { value: 'DATETIME', label: 'Fecha y hora' },
{ value: 'SELECT', label: 'Lista de opciones' }, { value: 'SELECT', label: 'Lista de opciones' },
]; ];
const EMPTY_FINDING_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION'; type CanonicalKind = 'EMPRESA' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type FamilyEditor = InventoryFamily | 'new' | null; type FamilyEditor = InventoryFamily | 'new' | null;
type FamilyFindingMode = 'ASSOCIATED' | 'ALL';
type TechnicalEditor = InventoryFamilyAttribute | 'new' | null;
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [ const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' }, { kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
{ kind: 'DEPARTAMENTO', label: 'Departamento', description: 'Raíz territorial de la estructura física.' }, { kind: 'AREA', label: 'Área', description: 'Raíz territorial de la estructura física.' },
{ kind: 'AREA', label: 'Área', description: 'Pertenece obligatoriamente a un Departamento.' },
{ kind: 'YACIMIENTO', label: 'Yacimiento', description: 'Pertenece a un Área; su nombre puede repetirse en otra Área.' }, { kind: 'YACIMIENTO', label: 'Yacimiento', description: 'Pertenece a un Área; su nombre puede repetirse en otra Área.' },
{ kind: 'INSTALACION', label: 'Instalación', description: 'Instancia dentro de un Yacimiento y con clasificación técnica.' }, { kind: 'INSTALACION', label: 'Instalación', description: 'Instancia física dentro de un Yacimiento y con clasificación técnica.' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia dentro de una Instalación y con clasificación técnica.' }, { kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia física dentro de una Instalación y con clasificación técnica.' },
]; ];
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null { function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
@@ -62,9 +48,11 @@ function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | nul
const code = kind.toLowerCase(); const code = kind.toLowerCase();
return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null; return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null;
} }
function attributeTypeLabel(value: AssetAttributeDataType | InventoryFamilyAttributeDataType) {
function attributeTypeLabel(value: AssetAttributeDataType) {
return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value; return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value;
} }
function attributeCodeFromName(value: string) { function attributeCodeFromName(value: string) {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim() return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80); .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
@@ -73,12 +61,9 @@ function attributeCodeFromName(value: string) {
export function AssetTypesPage() { export function AssetTypesPage() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canManage = hasPermission('asset_types.manage'); const canManage = hasPermission('asset_types.manage');
const canManageFindings = hasPermission('finding_catalog.manage');
const [types, setTypes] = useState<AssetType[]>([]); const [types, setTypes] = useState<AssetType[]>([]);
const [families, setFamilies] = useState<InventoryFamily[]>([]); const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [findingCatalog, setFindingCatalog] = useState<FindingAdminCatalog>(EMPTY_FINDING_CATALOG);
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('INSTALACION'); const [selectedKind, setSelectedKind] = useState<CanonicalKind>('INSTALACION');
const [selectedInstallationFamilyId, setSelectedInstallationFamilyId] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -87,22 +72,8 @@ export function AssetTypesPage() {
const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null); const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null);
const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION'); const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION');
const [familyName, setFamilyName] = useState(''); const [familyName, setFamilyName] = useState('');
const [familyParentIds, setFamilyParentIds] = useState<Set<string>>(new Set()); const [familyParentId, setFamilyParentId] = useState('');
const [familyActive, setFamilyActive] = useState(true); const [familyActive, setFamilyActive] = useState(true);
const [familyFindingIds, setFamilyFindingIds] = useState<Set<string>>(new Set());
const [familyFindingMode, setFamilyFindingMode] = useState<FamilyFindingMode>('ASSOCIATED');
const [familyFindingSearch, setFamilyFindingSearch] = useState('');
const [technicalAttributes, setTechnicalAttributes] = useState<InventoryFamilyAttribute[]>([]);
const [technicalLoading, setTechnicalLoading] = useState(false);
const [technicalEditor, setTechnicalEditor] = useState<TechnicalEditor>(null);
const [technicalCode, setTechnicalCode] = useState('');
const [technicalName, setTechnicalName] = useState('');
const [technicalType, setTechnicalType] = useState<InventoryFamilyAttributeDataType>('TEXT');
const [technicalRequired, setTechnicalRequired] = useState(false);
const [technicalActive, setTechnicalActive] = useState(true);
const [technicalUnit, setTechnicalUnit] = useState('');
const [technicalOptions, setTechnicalOptions] = useState('');
const [technicalOrder, setTechnicalOrder] = useState(0);
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null); const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
const [attributeCode, setAttributeCode] = useState(''); const [attributeCode, setAttributeCode] = useState('');
@@ -115,11 +86,14 @@ export function AssetTypesPage() {
const [attributeOrder, setAttributeOrder] = useState(0); const [attributeOrder, setAttributeOrder] = useState(0);
const load = async () => { const load = async () => {
const [loadedTypes, loadedFamilies, loadedFindingCatalog] = await Promise.all([ const [loadedTypes, loadedFamilies] = await Promise.all([
listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin(), listAssetTypes(),
listInventoryFamiliesAdmin(),
]); ]);
setTypes(loadedTypes); setFamilies(loadedFamilies); setFindingCatalog(loadedFindingCatalog); setTypes(loadedTypes);
setFamilies(loadedFamilies);
}; };
useEffect(() => { useEffect(() => {
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []); }, []);
@@ -127,102 +101,37 @@ export function AssetTypesPage() {
const selectedType = canonicalType(types, selectedKind); const selectedType = canonicalType(types, selectedKind);
const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]); const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]);
const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]); const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]);
const visibleSubinstallationFamilies = useMemo(() => subinstallationFamilies.filter((family) =>
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyIds.includes(selectedInstallationFamilyId)),
), [subinstallationFamilies, selectedInstallationFamilyId]);
const activeFindingCategoryIds = useMemo(() => new Set(
findingCatalog.categories.filter((category) => category.isActive).map((category) => category.id),
), [findingCatalog.categories]);
const findingCategoryNames = useMemo(() => new Map(
findingCatalog.categories.map((category) => [category.id, category.name]),
), [findingCatalog.categories]);
const visibleFamilyFindings = useMemo(() => {
const needle = familyFindingSearch.trim().toLocaleLowerCase('es-AR');
return findingCatalog.items.filter((item) => {
if (!item.isActive || !activeFindingCategoryIds.has(item.categoryId)) return false;
if (familyFindingMode === 'ASSOCIATED' && !familyFindingIds.has(item.id)) return false;
if (!needle) return true;
return [item.title, item.code, findingCategoryNames.get(item.categoryId) ?? '']
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle));
});
}, [findingCatalog.items, activeFindingCategoryIds, familyFindingMode, familyFindingIds, familyFindingSearch, findingCategoryNames]);
const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => { const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => {
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName(''); setFamilyEditor('new'); setFamilyLevel(level); setFamilyName(''); setFamilyParentId(''); setFamilyActive(true);
setFamilyParentIds(new Set(level === 'SUBINSTALLATION' && selectedInstallationFamilyId ? [selectedInstallationFamilyId] : [])); setError(''); setSuccess('');
setFamilyActive(true); setFamilyFindingIds(new Set()); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
setTechnicalAttributes([]); setTechnicalEditor(null); setError(''); setSuccess('');
}; };
const openFamily = async (family: InventoryFamily) => { const openFamily = (family: InventoryFamily) => {
if (family.level === 'INSTALLATION') setSelectedInstallationFamilyId(family.id);
setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name); setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name);
setFamilyParentIds(new Set(family.parentFamilyIds)); setFamilyActive(family.isActive !== false); setFamilyParentId(family.parentFamilyId ?? ''); setFamilyActive(family.isActive !== false);
setFamilyFindingIds(new Set(family.findingItemIds ?? [])); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch(''); setError(''); setSuccess('');
setTechnicalEditor(null); setTechnicalLoading(true); setError(''); setSuccess('');
try { setTechnicalAttributes((await getInventoryFamilyAttributes(family.id)).items); }
catch (requestError) { setError(errorMessage(requestError)); setTechnicalAttributes([]); }
finally { setTechnicalLoading(false); }
}; };
const toggleParentCompatibility = (id: string) => setFamilyParentIds((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const toggleFamilyFinding = (id: string) => setFamilyFindingIds((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const saveFamily = async (event: FormEvent) => { const saveFamily = async (event: FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); setSuccess(''); event.preventDefault(); setSaving(true); setError(''); setSuccess('');
try { try {
if (familyEditor === 'new') { if (familyEditor === 'new') {
const created = await createInventoryFamily({ await createInventoryFamily({
level: familyLevel, name: familyName.trim(), level: familyLevel,
parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [], name: familyName.trim(),
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
}); });
setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`); setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
await load(); await openFamily(created); return; } else if (familyEditor) {
}
if (familyEditor) {
await updateInventoryFamily(familyEditor.id, { await updateInventoryFamily(familyEditor.id, {
name: familyName.trim(), parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [], name: familyName.trim(),
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
isActive: familyActive, isActive: familyActive,
}); });
if (canManageFindings) await replaceInventoryFamilyFindings(familyEditor.id, { setSuccess('Clasificación actualizada.');
itemIds: [...familyFindingIds], reason: 'Actualización desde Configuración de Inventarios F6',
});
setSuccess('Clasificación actualizada.'); await load(); setFamilyEditor(null);
} }
} catch (requestError) { setError(errorMessage(requestError)); } await load();
finally { setSaving(false); } setFamilyEditor(null);
};
const openTechnicalAttribute = (attribute: InventoryFamilyAttribute | 'new') => {
setTechnicalEditor(attribute); setError(''); setSuccess('');
if (attribute === 'new') {
setTechnicalCode(''); setTechnicalName(''); setTechnicalType('TEXT'); setTechnicalRequired(false);
setTechnicalActive(true); setTechnicalUnit(''); setTechnicalOptions(''); setTechnicalOrder(technicalAttributes.length);
} else {
setTechnicalCode(attribute.code); setTechnicalName(attribute.name); setTechnicalType(attribute.dataType);
setTechnicalRequired(attribute.isRequired); setTechnicalActive(attribute.isActive); setTechnicalUnit(attribute.unit ?? '');
setTechnicalOptions(attribute.options?.join('\n') ?? ''); setTechnicalOrder(attribute.sortOrder);
}
};
const saveTechnicalAttribute = async (event: FormEvent) => {
event.preventDefault();
if (!familyEditor || familyEditor === 'new' || !technicalEditor) return;
setSaving(true); setError(''); setSuccess('');
const options = technicalOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
try {
if (technicalEditor === 'new') await createInventoryFamilyAttribute(familyEditor.id, {
code: technicalCode, name: technicalName.trim(), dataType: technicalType, isRequired: technicalRequired,
unit: technicalUnit || null, ...(technicalType === 'SELECT' ? { options } : {}), sortOrder: technicalOrder,
});
else await updateInventoryFamilyAttribute(familyEditor.id,technicalEditor.id,{
name:technicalName.trim(),dataType:technicalType,isRequired:technicalRequired,isActive:technicalActive,
unit:technicalUnit || null,options:technicalType==='SELECT' ? options : null,sortOrder:technicalOrder,
});
setTechnicalAttributes((await getInventoryFamilyAttributes(familyEditor.id)).items);
setTechnicalEditor(null); setSuccess('Campo técnico actualizado.'); await load();
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
@@ -231,63 +140,98 @@ export function AssetTypesPage() {
setAttributeEditor(attribute); setError(''); setSuccess(''); setAttributeEditor(attribute); setError(''); setSuccess('');
if (attribute === 'new') { if (attribute === 'new') {
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false); setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false);
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions(''); setAttributeOrder(selectedType?.attributes.length ?? 0); setAttributeActive(true); setAttributeUnit(''); setAttributeOptions('');
setAttributeOrder(selectedType?.attributes.length ?? 0);
} else { } else {
setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType); setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType);
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? ''); setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive);
setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder); setAttributeUnit(attribute.unit ?? ''); setAttributeOptions(attribute.options?.join('\n') ?? '');
setAttributeOrder(attribute.sortOrder);
} }
}; };
const saveAttribute = async (event: FormEvent) => { const saveAttribute = async (event: FormEvent) => {
event.preventDefault(); if (!selectedType || !attributeEditor) return; event.preventDefault();
if (!selectedType || !attributeEditor) return;
setSaving(true); setError(''); setSuccess(''); setSaving(true); setError(''); setSuccess('');
const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean); const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
try { try {
if (attributeEditor === 'new') await createAssetAttribute(selectedType.id, { if (attributeEditor === 'new') {
code: attributeCode,name: attributeName,dataType: attributeType,isRequired: attributeRequired, await createAssetAttribute(selectedType.id, {
unit: attributeUnit || null,...(attributeType === 'SELECT' ? { options } : {}),sortOrder: attributeOrder, code: attributeCode,
}); name: attributeName,
else await updateAssetAttribute(selectedType.id, attributeEditor.id, { dataType: attributeType,
name: attributeName,dataType: attributeType,isRequired: attributeRequired,isActive: attributeActive, isRequired: attributeRequired,
unit: attributeUnit || null,options: attributeType === 'SELECT' ? options : null,sortOrder: attributeOrder, unit: attributeUnit || null,
}); ...(attributeType === 'SELECT' ? { options } : {}),
await load(); setAttributeEditor(null); setSuccess('Campo general actualizado.'); sortOrder: attributeOrder,
});
setSuccess(`Nueva columna agregada a ${LEVELS.find((item) => item.kind === selectedKind)?.label}.`);
} else {
await updateAssetAttribute(selectedType.id, attributeEditor.id, {
name: attributeName,
dataType: attributeType,
isRequired: attributeRequired,
isActive: attributeActive,
unit: attributeUnit || null,
options: attributeType === 'SELECT' ? options : null,
sortOrder: attributeOrder,
});
setSuccess('Columna actualizada.');
}
await load();
setAttributeEditor(null);
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />; if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page"> return <section className="inventory-config-page">
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Administrá clasificaciones, compatibilidades, Hallazgos y campos técnicos desde un único lugar.</p></div>{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}</div> <div className="page-heading">
<div>
<span className="eyebrow">ADMINISTRACIÓN</span>
<h1>Configuración de Inventarios</h1>
<p>Administrá la estructura, los tipos de Instalación/Subinstalación y las columnas que se completan en oficina o desde la APK.</p>
</div>
{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}
</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>} {error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="panel" style={{ marginBottom: 18 }}><div className="panel-heading"><div><span className="eyebrow">MODELO F6</span><h2>Estructura física fija</h2><p className="section-copy">La clasificación describe qué es cada Instalación/Subinstalación, pero nunca altera su nivel jerárquico.</p></div></div><div className="asset-browser-levels" aria-label="Estructura de Inventarios"><div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i><div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i><div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i><div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i><div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div></div><div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente; su relación con Área no reescribe el árbol físico.</p></div></div> <div className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading"><div><span className="eyebrow">MODELO VIGENTE</span><h2>Estructura física</h2><p className="section-copy">Empresa no es padre del Área. La Operadora/Concesionaria se vincula al Área con vigencia temporal.</p></div></div>
<div className="dashboard-grid" style={{ alignItems: 'start' }}> <div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá uno para editar Hallazgos, campos técnicos y ver sus Subinstalaciones compatibles.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos · {family.technicalAttributeCount ?? 0} campos técnicos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div></article> <div><span>1</span><strong>Área</strong><small>raíz territorial</small></div><i></i>
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">COMPATIBILIDAD</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Una misma clasificación puede ser válida para varias Instalaciones.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'No hay tipos compatibles con esta Instalación.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin compatibilidades'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}</article> <div><span>2</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>3</span><strong>Instalación</strong><small>inventario real</small></div><i></i>
<div><span>4</span><strong>Subinstalación</strong><small>inventario real</small></div>
</div>
<div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente. Puede cambiar la Operadora de un Área sin mover ni reescribir Yacimientos, Instalaciones o Subinstalaciones.</p></div>
</div> </div>
<article className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">CAMPOS GENERALES DEL NIVEL</span><h2>Información estructural</h2><p className="section-copy">Usá estos campos sólo cuando correspondan a todo el nivel. Marca, potencia, capacidad u otros datos técnicos deben configurarse dentro de cada clasificación.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nuevo campo general</button>}</div><div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div><p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay campos generales para este nivel.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}</article> <div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Precargados desde final_modelov2.xlsx y ampliables por Hidrocarburos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
<div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.sourceReference?.startsWith('F5:final_modelov2.xlsx') ? 'Precargado desde fuente autorizada' : 'Agregado por Hidrocarburos'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
</article>
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN TÉCNICA</span><h2>{familyEditor === 'new' ? 'Nueva clasificación' : familyName}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields"> <article className="panel">
{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentIds(new Set()); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>} <div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Cada tipo queda asociado a un tipo de Instalación.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required /></label> <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{subinstallationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilyName ?? 'Sin Instalación padre'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
{familyLevel === 'SUBINSTALLATION' && <div className="field"><span>Tipos de Instalación compatibles <em>{familyParentIds.size} seleccionados</em></span><div className="finding-selection-list" style={{ maxHeight: 220, overflow: 'auto' }}>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label className="finding-selection-row" key={family.id}><input type="checkbox" checked={familyParentIds.has(family.id)} onChange={() => toggleParentCompatibility(family.id)} /><span><strong>{family.name}</strong><small>{family.code}</small></span></label>)}</div></div>} </article>
{familyEditor !== 'new' && <> </div>
<div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> La APK sólo verá estos Hallazgos para esta clasificación, además de OTROS.</p></div>
<div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button>{canManageFindings && <button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button>}</div>
<label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados.' : 'No hay coincidencias.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 280, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}
<div className="panel-heading" style={{ marginTop: 10 }}><div><span className="eyebrow">DATOS DEL RUBRO</span><h3>Campos técnicos</h3><p className="section-copy">Sólo aparecen cuando un elemento usa esta clasificación.</p></div>{canManage && !technicalEditor && <button type="button" className="button secondary" onClick={() => openTechnicalAttribute('new')}><Icon name="plus" />Nuevo campo</button>}</div> <article className="panel" style={{ marginTop: 18 }}>
{technicalLoading ? <LoadingBlock label="Cargando campos técnicos…" /> : technicalAttributes.length === 0 && !technicalEditor ? <div className="inline-empty">No hay campos técnicos definidos.</div> : !technicalEditor && <div className="attribute-list">{technicalAttributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openTechnicalAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span>{attribute.isRequired && <span className="tag">Obligatorio</span>}<Icon name="chevron" /></button>)}</div>} <div className="panel-heading"><div><span className="eyebrow">COLUMNAS / CAMPOS</span><h2>Información configurable</h2><p className="section-copy">Agregá las columnas que deben completarse para cada nivel. No modifica la jerarquía.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nueva columna</button>}</div>
{technicalEditor && <div className="panel" style={{ padding: 14 }}><div className="panel-heading"><div><strong>{technicalEditor === 'new' ? 'Nuevo campo técnico' : 'Editar campo técnico'}</strong></div><button type="button" className="icon-button" onClick={() => setTechnicalEditor(null)}>×</button></div><label className="field"><span>Nombre</span><input value={technicalName} onChange={(event) => { setTechnicalName(event.target.value); if (technicalEditor === 'new') setTechnicalCode(attributeCodeFromName(event.target.value)); }} required /></label><label className="field"><span>Código interno</span><input value={technicalCode} onChange={(event) => setTechnicalCode(event.target.value.toLowerCase())} disabled={technicalEditor !== 'new'} pattern="[a-z][a-z0-9_]*" required /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={technicalType} onChange={(event) => setTechnicalType(event.target.value as InventoryFamilyAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>{technicalType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={4} value={technicalOptions} onChange={(event) => setTechnicalOptions(event.target.value)} placeholder="Una por línea" required /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={technicalUnit} onChange={(event) => setTechnicalUnit(event.target.value)} /></label><label className="field"><span>Orden</span><input type="number" min={0} max={10000} value={technicalOrder} onChange={(event) => setTechnicalOrder(Number(event.target.value))} /></label><label className="check-row"><input type="checkbox" checked={technicalRequired} onChange={(event) => setTechnicalRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Debe completarse para esta clasificación.</small></span></label>{technicalEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={technicalActive} onChange={(event) => setTechnicalActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva datos existentes.</small></span></label>}<div className="form-actions"><button type="button" className="button secondary" onClick={() => setTechnicalEditor(null)}>Cancelar</button><button type="button" className="button primary" disabled={saving || !technicalName.trim() || !technicalCode || (technicalType === 'SELECT' && !technicalOptions.trim())} onClick={(event) => void saveTechnicalAttribute(event as unknown as FormEvent)}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></div>} <div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div>
<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir Catálogo completo <Icon name="chevron" /></Link> <p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>
<label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Clasificación disponible</strong><small>Al desactivarla deja de ofrecerse en nuevas altas.</small></span></label> {!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay columnas adicionales configuradas para {LEVELS.find((item) => item.kind === selectedKind)?.label}.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
</>} </article>
</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && familyParentIds.size === 0)}>{saving ? 'Guardando…' : familyEditor === 'new' ? 'Crear clasificación' : 'Guardar clasificación'}</button></div></form></div>}
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">CAMPO GENERAL DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nuevo campo' : 'Editar campo'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></form></div>} <div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo separado, relación clara</h2><p className="section-copy">Los Hallazgos se crean y editan en su catálogo, y allí se vinculan al tipo de Instalación/Subinstalación correspondiente. OTROS permanece siempre disponible.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div>
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN DE INVENTARIO</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentId(''); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required placeholder={familyLevel === 'INSTALLATION' ? 'Ej.: Estación, Planta…' : 'Ej.: Tanque, Bomba…'} /></label>{familyLevel === 'SUBINSTALLATION' && <label className="field"><span>Tipo de Instalación padre</span><SearchableSelect value={familyParentId} onChange={(event) => setFamilyParentId(event.target.value)} required><option value="">Seleccionar</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option value={family.id} key={family.id}>{family.name}</option>)}</SearchableSelect></label>}{familyEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarlo deja de ofrecerse en nuevas altas, sin borrar registros históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && !familyParentId)}>{saving ? 'Guardando…' : 'Guardar tipo'}</button></div></form></div>}
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">COLUMNA DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nueva columna' : 'Editar columna'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Columna activa</strong><small>Desactivarla conserva datos históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar columna'}</button></div></form></div>}
</section>; </section>;
} }
+38 -23
View File
@@ -22,7 +22,7 @@ import type {
AssetType, AssetType,
PageMeta, PageMeta,
} from '../lib/api'; } from '../lib/api';
import { listInventory } from '../lib/inventoryBrowserApi'; import { listRealInventory } from '../lib/inventoryBrowserApi';
import type { InventoryListItem, InventoryQuery } from '../lib/inventoryBrowserApi'; import type { InventoryListItem, InventoryQuery } from '../lib/inventoryBrowserApi';
import { formatDate } from '../lib/format'; import { formatDate } from '../lib/format';
@@ -60,19 +60,24 @@ export function AssetsPage() {
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus; const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
useEffect(() => { useEffect(() => {
listAssetTypes().then((data) => setTypes(data.filter((type) => listAssetTypes().then((data) => setTypes(data.filter((type) => ['instalacion','subinstalacion'].includes(type.code.toLowerCase())))).catch(() => undefined);
type.operationalRole === 'COMPANY'
|| ['departamento','area','yacimiento','instalacion','subinstalacion'].includes(type.code.toLowerCase()),
))).catch(() => undefined);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (view !== 'list') return; if (view !== 'list') return;
setLoading(true); setError(''); setLoading(true);
listInventory({ setError('');
page, pageSize: 25, search, typeId, status, listRealInventory({
page,
pageSize: 25,
search,
typeId,
status,
operationalStatus: effectiveOperationalStatus, operationalStatus: effectiveOperationalStatus,
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry, operationalAreaId,
operatorCompanyId,
needsValidation,
hasGeometry,
}) })
.then((response) => { setAssets(response.data); setMeta(response.meta); }) .then((response) => { setAssets(response.data); setMeta(response.meta); })
.catch((requestError) => setError(errorMessage(requestError))) .catch((requestError) => setError(errorMessage(requestError)))
@@ -80,26 +85,36 @@ export function AssetsPage() {
}, [view, page, search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]); }, [view, page, search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
const filters: InventoryQuery = useMemo(() => ({ const filters: InventoryQuery = useMemo(() => ({
search, typeId, status, operationalStatus: effectiveOperationalStatus, search,
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry, typeId,
status,
operationalStatus: effectiveOperationalStatus,
operationalAreaId,
operatorCompanyId,
needsValidation,
hasGeometry,
}), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]); }), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
const update = (changes: Record<string, string | null>) => { const update = (changes: Record<string, string | null>) => {
const next = new URLSearchParams(urlParams); const next = new URLSearchParams(urlParams);
Object.entries(changes).forEach(([key, value]) => value ? next.set(key, value) : next.delete(key)); Object.entries(changes).forEach(([key, value]) => value ? next.set(key, value) : next.delete(key));
next.delete('page'); setUrlParams(next); next.delete('page');
setUrlParams(next);
}; };
const applySearch = (event: FormEvent) => { event.preventDefault(); update({ search: draftSearch.trim() || null }); }; const applySearch = (event: FormEvent) => { event.preventDefault(); update({ search: draftSearch.trim() || null }); };
const setQuick = (key: string) => update({ quick: key === 'all' ? null : key, operationalStatus: key === 'out' ? null : rawOperationalStatus || null }); const setQuick = (key: string) => update({ quick: key === 'all' ? null : key, operationalStatus: key === 'out' ? null : rawOperationalStatus || null });
const clearFilters = () => { const clearFilters = () => {
const next = new URLSearchParams(); const next = new URLSearchParams();
if (view === 'list') next.set('view', 'list'); if (view === 'list') next.set('view', 'list');
const parentId=urlParams.get('parentId'); if (parentId) next.set('parentId',parentId); const parentId=urlParams.get('parentId');
setDraftSearch(''); setUrlParams(next); if (parentId) next.set('parentId',parentId);
setDraftSearch('');
setUrlParams(next);
}; };
const setPage = (value: number) => { const setPage = (value: number) => {
const next = new URLSearchParams(urlParams); const next = new URLSearchParams(urlParams);
value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next); value > 1 ? next.set('page', String(value)) : next.delete('page');
setUrlParams(next);
}; };
const advancedActive = Boolean(typeId || status || rawOperationalStatus); const advancedActive = Boolean(typeId || status || rawOperationalStatus);
@@ -108,7 +123,7 @@ export function AssetsPage() {
<div> <div>
<span className="eyebrow">INVENTARIOS</span> <span className="eyebrow">INVENTARIOS</span>
<h1>Inventarios</h1> <h1>Inventarios</h1>
<p>Todos los registros: Departamento Área Yacimiento Instalación Subinstalación, más el maestro independiente de Empresas.</p> <p>Instancias reales registradas en campo, organizadas por Área Yacimiento Instalación Subinstalación.</p>
</div> </div>
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate> <PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div> </div>
@@ -118,7 +133,7 @@ export function AssetsPage() {
<div className="asset-center-controls"> <div className="asset-center-controls">
<form className="asset-center-search" onSubmit={applySearch}> <form className="asset-center-search" onSubmit={applySearch}>
<Icon name="search" /> <Icon name="search" />
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por nombre, código o identificación…" /> <input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar Inventario, código o identificación…" />
<button className="button primary" type="submit">Buscar</button> <button className="button primary" type="submit">Buscar</button>
</form> </form>
<div className="quick-view-row" aria-label="Vistas rápidas"> <div className="quick-view-row" aria-label="Vistas rápidas">
@@ -126,7 +141,7 @@ export function AssetsPage() {
{view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>} {view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>}
</div> </div>
{view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel"> {view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel">
<label className="field compact-field"><span>Nivel</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos los niveles</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Nivel</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Instalaciones y Subinstalaciones</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button> <button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button>
@@ -137,16 +152,16 @@ export function AssetsPage() {
{view === 'hierarchy' {view === 'hierarchy'
? <AssetHierarchyView filters={filters} /> ? <AssetHierarchyView filters={filters} />
: loading : loading
? <LoadingBlock label="Cargando Inventarios…" /> ? <LoadingBlock label="Cargando Inventario real…" />
: assets.length === 0 : assets.length === 0
? <EmptyState title="Todavía no hay registros" text="La base está lista para comenzar la carga manual desde Departamento." /> ? <EmptyState title="Todavía no hay Inventario real" text="Las Áreas y Yacimientos precargados son contexto. Las Instalaciones/Subinstalaciones aparecerán aquí cuando sean registradas realmente." />
: <div className="table-panel compact-assets-table"> : <div className="table-panel compact-assets-table">
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div> <div className="table-summary"><strong>{meta.total} instancia{meta.total === 1 ? '' : 's'} real{meta.total === 1 ? '' : 'es'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Nivel</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody> <div className="table-scroll"><table><thead><tr><th>Inventario</th><th>Nivel</th><th>Área / Operadora vigente</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
{assets.map((asset) => <tr key={asset.id}> {assets.map((asset) => <tr key={asset.id}>
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td> <td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
<td><span className="tag">{asset.type.name}</span></td> <td><span className="tag">{asset.type.name}</span></td>
<td>{asset.operationalArea ? <span><strong className="table-primary">{asset.operationalArea.name}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora vigente'}</small></span> : asset.operatorCompany ? <span>{asset.operatorCompany.name}</span> : <span className="muted">{asset.parent?.name ?? 'Raíz / maestro independiente'}</span>}</td> <td>{asset.operationalArea ? <span><strong className="table-primary">{asset.operationalArea.name}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora vigente'}</small></span> : <span className="muted">Sin Área</span>}</td>
<td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td> <td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td>
<td>{formatDate(asset.updatedAt)}</td> <td>{formatDate(asset.updatedAt)}</td>
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td> <td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
+217 -66
View File
@@ -18,35 +18,37 @@ import type {
InventoryStructureParent, InventoryStructureParent,
} from '../lib/inventoryStructureApi'; } from '../lib/inventoryStructureApi';
const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [ const STRUCTURE_KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [
{ kind: 'DEPARTAMENTO', label: 'Departamento' }, { kind: 'AREA', label: 'Área', help: 'Nivel territorial raíz.', step: 1 },
{ kind: 'AREA', label: 'Área' }, { kind: 'YACIMIENTO', label: 'Yacimiento', help: 'Debe pertenecer a un Área.', step: 2 },
{ kind: 'YACIMIENTO', label: 'Yacimiento' }, { kind: 'INSTALACION', label: 'Instalación', help: 'Debe pertenecer a un Yacimiento.', step: 3 },
{ kind: 'INSTALACION', label: 'Instalación' }, { kind: 'SUBINSTALACION', label: 'Subinstalación', help: 'Debe pertenecer a una Instalación.', step: 4 },
{ kind: 'SUBINSTALACION', label: 'Subinstalación' },
{ kind: 'EMPRESA', label: 'Empresa' },
]; ];
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = { const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
departamento: 'AREA', area: 'YACIMIENTO', yacimiento: 'INSTALACION', instalacion: 'SUBINSTALACION', area: 'YACIMIENTO',
yacimiento: 'INSTALACION',
instalacion: 'SUBINSTALACION',
}; };
const parentLabelByKind: Partial<Record<InventoryStructureKind,string>> = {
AREA:'Departamento', YACIMIENTO:'Área', INSTALACION:'Yacimiento', SUBINSTALACION:'Instalación', function kindLabel(kind: InventoryStructureKind): string {
}; if (kind === 'EMPRESA') return 'Empresa';
function kindLabel(kind: InventoryStructureKind) { return KINDS.find((item) => item.kind===kind)?.label ?? kind; } return STRUCTURE_KINDS.find((item) => item.kind === kind)?.label ?? kind;
}
export function InventoryCreatePage() { export function InventoryCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const contextParentId = searchParams.get('parentId'); const contextParentId = searchParams.get('parentId');
const [options, setOptions] = useState<InventoryStructureOptions | null>(null); const [options, setOptions] = useState<InventoryStructureOptions | null>(null);
const [kind, setKind] = useState<InventoryStructureKind>('DEPARTAMENTO'); const [kind, setKind] = useState<InventoryStructureKind>('AREA');
const [parents, setParents] = useState<InventoryStructureParent[]>([]); const [parents, setParents] = useState<InventoryStructureParent[]>([]);
const [parentSearch, setParentSearch] = useState(''); const [parentSearch, setParentSearch] = useState('');
const [parentId, setParentId] = useState(''); const [parentId, setParentId] = useState('');
const [familyId, setFamilyId] = useState(''); const [familyId, setFamilyId] = useState('');
const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null); const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null);
const [name, setName] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [name, setName] = useState('');
const [commonName, setCommonName] = useState(''); const [commonName, setCommonName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -54,81 +56,230 @@ export function InventoryCreatePage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
getInventoryStructureOptions().then(async (loaded) => { getInventoryStructureOptions()
setOptions(loaded); .then(async (loaded) => {
if (contextParentId) { setOptions(loaded);
const parent = await getAsset(contextParentId); if (contextParentId) {
const inferred = childKindByParentType[parent.type.code.toLowerCase()]; const parent = await getAsset(contextParentId);
if (inferred) { setKind(inferred); setParentId(parent.id); } const inferred = childKindByParentType[parent.type.code.toLowerCase()];
} if (inferred) {
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); setKind(inferred);
setParentId(parent.id);
}
}
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [contextParentId]); }, [contextParentId]);
const requiresParent = Boolean(parentLabelByKind[kind]);
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const parentLabel = parentLabelByKind[kind] ?? '';
useEffect(() => { useEffect(() => {
if (!requiresParent) { setParents([]); setParentId(''); return; } if (kind === 'AREA' || kind === 'EMPRESA') {
setParents([]);
setParentId('');
return;
}
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
listInventoryStructureParents(kind,parentSearch).then((loaded) => { listInventoryStructureParents(kind, parentSearch)
setParents(loaded); .then((loaded) => {
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId); setParents(loaded);
}).catch((requestError) => setError(errorMessage(requestError))); if (contextParentId && loaded.some((item) => item.id === contextParentId)) {
},150); setParentId(contextParentId);
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}, 180);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [kind,parentSearch,contextParentId,requiresParent]); }, [kind, parentSearch, contextParentId]);
const selectedParent = parents.find((item) => item.id===parentId) ?? null; const selectedParent = parents.find((item) => item.id === parentId) ?? null;
const families = useMemo(() => { const families = useMemo(() => {
if (!options) return [] as InventoryFamily[]; if (!options) return [] as InventoryFamily[];
if (kind==='INSTALACION') return options.installationFamilies; if (kind === 'INSTALACION') return options.installationFamilies;
if (kind==='SUBINSTALACION') { if (kind === 'SUBINSTALACION') {
const parentFamilyId=selectedParent?.inventoryFamily?.id; const parentFamilyId = selectedParent?.inventoryFamily?.id;
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : []; return parentFamilyId
? options.subinstallationFamilies.filter((item) => item.parentFamilyId === parentFamilyId)
: [];
} }
return []; return [];
},[options,kind,selectedParent]); }, [options, kind, selectedParent]);
const selectedFamily=families.find((item)=>item.id===familyId) ?? null; const selectedFamily = families.find((item) => item.id === familyId) ?? null;
useEffect(() => { useEffect(() => {
if (!familyId) { setFamilyFindings(null); return; } if (!familyId) {
getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError))); setFamilyFindings(null);
},[familyId]); return;
useEffect(() => { }
if (!requiresFamily) setFamilyId(''); getInventoryFamilyFindings(familyId)
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId(''); .then(setFamilyFindings)
},[kind,requiresFamily,families,familyId]); .catch((requestError) => setError(errorMessage(requestError)));
}, [familyId]);
const changeKind=(next:InventoryStructureKind) => { useEffect(() => {
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null); setError(''); if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') setFamilyId('');
if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId('');
}, [kind, familyId, families]);
const chooseKind = (next: InventoryStructureKind) => {
setKind(next);
setParentId('');
setParentSearch('');
setFamilyId('');
setFamilyFindings(null);
setError('');
}; };
const save=async(event:FormEvent) => {
const save = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; } const requiresParent = kind !== 'AREA' && kind !== 'EMPRESA';
if (requiresFamily && !familyId) { setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); return; } const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
setSaving(true); setError(''); if (requiresParent && !parentId) {
setError(`Seleccioná el ${kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación'} padre.`);
return;
}
if (requiresFamily && !familyId) {
setError(`Seleccioná la familia de ${kindLabel(kind).toLowerCase()}.`);
return;
}
setSaving(true);
setError('');
try { try {
const created=await createInventoryStructure({ const created = await createInventoryStructure({
kind,name:name.trim(),parentId:parentId || null,familyId:familyId || null, kind,
code:code.trim() || null,commonName:commonName.trim() || null,description:description.trim() || null, code: code.trim() || null,
name: name.trim(),
commonName: commonName.trim() || null,
parentId: parentId || null,
familyId: familyId || null,
description: description.trim() || null,
}); });
navigate(`/inventarios/${created.id}`,{replace:true}); navigate(`/inventarios/${created.id}`, { replace: true });
} catch(requestError) { setError(errorMessage(requestError)); } } catch (requestError) {
finally { setSaving(false); } setError(errorMessage(requestError));
} finally {
setSaving(false);
}
}; };
if (loading) return <LoadingBlock label="Preparando alta…" />; if (loading) return <LoadingBlock label="Preparando alta…" />;
const isIndependentCompany = kind === 'EMPRESA';
const parentLabel = kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación';
const currentStep = STRUCTURE_KINDS.find((item) => item.kind === kind)?.step ?? 1;
const identificationStep = isIndependentCompany ? '2' : kind === 'AREA' ? '2' : kind === 'YACIMIENTO' ? '3' : '4';
const requiresParent = kind !== 'AREA' && kind !== 'EMPRESA';
return <section className="narrow-section asset-detail-page"> 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> <nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Para crear sólo necesitás ubicación, clasificación cuando corresponda y nombre. DH genera el código automáticamente.</p></div></div> <Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong>
</nav>
<div className="page-heading asset-editor-heading">
<div>
<span className="eyebrow">CONFIGURACIÓN E INVENTARIO</span>
<h1>Agregar registro</h1>
<p>Empresa es un maestro independiente. La estructura física es Área Yacimiento Instalación Subinstalación.</p>
</div>
</div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="panel" style={{ marginBottom: 18 }}>
<div className="form-section">
<div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Creá una Empresa independiente o agregá un nivel a la estructura física.</p></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
<button
type="button"
className={`button ${kind === 'EMPRESA' ? 'primary' : 'secondary'}`}
onClick={() => chooseKind('EMPRESA')}
style={{ minHeight: 76, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 3 }}
>
<strong>Empresa</strong>
<small>Maestro independiente.</small>
</button>
{STRUCTURE_KINDS.map((item) => <button
key={item.kind}
type="button"
className={`button ${kind === item.kind ? 'primary' : 'secondary'}`}
onClick={() => chooseKind(item.kind)}
style={{ minHeight: 76, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 3 }}
>
<strong>{item.step}. {item.label}</strong>
<small>{item.help}</small>
</button>)}
</div>
{isIndependentCompany ? <div className="temporal-notice" style={{ marginTop: 12 }}>
<Icon name="users" />
<p><strong>Empresa:</strong> no forma parte de la jerarquía física. Su vínculo con un Área se administra como relación temporal de operación/concesión.</p>
</div> : <div className="temporal-notice" style={{ marginTop: 12 }}>
<Icon name="layers" />
<p><strong>Ruta física:</strong> {STRUCTURE_KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')}</p>
</div>}
</div>
</div>
<form className="panel form-panel" onSubmit={save}> <form className="panel form-panel" onSubmit={save}>
<div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Departamento Área Yacimiento Instalación Subinstalación. Empresa queda fuera del árbol físico.</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">
{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>} <div><h2>2. Ubicación en la estructura</h2><p className="section-copy">Primero elegí el {parentLabel} al que pertenece este registro.</p></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}. Configuralos primero en Configuración de Inventarios.</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>} <label className="field">
<div className="form-section"><div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">Los datos técnicos se completan después, en la ficha del elemento.</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> <span>Buscar {parentLabel.toLowerCase()}</span>
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div> <input value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder={`Buscar por nombre o código de ${parentLabel.toLowerCase()}`} />
</label>
<label className="field">
<span>{parentLabel} padre <em>obligatorio</em></span>
<select value={parentId} onChange={(event) => { setParentId(event.target.value); setFamilyId(''); }} required>
<option value="">Seleccionar {parentLabel.toLowerCase()}</option>
{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code}{parent.inventoryFamily ? ` · ${parent.inventoryFamily.name}` : ''}</option>)}
</select>
<small>No se permiten saltos de nivel ni padres incompatibles.</small>
</label>
</div>}
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="form-section">
<div><h2>3. Clasificación técnica</h2><p className="section-copy">La clasificación no crea otro nivel: define el tipo de Instalación/Subinstalación y los Hallazgos aplicables.</p></div>
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación seleccionada todavía no tiene una clasificación técnica. Revisala antes de crear una Subinstalación.</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 tipo</option>
{families.map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}
</select>
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && <small>Se muestran sólo las Subinstalaciones válidas para {selectedParent.inventoryFamily.name}.</small>}
</label>}
{selectedFamily && <div className="context-create-banner" style={{ alignItems: 'flex-start' }}>
<Icon name="alert" />
<div style={{ flex: 1 }}>
<strong>Hallazgos asociados automáticamente</strong>
<span>{familyFindings ? `${familyFindings.count} Hallazgos aplicables para ${selectedFamily.name}` : 'Cargando catálogo asociado…'}</span>
{familyFindings && familyFindings.items.length > 0 && <ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{familyFindings.items.slice(0, 7).map((item) => <li key={item.id}>{item.title}</li>)}
{familyFindings.items.length > 7 && <li><strong>+ {familyFindings.items.length - 7} Hallazgos más</strong></li>}
</ul>}
</div>
</div>}
{selectedFamily && selectedFamily.informationLabels.length > 0 && <div className="temporal-notice">
<Icon name="clipboard" />
<p><strong>Información técnica esperada:</strong> {selectedFamily.informationLabels.join(' · ')}</p>
</div>}
</div>}
<div className="form-section">
<div><h2>{identificationStep}. Identificación</h2><p className="section-copy">{isIndependentCompany ? 'Registrá la denominación de la Empresa.' : 'Usá el nombre real de campo.'} El código DH puede generarse automáticamente.</p></div>
<div className="form-grid">
<label className="field"><span>Nombre <em>obligatorio</em></span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label>
<label className="field"><span>Código DH <em>opcional</em></span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejar vacío para generar automáticamente" /><small>Si no lo informás, DH genera un código único.</small></label>
<label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} placeholder={isIndependentCompany ? 'Nombre habitual o abreviado' : 'Nombre usado por los inspectores en campo'} /></label>
</div>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
</div>
<div className="form-actions">
<Link className="button secondary" to="/inventarios">Cancelar</Link>
<button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || ((kind === 'INSTALACION' || kind === 'SUBINSTALACION') && !familyId)}>
<Icon name="check" />{saving ? 'Creando…' : `Crear ${kindLabel(kind)}`}
</button>
</div>
</form> </form>
</section>; </section>;
} }