Files
dh-inspeccion-v2/web-v2/src/pages/SimpleInventoryDetailPage.tsx
T

351 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}