974 lines
39 KiB
TypeScript
974 lines
39 KiB
TypeScript
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<string, unknown>,
|
||
): Record<string, unknown> {
|
||
const result: Record<string, unknown> = {};
|
||
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<AssetType[]>([]);
|
||
const [asset, setAsset] = useState<AssetDetail | null>(null);
|
||
const [contextParent, setContextParent] = useState<AssetDetail | null>(null);
|
||
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
|
||
const [parents, setParents] = useState<AssetListItem[]>([]);
|
||
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<OperationalAssetSummary[]>([]);
|
||
const [operationalCompanies, setOperationalCompanies] = useState<OperationalAssetSummary[]>([]);
|
||
const [description, setDescription] = useState('');
|
||
const [status, setStatus] = useState<AssetInformationStatus>('DRAFT');
|
||
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>('UNKNOWN');
|
||
const [attributeValues, setAttributeValues] = useState<Record<string, unknown>>({});
|
||
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 <LoadingBlock label="Cargando registro…" />;
|
||
|
||
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 <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div>;
|
||
}
|
||
|
||
return <div className="dynamic-attributes">
|
||
{definitions.map((definition) => {
|
||
const value = attributeValues[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) => setAttribute(definition.id, event.target.checked)}
|
||
disabled={!canEdit}
|
||
/>
|
||
<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) => setAttribute(definition.id, event.target.value)}
|
||
disabled={!canEdit}
|
||
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 ?? '')}
|
||
onChange={(event) => setAttribute(definition.id, event.target.value)}
|
||
disabled={!canEdit}
|
||
required={definition.isRequired}
|
||
step={definition.dataType === 'NUMBER' ? 'any' : undefined}
|
||
maxLength={definition.dataType === 'TEXT' ? 4000 : undefined}
|
||
/>
|
||
</label>;
|
||
})}
|
||
</div>;
|
||
};
|
||
|
||
const renderSaveActions = () => canSave && <div className="form-actions">
|
||
<Link className="button secondary" to="/inventarios">Cancelar</Link>
|
||
<button className="button primary" disabled={saving}>
|
||
<Icon name="check" />
|
||
{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}
|
||
</button>
|
||
</div>;
|
||
|
||
const renderCompactStructuralForm = () => <form className="panel form-panel asset-compact-form" onSubmit={save}>
|
||
<section className="asset-compact-section">
|
||
<div className="asset-compact-section-heading">
|
||
<div>
|
||
<h2>Datos principales</h2>
|
||
<p className="section-copy">Lo que se usa todos los días para reconocer este registro.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-grid asset-compact-primary-grid">
|
||
<label className="field">
|
||
<span>Nombre técnico</span>
|
||
<input
|
||
value={name}
|
||
onChange={(event) => setName(event.target.value)}
|
||
disabled={!canEdit}
|
||
required
|
||
maxLength={200}
|
||
/>
|
||
</label>
|
||
<label className="field">
|
||
<span>Nombre habitual / sobrenombre <em>opcional</em></span>
|
||
<input
|
||
value={commonName}
|
||
onChange={(event) => setCommonName(event.target.value)}
|
||
disabled={!canEdit}
|
||
maxLength={200}
|
||
placeholder="Ej.: planta vieja, celda principal…"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="asset-compact-meta">
|
||
<span><small>Código DH</small><strong>{code}</strong></span>
|
||
<span><small>Tipo</small><strong>{selectedType?.name ?? 'Sin tipo'}</strong></span>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="asset-compact-section">
|
||
<div className="asset-compact-section-heading">
|
||
<div>
|
||
<h2>Ubicación actual</h2>
|
||
<p className="section-copy">Dónde está contenido el elemento dentro del Inventario.</p>
|
||
</div>
|
||
{canReadHistory && id && <Link className="button secondary asset-compact-context-button" to={`/inventarios/${id}?tab=history`}>
|
||
<Icon name="edit" />Cambiar / ver historial
|
||
</Link>}
|
||
</div>
|
||
|
||
<div className="asset-compact-context-grid">
|
||
<div className="asset-compact-context-item">
|
||
<small>Registro padre</small>
|
||
<strong>{asset?.parent?.name ?? 'Sin padre'}</strong>
|
||
{asset?.parent?.code && <span>{asset.parent.code}</span>}
|
||
</div>
|
||
<div className="asset-compact-context-item">
|
||
<small>Área</small>
|
||
<strong>{asset?.operationalArea?.name ?? 'Sin área asignada'}</strong>
|
||
{asset?.operationalArea?.code && <span>{asset.operationalArea.code}</span>}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{(canChangeStatus || canChangeOperationalStatus) && <details className="asset-compact-details">
|
||
<summary>
|
||
<span><strong>Estado y opciones</strong><small>Calidad del dato y situación operativa</small></span>
|
||
<span aria-hidden="true">⌄</span>
|
||
</summary>
|
||
<div className="asset-compact-details-body">
|
||
<div className="form-grid">
|
||
{canChangeStatus && <label className="field">
|
||
<span>Estado del dato</span>
|
||
<SearchableSelect
|
||
value={status}
|
||
onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}
|
||
disabled={!canEdit && !canChangeStatus}
|
||
>
|
||
{ASSET_STATUSES.map((item) =>
|
||
<option key={item.value} value={item.value}>{item.label}</option>)}
|
||
</SearchableSelect>
|
||
</label>}
|
||
{canChangeOperationalStatus && <label className="field">
|
||
<span>Estado operativo</span>
|
||
<SearchableSelect
|
||
value={operationalStatus}
|
||
onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}
|
||
>
|
||
{ASSET_OPERATIONAL_STATUSES.map((item) =>
|
||
<option key={item.value} value={item.value}>{item.label}</option>)}
|
||
</SearchableSelect>
|
||
</label>}
|
||
</div>
|
||
</div>
|
||
</details>}
|
||
|
||
<details className="asset-compact-details" open={missingRequiredTechnical || canDirectContextEdit}>
|
||
<summary>
|
||
<span>
|
||
<strong>Más datos</strong>
|
||
<small>
|
||
{description.trim() ? 'Descripción cargada' : 'Sin descripción'}
|
||
{' · '}
|
||
{technicalFilledCount}/{definitions.length} datos técnicos cargados
|
||
</small>
|
||
</span>
|
||
<span aria-hidden="true">⌄</span>
|
||
</summary>
|
||
<div className="asset-compact-details-body">
|
||
<div className="form-grid">
|
||
<label className="field">
|
||
<span>Código DH</span>
|
||
<input
|
||
value={code}
|
||
onChange={(event) => setCode(event.target.value.toUpperCase())}
|
||
disabled={!canEdit}
|
||
required
|
||
maxLength={120}
|
||
pattern="[A-Z0-9][A-Z0-9._/-]*"
|
||
/>
|
||
</label>
|
||
|
||
{canDirectContextEdit && <label className="field">
|
||
<span>Tipo de elemento</span>
|
||
<SearchableSelect
|
||
value={typeId}
|
||
onChange={(event) => changeType(event.target.value)}
|
||
disabled={!canEdit}
|
||
required
|
||
>
|
||
<option value="">Seleccionar…</option>
|
||
{types.filter((type) => type.isActive || type.id === typeId).map((type) =>
|
||
<option key={type.id} value={type.id}>{type.name}</option>)}
|
||
</SearchableSelect>
|
||
</label>}
|
||
</div>
|
||
|
||
{canDirectContextEdit && <div className="form-grid">
|
||
<div className="field parent-picker">
|
||
<span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span>
|
||
<input
|
||
className="parent-search"
|
||
value={parentSearch}
|
||
onChange={(event) => setParentSearch(event.target.value)}
|
||
disabled={!canEdit}
|
||
placeholder="Buscar planta, batería, estación…"
|
||
/>
|
||
<SearchableSelect
|
||
value={parentId}
|
||
onChange={(event) => {
|
||
setParentId(event.target.value);
|
||
setParentSearch('');
|
||
}}
|
||
disabled={!canEdit}
|
||
required={!selectedType?.canBeRoot}
|
||
>
|
||
<option value="">
|
||
{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}
|
||
</option>
|
||
{parents.map((parent) =>
|
||
<option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}
|
||
</SearchableSelect>
|
||
</div>
|
||
|
||
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <label className="field">
|
||
<span>Área</span>
|
||
<SearchableSelect
|
||
value={operationalAreaId}
|
||
onChange={(event) => setOperationalAreaId(event.target.value)}
|
||
disabled={!canEdit}
|
||
>
|
||
<option value="">Sin asignación operativa</option>
|
||
{operationalAreas.map((area) =>
|
||
<option key={area.id} value={area.id}>{area.name}</option>)}
|
||
</SearchableSelect>
|
||
</label>}
|
||
</div>}
|
||
|
||
<label className="field">
|
||
<span>Descripción <em>opcional</em></span>
|
||
<textarea
|
||
value={description}
|
||
onChange={(event) => setDescription(event.target.value)}
|
||
disabled={!canEdit}
|
||
maxLength={4000}
|
||
rows={2}
|
||
/>
|
||
</label>
|
||
|
||
<div className="asset-compact-technical">
|
||
<div>
|
||
<h3>Datos técnicos</h3>
|
||
<p className="section-copy">Sólo completá lo que corresponda para este tipo.</p>
|
||
</div>
|
||
{renderAttributes()}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
{renderSaveActions()}
|
||
</form>;
|
||
|
||
const renderFullForm = () => <form className="panel form-panel" onSubmit={save}>
|
||
<div className="form-section">
|
||
<div>
|
||
<h2>Identificación</h2>
|
||
<p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p>
|
||
</div>
|
||
<div className="form-grid">
|
||
<label className="field">
|
||
<span>Código DH</span>
|
||
<input
|
||
value={code}
|
||
onChange={(event) => setCode(event.target.value.toUpperCase())}
|
||
disabled={!canEdit}
|
||
required
|
||
maxLength={120}
|
||
pattern="[A-Z0-9][A-Z0-9._/-]*"
|
||
/>
|
||
</label>
|
||
<label className="field">
|
||
<span>Nombre técnico</span>
|
||
<input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} />
|
||
</label>
|
||
<label className="field">
|
||
<span>Nombre habitual / sobrenombre <em>opcional</em></span>
|
||
<input
|
||
value={commonName}
|
||
onChange={(event) => setCommonName(event.target.value)}
|
||
disabled={!canEdit}
|
||
maxLength={200}
|
||
placeholder="Ej.: tanque grande, ET vieja, batería norte…"
|
||
/>
|
||
<small>También se usa en las búsquedas del Inventario.</small>
|
||
</label>
|
||
</div>
|
||
<label className="field">
|
||
<span>Descripción <em>opcional</em></span>
|
||
<textarea
|
||
value={description}
|
||
onChange={(event) => setDescription(event.target.value)}
|
||
disabled={!canEdit}
|
||
maxLength={4000}
|
||
rows={2}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="form-section">
|
||
<div>
|
||
<h2>Ubicación en la estructura</h2>
|
||
<p className="section-copy">
|
||
Elegí qué es y dónde está contenido. La ubicación física es independiente de la Operadora del Área.
|
||
Los registros ya consolidados cambian de ubicación desde el bloque histórico inferior.
|
||
</p>
|
||
</div>
|
||
<div className="form-grid">
|
||
<label className="field">
|
||
<span>Tipo de elemento</span>
|
||
<SearchableSelect
|
||
value={typeId}
|
||
onChange={(event) => changeType(event.target.value)}
|
||
disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit}
|
||
required
|
||
>
|
||
<option value="">Seleccionar…</option>
|
||
{types.filter((type) => type.isActive || type.id === typeId).map((type) =>
|
||
<option key={type.id} value={type.id}>{type.name}</option>)}
|
||
</SearchableSelect>
|
||
</label>
|
||
|
||
<div className="field parent-picker">
|
||
<span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span>
|
||
<input
|
||
className="parent-search"
|
||
value={parentSearch}
|
||
onChange={(event) => setParentSearch(event.target.value)}
|
||
disabled={!canEdit || !canDirectContextEdit}
|
||
placeholder="Buscar planta, batería, estación…"
|
||
/>
|
||
<SearchableSelect
|
||
value={parentId}
|
||
onChange={(event) => {
|
||
setParentId(event.target.value);
|
||
setParentSearch('');
|
||
}}
|
||
disabled={!canEdit || !canDirectContextEdit}
|
||
required={!selectedType?.canBeRoot}
|
||
>
|
||
<option value="">
|
||
{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}
|
||
</option>
|
||
{parents.map((parent) =>
|
||
<option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}
|
||
</SearchableSelect>
|
||
<small>Escribí para buscar entre los registros compatibles.</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-grid">
|
||
{canChangeStatus && <label className="field">
|
||
<span>Estado del dato</span>
|
||
<SearchableSelect
|
||
value={status}
|
||
onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}
|
||
disabled={!canEdit && !canChangeStatus}
|
||
>
|
||
{ASSET_STATUSES.map((item) =>
|
||
<option key={item.value} value={item.value}>{item.label}</option>)}
|
||
</SearchableSelect>
|
||
<small>Calidad y validación del registro.</small>
|
||
</label>}
|
||
|
||
{editing && canChangeOperationalStatus && <label className="field">
|
||
<span>Estado operativo</span>
|
||
<SearchableSelect
|
||
value={operationalStatus}
|
||
onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}
|
||
>
|
||
{ASSET_OPERATIONAL_STATUSES.map((item) =>
|
||
<option key={item.value} value={item.value}>{item.label}</option>)}
|
||
</SearchableSelect>
|
||
<small>Situación física u operativa del elemento.</small>
|
||
</label>}
|
||
</div>
|
||
</div>
|
||
|
||
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section">
|
||
<div>
|
||
<h2>{editing ? 'Área física' : 'Área y operadora al alta'}</h2>
|
||
<p className="section-copy">
|
||
{editing
|
||
? 'El Inventario pertenece físicamente al Área. La Operadora vigente se administra en las relaciones temporales del Área y no se reescribe dentro del Inventario.'
|
||
: 'Al crear el registro, la Operadora activa del Área queda guardada únicamente como snapshot histórico de alta.'}
|
||
</p>
|
||
</div>
|
||
<div className="form-grid">
|
||
<label className="field">
|
||
<span>Área</span>
|
||
<SearchableSelect
|
||
value={operationalAreaId}
|
||
onChange={(event) => {
|
||
setOperationalAreaId(event.target.value);
|
||
if (!editing) setOperatorCompanyId('');
|
||
}}
|
||
disabled={!canEdit || !canDirectContextEdit}
|
||
>
|
||
<option value="">Sin asignación operativa</option>
|
||
{operationalAreas.map((area) =>
|
||
<option key={area.id} value={area.id}>{area.name}</option>)}
|
||
</SearchableSelect>
|
||
{editing && <small>La Operadora se resuelve por la relación Área↔Empresa vigente.</small>}
|
||
</label>
|
||
|
||
{!editing && <label className="field">
|
||
<span>Operadora {operationalAreaId && <em>obligatoria</em>}</span>
|
||
<SearchableSelect
|
||
value={operatorCompanyId}
|
||
onChange={(event) => setOperatorCompanyId(event.target.value)}
|
||
disabled={!canEdit || !operationalAreaId}
|
||
required={Boolean(operationalAreaId)}
|
||
>
|
||
<option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>
|
||
{operationalCompanies.map((company) =>
|
||
<option key={company.id} value={company.id}>{company.name}</option>)}
|
||
</SearchableSelect>
|
||
</label>}
|
||
</div>
|
||
|
||
{!editing && operationalAreaId && operatorCompanyId && <div className="temporal-notice">
|
||
<Icon name="check" />
|
||
<p><strong>Contexto de alta confirmado.</strong> La Operadora seleccionada se conservará como referencia histórica; futuros cambios se harán en la relación temporal del Área.</p>
|
||
</div>}
|
||
|
||
{editing && asset?.operatorCompany && <div className="temporal-notice">
|
||
<Icon name="layers" />
|
||
<p><strong>Snapshot histórico de alta:</strong> {asset.operatorCompany.name}. No se modifica desde este registro.</p>
|
||
</div>}
|
||
</div>}
|
||
|
||
<div className="form-section">
|
||
<div>
|
||
<h2>Datos técnicos</h2>
|
||
<p className="section-copy">Campos definidos para el tipo seleccionado.</p>
|
||
</div>
|
||
{renderAttributes()}
|
||
</div>
|
||
|
||
{renderSaveActions()}
|
||
</form>;
|
||
|
||
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>
|
||
<Link to={breadcrumbSection === 'companies' ? '/inventarios?section=companies' : '/inventarios?section=territory'}>
|
||
{breadcrumbSection === 'companies' ? 'Empresas' : 'Áreas y yacimientos'}
|
||
</Link>
|
||
{lineage.map((item, index) => {
|
||
const isLast = index === lineage.length - 1;
|
||
const showAsCurrent = editing ? isLast : false;
|
||
return <span className="asset-detail-crumb-part" key={item.id}>
|
||
<span>›</span>
|
||
{showAsCurrent
|
||
? <strong>{item.name}</strong>
|
||
: <Link to={`/inventarios?section=${breadcrumbSection}&parentId=${item.id}`}>{item.name}</Link>}
|
||
</span>;
|
||
})}
|
||
{!editing && <><span>›</span><strong>Nuevo registro</strong></>}
|
||
</nav>
|
||
|
||
<div className="page-heading asset-editor-heading">
|
||
<div>
|
||
<span className="eyebrow">INVENTARIO</span>
|
||
<h1>{editing ? asset?.name ?? 'Registro' : contextParent ? `Agregar en ${contextParent.name}` : 'Nuevo registro'}</h1>
|
||
<p>
|
||
{editing
|
||
? `${asset?.type.name} · ${asset?.code}`
|
||
: contextParent
|
||
? `El sistema heredará el contexto disponible de ${contextParent.code}.`
|
||
: 'Creá una entidad con identidad, jerarquía y atributos propios.'}
|
||
</p>
|
||
</div>
|
||
<div className="asset-heading-actions">
|
||
{editing && asset && <div className="heading-statuses">
|
||
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>
|
||
{assetStatusLabel(asset.informationStatus)}
|
||
</span>
|
||
<small>{assetOperationalStatusLabel(asset.operationalStatus)}</small>
|
||
</div>}
|
||
{editing && id && canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${id}`}>
|
||
<Icon name="plus" />Agregar registro aquí
|
||
</Link>}
|
||
</div>
|
||
</div>
|
||
|
||
{error && <Alert>{error}</Alert>}
|
||
{success && <Alert type="success">{success}</Alert>}
|
||
|
||
{editing && <nav className="asset-detail-tabs">
|
||
{detailTabs.filter((item) => item.show).map((item) =>
|
||
<Link
|
||
key={item.key}
|
||
className={tab === item.key ? 'active' : ''}
|
||
to={`/inventarios/${id}${item.key === 'summary' ? '' : `?tab=${item.key}`}`}
|
||
>
|
||
{item.label}
|
||
</Link>)}
|
||
</nav>}
|
||
|
||
{(!editing || tab === 'summary') && <>
|
||
{contextParent && !editing && <div className="context-create-banner">
|
||
<Icon name="layers" />
|
||
<div><strong>Alta contextual</strong><span>{contextParent.name} · {contextParent.code}</span></div>
|
||
<Link to={`/inventarios/${contextParent.id}`}>Ver padre</Link>
|
||
</div>}
|
||
|
||
{compactStructuralSummary ? renderCompactStructuralForm() : renderFullForm()}
|
||
|
||
{!compactStructuralSummary && editing && id && asset && selectedType && canReadHistory
|
||
&& <AssetContextHistoryPanel
|
||
asset={asset}
|
||
type={selectedType}
|
||
canManage={canManageContext}
|
||
onChanged={handleContextChanged}
|
||
/>}
|
||
|
||
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC'
|
||
&& <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
|
||
</>}
|
||
|
||
{editing && id && asset && tab === 'findings' && canReadFindingCatalog
|
||
&& <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
|
||
|
||
{editing && id && asset && tab === 'dossier' && canReadDossier
|
||
&& <AssetDossierPanel assetId={id} />}
|
||
|
||
{editing && id && asset && tab === 'location'
|
||
&& <Suspense fallback={<div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>}>
|
||
<AssetGeometryEditor
|
||
assetId={id}
|
||
assetName={asset.name}
|
||
canEdit={canEditGeometry}
|
||
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
|
||
/>
|
||
</Suspense>}
|
||
|
||
{editing && id && asset && tab === 'registry' && <div className="asset-tab-stack">
|
||
{canReadRegistry && selectedType && <AssetRegistryPanel
|
||
assetId={id}
|
||
assetName={asset.name}
|
||
role={selectedType.operationalRole}
|
||
canManage={canManageRegistry}
|
||
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
|
||
/>}
|
||
{canReadProvenance && <AssetProvenancePanel
|
||
assetId={id}
|
||
canManage={canManageProvenance}
|
||
canVerify={canVerifyProvenance}
|
||
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
|
||
/>}
|
||
</div>}
|
||
|
||
{editing && id && asset && tab === 'files' && canReadMedia
|
||
&& <AssetMediaPanel
|
||
assetId={id}
|
||
assetName={asset.name}
|
||
canManage={canManageMedia}
|
||
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
|
||
/>}
|
||
|
||
{editing && id && tab === 'history' && canReadHistory && <div className="asset-tab-stack">
|
||
{compactStructuralSummary && asset && selectedType && <AssetContextHistoryPanel
|
||
asset={asset}
|
||
type={selectedType}
|
||
canManage={canManageContext}
|
||
onChanged={handleContextChanged}
|
||
/>}
|
||
<AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />
|
||
</div>}
|
||
</section>;
|
||
}
|