Merge pull request #39 from enlineawork/feat/structured-inventory-navigation
feat: navegación estructurada de Inventarios
This commit is contained in:
@@ -4,7 +4,7 @@ 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 type { AssetLineageItem, AssetType } from '../../lib/api';
|
||||
import {
|
||||
listInventoryCompanies,
|
||||
listInventoryDepartments,
|
||||
@@ -18,16 +18,61 @@ import type {
|
||||
} from '../../lib/inventoryBrowserApi';
|
||||
import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation';
|
||||
|
||||
const structuredInventorySections = [
|
||||
{
|
||||
code: 'empresa',
|
||||
label: 'Empresas',
|
||||
description: 'Maestro de empresas operadoras. Se vinculan a una o más Áreas sin formar parte del árbol territorial.',
|
||||
icon: 'users' as const,
|
||||
},
|
||||
{
|
||||
code: 'departamento',
|
||||
label: 'Departamentos',
|
||||
description: 'Primer nivel territorial. Desde cada Departamento se accede a sus Áreas.',
|
||||
icon: 'map' as const,
|
||||
},
|
||||
{
|
||||
code: 'area',
|
||||
label: 'Áreas',
|
||||
description: 'Cada Área pertenece a un Departamento y puede tener una o más Empresas operadoras vinculadas.',
|
||||
icon: 'map' as const,
|
||||
},
|
||||
{
|
||||
code: 'yacimiento',
|
||||
label: 'Yacimientos',
|
||||
description: 'Nivel operativo dentro de un Área. Organiza las Instalaciones que pertenecen a ese Yacimiento.',
|
||||
icon: 'map' as const,
|
||||
},
|
||||
{
|
||||
code: 'instalacion',
|
||||
label: 'Instalaciones',
|
||||
description: 'Elementos técnicos ubicados dentro de un Yacimiento y clasificados por su familia de Inventario.',
|
||||
icon: 'layers' as const,
|
||||
},
|
||||
{
|
||||
code: 'subinstalacion',
|
||||
label: 'Subinstalaciones',
|
||||
description: 'Último nivel estructural. Dependen de una Instalación y conservan su clasificación técnica.',
|
||||
icon: 'layers' as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function navigationHref(base: URLSearchParams, parentId?: string) {
|
||||
const params = new URLSearchParams(base);
|
||||
params.delete('view');
|
||||
params.delete('page');
|
||||
params.delete('section');
|
||||
params.delete('companyId');
|
||||
params.delete('typeId');
|
||||
parentId ? params.set('parentId', parentId) : params.delete('parentId');
|
||||
return `/inventarios${params.size ? `?${params}` : ''}`;
|
||||
}
|
||||
|
||||
function inventoryTypeForSection(types: AssetType[], code: string) {
|
||||
if (code === 'empresa') return types.find((type) => type.operationalRole === 'COMPANY') ?? null;
|
||||
return types.find((type) => type.code.toLowerCase() === code) ?? null;
|
||||
}
|
||||
|
||||
function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) {
|
||||
return <Link className="asset-browser-item" to={href}>
|
||||
<span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
|
||||
@@ -80,7 +125,7 @@ function nextLevelLabel(typeCode: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
|
||||
export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery; types: AssetType[] }) {
|
||||
const { hasPermission } = useAuth();
|
||||
const canCreate = hasPermission('assets.create');
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -123,24 +168,48 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
|
||||
return <div className="asset-browser-panel">
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="asset-browser-section-heading">
|
||||
<div><span className="eyebrow">INVENTARIO COMPLETO</span><h2>Departamentos</h2><p>Entrá por Departamento y navegá Área → Yacimiento → Instalación → Subinstalación hasta llegar a su clasificación y Hallazgos asociados.</p></div>
|
||||
<div>
|
||||
<span className="eyebrow">INVENTARIOS ESTRUCTURADOS</span>
|
||||
<h2>Elegí qué querés administrar</h2>
|
||||
<p>La estructura queda fija y fácil de leer: Empresas como maestro de operadoras y, por separado, Departamento → Área → Yacimiento → Instalación → Subinstalación.</p>
|
||||
</div>
|
||||
<div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div>
|
||||
</div>
|
||||
{departments.length === 0
|
||||
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
|
||||
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
|
||||
|
||||
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
|
||||
<div className="asset-browser-list" aria-label="Secciones estructuradas de Inventarios">
|
||||
{structuredInventorySections.map((section, index) => {
|
||||
const assetType = inventoryTypeForSection(types, section.code);
|
||||
const content = <>
|
||||
<span className="asset-browser-item-icon"><Icon name={section.icon} size={17} /></span>
|
||||
<span className="asset-browser-item-main">
|
||||
<strong>{section.label}</strong>
|
||||
<small>{section.description}</small>
|
||||
</span>
|
||||
<span className="asset-browser-item-status"><small>{section.code === 'empresa' ? 'Maestro independiente' : `Nivel ${index}`}</small></span>
|
||||
{assetType && <Icon name="chevron" size={16} />}
|
||||
</>;
|
||||
return assetType
|
||||
? <Link key={section.code} className="asset-browser-item" to={`/inventarios?view=list&typeId=${assetType.id}`}>{content}</Link>
|
||||
: <div key={section.code} className="asset-browser-item" aria-disabled="true">{content}</div>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="asset-browser-levels" aria-label="Jerarquía territorial de Inventarios">
|
||||
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i>›</i>
|
||||
<div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i>›</i>
|
||||
<div><span>2</span><strong>Área</strong><small>operadoras vinculadas</small></div><i>›</i>
|
||||
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i>›</i>
|
||||
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i>›</i>
|
||||
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
|
||||
</div>
|
||||
|
||||
<section className="asset-browser-group" style={{ marginTop: 20 }}>
|
||||
<div className="asset-browser-group-heading"><div><h3>Empresas</h3><p>Maestro independiente. La Empresa se vincula al Área como operadora sin alterar la estructura física.</p></div><span>{companies.length}</span></div>
|
||||
{companies.length === 0 ? <div className="inline-empty">Todavía no hay Empresas cargadas.</div> : <div className="asset-browser-list">{companies.map((company) => <CompanyCard key={company.id} company={company} />)}</div>}
|
||||
<div className="asset-browser-group-heading">
|
||||
<div><h3>Recorrido por territorio</h3><p>También podés navegar como árbol: entrá por un Departamento y descendé hasta la Instalación o Subinstalación concreta.</p></div>
|
||||
<span>{departments.length}</span>
|
||||
</div>
|
||||
{departments.length === 0
|
||||
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
|
||||
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,40 @@ const quickViews = [
|
||||
{ key: 'out', label: 'Fuera de servicio' },
|
||||
] as const;
|
||||
|
||||
const structuredTypeCopy: Record<string, { title: string; description: string }> = {
|
||||
departamento: {
|
||||
title: 'Departamentos',
|
||||
description: 'Raíz territorial del Inventario. Cada Departamento contiene sus Áreas.',
|
||||
},
|
||||
area: {
|
||||
title: 'Áreas',
|
||||
description: 'Áreas dentro de un Departamento, con sus Empresas operadoras vinculadas.',
|
||||
},
|
||||
yacimiento: {
|
||||
title: 'Yacimientos',
|
||||
description: 'Yacimientos pertenecientes a un Área y utilizados para organizar sus Instalaciones.',
|
||||
},
|
||||
instalacion: {
|
||||
title: 'Instalaciones',
|
||||
description: 'Instalaciones dentro de un Yacimiento, con clasificación técnica y contexto operativo.',
|
||||
},
|
||||
subinstalacion: {
|
||||
title: 'Subinstalaciones',
|
||||
description: 'Subinstalaciones dependientes de una Instalación, último nivel de la estructura.',
|
||||
},
|
||||
};
|
||||
|
||||
function structuredPresentation(type: AssetType | undefined) {
|
||||
if (!type) return null;
|
||||
if (type.operationalRole === 'COMPANY') {
|
||||
return {
|
||||
title: 'Empresas',
|
||||
description: 'Maestro independiente de Empresas operadoras. Se vinculan a una o más Áreas.',
|
||||
};
|
||||
}
|
||||
return structuredTypeCopy[type.code.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
export function AssetsPage() {
|
||||
const [urlParams, setUrlParams] = useSearchParams();
|
||||
const operationalContext = useOperationalContext();
|
||||
@@ -58,6 +92,8 @@ export function AssetsPage() {
|
||||
const needsValidation = quick === 'validation' ? true : undefined;
|
||||
const hasGeometry = quick === 'location' ? false : undefined;
|
||||
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
|
||||
const selectedType = types.find((type) => type.id === typeId);
|
||||
const selectedPresentation = structuredPresentation(selectedType);
|
||||
|
||||
useEffect(() => {
|
||||
listAssetTypes().then((data) => setTypes(data.filter((type) =>
|
||||
@@ -102,15 +138,22 @@ export function AssetsPage() {
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next);
|
||||
};
|
||||
const advancedActive = Boolean(typeId || status || rawOperationalStatus);
|
||||
const sectionTitle = view === 'list' && selectedPresentation ? selectedPresentation.title : 'Inventarios';
|
||||
const sectionDescription = view === 'list' && selectedPresentation
|
||||
? selectedPresentation.description
|
||||
: 'Empresas y estructura territorial: Departamento → Área → Yacimiento → Instalación → Subinstalación.';
|
||||
|
||||
return <section>
|
||||
<div className="page-heading asset-center-heading">
|
||||
<div>
|
||||
<span className="eyebrow">INVENTARIOS</span>
|
||||
<h1>Inventarios</h1>
|
||||
<p>Todos los registros: Departamento → Área → Yacimiento → Instalación → Subinstalación, más el maestro independiente de Empresas.</p>
|
||||
<span className="eyebrow">{selectedPresentation && view === 'list' ? 'INVENTARIOS · SECCIÓN' : 'INVENTARIOS'}</span>
|
||||
<h1>{sectionTitle}</h1>
|
||||
<p>{sectionDescription}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{selectedPresentation && view === 'list' && <Link className="button secondary" to="/inventarios"><Icon name="layers" />Ver estructura</Link>}
|
||||
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
|
||||
</div>
|
||||
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
|
||||
</div>
|
||||
|
||||
<AssetCenterTabs active={view === 'hierarchy' ? 'navigate' : 'list'} />
|
||||
@@ -126,7 +169,7 @@ export function AssetsPage() {
|
||||
{view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>}
|
||||
</div>
|
||||
{view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel">
|
||||
<label className="field compact-field"><span>Nivel</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos los niveles</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Sección</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todas las secciones</option>{types.map((type) => <option key={type.id} value={type.id}>{type.operationalRole === 'COMPANY' ? 'Empresas' : structuredTypeCopy[type.code.toLowerCase()]?.title ?? type.name}</option>)}</SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
|
||||
<button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button>
|
||||
@@ -135,14 +178,14 @@ export function AssetsPage() {
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{view === 'hierarchy'
|
||||
? <AssetHierarchyView filters={filters} />
|
||||
? <AssetHierarchyView filters={filters} types={types} />
|
||||
: loading
|
||||
? <LoadingBlock label="Cargando Inventarios…" />
|
||||
: assets.length === 0
|
||||
? <EmptyState title="Todavía no hay registros" text="La base está lista para comenzar la carga manual desde Departamento." />
|
||||
? <EmptyState title="Todavía no hay registros" text={selectedPresentation ? `No hay registros cargados en ${selectedPresentation.title}.` : 'La base está lista para comenzar la carga manual desde Departamento.'} />
|
||||
: <div className="table-panel compact-assets-table">
|
||||
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Nivel</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
|
||||
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Sección</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
|
||||
{assets.map((asset) => <tr key={asset.id}>
|
||||
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
|
||||
<td><span className="tag">{asset.type.name}</span></td>
|
||||
|
||||
Reference in New Issue
Block a user