F5 · Inventario operativo, territorio y catálogo autorizado (#25)

* fix(web): simplify inventory administration menu

* fix(web): remove legacy imports and function catalog routes

* fix(web): remove redundant inspections lifecycle legend

* feat(inventory): distinguish physical instances from structural records

* fix(dashboard): align inventory and act follow-up metrics

* fix(web): align dashboard summary contract

* fix(web): clarify dashboard act and report concepts

* feat(inventory): mark field-created records as real instances

* feat(inventory): map physical instance flag on asset entity

* fix(inventory): keep field yacimientos structural

* feat(inventory): classify future concrete instances at database level

* fix(inventory): count only installation and subinstallation instances

* feat(inventory): add authoritative F5 source snapshot

* feat(inventory): preload authoritative territory model

* feat(inventory): preload authoritative technical catalog

* fix(findings): use only authoritative F5 family catalog

* fix(inventory): preserve non-hierarchical operator snapshot compatibility

* feat(inventory): add inventory-only asset filter

* feat(inventory): add inventory-only tree filter

* feat(inventory): add inventory browser query contract

* feat(inventory): add area-owned inventory browser

* feat(inventory): expose area-owned inventory browser

* refactor(inventory): remove function catalog and add inventory browser

* fix(inventory): make operator relation temporal and non-owning

* feat(web): add inventory browser API client

* feat(inventory): extend inventory browser filters

* feat(inventory): add real inventory list endpoint logic

* feat(inventory): expose real inventory list

* feat(web): add real inventory list client

* refactor(web): make inventory hierarchy area-owned

* fix(web): show only real inventory instances

* fix(web): style act follow-up tabs and F5 inventory context

* fix(web): load F5 flow styles

* fix(inventory): apply area-owned operational guard on F5 up

* fix(inventory): treat company on asset as non-owning creation snapshot

* fix(inventory): resolve field inventory by area hierarchy, not company ownership

* fix(inventory): preserve custom catalog and apply authoritative universal findings

* fix(inventory): harden authoritative catalog migration checks

* fix(inventory): make authoritative territory preload safely reversible

* feat(inventory): allow independent company master creation

* fix(inventory): make guided creation area-owned and support companies

* feat(web): expose independent company master in inventory setup

* feat(web): create companies independently from physical inventory hierarchy

* fix(inventory): merge by physical area and preserve sealed documents

* test(inventory): lock F5 authoritative model and merge invariants

* feat(inventory): add family administration DTOs

* feat(inventory): administer installation and subinstallation classifications

* feat(inventory): expose family classification administration

* feat(web): add inventory classification administration API

* fix(web): configure finding applicability by inventory classification

* fix(web): redefine inventory configuration around hierarchy classifications and columns

* chore(release): identify F5 inventory model

* chore(release): bump API for F5 inventory model

* test(release): expect F5 health metadata

* chore(release): align WEB package with F5 inventory cut

* chore(release): expose F5 WEB phase

* test(dashboard): expect inspector activity and act follow-up metrics

* test(dashboard): route F5 summary query mocks explicitly

* ci: rehearse all migrations on clean PostGIS before merge

* ci: prove F5 migrations revert and reapply cleanly

* test(f5): align operational navigation contract

* test(f5): align operator lifecycle with area-owned inventory

* test(f5): make merge compatibility area-based

* test(f5): distinguish literal and normalized yacimiento counts

* test(f5): model normalized yacimiento collision explicitly

* ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal

* ci(f5): bypass irreversible historical reset in clean rehearsal

* fix(f5): make territory SQL parameter types explicit

* fix(f5): guarantee canonical inventory hierarchy before territory preload

* ci(f5): include canonical hierarchy migration in rollback gate

* fix(f5): type relation backup markers explicitly

* fix(f5): make catalog SQL text parameter types explicit
This commit is contained in:
2026-09-08 23:18:15 -03:00
committed by GitHub
parent 1dc3282055
commit 35d4630581
49 changed files with 3988 additions and 669 deletions
+127 -184
View File
@@ -1,108 +1,87 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem } from '../../lib/api';
import {
getAsset,
getAssetLineage,
listAreasForCompany,
listCompaniesForArea,
listAssetTreeChildren,
listOperationalAreas,
listOperationalCompanies,
} from '../../lib/api';
import type { AssetDetail, AssetLineageItem, AssetListItem, OperationalAssetSummary } from '../../lib/api';
listInventoryAreas,
listInventoryChildren,
} from '../../lib/inventoryBrowserApi';
import type {
InventoryBrowserArea,
InventoryBrowserItem,
InventoryQuery,
} from '../../lib/inventoryBrowserApi';
import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation';
type TreeFilters = Omit<NonNullable<Parameters<typeof listAssetTreeChildren>[0]>, 'parentId' | 'limit'>;
type NavigationSection = 'companies' | 'territory';
type ChildGroupKey = 'fields' | 'installations' | 'wells' | 'equipment';
const INSTALLATION_CODES = new Set([
'estructura_local', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion', 'subestacion',
'zona_bombas', 'sistema_drenaje', 'sistema_electrico_iluminacion', 'sistema_defensa_incendios',
'pileta_api', 'cargadero_descargadero',
]);
const GROUP_ORDER: ChildGroupKey[] = ['fields', 'installations', 'wells', 'equipment'];
const GROUP_LABELS: Record<ChildGroupKey, { title: string; description: string }> = {
fields: { title: 'Yacimientos', description: 'Unidades territoriales u operativas dentro del Área.' },
installations: { title: 'Instalaciones y estructura', description: 'Plantas, baterías, estaciones, locaciones y niveles estructurales.' },
wells: { title: 'Pozos', description: 'Pozos identificados dentro del contexto seleccionado.' },
equipment: { title: 'Equipos y otros elementos', description: 'Equipos técnicos y demás elementos registrados en el inventario.' },
};
function childGroup(item: AssetListItem): ChildGroupKey {
const code = item.type.code.toLowerCase();
if (code === 'yacimiento') return 'fields';
if (INSTALLATION_CODES.has(code)) return 'installations';
if (code === 'pozo') return 'wells';
return 'equipment';
}
function normalizeSearch(value: string | undefined) {
return value?.trim().toLocaleLowerCase('es-AR') ?? '';
}
function matchesSearch(item: { name: string; code: string; commonName?: string | null }, search?: string) {
if (!search) return true;
const term = normalizeSearch(search);
return item.name.toLocaleLowerCase('es-AR').includes(term) || item.code.toLocaleLowerCase('es-AR').includes(term) || Boolean(item.commonName?.toLocaleLowerCase('es-AR').includes(term));
}
function navigationHref(base: URLSearchParams, section: NavigationSection, options: { companyId?: string; parentId?: string } = {}) {
function navigationHref(base: URLSearchParams, parentId?: string) {
const params = new URLSearchParams(base);
params.delete('view');
params.delete('page');
params.delete('operationalAreaId');
params.delete('operatorCompanyId');
params.set('section', section);
options.companyId ? params.set('companyId', options.companyId) : params.delete('companyId');
options.parentId ? params.set('parentId', options.parentId) : params.delete('parentId');
return `/inventarios?${params}`;
params.delete('section');
params.delete('companyId');
parentId ? params.set('parentId', parentId) : params.delete('parentId');
return `/inventarios${params.size ? `?${params}` : ''}`;
}
function AssetCard({ item, href }: { item: AssetListItem; href: string }) {
function AreaCard({ area, href }: { area: InventoryBrowserArea; href: string }) {
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="layers" size={17} /></span>
<span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
<span className="asset-browser-item-main">
<strong>{item.name}</strong>
<small>{item.code} · {item.type.name}{item.commonName ? ` · ${item.commonName}` : ''}</small>
<strong>{area.name}</strong>
<small>
{area.code} · {area.yacimientoCount} yacimiento{area.yacimientoCount === 1 ? '' : 's'}
{area.currentOperator ? ` · Operadora vigente: ${area.currentOperator.name}` : ' · Sin operadora vigente'}
</small>
</span>
<span className="asset-browser-item-status">
<span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span>
<small>{assetOperationalStatusLabel(item.operationalStatus)}</small>
<strong>{area.inventoryCount}</strong>
<small>instancia{area.inventoryCount === 1 ? '' : 's'} real{area.inventoryCount === 1 ? '' : 'es'}</small>
</span>
<Icon name="chevron" size={16} />
</Link>;
}
function SummaryCard({ item, href, subtitle }: { item: OperationalAssetSummary; href: string; subtitle: string }) {
function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) {
const structural = !item.isInventoryInstance;
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="layers" size={17} /></span>
<span className="asset-browser-item-icon"><Icon name={structural ? 'map' : 'layers'} size={17} /></span>
<span className="asset-browser-item-main">
<strong>{item.name}</strong>
<small>{item.code} · {subtitle}{item.commonName ? ` · ${item.commonName}` : ''}</small>
<small>
{item.code} · {item.type.name}
{item.inventoryFamily ? ` · ${item.inventoryFamily.name}` : ''}
{item.commonName ? ` · ${item.commonName}` : ''}
</small>
</span>
<span className="asset-browser-item-status">
{structural
? <><span className="tag">Contexto</span><small>{item.childrenCount} nivel{item.childrenCount === 1 ? '' : 'es'} inferior{item.childrenCount === 1 ? '' : 'es'}</small></>
: <><span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span><small>{assetOperationalStatusLabel(item.operationalStatus)}</small></>}
</span>
<Icon name="chevron" size={16} />
</Link>;
}
export function AssetHierarchyView({ filters }: { filters: TreeFilters }) {
function nextLevelLabel(typeCode: string | undefined) {
switch (typeCode?.toLowerCase()) {
case 'area': return 'Yacimientos';
case 'yacimiento': return 'Instalaciones';
case 'instalacion': return 'Subinstalaciones';
default: return 'Niveles inferiores';
}
}
export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
const { hasPermission } = useAuth();
const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams();
const rawSection = searchParams.get('section');
const section: NavigationSection | null = rawSection === 'companies' || rawSection === 'territory' ? rawSection : null;
const companyId = searchParams.get('companyId') ?? '';
const parentId = searchParams.get('parentId') ?? '';
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]);
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [company, setCompany] = useState<AssetDetail | null>(null);
const [areas, setAreas] = useState<InventoryBrowserArea[]>([]);
const [children, setChildren] = useState<InventoryBrowserItem[]>([]);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -111,146 +90,110 @@ export function AssetHierarchyView({ filters }: { filters: TreeFilters }) {
let active = true;
setLoading(true);
setError('');
setCompanies([]);
setAreas([]);
setCompany(null);
setLineage([]);
setChildren([]);
setLineage([]);
setHasMore(false);
const run = async () => {
if ((!section || section === 'companies') && !companyId && !parentId) {
const loaded = filters.operationalAreaId ? await listCompaniesForArea(filters.operationalAreaId) : await listOperationalCompanies();
if (active) setCompanies(loaded.filter((item) => (!filters.operatorCompanyId || item.id === filters.operatorCompanyId) && matchesSearch(item, filters.search)));
if (!parentId) {
const response = await listInventoryAreas({
search: filters.search,
operationalAreaId: filters.operationalAreaId,
operatorCompanyId: filters.operatorCompanyId,
});
if (active) setAreas(response.data);
return;
}
if (section === 'companies' && companyId && !parentId) {
const [loadedCompany, loadedAreas] = await Promise.all([getAsset(companyId), listAreasForCompany(companyId)]);
if (!active) return;
setCompany(loadedCompany);
setAreas(loadedAreas.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search)));
return;
}
if (section === 'territory' && !parentId) {
const loaded = await listOperationalAreas();
if (active) setAreas(loaded.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search)));
return;
}
if (parentId) {
const [loadedLineage, childResponse, loadedCompany] = await Promise.all([
getAssetLineage(parentId),
listAssetTreeChildren({
...filters,
operatorCompanyId: companyId || filters.operatorCompanyId,
parentId,
limit: 200,
}),
companyId ? getAsset(companyId) : Promise.resolve(null),
]);
if (!active) return;
setLineage(loadedLineage);
setChildren(childResponse.data);
setHasMore(childResponse.meta.hasMore);
setCompany(loadedCompany);
}
const [loadedLineage, response] = await Promise.all([
getAssetLineage(parentId),
listInventoryChildren(parentId, { search: filters.search }),
]);
if (!active) return;
setLineage(loadedLineage.filter((item) => ['area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase())));
setChildren(response.data);
setHasMore(response.meta.hasMore);
};
run().catch((requestError) => active && setError(errorMessage(requestError))).finally(() => active && setLoading(false));
run()
.catch((requestError) => active && setError(errorMessage(requestError)))
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [section, companyId, parentId, JSON.stringify(filters)]);
const groupedChildren = useMemo(() => {
const groups = new Map<ChildGroupKey, AssetListItem[]>();
children.forEach((item) => {
const key = childGroup(item);
groups.set(key, [...(groups.get(key) ?? []), item]);
});
return GROUP_ORDER.map((key) => ({ key, items: groups.get(key) ?? [] })).filter((group) => group.items.length > 0);
}, [children]);
const current = lineage.at(-1) ?? null;
const activeSection: NavigationSection = section ?? 'companies';
}, [parentId, filters.search, filters.operationalAreaId, filters.operatorCompanyId]);
if (loading) return <LoadingBlock label="Cargando inventarios…" />;
if (!section && !companyId && !parentId) {
if (!parentId) {
const realTotal = areas.reduce((sum, area) => sum + Number(area.inventoryCount ?? 0), 0);
return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading">
<div><span className="eyebrow">INVENTARIOS POR EMPRESA</span><h2>Elegí una empresa</h2><p>Cada empresa tiene su propio inventario. Ingresá para recorrer Áreas, Yacimientos, instalaciones y equipos.</p></div>
<div className="asset-browser-current-actions"><Link className="button secondary" to={navigationHref(searchParams, 'territory')}><Icon name="map" />Vista territorial</Link><span className="count-pill">{companies.length}</span></div>
<div>
<span className="eyebrow">ESTRUCTURA TERRITORIAL</span>
<h2>Áreas</h2>
<p>Las Áreas y Yacimientos son contexto de navegación. El Inventario real comienza en las Instalaciones/Subinstalaciones efectivamente registradas.</p>
</div>
<div className="asset-browser-current-actions">
<span className="count-pill">{realTotal} Inventario real</span>
<span className="count-pill">{areas.length} Áreas</span>
</div>
</div>
{companies.length === 0 ? <EmptyState title="No hay inventarios para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{companies.map((item) => <SummaryCard key={item.id} item={item} subtitle="Inventario de empresa" href={navigationHref(searchParams, 'companies', { companyId: item.id })} />)}</div>}
<div className="asset-browser-levels" aria-label="Estructura de los inventarios">
<div><span>1</span><strong>Empresa</strong><small>Inventario principal</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>Contexto territorial</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>Nivel territorial</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>Planta, batería, estación</small></div><i></i>
<div><span>5</span><strong>Equipo</strong><small>Equipo, pozo, tanque</small></div>
{areas.length === 0
? <EmptyState title="No hay Áreas para mostrar" text="Probá con otra búsqueda o revisá el contexto seleccionado." />
: <div className="asset-browser-list">{areas.map((area) => <AreaCard key={area.id} area={area} href={navigationHref(searchParams, area.id)} />)}</div>}
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Área</strong><small>Ancla territorial</small></div><i></i>
<div><span>2</span><strong>Yacimiento</strong><small>Contexto dentro del Área</small></div><i></i>
<div><span>3</span><strong>Instalación</strong><small>Inventario real</small></div><i></i>
<div><span>4</span><strong>Subinstalación</strong><small>Inventario real</small></div>
</div>
</div>;
}
const breadcrumb = <nav className="asset-browser-breadcrumb" aria-label="Ruta del inventario">
const current = lineage.at(-1) ?? null;
const breadcrumb = <nav className="asset-browser-breadcrumb" aria-label="Ruta del Inventario">
<Link to="/inventarios">Inventarios</Link>
{activeSection === 'territory' && <><span></span><Link to={navigationHref(searchParams, 'territory')}>Vista territorial</Link></>}
{company && <><span></span>{parentId ? <Link to={navigationHref(searchParams, 'companies', { companyId: company.id })}>{company.name}</Link> : <strong>{company.name}</strong>}</>}
{lineage.map((item, index) => {
const isLast = index === lineage.length - 1;
return <span className="asset-browser-crumb-part" key={item.id}><span></span>{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams, activeSection, { companyId: companyId || undefined, parentId: item.id })}>{item.name}</Link>}</span>;
{lineage.map((item,index) => {
const isLast=index===lineage.length-1;
return <span className="asset-browser-crumb-part" key={item.id}>
<span></span>
{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams,item.id)}>{item.name}</Link>}
</span>;
})}
</nav>;
if (section === 'companies' && !companyId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"><div><span className="eyebrow">INVENTARIOS POR EMPRESA</span><h2>Elegí una empresa</h2><p>Ingresá al inventario de una empresa para ver sus Áreas y continuar hacia Yacimientos, instalaciones y equipos.</p></div><span className="count-pill">{companies.length}</span></div>
{companies.length === 0 ? <EmptyState title="No hay empresas para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{companies.map((item) => <SummaryCard key={item.id} item={item} subtitle="Inventario de empresa" href={navigationHref(searchParams, 'companies', { companyId: item.id })} />)}</div>}
</div>;
}
if (section === 'companies' && companyId && !parentId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-current-heading"><div><span className="eyebrow">INVENTARIO DE EMPRESA</span><h2>{company?.name ?? 'Organización'}</h2><p>{company?.code} · Áreas con registros asociados a este inventario.</p></div>{company && <Link className="button secondary" to={`/inventarios/${company.id}`}>Ver ficha</Link>}</div>
<div className="asset-browser-group">
<div className="asset-browser-group-heading"><div><h3>Áreas del inventario</h3><p>Seleccioná un Área para continuar hacia Yacimientos, instalaciones y equipos.</p></div><span>{areas.length}</span></div>
{areas.length === 0 ? <EmptyState title="Sin Áreas en el inventario" text="No hay Áreas con registros asignados a esta empresa para los filtros actuales." /> : <div className="asset-browser-list">{areas.map((item) => <SummaryCard key={item.id} item={item} subtitle="Área" href={navigationHref(searchParams, 'companies', { companyId, parentId: item.id })} />)}</div>}
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
{hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda para acotar el resultado.</Alert>}
<div className="asset-browser-current-heading">
<div>
<span className="eyebrow">{current?.type.name ?? 'INVENTARIO'}</span>
<h2>{current?.name ?? 'Nivel de Inventario'}</h2>
<p>{current?.code ?? ''}</p>
</div>
</div>;
}
if (section === 'territory' && !parentId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"><div><span className="eyebrow">TERRITORIO</span><h2>Áreas y yacimientos</h2><p>Ingresá por un Área para navegar su estructura física.</p></div><span className="count-pill">{areas.length}</span></div>
{areas.length === 0 ? <EmptyState title="No hay Áreas para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{areas.map((item) => <SummaryCard key={item.id} item={item} subtitle="Área" href={navigationHref(searchParams, 'territory', { parentId: item.id })} />)}</div>}
</div>;
}
if (parentId && current) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
{hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda o los filtros para acotar los resultados.</Alert>}
<div className="asset-browser-current-heading">
<div><span className="eyebrow">{current.type.name}</span><h2>{current.name}</h2><p>{current.code}{current.commonName ? ` · ${current.commonName}` : ''}{company ? ` · Contexto: ${company.name}` : ''}</p></div>
<div className="asset-browser-current-actions"><Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha</Link>{canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${current.id}`}><Icon name="plus" />Agregar aquí</Link>}</div>
<div className="asset-browser-current-actions">
{current && <Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha</Link>}
{canCreate && current?.type.code.toLowerCase() !== 'subinstalacion' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${parentId}`}><Icon name="plus" />Agregar aquí</Link>}
</div>
{groupedChildren.length === 0 ? <EmptyState title="No hay niveles inferiores" text="Este nivel no tiene registros inferiores que coincidan con los filtros actuales." /> : <div className="asset-browser-groups">
{groupedChildren.map(({ key, items }) => <section className={`asset-browser-group group-${key}`} key={key}>
<div className="asset-browser-group-heading"><div><h3>{GROUP_LABELS[key].title}</h3><p>{GROUP_LABELS[key].description}</p></div><span>{items.length}</span></div>
<div className="asset-browser-list">{items.map((item) => <AssetCard key={item.id} item={item} href={navigationHref(searchParams, activeSection, { companyId: companyId || undefined, parentId: item.id })} />)}</div>
</section>)}
</div>}
</div>;
}
</div>
return <>{error && <Alert>{error}</Alert>}<EmptyState title="No se pudo abrir la estructura" text="Volvé al inicio de Inventarios e intentá nuevamente." /></>;
<section className="asset-browser-group">
<div className="asset-browser-group-heading">
<div><h3>{nextLevelLabel(current?.type.code)}</h3><p>La jerarquía permitida es Área Yacimiento Instalación Subinstalación.</p></div>
<span>{children.length}</span>
</div>
{children.length === 0
? <EmptyState
title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'}
text={current?.type.code.toLowerCase() === 'yacimiento'
? 'Todavía no hay Instalaciones reales registradas en este Yacimiento.'
: current?.type.code.toLowerCase() === 'instalacion'
? 'Todavía no hay Subinstalaciones registradas en esta Instalación.'
: 'No hay registros que coincidan con la búsqueda actual.'}
/>
: <div className="asset-browser-list">{children.map((item) => <InventoryCard key={item.id} item={item} href={navigationHref(searchParams,item.id)} />)}</div>}
</section>
</div>;
}
@@ -2,54 +2,66 @@ import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getFindingCatalogAdmin } from '../../lib/api';
import type { FindingAdminCatalog } from '../../lib/api';
import {
getFindingCatalogAssetTypeSelection,
listAssetTypes,
replaceFindingCatalogAssetTypeSelection,
} from '../../lib/api';
import type { AssetType, FindingCatalogAssetTypeSelection } from '../../lib/api';
listInventoryFamiliesAdmin,
replaceInventoryFamilyFindings,
} from '../../lib/inventoryStructureApi';
import type { InventoryFamily } from '../../lib/inventoryStructureApi';
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
export function FindingCatalogTypeApplicabilityPanel() {
const [types, setTypes] = useState<AssetType[]>([]);
const [typeId, setTypeId] = useState('');
const [selection, setSelection] = useState<FindingCatalogAssetTypeSelection | null>(null);
const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [familyId, setFamilyId] = useState('');
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('');
const [reason, setReason] = useState('Actualización de aplicabilidad por clasificación de Inventario');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const selectedFamily = families.find((family) => family.id === familyId) ?? null;
useEffect(() => {
listAssetTypes()
.then((loaded) => {
const technical = loaded.filter((type) => type.isActive && type.operationalRole === 'GENERIC');
setTypes(technical);
setTypeId(technical[0]?.id ?? '');
Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
.then(([loadedFamilies, loadedCatalog]) => {
const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
setFamilies(activeFamilies);
setCatalog(loadedCatalog);
setFamilyId(activeFamilies[0]?.id ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!typeId) { setSelection(null); return; }
setLoading(true); setError(''); setSuccess('');
getFindingCatalogAssetTypeSelection(typeId)
.then((loaded) => {
setSelection(loaded);
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(loaded.reason ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [typeId]);
const selected = families.find((family) => family.id === familyId);
setEnabled(new Set(selected?.findingItemIds ?? []));
setSuccess('');
setError('');
}, [familyId, families]);
const activeCategoryIds = useMemo(() => new Set(
catalog.categories.filter((category) => category.isActive).map((category) => category.id),
), [catalog.categories]);
const categoryName = useMemo(() => new Map(
catalog.categories.map((category) => [category.id, category.name]),
), [catalog.categories]);
const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName]
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? [];
}, [selection, search]);
const needle = search.trim().toLocaleLowerCase('es-AR');
return catalog.items.filter((item) =>
item.isActive
&& activeCategoryIds.has(item.categoryId)
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? '']
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle))),
);
}, [catalog.items, search, activeCategoryIds, categoryName]);
const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
@@ -58,17 +70,19 @@ export function FindingCatalogTypeApplicabilityPanel() {
});
const save = async () => {
if (!selection) return;
if (!selectedFamily) return;
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetTypeSelection(selection.assetType.id, {
enabledItemIds: [...enabled],
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, {
itemIds: [...enabled],
reason,
});
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(saved.reason ?? reason);
setSuccess(`Aplicabilidad guardada para ${saved.assetType.name}.`);
const itemIds = saved.items.map((item) => item.id);
setEnabled(new Set(itemIds));
setFamilies((current) => current.map((family) => family.id === selectedFamily.id
? { ...family, findingItemIds: itemIds, findingCount: itemIds.length }
: family));
setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
@@ -76,20 +90,43 @@ export function FindingCatalogTypeApplicabilityPanel() {
}
};
if (loading && types.length === 0) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (types.length === 0) return null;
if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>;
return <section className="panel finding-applicability-panel">
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR TIPO TÉCNICO</span><h2>Qué hallazgos verá el inspector</h2><p className="section-copy">Configurá el catálogo base para cada tipo de elemento del Inventario. Después se pueden hacer excepciones por objeto concreto.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
<div className="panel-heading">
<div>
<span className="eyebrow">APLICABILIDAD POR INSTALACIÓN / SUBINSTALACIÓN</span>
<h2>Qué Hallazgos verá el inspector</h2>
<p className="section-copy">Los Hallazgos se vinculan a la clasificación concreta del elemento, no a una función. La opción OTROS permanece siempre disponible en la APK.</p>
</div>
<span className="count-pill">{enabled.size} vinculados</span>
</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar">
<label className="field"><span>Tipo técnico</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}>{types.map((type) => <option value={type.id} key={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Buscar hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
<label className="field">
<span>Clasificación de Inventario</span>
<SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>
{families.map((family) => <option value={family.id} key={family.id}>
{family.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'} · {family.parentFamilyName ? `${family.parentFamilyName}` : ''}{family.name}
</option>)}
</SearchableSelect>
</label>
<label className="field"><span>Buscar Hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
</div>
{selection && !selection.configured && <div className="temporal-notice"><Icon name="alert" /><p><strong>Este tipo todavía no fue configurado.</strong> Para no romper el funcionamiento actual, hoy recibe todo el catálogo activo. Al guardar esta pantalla, sólo quedarán habilitados los seleccionados.</p></div>}
<div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={() => setEnabled(new Set(selection?.items.map((item) => item.id) ?? []))}>Seleccionar todos</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
<label className="field"><span>Motivo de configuración</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: catálogo aplicable a tanques según criterio técnico de Hidrocarburos…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar aplicabilidad'}</button></div>
{selectedFamily && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>{selectedFamily.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'}:</strong> {selectedFamily.parentFamilyName ? `${selectedFamily.parentFamilyName}` : ''}{selectedFamily.name}. Actualmente tiene {selectedFamily.findingCount ?? enabled.size} Hallazgo{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'} asociado{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'}.</p>
</div>}
<div className="catalog-selection-actions">
<button type="button" className="button secondary" onClick={() => setEnabled(new Set(visible.map((item) => item.id)))}>Seleccionar visibles</button>
<button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button>
</div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}>
<input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} />
<span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span>
</label>)}</div>
<label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: Hallazgos aplicables a esta Subinstalación según criterio técnico…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar Hallazgos vinculados'}</button></div>
</section>;
}