import './AssetEditorPage.css'; import { SearchableSelect } from '../components/SearchableSelect'; import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; import type { FormEvent, ReactNode } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; import { useAuth } from '../auth/AuthContext'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Icon } from '../components/Icon'; import { ASSET_OPERATIONAL_STATUSES, ASSET_STATUSES, assetOperationalStatusLabel, assetStatusClass, assetStatusLabel, } from '../features/assets/assetPresentation'; import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel'; import { AssetDossierPanel } from '../features/assets/AssetDossierPanel'; import { AssetMediaPanel } from '../features/assets/AssetMediaPanel'; import { AssetProvenancePanel } from '../features/assets/AssetProvenancePanel'; import { AssetOperationalRelationsPanel } from '../features/assets/AssetOperationalRelationsPanel'; import { AssetRegistryPanel } from '../features/assets/AssetRegistryPanel'; import { AssetContextHistoryPanel } from '../features/assets/AssetContextHistoryPanel'; import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel'; import { createAsset, getAsset, getAssetLineage, listAssetParentOptions, listAssetTypes, listCompaniesForArea, listOperationalAreas, updateAsset, updateAssetInformationStatus, updateAssetOperationalStatus, } from '../lib/api'; import type { AssetAttributeDefinition, AssetDetail, AssetInformationStatus, AssetOperationalStatus, AssetLineageItem, AssetListItem, AssetType, OperationalAssetSummary, } from '../lib/api'; const AssetGeometryEditor = lazy(() => import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })), ); type DetailTab = 'summary' | 'dossier' | 'findings' | 'location' | 'registry' | 'files' | 'history'; function localDateTime(value: unknown): string { 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() * 60_000); return local.toISOString().slice(0, 16); } function normalizeAttributeValues( definitions: AssetAttributeDefinition[], values: Record, ): Record { const result: Record = {}; definitions.filter((item) => item.isActive).forEach((definition) => { const raw = values[definition.id]; if (definition.dataType === 'BOOLEAN') result[definition.id] = Boolean(raw); else if (raw !== undefined && raw !== null && raw !== '') { result[definition.id] = definition.dataType === 'NUMBER' ? Number(raw) : raw; } }); return result; } function hasAttributeValue(definition: AssetAttributeDefinition, value: unknown): boolean { if (definition.dataType === 'BOOLEAN') return value === true; return value !== undefined && value !== null && String(value).trim() !== ''; } function normalizeStructuralType(value: string | null | undefined): string { return (value ?? '') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .trim() .toLowerCase(); } export function AssetEditorPage() { const { id } = useParams(); const editing = Boolean(id); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const contextParentId = !editing ? searchParams.get('parentId') : null; const requestedTab = searchParams.get('tab') as DetailTab | null; const tab: DetailTab = requestedTab && ['summary', 'dossier', 'findings', 'location', 'registry', 'files', 'history'].includes(requestedTab) ? requestedTab : 'summary'; const { hasPermission } = useAuth(); const canEdit = editing ? hasPermission('assets.update') : hasPermission('assets.create'); const canCreate = hasPermission('assets.create'); const canChangeStatus = hasPermission('assets.change_status'); const canChangeOperationalStatus = hasPermission('assets.change_operational_status'); const canEditGeometry = hasPermission('assets.update_geometry'); const canReadHistory = hasPermission('assets.read_history'); const canReadMedia = hasPermission('assets.read_media'); const canManageMedia = hasPermission('assets.manage_media'); const canReadProvenance = hasPermission('assets.read_provenance'); const canManageProvenance = hasPermission('assets.manage_provenance'); const canVerifyProvenance = hasPermission('assets.verify_provenance'); const canReadRelations = hasPermission('asset_relations.read'); const canManageRelations = hasPermission('asset_relations.manage'); const canManageContext = hasPermission('assets.manage_context'); const canReadFindingCatalog = hasPermission('finding_catalog.read'); const canManageFindingCatalog = hasPermission('finding_catalog.manage'); const canReadDossier = hasPermission('inspections.read') && hasPermission('inspection_acts.read') && hasPermission('inspection_findings.read') && hasPermission('inspection_evidence.read') && hasPermission('inspection_communications.read'); const canReadRegistry = hasPermission('asset_registry.read'); const canManageRegistry = hasPermission('asset_registry.manage'); const canSave = canEdit || (editing && (canChangeStatus || canChangeOperationalStatus)); const [types, setTypes] = useState([]); const [asset, setAsset] = useState(null); const [contextParent, setContextParent] = useState(null); const [lineage, setLineage] = useState([]); const [parents, setParents] = useState([]); const [parentSearch, setParentSearch] = useState(''); const [code, setCode] = useState(''); const [name, setName] = useState(''); const [commonName, setCommonName] = useState(''); const [typeId, setTypeId] = useState(''); const [parentId, setParentId] = useState(''); const [operationalAreaId, setOperationalAreaId] = useState(''); const [operatorCompanyId, setOperatorCompanyId] = useState(''); const [operationalAreas, setOperationalAreas] = useState([]); const [operationalCompanies, setOperationalCompanies] = useState([]); const [description, setDescription] = useState(''); const [status, setStatus] = useState('DRAFT'); const [operationalStatus, setOperationalStatus] = useState('UNKNOWN'); const [attributeValues, setAttributeValues] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [historyRefreshKey, setHistoryRefreshKey] = useState(0); const canDirectContextEdit = !editing || Boolean(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT'); const selectedType = types.find((type) => type.id === typeId) ?? null; const definitions = useMemo( () => selectedType?.attributes.filter((item) => item.isActive) ?? [], [selectedType], ); useEffect(() => { Promise.all([ listAssetTypes(), id ? getAsset(id) : Promise.resolve(null), id ? getAssetLineage(id) : Promise.resolve([] as AssetLineageItem[]), ]) .then(([loadedTypes, loadedAsset, loadedLineage]) => { setTypes(loadedTypes); setLineage(loadedLineage); if (loadedAsset) { setAsset(loadedAsset); setCode(loadedAsset.code); setName(loadedAsset.name); setCommonName(loadedAsset.commonName ?? ''); setTypeId(loadedAsset.type.id); setParentId(loadedAsset.parent?.id ?? ''); setOperationalAreaId(loadedAsset.operationalArea?.id ?? ''); setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? ''); setDescription(loadedAsset.description ?? ''); setStatus(loadedAsset.informationStatus); setOperationalStatus(loadedAsset.operationalStatus); setAttributeValues(Object.fromEntries( loadedAsset.attributes.map((attribute) => [ attribute.definitionId, attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? '', ]), )); } else if (!contextParentId) { const first = loadedTypes.find((type) => type.isActive && type.canBeRoot) ?? loadedTypes.find((type) => type.isActive); if (first) setTypeId(first.id); } }) .catch((requestError) => setError(errorMessage(requestError))) .finally(() => setLoading(false)); }, [id]); useEffect(() => { if (editing || !contextParentId || types.length === 0) return; Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)]) .then(([parent, parentLineage]) => { setContextParent(parent); setLineage(parentLineage); const compatible = types.find( (type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id), ); if (compatible) { setTypeId(compatible.id); setParentId(parent.id); const parentType = types.find((type) => type.id === parent.type.id); if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id); else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id); } }) .catch((requestError) => setError(errorMessage(requestError))); }, [editing, contextParentId, types]); useEffect(() => { if (!typeId) { setParents([]); return; } const timer = window.setTimeout( () => listAssetParentOptions(typeId, id, parentSearch) .then(setParents) .catch((requestError) => setError(errorMessage(requestError))), 220, ); return () => window.clearTimeout(timer); }, [typeId, id, parentSearch]); useEffect(() => { if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) { setOperationalAreas([]); if (!parentId) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); } return; } listOperationalAreas(parentId) .then((loadedAreas) => { setOperationalAreas(loadedAreas); if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); } }) .catch((requestError) => setError(errorMessage(requestError))); }, [canReadRelations, selectedType?.operationalRole, parentId, editing]); useEffect(() => { if (editing || !canReadRelations || !operationalAreaId || selectedType?.operationalRole !== 'GENERIC') { setOperationalCompanies([]); return; } listCompaniesForArea(operationalAreaId) .then((loadedCompanies) => { setOperationalCompanies(loadedCompanies); setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current) ? current : loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : ''); }) .catch((requestError) => { setOperationalCompanies([]); setOperatorCompanyId(''); setError(errorMessage(requestError)); }); }, [editing, canReadRelations, operationalAreaId, selectedType?.operationalRole]); const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); }; const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value })); const save = async (event: FormEvent) => { event.preventDefault(); if (!selectedType) return; setSaving(true); setError(''); setSuccess(''); try { const attributes = normalizeAttributeValues(definitions, attributeValues); let saved: AssetDetail; if (editing && id) { if (canEdit) { saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, description: description.trim() || null, attributes, }); } else if (asset) saved = asset; else return; if (canChangeStatus && saved.informationStatus !== status) { saved = await updateAssetInformationStatus(id, status); } if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) { saved = await updateAssetOperationalStatus(id, operationalStatus); } setAsset(saved); setSuccess('Registro actualizado correctamente'); setHistoryRefreshKey((current) => current + 1); } else { saved = await createAsset({ code, name, commonName: commonName.trim() || null, typeId, parentId: parentId || null, operationalAreaId: operationalAreaId || null, operatorCompanyId: operatorCompanyId || null, description: description.trim() || null, informationStatus: canChangeStatus ? status : 'DRAFT', attributes, }); navigate(`/inventarios/${saved.id}`, { replace: true }); } } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }; if (loading) return ; const detailTabs = [ { key: 'summary', label: 'Resumen', show: true }, { key: 'dossier', label: 'Expediente', show: editing && canReadDossier }, { key: 'findings', label: 'Hallazgos aplicables', show: editing && canReadFindingCatalog }, { key: 'location', label: 'Ubicación', show: editing }, { key: 'registry', label: 'Documentos y registro', show: editing && (canReadRegistry || canReadProvenance) }, { key: 'files', label: 'Archivos', show: editing && canReadMedia }, { key: 'history', label: 'Historial', show: editing && canReadHistory }, ] as const; const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa' ? 'companies' : 'territory'; const structuralTypeCode = normalizeStructuralType(selectedType?.code); const structuralTypeName = normalizeStructuralType(selectedType?.name); const compactStructuralSummary = editing && ( ['instalacion', 'instalacion-superficie', 'instalacion_de_superficie', 'instalacion-de-superficie', 'subinstalacion'] .includes(structuralTypeCode) || structuralTypeName === 'instalacion' || structuralTypeName === 'instalacion de superficie' || structuralTypeName === 'subinstalacion' ); const technicalFilledCount = definitions.filter( (definition) => hasAttributeValue(definition, attributeValues[definition.id]), ).length; const missingRequiredTechnical = definitions.some( (definition) => definition.isRequired && !hasAttributeValue(definition, attributeValues[definition.id]), ); const handleContextChanged = (saved: AssetDetail) => { setAsset(saved); setParentId(saved.parent?.id ?? ''); setOperationalAreaId(saved.operationalArea?.id ?? ''); setOperatorCompanyId(saved.operatorCompany?.id ?? ''); setLineage([]); getAssetLineage(saved.id).then(setLineage).catch(() => undefined); setHistoryRefreshKey((current) => current + 1); }; const renderAttributes = (): ReactNode => { if (definitions.length === 0) { return
Este tipo no requiere datos técnicos adicionales.
; } return
{definitions.map((definition) => { const value = attributeValues[definition.id]; const label = {definition.name}{definition.unit ? ` (${definition.unit})` : ''} {definition.isRequired ? obligatorio : opcional} ; if (definition.dataType === 'BOOLEAN') { return ; } if (definition.dataType === 'SELECT') { return ; } const inputType = definition.dataType === 'NUMBER' ? 'number' : definition.dataType === 'DATE' ? 'date' : definition.dataType === 'DATETIME' ? 'datetime-local' : 'text'; return ; })}
; }; const renderSaveActions = () => canSave &&
Cancelar
; const renderCompactStructuralForm = () =>

Datos principales

Lo que se usa todos los días para reconocer este registro.

Código DH{code} Tipo{selectedType?.name ?? 'Sin tipo'}

Ubicación actual

Dónde está contenido el elemento dentro del Inventario.

{canReadHistory && id && Cambiar / ver historial }
Registro padre {asset?.parent?.name ?? 'Sin padre'} {asset?.parent?.code && {asset.parent.code}}
Área {asset?.operationalArea?.name ?? 'Sin área asignada'} {asset?.operationalArea?.code && {asset.operationalArea.code}}
{(canChangeStatus || canChangeOperationalStatus) &&
Estado y opcionesCalidad del dato y situación operativa
{canChangeStatus && } {canChangeOperationalStatus && }
}
Más datos {description.trim() ? 'Descripción cargada' : 'Sin descripción'} {' · '} {technicalFilledCount}/{definitions.length} datos técnicos cargados
{canDirectContextEdit && }
{canDirectContextEdit &&
Registro padre {!selectedType?.canBeRoot && obligatorio} setParentSearch(event.target.value)} disabled={!canEdit} placeholder="Buscar planta, batería, estación…" /> { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit} required={!selectedType?.canBeRoot} > {parents.map((parent) => )}
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && }
}