feat(web): simplify canonical inventory details

This commit is contained in:
2026-09-11 22:11:28 -03:00
parent 5695643fa8
commit 1b769a0ff4
@@ -0,0 +1,350 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
import { AssetTechnicalDataPanel } from '../features/assets/AssetTechnicalDataPanel';
import {
ASSET_OPERATIONAL_STATUSES,
ASSET_STATUSES,
assetOperationalStatusLabel,
assetStatusClass,
assetStatusLabel,
} from '../features/assets/assetPresentation';
import {
getAssetLineage,
listAssetTreeChildren,
updateAsset,
updateAssetInformationStatus,
updateAssetOperationalStatus,
} from '../lib/api';
import type {
AssetDetail,
AssetInformationStatus,
AssetLineageItem,
AssetListItem,
AssetOperationalStatus,
} from '../lib/api';
import { getInventoryTechnicalValues } from '../lib/inventoryStructureApi';
import type { InventoryTechnicalValues } from '../lib/inventoryStructureApi';
import './SimpleInventoryDetailPage.css';
const AssetGeometryEditor = lazy(() =>
import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })),
);
export type SimpleInventoryKind =
| 'DEPARTAMENTO'
| 'AREA'
| 'YACIMIENTO'
| 'INSTALACION'
| 'SUBINSTALACION';
type SimpleTab = 'summary' | 'activity' | 'findings' | 'location' | 'photos' | 'history';
function normalized(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[_\s]+/g, '-');
}
export function simpleInventoryKind(asset: AssetDetail): SimpleInventoryKind | null {
const code = normalized(asset.type.code);
const name = normalized(asset.type.name);
const values = new Set([code, name]);
if (values.has('departamento')) return 'DEPARTAMENTO';
if (values.has('area')) return 'AREA';
if (values.has('yacimiento')) return 'YACIMIENTO';
if (values.has('subinstalacion')) return 'SUBINSTALACION';
if (
values.has('instalacion')
|| values.has('instalacion-de-superficie')
|| values.has('instalacion-superficie')
) return 'INSTALACION';
return null;
}
export function isSimpleInventoryAsset(asset: AssetDetail): boolean {
return simpleInventoryKind(asset) !== null;
}
const labels: Record<SimpleInventoryKind, string> = {
DEPARTAMENTO: 'Departamento',
AREA: 'Área',
YACIMIENTO: 'Yacimiento',
INSTALACION: 'Instalación',
SUBINSTALACION: 'Subinstalación',
};
const childLabels: Partial<Record<SimpleInventoryKind, string>> = {
DEPARTAMENTO: 'Área',
AREA: 'Yacimiento',
YACIMIENTO: 'Instalación',
INSTALACION: 'Subinstalación',
};
function lineageLabel(item: AssetLineageItem): string {
return item.commonName ? `${item.name} · ${item.commonName}` : item.name;
}
export function SimpleInventoryDetailPage({ initialAsset }: { initialAsset: AssetDetail }) {
const [params, setParams] = useSearchParams();
const { user, hasPermission } = useAuth();
const kind = simpleInventoryKind(initialAsset);
const [asset, setAsset] = useState(initialAsset);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [childrenHasMore, setChildrenHasMore] = useState(false);
const [technical, setTechnical] = useState<InventoryTechnicalValues | null>(null);
const [loadingRelated, setLoadingRelated] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState(initialAsset.name);
const [commonName, setCommonName] = useState(initialAsset.commonName ?? '');
const [description, setDescription] = useState(initialAsset.description ?? '');
const [status, setStatus] = useState<AssetInformationStatus>(initialAsset.informationStatus);
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>(initialAsset.operationalStatus);
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create');
const canChangeStatus = hasPermission('assets.change_status');
const canChangeOperationalStatus = hasPermission('assets.change_operational_status');
const canReadHistory = hasPermission('assets.read_history');
const canReadMedia = hasPermission('assets.read_media');
const canManageMedia = hasPermission('assets.manage_media');
const canEditGeometry = hasPermission('assets.update_geometry');
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 canAdvanced = hasAdministratorRole(user);
const inspectable = kind === 'YACIMIENTO' || kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const technicalLevel = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const hasChildren = kind !== 'SUBINSTALACION';
const requestedTab = params.get('tab') as SimpleTab | null;
const availableTabs = useMemo(() => new Set<SimpleTab>([
'summary',
...(inspectable && canReadDossier ? ['activity' as const] : []),
...(technicalLevel && canReadFindingCatalog ? ['findings' as const] : []),
'location',
...(canReadMedia ? ['photos' as const] : []),
...(canReadHistory ? ['history' as const] : []),
]), [inspectable, technicalLevel, canReadDossier, canReadFindingCatalog, canReadMedia, canReadHistory]);
const tab: SimpleTab = requestedTab && availableTabs.has(requestedTab) ? requestedTab : 'summary';
const loadRelated = async () => {
setLoadingRelated(true);
try {
const [loadedLineage, childPage, loadedTechnical] = await Promise.all([
getAssetLineage(asset.id),
hasChildren
? listAssetTreeChildren({ parentId: asset.id, limit: 100 })
: Promise.resolve({ data: [] as AssetListItem[], meta: { count: 0, hasMore: false } }),
technicalLevel
? getInventoryTechnicalValues(asset.id).catch(() => null)
: Promise.resolve(null),
]);
setLineage(loadedLineage);
setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore);
setTechnical(loadedTechnical);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoadingRelated(false);
}
};
useEffect(() => { void loadRelated(); }, [asset.id]);
if (!kind) return <Alert>Este registro no pertenece al modelo simple de Inventarios.</Alert>;
const save = async (event: FormEvent) => {
event.preventDefault();
if (!name.trim()) return;
setSaving(true);
setError('');
setSuccess('');
try {
let saved = asset;
if (canEdit) {
saved = await updateAsset(asset.id, {
name: name.trim(),
commonName: commonName.trim() || null,
description: description.trim() || null,
});
}
if (canChangeStatus && saved.informationStatus !== status) {
saved = await updateAssetInformationStatus(asset.id, status);
}
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
saved = await updateAssetOperationalStatus(asset.id, operationalStatus);
}
setAsset(saved);
setName(saved.name);
setCommonName(saved.commonName ?? '');
setDescription(saved.description ?? '');
setStatus(saved.informationStatus);
setOperationalStatus(saved.operationalStatus);
setEditing(false);
setHistoryRefreshKey((current) => current + 1);
setSuccess('Registro actualizado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const cancelEdit = () => {
setEditing(false);
setName(asset.name);
setCommonName(asset.commonName ?? '');
setDescription(asset.description ?? '');
setStatus(asset.informationStatus);
setOperationalStatus(asset.operationalStatus);
};
const setTab = (next: SimpleTab) => {
const nextParams = new URLSearchParams(params);
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
nextParams.delete('advanced');
setParams(nextParams);
};
const childLabel = childLabels[kind];
const parentLabel = kind === 'AREA'
? 'Departamento'
: kind === 'YACIMIENTO'
? 'Área'
: kind === 'INSTALACION'
? 'Yacimiento'
: kind === 'SUBINSTALACION'
? 'Instalación'
: null;
return <section className="narrow-section simple-inventory-page">
<nav className="breadcrumb simple-inventory-breadcrumb" aria-label="Ruta de Inventario">
<Link to="/inventarios">Inventarios</Link>
{lineage.filter((item) => item.id !== asset.id).map((item) => <span className="simple-inventory-breadcrumb-part" key={item.id}><span></span><Link to={`/inventarios/${item.id}`}>{lineageLabel(item)}</Link></span>)}
<span></span><strong>{asset.name}</strong>
</nav>
<header className="simple-inventory-hero">
<div>
<span className="eyebrow">{labels[kind].toUpperCase()}</span>
<h1>{asset.name}</h1>
<div className="simple-inventory-hero-meta"><strong>{asset.code}</strong>{asset.commonName && <span>{asset.commonName}</span>}</div>
</div>
<div className="simple-inventory-hero-actions">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
{tab === 'summary' && (canEdit || canChangeStatus || canChangeOperationalStatus) && <button type="button" className="button secondary" onClick={() => editing ? cancelEdit() : setEditing(true)}><Icon name="edit" />{editing ? 'Cancelar' : 'Editar'}</button>}
{tab === 'summary' && canCreate && childLabel && <Link className="button primary" to={`/inventarios/nuevo?parentId=${asset.id}`}><Icon name="plus" />Agregar {childLabel.toLowerCase()}</Link>}
</div>
</header>
<div className="simple-inventory-purpose">
<Icon name={inspectable ? 'clipboard' : 'layers'} />
<span>{inspectable
? 'Ficha operativa: identidad, ubicación y actividad de inspección. La información administrativa avanzada queda fuera de la vista cotidiana.'
: 'Este nivel organiza la estructura del Inventario. La vista cotidiana muestra sólo los datos necesarios para ubicar y navegar.'}</span>
</div>
<nav className="simple-inventory-tabs" aria-label="Secciones del Inventario">
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
{inspectable && canReadDossier && <button type="button" className={tab === 'activity' ? 'active' : ''} onClick={() => setTab('activity')}>Actividad</button>}
{technicalLevel && canReadFindingCatalog && <button type="button" className={tab === 'findings' ? 'active' : ''} onClick={() => setTab('findings')}>Hallazgos</button>}
<button type="button" className={tab === 'location' ? 'active' : ''} onClick={() => setTab('location')}>Ubicación</button>
{canReadMedia && <button type="button" className={tab === 'photos' ? 'active' : ''} onClick={() => setTab('photos')}>Fotos</button>}
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Cambios</button>}
</nav>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{tab === 'summary' && <div className="simple-inventory-stack">
<article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">DATOS PRINCIPALES</span><h2>Identificación</h2></div></div>
{editing ? <form className="simple-inventory-form" onSubmit={save}>
<div className="form-grid two">
<label className="field"><span>Nombre <em>obligatorio</em></span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} /></label>
<label className="field"><span>Nombre habitual <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} /></label>
</div>
<details className="simple-inventory-details">
<summary>Más datos</summary>
<label className="field"><span>Descripción <em>opcional</em></span><textarea rows={3} value={description} onChange={(event) => setDescription(event.target.value)} maxLength={4000} /></label>
</details>
{(canChangeStatus || canChangeOperationalStatus) && <details className="simple-inventory-details">
<summary>Estado y opciones</summary>
<div className="form-grid two">
{canChangeStatus && <label className="field"><span>Estado del dato</span><select value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>}
{canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><select 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>)}</select></label>}
</div>
</details>}
<div className="form-actions"><button type="button" className="button secondary" onClick={cancelEdit}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar cambios'}</button></div>
</form> : <div className="simple-inventory-data-grid">
<div><small>Nombre</small><strong>{asset.name}</strong></div>
<div><small>Código DH</small><strong>{asset.code}</strong></div>
{asset.commonName && <div><small>Nombre habitual</small><strong>{asset.commonName}</strong></div>}
<div><small>Estado operativo</small><strong>{assetOperationalStatusLabel(asset.operationalStatus)}</strong></div>
</div>}
</article>
<article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">UBICACIÓN ACTUAL</span><h2>Dentro del Inventario</h2></div></div>
<div className="simple-inventory-context-grid">
{parentLabel && <div className="simple-inventory-context-card"><small>{parentLabel}</small>{asset.parent ? <Link to={`/inventarios/${asset.parent.id}`}><strong>{asset.parent.name}</strong><span>{asset.parent.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{kind === 'YACIMIENTO' && <div className="simple-inventory-context-card"><small>Empresa operadora</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="simple-inventory-context-card"><small>Empresa del Yacimiento</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{technicalLevel && <div className="simple-inventory-context-card"><small>Tipo técnico</small><strong>{technical?.family.name ?? 'Sin clasificación técnica'}</strong>{technical?.family.code && <span>{technical.family.code}</span>}</div>}
</div>
</article>
{hasChildren && <article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">CONTENIDO</span><h2>{childLabel ? `${childLabel}s dentro de ${asset.name}` : `Contenido de ${asset.name}`}</h2></div><span className="count-pill">{children.length}{childrenHasMore ? '+' : ''}</span></div>
{loadingRelated ? <LoadingBlock label="Cargando contenido…" /> : children.length === 0 ? <div className="inline-empty">Todavía no hay registros dentro de {asset.name}.</div> : <div className="simple-inventory-child-list">{children.map((child) => <Link key={child.id} to={`/inventarios/${child.id}`} className="simple-inventory-child-row"><span className="asset-symbol"><Icon name="layers" /></span><span><strong>{child.name}</strong><small>{child.type.name} · {child.code}</small></span><span className={`status-badge ${assetStatusClass(child.informationStatus)}`}>{assetStatusLabel(child.informationStatus)}</span><Icon name="chevron" size={16} /></Link>)}</div>}
</article>}
{technicalLevel && <details className="panel simple-inventory-card simple-inventory-technical-details">
<summary><span><small>DATOS TÉCNICOS</small><strong>{technical?.family.name ?? 'Información técnica'}</strong></span><span>Ver / editar</span></summary>
<AssetTechnicalDataPanel assetId={asset.id} canEdit={canEdit} />
</details>}
{!editing && asset.description && <details className="panel simple-inventory-card simple-inventory-extra-details">
<summary>Más datos</summary>
<p>{asset.description}</p>
</details>}
{!editing && <details className="panel simple-inventory-card simple-inventory-extra-details">
<summary>Estado y opciones</summary>
<div className="simple-inventory-data-grid">
<div><small>Estado del dato</small><strong>{assetStatusLabel(asset.informationStatus)}</strong></div>
<div><small>Estado operativo</small><strong>{assetOperationalStatusLabel(asset.operationalStatus)}</strong></div>
</div>
{canAdvanced && <div className="simple-inventory-advanced"><span>Las herramientas históricas y administrativas quedan separadas de esta ficha.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>}
</details>}
</div>}
{tab === 'activity' && inspectable && canReadDossier && <AssetDossierPanel assetId={asset.id} />}
{tab === 'findings' && technicalLevel && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={asset.id} canManage={canManageFindingCatalog} />}
{tab === 'location' && <Suspense fallback={<LoadingBlock label="Cargando mapa…" />}><AssetGeometryEditor assetId={asset.id} assetName={asset.name} canEdit={canEditGeometry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} /></Suspense>}
{tab === 'photos' && canReadMedia && <AssetMediaPanel assetId={asset.id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} refreshKey={historyRefreshKey} />}
</section>;
}