From 636be088e86f5a7704055ed3fc3e0003891c2111 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 09:25:57 -0300 Subject: [PATCH] =?UTF-8?q?feat(f6):=20consolidar=20configuraci=C3=B3n=20t?= =?UTF-8?q?=C3=A9cnica=20por=20clasificaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-v2/src/pages/AssetTypesPage.tsx | 222 ++++++++++++++-------------- 1 file changed, 114 insertions(+), 108 deletions(-) diff --git a/web-v2/src/pages/AssetTypesPage.tsx b/web-v2/src/pages/AssetTypesPage.tsx index b22c5ae..3b2fbef 100644 --- a/web-v2/src/pages/AssetTypesPage.tsx +++ b/web-v2/src/pages/AssetTypesPage.tsx @@ -19,11 +19,18 @@ import type { } from '../lib/api'; import { createInventoryFamily, + createInventoryFamilyAttribute, + getInventoryFamilyAttributes, listInventoryFamiliesAdmin, replaceInventoryFamilyFindings, updateInventoryFamily, + updateInventoryFamilyAttribute, +} from '../lib/inventoryStructureApi'; +import type { + InventoryFamily, + InventoryFamilyAttribute, + InventoryFamilyAttributeDataType, } from '../lib/inventoryStructureApi'; -import type { InventoryFamily } from '../lib/inventoryStructureApi'; const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [ { value: 'TEXT', label: 'Texto' }, @@ -33,12 +40,12 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = { value: 'DATETIME', label: 'Fecha y hora' }, { value: 'SELECT', label: 'Lista de opciones' }, ]; - const EMPTY_FINDING_CATALOG: FindingAdminCatalog = { categories: [], items: [] }; type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION'; type FamilyEditor = InventoryFamily | 'new' | null; type FamilyFindingMode = 'ASSOCIATED' | 'ALL'; +type TechnicalEditor = InventoryFamilyAttribute | 'new' | null; const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [ { kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' }, @@ -55,11 +62,9 @@ function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | nul const code = kind.toLowerCase(); return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null; } - -function attributeTypeLabel(value: AssetAttributeDataType) { +function attributeTypeLabel(value: AssetAttributeDataType | InventoryFamilyAttributeDataType) { return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value; } - function attributeCodeFromName(value: string) { return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim() .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80); @@ -68,6 +73,7 @@ function attributeCodeFromName(value: string) { export function AssetTypesPage() { const { hasPermission } = useAuth(); const canManage = hasPermission('asset_types.manage'); + const canManageFindings = hasPermission('finding_catalog.manage'); const [types, setTypes] = useState([]); const [families, setFamilies] = useState([]); const [findingCatalog, setFindingCatalog] = useState(EMPTY_FINDING_CATALOG); @@ -81,11 +87,22 @@ export function AssetTypesPage() { const [familyEditor, setFamilyEditor] = useState(null); const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION'); const [familyName, setFamilyName] = useState(''); - const [familyParentId, setFamilyParentId] = useState(''); + const [familyParentIds, setFamilyParentIds] = useState>(new Set()); const [familyActive, setFamilyActive] = useState(true); const [familyFindingIds, setFamilyFindingIds] = useState>(new Set()); const [familyFindingMode, setFamilyFindingMode] = useState('ASSOCIATED'); const [familyFindingSearch, setFamilyFindingSearch] = useState(''); + const [technicalAttributes, setTechnicalAttributes] = useState([]); + const [technicalLoading, setTechnicalLoading] = useState(false); + const [technicalEditor, setTechnicalEditor] = useState(null); + const [technicalCode, setTechnicalCode] = useState(''); + const [technicalName, setTechnicalName] = useState(''); + const [technicalType, setTechnicalType] = useState('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(null); const [attributeCode, setAttributeCode] = useState(''); @@ -99,15 +116,10 @@ export function AssetTypesPage() { const load = async () => { const [loadedTypes, loadedFamilies, loadedFindingCatalog] = await Promise.all([ - listAssetTypes(), - listInventoryFamiliesAdmin(), - getFindingCatalogAdmin(), + listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin(), ]); - setTypes(loadedTypes); - setFamilies(loadedFamilies); - setFindingCatalog(loadedFindingCatalog); + setTypes(loadedTypes); setFamilies(loadedFamilies); setFindingCatalog(loadedFindingCatalog); }; - useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, []); @@ -116,7 +128,7 @@ export function AssetTypesPage() { const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]); const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]); const visibleSubinstallationFamilies = useMemo(() => subinstallationFamilies.filter((family) => - family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyId === selectedInstallationFamilyId), + family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyIds.includes(selectedInstallationFamilyId)), ), [subinstallationFamilies, selectedInstallationFamilyId]); const activeFindingCategoryIds = useMemo(() => new Set( @@ -138,48 +150,79 @@ export function AssetTypesPage() { const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => { setFamilyEditor('new'); setFamilyLevel(level); setFamilyName(''); - setFamilyParentId(level === 'SUBINSTALLATION' ? selectedInstallationFamilyId : ''); + setFamilyParentIds(new Set(level === 'SUBINSTALLATION' && selectedInstallationFamilyId ? [selectedInstallationFamilyId] : [])); setFamilyActive(true); setFamilyFindingIds(new Set()); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch(''); - setError(''); setSuccess(''); + setTechnicalAttributes([]); setTechnicalEditor(null); setError(''); setSuccess(''); }; - const openFamily = (family: InventoryFamily) => { + const openFamily = async (family: InventoryFamily) => { if (family.level === 'INSTALLATION') setSelectedInstallationFamilyId(family.id); setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name); - setFamilyParentId(family.parentFamilyId ?? ''); setFamilyActive(family.isActive !== false); + setFamilyParentIds(new Set(family.parentFamilyIds)); 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 next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const saveFamily = async (event: FormEvent) => { event.preventDefault(); setSaving(true); setError(''); setSuccess(''); try { if (familyEditor === 'new') { - await createInventoryFamily({ - level: familyLevel, - name: familyName.trim(), - parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null, + const created = await createInventoryFamily({ + level: familyLevel, name: familyName.trim(), + parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [], }); setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`); - } else if (familyEditor) { + await load(); await openFamily(created); return; + } + if (familyEditor) { await updateInventoryFamily(familyEditor.id, { - name: familyName.trim(), - parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null, + name: familyName.trim(), parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [], isActive: familyActive, }); - await replaceInventoryFamilyFindings(familyEditor.id, { - itemIds: [...familyFindingIds], - reason: 'Actualización desde Configuración de Inventarios', + if (canManageFindings) await replaceInventoryFamilyFindings(familyEditor.id, { + itemIds: [...familyFindingIds], reason: 'Actualización desde Configuración de Inventarios F6', }); - setSuccess('Clasificación y Hallazgos asociados actualizados.'); + setSuccess('Clasificación actualizada.'); await load(); setFamilyEditor(null); } - await load(); - setFamilyEditor(null); + } catch (requestError) { setError(errorMessage(requestError)); } + finally { setSaving(false); } + }; + + 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)); } finally { setSaving(false); } }; @@ -188,100 +231,63 @@ export function AssetTypesPage() { setAttributeEditor(attribute); setError(''); setSuccess(''); if (attribute === 'new') { 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 { setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType); - setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive); - setAttributeUnit(attribute.unit ?? ''); setAttributeOptions(attribute.options?.join('\n') ?? ''); - setAttributeOrder(attribute.sortOrder); + setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? ''); + setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder); } }; - const saveAttribute = async (event: FormEvent) => { - event.preventDefault(); - if (!selectedType || !attributeEditor) return; + event.preventDefault(); if (!selectedType || !attributeEditor) return; setSaving(true); setError(''); setSuccess(''); const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean); try { - if (attributeEditor === 'new') { - await createAssetAttribute(selectedType.id, { - code: attributeCode, - name: attributeName, - dataType: attributeType, - isRequired: attributeRequired, - unit: attributeUnit || null, - ...(attributeType === 'SELECT' ? { options } : {}), - 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); + if (attributeEditor === 'new') await createAssetAttribute(selectedType.id, { + code: attributeCode,name: attributeName,dataType: attributeType,isRequired: attributeRequired, + unit: attributeUnit || null,...(attributeType === 'SELECT' ? { options } : {}),sortOrder: attributeOrder, + }); + 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, + }); + await load(); setAttributeEditor(null); setSuccess('Campo general actualizado.'); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }; if (loading) return ; - return
-
-
- ADMINISTRACIÓN -

Configuración de Inventarios

-

Administrá la estructura, los tipos de Instalación/Subinstalación, sus Hallazgos y las columnas de información.

-
- {canManage && Agregar registro} -
+
ADMINISTRACIÓN

Configuración de Inventarios

Administrá clasificaciones, compatibilidades, Hallazgos y campos técnicos desde un único lugar.

{canManage && Agregar registro}
{error && {error}}{success && {success}} -
-
MODELO VIGENTE

Estructura física

Cada nivel tiene un único padre estructural. Empresa queda como maestro independiente y se relaciona con Área.

-
-
1Departamentoraíz territorial
-
2Áreadentro del Departamento
-
3Yacimientodentro del Área
-
4Instalaciónclasificación técnica
-
5Subinstalaciónclasificación técnica
-
-

Empresa: maestro independiente. Cambiar la operadora de un Área no mueve ni reescribe la estructura física.

-
+
MODELO F6

Estructura física fija

La clasificación describe qué es cada Instalación/Subinstalación, pero nunca altera su nivel jerárquico.

1Departamentoraíz territorial
2Áreadentro del Departamento
3Yacimientodentro del Área
4Instalaciónclasificación técnica
5Subinstalaciónclasificación técnica

Empresa: maestro independiente; su relación con Área no reescribe el árbol físico.

-
-
CLASIFICACIONES

Tipos de Instalación

Tocá una Instalación para filtrar sus Subinstalaciones y editar sus Hallazgos.

{canManage && }
-
{installationFamilies.filter((family) => family.isActive !== false).map((family) => )}
-
- -
-
CLASIFICACIONES

Tipos de Subinstalación

Mostrá todas o sólo las que pertenecen a una Instalación.

{canManage && }
- - {visibleSubinstallationFamilies.length === 0 ?
{selectedInstallationFamilyId ? 'Esta Instalación todavía no tiene tipos de Subinstalación asociados.' : 'No hay tipos de Subinstalación configurados.'}
:
{visibleSubinstallationFamilies.map((family) => )}
} -
+
CLASIFICACIONES

Tipos de Instalación

Tocá uno para editar Hallazgos, campos técnicos y ver sus Subinstalaciones compatibles.

{canManage && }
{installationFamilies.filter((family) => family.isActive !== false).map((family) => )}
+
COMPATIBILIDAD

Tipos de Subinstalación

Una misma clasificación puede ser válida para varias Instalaciones.

{canManage && }
{visibleSubinstallationFamilies.length === 0 ?
{selectedInstallationFamilyId ? 'No hay tipos compatibles con esta Instalación.' : 'No hay tipos de Subinstalación configurados.'}
:
{visibleSubinstallationFamilies.map((family) => )}
}
-
-
COLUMNAS / CAMPOS

Información configurable

Agregá las columnas que deben completarse para cada nivel. No modifica la jerarquía.

{canManage && selectedType && }
-
{LEVELS.map((level) => )}
-

{LEVELS.find((item) => item.kind === selectedKind)?.description}

- {!selectedType ? Este nivel todavía no tiene un tipo maestro activo. : selectedType.attributes.length === 0 ?
No hay columnas adicionales configuradas para {LEVELS.find((item) => item.kind === selectedKind)?.label}.
:
{selectedType.attributes.map((attribute) => )}
} -
+
CAMPOS GENERALES DEL NIVEL

Información estructural

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.

{canManage && selectedType && }
{LEVELS.map((level) => )}

{LEVELS.find((item) => item.kind === selectedKind)?.description}

{!selectedType ? Este nivel todavía no tiene un tipo maestro activo. : selectedType.attributes.length === 0 ?
No hay campos generales para este nivel.
:
{selectedType.attributes.map((attribute) => )}
}
-
HALLAZGOS

Relación por clasificación

Los Hallazgos se pueden revisar y editar directamente dentro de cada tipo. El Catálogo completo sigue disponible para una administración masiva.

Abrir Catálogo de hallazgos
+ {familyEditor &&
CLASIFICACIÓN TÉCNICA

{familyEditor === 'new' ? 'Nueva clasificación' : familyName}

+ {familyEditor === 'new' && } + + {familyLevel === 'SUBINSTALLATION' &&
Tipos de Instalación compatibles {familyParentIds.size} seleccionados
{installationFamilies.filter((family) => family.isActive !== false).map((family) => )}
} + {familyEditor !== 'new' && <> +

{familyFindingIds.size} Hallazgos asociados. La APK sólo verá estos Hallazgos para esta clasificación, además de OTROS.

+
{canManageFindings && }
+ + {visibleFamilyFindings.length === 0 ?
{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados.' : 'No hay coincidencias.'}
:
{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? :
{item.title}{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}
)}
} - {familyEditor &&
CLASIFICACIÓN DE INVENTARIO

{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}

{familyEditor === 'new' && }{familyLevel === 'SUBINSTALLATION' && }{familyEditor !== 'new' && <>

{familyFindingIds.size} Hallazgos asociados. Se guardan junto con esta clasificación.

{visibleFamilyFindings.length === 0 ?
{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados. Tocá “Agregar o quitar” para vincularlos.' : 'No hay Hallazgos que coincidan con la búsqueda.'}
:
{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? :
{item.title}{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}
)}
}Abrir en Catálogo completo }
} +
DATOS DEL RUBRO

Campos técnicos

Sólo aparecen cuando un elemento usa esta clasificación.

{canManage && !technicalEditor && }
+ {technicalLoading ? : technicalAttributes.length === 0 && !technicalEditor ?
No hay campos técnicos definidos.
: !technicalEditor &&
{technicalAttributes.map((attribute) => )}
} + {technicalEditor &&
{technicalEditor === 'new' ? 'Nuevo campo técnico' : 'Editar campo técnico'}
{technicalType === 'SELECT' &&