feat(web): simplify canonical inventory details
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user