From 0213c294659ffb280c342433022a69538e4a591f Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 21:13:39 -0300 Subject: [PATCH] ux(admin): replace inventory model screen with simple type and field editor --- .../AuthoritativeInventoryConfigPage.tsx | 412 +++++++++++------- 1 file changed, 257 insertions(+), 155 deletions(-) diff --git a/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx b/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx index 5b2479d..a52f071 100644 --- a/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx +++ b/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx @@ -1,203 +1,305 @@ import { useEffect, useMemo, useState } from 'react'; -import { Link } from 'react-router'; +import { useAuth } from '../auth/AuthContext'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Icon } from '../components/Icon'; -import { getFindingCatalogAdmin, listAssetTypes } from '../lib/api'; -import type { AssetType, FindingAdminCatalog } from '../lib/api'; -import { listInventoryFamiliesAdmin } from '../lib/inventoryStructureApi'; -import type { InventoryFamily } from '../lib/inventoryStructureApi'; +import { + createInventoryFamily, + createInventoryFamilyAttribute, + getInventoryFamilyAttributes, + listInventoryFamiliesAdmin, + updateInventoryFamily, + updateInventoryFamilyAttribute, +} from '../lib/inventoryStructureApi'; +import type { + InventoryFamily, + InventoryFamilyAttribute, + InventoryFamilyAttributeDataType, +} from '../lib/inventoryStructureApi'; -type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION'; +type FamilyLevel = 'INSTALLATION' | 'SUBINSTALLATION'; -type StructuralField = { - label: string; - detail: string; - required?: boolean; - relation?: boolean; -}; - -const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string; fields: StructuralField[] }> = [ - { - kind: 'EMPRESA', - label: 'Empresa', - description: 'Maestro independiente. La Empresa se relaciona directamente con uno o más Yacimientos.', - fields: [{ label: 'Nombre', detail: 'Nombre de la Empresa / Operadora.', required: true }], - }, - { - kind: 'DEPARTAMENTO', - label: 'Departamento', - description: 'Raíz territorial. No depende de ningún otro nivel.', - fields: [{ label: 'Nombre', detail: 'Nombre del Departamento.', required: true }], - }, - { - kind: 'AREA', - label: 'Área', - description: 'El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento.', - fields: [ - { label: 'Departamento', detail: 'Relación obligatoria Departamento → Área.', required: true, relation: true }, - { label: 'Nombre', detail: 'Nombre del Área.', required: true }, - ], - }, - { - kind: 'YACIMIENTO', - label: 'Yacimiento', - description: 'El Yacimiento concentra el contexto operativo: Área, Tipo de concesión y Empresa relacionada.', - fields: [ - { label: 'Área', detail: 'Relación obligatoria Área → Yacimiento.', required: true, relation: true }, - { label: 'Tipo de concesión', detail: 'Explotación o Exploración, según la fuente.', required: true, relation: true }, - { label: 'Empresa relacionada', detail: 'Empresa / Operadora asociada directamente al Yacimiento.', required: true, relation: true }, - { label: 'Nombre', detail: 'Nombre del Yacimiento. Puede repetirse en otra Área.', required: true }, - ], - }, - { - kind: 'INSTALACION', - label: 'Instalación', - description: 'Cada Instalación pertenece a un Yacimiento y posee una clasificación técnica.', - fields: [ - { label: 'Yacimiento', detail: 'Relación obligatoria Yacimiento → Instalación.', required: true, relation: true }, - { label: 'Tipo de instalación', detail: 'Clasificación técnica tomada del modelo de Instalaciones.', required: true, relation: true }, - ], - }, - { - kind: 'SUBINSTALACION', - label: 'Subinstalación', - description: 'Cada Subinstalación pertenece a una Instalación y usa una clasificación compatible con ella.', - fields: [ - { label: 'Instalación', detail: 'Relación obligatoria Instalación → Subinstalación.', required: true, relation: true }, - { label: 'Tipo de subinstalación', detail: 'Clasificación técnica compatible con el Tipo de instalación padre.', required: true, relation: true }, - ], - }, +const FIELD_TYPES: Array<{ value: InventoryFamilyAttributeDataType; label: string }> = [ + { value: 'TEXT', label: 'Texto' }, + { value: 'NUMBER', label: 'Número' }, + { value: 'DATE', label: 'Fecha' }, + { value: 'BOOLEAN', label: 'Sí / No' }, ]; -const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] }; +function fieldTypeLabel(value: InventoryFamilyAttributeDataType) { + return FIELD_TYPES.find((item) => item.value === value)?.label + ?? (value === 'DATETIME' ? 'Fecha y hora' : value === 'SELECT' ? 'Lista' : value); +} -function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null { - if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null; - if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null; - return types.find((type) => type.code.toLowerCase() === kind.toLowerCase() && type.isActive) ?? null; +function fieldCode(name: string) { + return name + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 100) || `campo_${Date.now()}`; } export function AuthoritativeInventoryConfigPage() { - const [types, setTypes] = useState([]); + const { hasPermission } = useAuth(); + const canManage = hasPermission('asset_types.manage'); const [families, setFamilies] = useState([]); - const [catalog, setCatalog] = useState(EMPTY_CATALOG); - const [selectedKind, setSelectedKind] = useState('AREA'); + const [level, setLevel] = useState('INSTALLATION'); + const [selectedFamilyId, setSelectedFamilyId] = useState(''); + const [attributes, setAttributes] = useState([]); + const [newTypeName, setNewTypeName] = useState(''); + const [newTypeParents, setNewTypeParents] = useState([]); + const [newFieldName, setNewFieldName] = useState(''); + const [newFieldType, setNewFieldType] = useState('TEXT'); + const [newFieldRequired, setNewFieldRequired] = useState(false); const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const refreshFamilies = async () => { + const loaded = await listInventoryFamiliesAdmin(); + setFamilies(loaded); + return loaded; + }; useEffect(() => { - Promise.all([listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin()]) - .then(([loadedTypes, loadedFamilies, loadedCatalog]) => { - setTypes(loadedTypes); - setFamilies(loadedFamilies); - setCatalog(loadedCatalog); - }) + refreshFamilies() .catch((requestError) => setError(errorMessage(requestError))) .finally(() => setLoading(false)); }, []); - const level = LEVELS.find((item) => item.kind === selectedKind) ?? LEVELS[0]!; - const selectedType = canonicalType(types, selectedKind); - const commonAttributes = useMemo(() => { - const attributes = selectedType?.attributes.filter((attribute) => attribute.isActive) ?? []; - return selectedKind === 'INSTALACION' - ? attributes.filter((attribute) => attribute.code !== 'tipo_instalacion') - : attributes; - }, [selectedKind, selectedType]); - const installationFamilies = families.filter((family) => family.level === 'INSTALLATION' && family.isActive !== false); - const subinstallationFamilies = families.filter((family) => family.level === 'SUBINSTALLATION' && family.isActive !== false); - const activeFindings = catalog.items.filter((item) => item.isActive).length; + const installationFamilies = useMemo( + () => families.filter((family) => family.level === 'INSTALLATION'), + [families], + ); + const visibleFamilies = useMemo( + () => families.filter((family) => family.level === level), + [families, level], + ); + const selectedFamily = families.find((family) => family.id === selectedFamilyId) ?? null; + + useEffect(() => { + const first = visibleFamilies.find((family) => family.isActive !== false) ?? visibleFamilies[0] ?? null; + setSelectedFamilyId((current) => visibleFamilies.some((family) => family.id === current) ? current : first?.id ?? ''); + }, [level, visibleFamilies]); + + useEffect(() => { + if (!selectedFamilyId) { + setAttributes([]); + return; + } + getInventoryFamilyAttributes(selectedFamilyId) + .then((result) => setAttributes(result.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)))) + .catch((requestError) => setError(errorMessage(requestError))); + }, [selectedFamilyId]); + + const run = async (action: () => Promise, message: string) => { + setSaving(true); + setError(''); + setSuccess(''); + try { + await action(); + setSuccess(message); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + const createType = async () => { + if (!newTypeName.trim()) return; + if (level === 'SUBINSTALLATION' && newTypeParents.length === 0) { + setError('Elegí al menos un Tipo de Instalación que pueda contener esta Subinstalación.'); + return; + } + await run(async () => { + const created = await createInventoryFamily({ + level, + name: newTypeName.trim(), + parentFamilyIds: level === 'SUBINSTALLATION' ? newTypeParents : undefined, + }); + await refreshFamilies(); + setSelectedFamilyId(created.id); + setNewTypeName(''); + setNewTypeParents([]); + }, `${level === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`); + }; + + const toggleParent = (parentId: string) => { + setNewTypeParents((current) => current.includes(parentId) + ? current.filter((id) => id !== parentId) + : [...current, parentId]); + }; + + const toggleSelectedParent = async (parentId: string) => { + if (!selectedFamily || selectedFamily.level !== 'SUBINSTALLATION') return; + const next = selectedFamily.parentFamilyIds.includes(parentId) + ? selectedFamily.parentFamilyIds.filter((id) => id !== parentId) + : [...selectedFamily.parentFamilyIds, parentId]; + if (next.length === 0) { + setError('Una Subinstalación debe quedar habilitada dentro de al menos un Tipo de Instalación.'); + return; + } + await run(async () => { + await updateInventoryFamily(selectedFamily.id, { parentFamilyIds: next }); + await refreshFamilies(); + }, 'Tipos permitidos actualizados.'); + }; + + const createField = async () => { + if (!selectedFamily || !newFieldName.trim()) return; + await run(async () => { + await createInventoryFamilyAttribute(selectedFamily.id, { + code: fieldCode(newFieldName), + name: newFieldName.trim(), + dataType: newFieldType, + isRequired: newFieldRequired, + sortOrder: attributes.length, + }); + const loaded = await getInventoryFamilyAttributes(selectedFamily.id); + setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))); + setNewFieldName(''); + setNewFieldType('TEXT'); + setNewFieldRequired(false); + }, 'Campo agregado.'); + }; + + const toggleField = async (attribute: InventoryFamilyAttribute, key: 'isRequired' | 'isActive') => { + if (!selectedFamily) return; + await run(async () => { + await updateInventoryFamilyAttribute(selectedFamily.id, attribute.id, { [key]: !attribute[key] }); + const loaded = await getInventoryFamilyAttributes(selectedFamily.id); + setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))); + }, key === 'isActive' ? 'Visibilidad del campo actualizada.' : 'Obligatoriedad actualizada.'); + }; + + const toggleFamilyActive = async () => { + if (!selectedFamily) return; + await run(async () => { + await updateInventoryFamily(selectedFamily.id, { isActive: selectedFamily.isActive === false }); + await refreshFamilies(); + }, selectedFamily.isActive === false ? 'Tipo activado.' : 'Tipo desactivado.'); + }; if (loading) return ; return
- ADMINISTRACIÓN · MODELO AUTORITATIVO -

Configuración de Inventarios

-

Relaciones fijas cargadas desde los SQL definitivos. Los vínculos estructurales no son campos de texto editables.

+ ADMINISTRACIÓN · INVENTARIOS +

Tipos y campos

+

Configuración simple para la puesta a punto. Los usuarios de campo no ven esta pantalla.

- Agregar registro
+ {error && {error}} + {success && {success}}
- ESTRUCTURA FÍSICA -

Jerarquía obligatoria

-

Empresa es un maestro independiente y se asocia al Yacimiento. El árbol físico queda separado y sin ambigüedades.

+ ESTRUCTURA FIJA +

Departamento → Área → Yacimiento → Instalación → Subinstalación

+

Un Área puede estar vinculada a varias Empresas. Cada Yacimiento elige una sola Empresa operadora de las vinculadas a su Área.

-
-
1Departamentoraíz
-
2ÁreaDepartamento
-
3YacimientoÁrea + Empresa + concesión
-
4InstalaciónYacimiento
-
5SubinstalaciónInstalación
-
-
- -

Empresa: ya no pertenece al Área. La relación canónica es Yacimiento → Empresa relacionada.

+
+ +

Campos básicos del sistema: nombre, código, ubicación jerárquica, estado y GPS. No hace falta configurarlos acá.

-
-
-
- INFORMACIÓN ESTRUCTURAL -

{level.label}

-

{level.description}

-
- Modelo SQL -
-
- {LEVELS.map((item) => )} -
- -
- {level.fields.map((field, index) =>
- {index + 1} - - {field.label} - {field.detail} - - - {field.relation && Relación} - {field.required && Obligatorio} - -
)} - {commonAttributes.map((attribute, index) =>
- {level.fields.length + index + 1} - - {attribute.name} - {attribute.code} · Campo común del nivel - - {attribute.isRequired && Obligatorio} -
)} -
-
+
+ + +
-
MODELO TÉCNICO

Tipos de Instalación

{installationFamilies.length} clasificaciones exactas del SQL.

-
- {installationFamilies.map((family) =>
{family.name}{family.findingCount ?? 0} Hallazgos asociados{family.code}
)} +
+
+ TIPOS +

{level === 'INSTALLATION' ? 'Instalaciones' : 'Subinstalaciones'}

+

Seleccioná un tipo para administrar sus campos.

+
+ +
+ {visibleFamilies.length === 0 &&

Todavía no hay tipos configurados.

} + {visibleFamilies.map((family) => )} +
+ + {canManage &&
+

+ Nuevo tipo

+ + {level === 'SUBINSTALLATION' &&
Puede estar dentro de
+ {installationFamilies.filter((family) => family.isActive !== false).map((family) => )} +
} + +
}
+
-
MODELO TÉCNICO

Tipos de Subinstalación

{subinstallationFamilies.length} clasificaciones, cada una vinculada a su Tipo de instalación.

-
- {subinstallationFamilies.map((family) =>
{family.name}{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin padre'} · {family.findingCount ?? 0} Hallazgos
)} -
+ {!selectedFamily ?
Seleccioná un tipo para ver sus campos.
: <> +
+
+ {selectedFamily.level === 'INSTALLATION' ? 'INSTALACIÓN' : 'SUBINSTALACIÓN'} +

{selectedFamily.name}

+

Campos sencillos que se muestran al cargar este tipo.

+
+ {canManage && } +
+ + {selectedFamily.level === 'SUBINSTALLATION' &&
+ Puede estar dentro de: +
+ {installationFamilies.filter((family) => family.isActive !== false).map((family) => )} +
+
} + +
+ {attributes.length === 0 &&

Este tipo no tiene campos específicos. Puede usarse sólo con los datos básicos.

} + {attributes.map((attribute) =>
+ {attribute.sortOrder + 1} + + {attribute.name} + {fieldTypeLabel(attribute.dataType)}{attribute.isRequired ? ' · Obligatorio' : ' · Opcional'}{attribute.isActive ? '' : ' · Oculto'} + + {canManage && + + + } +
)} +
+ + {canManage && selectedFamily.isActive !== false &&
+

+ Agregar campo

+
+ + +
+ + +

Teléfono, email o identificadores simples se cargan como Texto. GPS es un dato básico del registro y no se configura como campo.

+
} + }
- -
-
-
HALLAZGOS

Catálogo contextual

{activeFindings} Hallazgos cargados con sus relaciones exactas a Instalaciones y Subinstalaciones.

- Abrir catálogo -
-
; }