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

277 lines
14 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 { 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 { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import {
getAssetLineage,
listAreaCompanyRelations,
listAssetTreeChildren,
listInspectionVisits,
updateAsset,
} from '../lib/api';
import type { AreaCompanyRelation, AssetDetail, AssetLineageItem, AssetListItem } from '../lib/api';
import {
listInspectionActsGlobalF4,
type InspectionActStatusF4,
} from '../lib/inspectionActF4Api';
import { listInspectionReportsF4 } from '../lib/reportWorkflowApi';
import './territorialInventory.css';
type TerritoryTab = 'summary' | 'history';
function normalized(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function tokensForType(type: { code: string; name: string }) {
return new Set(`${normalized(type.code)} ${normalized(type.name)}`.split(' ').filter(Boolean));
}
function typeTokens(asset: AssetDetail) {
return tokensForType(asset.type);
}
export function isTerritorialInventoryAsset(asset: AssetDetail) {
const tokens = typeTokens(asset);
return tokens.has('departamento') || tokens.has('area') || tokens.has('yacimiento');
}
function isOperationalArea(asset: AssetDetail) {
return typeTokens(asset).has('area');
}
function isYacimiento(asset: AssetDetail) {
return typeTokens(asset).has('yacimiento');
}
function lineageArea(lineage: AssetLineageItem[]) {
return [...lineage].reverse().find((item) => tokensForType(item.type).has('area')) ?? null;
}
function relationRoleLabel(role: AreaCompanyRelation['relationRole']) {
if (role === 'OPERATOR') return 'Operadora';
if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica';
if (role === 'CONCESSIONAIRE') return 'Concesionaria / titular';
if (role === 'PERMIT_HOLDER') return 'Permisionaria';
if (role === 'PARTICIPANT') return 'Participante';
return 'Vinculada';
}
export function TerritorialInventoryPage({ initialAsset }: { initialAsset: AssetDetail }) {
const [params, setParams] = useSearchParams();
const requestedTab = params.get('tab') as TerritoryTab | null;
const tab: TerritoryTab = requestedTab === 'history' ? 'history' : 'summary';
const { user, hasPermission } = useAuth();
const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create');
const canReadHistory = hasPermission('assets.read_history');
const canReadRelations = hasPermission('asset_relations.read');
const canReadActs = hasPermission('inspection_acts.read');
const canReadReports = hasPermission('inspection_reports.read');
const canReadInspections = hasPermission('inspections.read');
const canAdvanced = hasAdministratorRole(user);
const [asset, setAsset] = useState(initialAsset);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [childrenHasMore, setChildrenHasMore] = useState(false);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState(initialAsset.name);
const [description, setDescription] = useState(initialAsset.description ?? '');
const [metrics, setMetrics] = useState({ activeInspections: 0, acts: 0, reports: 0 });
const area = isOperationalArea(asset);
const yacimiento = isYacimiento(asset);
const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]);
const relationAreaContext = useMemo(() => {
if (area) return { id: asset.id, code: asset.code, name: asset.name };
if (asset.operationalArea) return asset.operationalArea;
const ancestor = lineageArea(lineage);
return ancestor ? { id: ancestor.id, code: ancestor.code, name: ancestor.name } : null;
}, [area, asset.id, asset.code, asset.name, asset.operationalArea, lineage]);
const loadSummary = async () => {
setLoading(true);
setError('');
try {
const loadedLineage = await getAssetLineage(asset.id);
const ancestorArea = lineageArea(loadedLineage);
const relationAreaId = area
? asset.id
: asset.operationalArea?.id ?? ancestorArea?.id ?? null;
const [childPage, loadedRelations] = await Promise.all([
listAssetTreeChildren({ parentId: asset.id, limit: 100 }),
relationAreaId && canReadRelations
? listAreaCompanyRelations({ areaId: relationAreaId, includeHistory: true })
: Promise.resolve([]),
]);
setLineage(loadedLineage);
setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore);
setRelations(loadedRelations);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoading(false);
}
};
useEffect(() => { void loadSummary(); }, [asset.id, area, canReadRelations]);
useEffect(() => {
if (!area) {
setMetrics({ activeInspections: 0, acts: 0, reports: 0 });
return;
}
let cancelled = false;
const loadMetrics = async () => {
try {
const actStatuses: InspectionActStatusF4[] = ['SEALED', 'CLOSED', 'RECTIFIED'];
const [planned, inProgress, actPages, reportPage] = await Promise.all([
canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'PLANNED', pageSize: 1 }) : Promise.resolve(null),
canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'IN_PROGRESS', pageSize: 1 }) : Promise.resolve(null),
canReadActs
? Promise.all(actStatuses.map((status) => listInspectionActsGlobalF4({ areaId: asset.id, status, pageSize: 1 })))
: Promise.resolve([]),
canReadReports ? listInspectionReportsF4({ areaId: asset.id, pageSize: 1 }) : Promise.resolve(null),
]);
if (cancelled) return;
setMetrics({
activeInspections: (planned?.meta.total ?? 0) + (inProgress?.meta.total ?? 0),
acts: actPages.reduce((total, page) => total + page.meta.total, 0),
reports: reportPage?.meta.total ?? 0,
});
} catch {
if (!cancelled) setMetrics({ activeInspections: 0, acts: 0, reports: 0 });
}
};
void loadMetrics();
return () => { cancelled = true; };
}, [asset.id, area, canReadActs, canReadReports, canReadInspections]);
const save = async (event: FormEvent) => {
event.preventDefault();
if (!canEdit || !name.trim()) return;
setSaving(true); setError(''); setSuccess('');
try {
const saved = await updateAsset(asset.id, {
name: name.trim(),
description: description.trim() || null,
});
setAsset(saved);
setName(saved.name);
setDescription(saved.description ?? '');
setEditing(false);
setSuccess('Datos actualizados.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const setTab = (next: TerritoryTab) => {
const nextParams = new URLSearchParams(params);
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
nextParams.delete('advanced');
setParams(nextParams);
};
return <section className="narrow-section territory-page">
<nav className="breadcrumb territory-breadcrumb" aria-label="Ruta territorial">
<Link to="/inventarios">Inventarios</Link><span></span>
<Link to="/inventarios?section=territory">Áreas y yacimientos</Link><span></span><strong>{asset.name}</strong>
</nav>
<header className="territory-hero">
<div>
<span className="eyebrow">{asset.type.name.toUpperCase()}</span>
<h1>{asset.name}</h1>
<p><strong>{asset.code}</strong></p>
</div>
<div className="territory-hero-actions">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
{canEdit && tab === 'summary' && <button type="button" className="button secondary" onClick={() => setEditing((current) => !current)}><Icon name="edit" />{editing ? 'Cancelar edición' : 'Editar'}</button>}
{canCreate && tab === 'summary' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${asset.id}`}><Icon name="plus" />Agregar elemento</Link>}
</div>
</header>
<div className="territory-purpose-note"><Icon name="map" /><span>Este nivel organiza el territorio y la navegación. Los hallazgos se cargan sobre elementos inspeccionables, no sobre {asset.type.name.toLowerCase()}s.</span></div>
<nav className="territory-tabs" aria-label="Secciones territoriales">
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Historial</button>}
</nav>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{tab === 'summary' && <div className="territory-stack">
<article className="panel territory-card">
<div className="territory-card-heading">
<div><span className="eyebrow">DATOS BÁSICOS</span><h2>Identificación</h2></div>
</div>
{editing ? <form className="territory-edit-form" onSubmit={save}>
<label className="field"><span>Nombre</span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} /></label>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} maxLength={4000} /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => { setEditing(false); setName(asset.name); setDescription(asset.description ?? ''); }}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar'}</button></div>
</form> : <div className="territory-data-grid">
<div><small>Tipo</small><strong>{asset.type.name}</strong></div>
<div><small>Código DH</small><strong>{asset.code}</strong></div>
{asset.description && <div className="wide"><small>Descripción</small><strong>{asset.description}</strong></div>}
</div>}
</article>
<article className="panel territory-card">
<div className="territory-card-heading">
<div><span className="eyebrow">CONTENIDO</span><h2>Registros dentro de {asset.name}</h2><p>Acceso directo a los niveles que dependen de este registro.</p></div>
<span className="count-pill">{children.length}{childrenHasMore ? '+' : ''}</span>
</div>
{loading ? <LoadingBlock label="Cargando contenido…" /> : children.length === 0 ? <div className="inline-empty">Todavía no hay registros dentro de {asset.name}.</div> : <div className="territory-child-list">
{children.map((child) => <Link to={`/inventarios/${child.id}`} key={child.id} className="territory-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>}
{childrenHasMore && <div className="territory-card-actions"><Link className="button secondary" to={`/inventarios?section=territory&parentId=${asset.id}`}>Ver contenido completo</Link></div>}
</article>
{relationAreaContext && canReadRelations && <article className="panel territory-card">
<div className="territory-card-heading"><div><span className="eyebrow">OPERACIÓN</span><h2>Empresas vinculadas</h2><p>{area ? 'Quién opera o participa actualmente en esta área.' : yacimiento ? `Empresas que operan o participan en el Área ${relationAreaContext.name}, contexto operativo de este yacimiento.` : `Empresas vinculadas al Área ${relationAreaContext.name}.`}</p></div><span className="count-pill">{activeRelations.length}</span></div>
{loading ? <LoadingBlock label="Cargando empresas…" /> : activeRelations.length === 0 ? <div className="inline-empty">No hay empresas con vínculo vigente en el Área {relationAreaContext.name}.</div> : <div className="territory-company-list">
{activeRelations.map((relation) => <Link to={`/inventarios/${relation.company.id}`} key={relation.id} className="territory-company-row"><span><strong>{relation.company.name}</strong><small>{relationRoleLabel(relation.relationRole)}{!area ? ` · ${relation.area.name}` : ''}</small></span><Icon name="chevron" size={16} /></Link>)}
</div>}
</article>}
{area && (canReadInspections || canReadActs || canReadReports) && <article className="panel territory-card">
<div className="territory-card-heading"><div><span className="eyebrow">ACTIVIDAD</span><h2>Actividad del área</h2><p>Resumen documental y operativo, sin mezclarlo con hallazgos de los elementos inspeccionados.</p></div></div>
<div className="territory-metrics">
{canReadInspections && <div><small>Inspecciones activas</small><strong>{metrics.activeInspections}</strong></div>}
{canReadActs && <div><small>Actas emitidas</small><strong>{metrics.acts}</strong></div>}
{canReadReports && <div><small>Informes</small><strong>{metrics.reports}</strong></div>}
</div>
</article>}
{canAdvanced && <div className="territory-advanced-link"><span>Los datos estructurales, procedencia, ubicación y opciones técnicas quedan fuera de la vista cotidiana.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>}
</div>}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} />}
</section>;
}