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:
@@ -18,7 +18,6 @@ import { AssetEditorPage } from '../pages/AssetEditorPage';
|
||||
import { InventoryCreatePage } from '../pages/InventoryCreatePage';
|
||||
import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage';
|
||||
import { AssetTypesPage } from '../pages/AssetTypesPage';
|
||||
import { InventoryFunctionsPage } from '../pages/InventoryFunctionsPage';
|
||||
import { HistoryPage } from '../pages/HistoryPage';
|
||||
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
|
||||
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
|
||||
@@ -27,7 +26,6 @@ import { InspectionActEditorPage } from '../pages/InspectionActEditorPage';
|
||||
import { FindingCatalogPage } from '../pages/FindingCatalogPage';
|
||||
import { FindingDetailPage } from '../pages/FindingDetailPage';
|
||||
import { FindingsPage } from '../pages/FindingsPage';
|
||||
import { AssetImportsPage } from '../pages/AssetImportsPage';
|
||||
import { ActsPage } from '../pages/ActsPage';
|
||||
import { ReportsPage } from '../pages/ReportsPage';
|
||||
import { ReportDetailPage } from '../pages/ReportDetailPage';
|
||||
@@ -55,7 +53,6 @@ export function App() {
|
||||
<Route path="/activos/:id" element={<AssetEditorPage />} />
|
||||
</Route>
|
||||
<Route element={<PermissionRoute permission="assets.change_status" />}><Route path="/inventarios/revision-campo" element={<FieldDiscoveriesPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="asset_imports.read" />}><Route path="/importaciones" element={<AssetImportsPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="assets.create" />}><Route path="/inventarios/nuevo" element={<InventoryCreatePage />} /><Route path="/activos/nuevo" element={<Navigate to="/inventarios/nuevo" replace />} /></Route>
|
||||
<Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} />
|
||||
<Route element={<PermissionRoute permission="inspections.read" />}>
|
||||
@@ -82,7 +79,6 @@ export function App() {
|
||||
<Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AssetTypesPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="asset_types.manage" />}><Route path="/admin/inventory-functions" element={<InventoryFunctionsPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route>
|
||||
<Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route>
|
||||
<Route path="/sin-acceso" element={<AccessDeniedPage />} />
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.21.0-1';
|
||||
export const APP_PHASE = 'Fase F3.1 · Inventario estructural y catálogo contextual';
|
||||
export const APP_PHASE = 'F5 · Inventario operativo y catálogo autorizado';
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/* F5 · ajustes visuales acotados a los flujos saneados. */
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 18px;
|
||||
padding: 7px;
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
background: var(--surface, #fff);
|
||||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.status-tabs button {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #64748b);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
|
||||
.status-tabs button:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.status-tabs button.active {
|
||||
background: #eef4ff;
|
||||
border-color: #bfd2fb;
|
||||
color: #173d7a;
|
||||
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.04);
|
||||
}
|
||||
|
||||
.status-tabs button small {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: #edf2f7;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-tabs button.active small {
|
||||
background: #d7e5ff;
|
||||
color: #173d7a;
|
||||
}
|
||||
|
||||
.asset-browser-item-status > strong {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.asset-browser-item-status > small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.status-tabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.status-tabs button {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -32,14 +32,12 @@ const documents: NavItem[] = [
|
||||
const master: NavItem[] = [
|
||||
{ to: '/inventarios', label: 'Inventarios', icon: 'layers', permission: 'assets.read' },
|
||||
{ to: '/mapa', label: 'Mapa', icon: 'map', permission: 'assets.read' },
|
||||
{ to: '/importaciones', label: 'Importaciones', icon: 'upload', permission: 'asset_imports.read' },
|
||||
];
|
||||
|
||||
const administration: NavItem[] = [
|
||||
{ to: '/admin/users', label: 'Usuarios', icon: 'users', permission: 'users.read' },
|
||||
{ to: '/admin/roles', label: 'Roles y permisos', icon: 'shield', permission: 'roles.read' },
|
||||
{ to: '/admin/asset-types', label: 'Configuración de Inventarios', icon: 'layers', permission: 'asset_types.read' },
|
||||
{ to: '/admin/inventory-functions', label: 'Catálogo de funciones', icon: 'history', permission: 'asset_types.manage' },
|
||||
{ to: '/admin/finding-catalog', label: 'Catálogo de hallazgos', icon: 'alert', permission: 'finding_catalog.manage' },
|
||||
{ to: '/admin/document-delivery', label: 'Entrega documental', icon: 'audit', permission: 'document_delivery.read' },
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface DashboardSummaryF4 {
|
||||
inactiveUsers: number;
|
||||
activeSessions: number;
|
||||
openFindings: number;
|
||||
actsInFollowUp: number;
|
||||
findingsWithoutControlDate: number;
|
||||
overdueControls: number;
|
||||
controlsNext30Days: number;
|
||||
@@ -17,7 +18,7 @@ export interface DashboardSummaryF4 {
|
||||
reportsOfficialized: number;
|
||||
sealedActsWithoutReport: number;
|
||||
};
|
||||
recentAudit: Array<{
|
||||
recentInspectorActivity: Array<{
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
actorUsername: string | null;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { apiRequest } from './api';
|
||||
import type {
|
||||
AssetDataOrigin,
|
||||
AssetInformationStatus,
|
||||
AssetOperationalStatus,
|
||||
PageMeta,
|
||||
} from './api';
|
||||
|
||||
export interface InventoryBrowserArea {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
type: { id: string; code: string; name: string };
|
||||
informationStatus: AssetInformationStatus;
|
||||
operationalStatus: AssetOperationalStatus;
|
||||
currentOperator: { id: string; code: string; name: string } | null;
|
||||
yacimientoCount: number;
|
||||
inventoryCount: number;
|
||||
}
|
||||
|
||||
export interface InventoryBrowserItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
type: { id: string; code: string; name: string };
|
||||
parent: { id: string; code: string; name: string } | null;
|
||||
informationStatus: AssetInformationStatus;
|
||||
operationalStatus: AssetOperationalStatus;
|
||||
isInventoryInstance: boolean;
|
||||
inventoryFamily: { id: string; code: string; name: string; level: string } | null;
|
||||
childrenCount: number;
|
||||
hasGeometry: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InventoryListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
type: { id: string; code: string; name: string };
|
||||
parent: { id: string; code: string; name: string } | null;
|
||||
operationalArea: { id: string; code: string; name: string } | null;
|
||||
operatorCompany: { id: string; code: string; name: string } | null;
|
||||
informationStatus: AssetInformationStatus;
|
||||
operationalStatus: AssetOperationalStatus;
|
||||
childrenCount: number;
|
||||
hasGeometry: boolean;
|
||||
geometryType: string | null;
|
||||
mediaCount: number;
|
||||
dataOrigin: AssetDataOrigin;
|
||||
provenanceVerified: boolean;
|
||||
currentVersion: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InventoryBrowserParent {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
typeCode: string;
|
||||
}
|
||||
|
||||
export interface InventoryQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
typeId?: string;
|
||||
status?: AssetInformationStatus | '';
|
||||
operationalStatus?: AssetOperationalStatus | '';
|
||||
operationalAreaId?: string;
|
||||
operatorCompanyId?: string;
|
||||
needsValidation?: boolean;
|
||||
hasGeometry?: boolean;
|
||||
}
|
||||
|
||||
function queryString(params: InventoryQuery): string {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key,value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
query.set(key,String(value));
|
||||
});
|
||||
return query.size ? `?${query}` : '';
|
||||
}
|
||||
|
||||
export function listRealInventory(params: InventoryQuery = {}) {
|
||||
return apiRequest<{ data: InventoryListItem[]; meta: PageMeta }>(
|
||||
`/inventory-browser/items${queryString(params)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function listInventoryAreas(params: InventoryQuery = {}) {
|
||||
return apiRequest<{ data: InventoryBrowserArea[]; meta: { count: number } }>(
|
||||
`/inventory-browser/areas${queryString(params)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function listInventoryChildren(parentId: string, params: InventoryQuery = {}) {
|
||||
return apiRequest<{
|
||||
parent: InventoryBrowserParent;
|
||||
data: InventoryBrowserItem[];
|
||||
meta: { count: number; hasMore: boolean };
|
||||
}>(`/inventory-browser/${parentId}/children${queryString(params)}`);
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export type InventoryStructureKind = 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
export type InventoryStructureKind = 'EMPRESA' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
|
||||
export interface InventoryFamily {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
legacyTypeCode: string | null;
|
||||
legacyTypeCode?: string | null;
|
||||
informationLabels: string[];
|
||||
sourceReference?: string | null;
|
||||
isActive?: boolean;
|
||||
parentFamilyId: string | null;
|
||||
parentFamilyCode: string | null;
|
||||
parentFamilyName: string | null;
|
||||
assetCount?: number;
|
||||
findingCount?: number;
|
||||
findingItemIds?: string[];
|
||||
}
|
||||
|
||||
export interface InventoryStructureLevel {
|
||||
@@ -23,6 +28,7 @@ export interface InventoryStructureLevel {
|
||||
}
|
||||
|
||||
export interface InventoryStructureOptions {
|
||||
independentMasters: InventoryStructureLevel[];
|
||||
levels: InventoryStructureLevel[];
|
||||
installationFamilies: InventoryFamily[];
|
||||
subinstallationFamilies: InventoryFamily[];
|
||||
@@ -79,6 +85,7 @@ export interface CreatedInventoryStructure {
|
||||
operationalStatus: string;
|
||||
type: { id: string; code: string; name: string };
|
||||
parent: null | { id: string; code: string; name: string };
|
||||
operationalArea?: null | { id: string; code: string; name: string };
|
||||
inventoryFamily: null | {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -105,6 +112,41 @@ export function getInventoryFamilyFindings(familyId: string) {
|
||||
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);
|
||||
}
|
||||
|
||||
export async function listInventoryFamiliesAdmin() {
|
||||
return (await apiRequest<{ data: InventoryFamily[] }>('/inventory-families/admin')).data;
|
||||
}
|
||||
|
||||
export function createInventoryFamily(input: {
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
name: string;
|
||||
parentFamilyId?: string | null;
|
||||
informationLabels?: string[];
|
||||
}) {
|
||||
return apiRequest<InventoryFamily>('/inventory-families', {
|
||||
method: 'POST', body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateInventoryFamily(familyId: string, input: {
|
||||
name?: string;
|
||||
parentFamilyId?: string | null;
|
||||
informationLabels?: string[];
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`, {
|
||||
method: 'PATCH', body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceInventoryFamilyFindings(familyId: string, input: {
|
||||
itemIds: string[];
|
||||
reason: string;
|
||||
}) {
|
||||
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`, {
|
||||
method: 'PUT', body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createInventoryStructure(input: {
|
||||
kind: InventoryStructureKind;
|
||||
code?: string | null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import './styles.css';
|
||||
import './f5.css';
|
||||
import { App } from './app/App';
|
||||
import { AuthProvider } from './auth/AuthContext';
|
||||
|
||||
|
||||
+157
-152
@@ -1,32 +1,26 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import {
|
||||
bootstrapMasterDefaults,
|
||||
createAssetAttribute,
|
||||
createAssetType,
|
||||
enrichMasterDefaults,
|
||||
getMasterEnrichmentStatus,
|
||||
listAssetTypes,
|
||||
updateAssetAttribute,
|
||||
updateAssetType,
|
||||
} from '../lib/api';
|
||||
import type {
|
||||
AssetAttributeDataType,
|
||||
AssetAttributeDefinition,
|
||||
AssetType,
|
||||
AssetTypeOperationalRole,
|
||||
MasterEnrichmentStatus,
|
||||
} from '../lib/api';
|
||||
|
||||
const OPERATIONAL_ROLES: Array<{ value: AssetTypeOperationalRole; label: string; help: string }> = [
|
||||
{ value: 'GENERIC', label: 'Elemento operativo / genérico', help: 'Instalaciones, estaciones, equipos y demás elementos administrables.' },
|
||||
{ value: 'AREA', label: 'Área', help: 'Representa el ámbito territorial de operación.' },
|
||||
{ value: 'COMPANY', label: 'Organización', help: 'Empresa, UTE u otra organización vinculable a áreas.' },
|
||||
];
|
||||
import {
|
||||
createInventoryFamily,
|
||||
listInventoryFamiliesAdmin,
|
||||
updateInventoryFamily,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
import type { InventoryFamily } from '../lib/inventoryStructureApi';
|
||||
|
||||
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
|
||||
{ value: 'TEXT', label: 'Texto' },
|
||||
@@ -37,27 +31,50 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
|
||||
{ value: 'SELECT', label: 'Lista de opciones' },
|
||||
];
|
||||
|
||||
type CanonicalKind = 'EMPRESA' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
|
||||
type FamilyEditor = InventoryFamily | 'new' | null;
|
||||
|
||||
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
|
||||
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
|
||||
{ kind: 'AREA', label: 'Área', description: 'Raíz territorial de la estructura física.' },
|
||||
{ kind: 'YACIMIENTO', label: 'Yacimiento', description: 'Pertenece a un Área; su nombre puede repetirse en otra Área.' },
|
||||
{ kind: 'INSTALACION', label: 'Instalación', description: 'Instancia física dentro de un Yacimiento y con clasificación técnica.' },
|
||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia física dentro de una Instalación y con clasificación técnica.' },
|
||||
];
|
||||
|
||||
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
|
||||
if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null;
|
||||
if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null;
|
||||
const code = kind.toLowerCase();
|
||||
return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null;
|
||||
}
|
||||
|
||||
function attributeTypeLabel(value: AssetAttributeDataType) {
|
||||
return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function typeCodeFromName(value: string) {
|
||||
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
|
||||
function attributeCodeFromName(value: string) {
|
||||
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
|
||||
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
|
||||
}
|
||||
|
||||
export function AssetTypesPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canManage = hasPermission('asset_types.manage');
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [canBeRoot, setCanBeRoot] = useState(false);
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [operationalRole, setOperationalRole] = useState<AssetTypeOperationalRole>('GENERIC');
|
||||
const [parentTypeIds, setParentTypeIds] = useState<string[]>([]);
|
||||
const [families, setFamilies] = useState<InventoryFamily[]>([]);
|
||||
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('INSTALACION');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null);
|
||||
const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION');
|
||||
const [familyName, setFamilyName] = useState('');
|
||||
const [familyParentId, setFamilyParentId] = useState('');
|
||||
const [familyActive, setFamilyActive] = useState(true);
|
||||
|
||||
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
|
||||
const [attributeCode, setAttributeCode] = useState('');
|
||||
const [attributeName, setAttributeName] = useState('');
|
||||
@@ -67,166 +84,154 @@ export function AssetTypesPage() {
|
||||
const [attributeUnit, setAttributeUnit] = useState('');
|
||||
const [attributeOptions, setAttributeOptions] = useState('');
|
||||
const [attributeOrder, setAttributeOrder] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [enrichment, setEnrichment] = useState<MasterEnrichmentStatus | null>(null);
|
||||
|
||||
const selected = types.find((type) => type.id === selectedId) ?? null;
|
||||
|
||||
const selectType = (type: AssetType) => {
|
||||
setCreating(false); setSelectedId(type.id); setCode(type.code); setName(type.name);
|
||||
setDescription(type.description); setCanBeRoot(type.canBeRoot); setIsActive(type.isActive);
|
||||
setOperationalRole(type.operationalRole);
|
||||
setParentTypeIds(type.allowedParentTypes.map((parent) => parent.id));
|
||||
setAttributeEditor(null); setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const startCreate = () => {
|
||||
setCreating(true); setSelectedId(null); setCode(''); setName(''); setDescription('');
|
||||
setCanBeRoot(false); setIsActive(true); setOperationalRole('GENERIC'); setParentTypeIds([]);
|
||||
setAttributeEditor(null); setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const load = async (preferId?: string) => {
|
||||
const loaded = await listAssetTypes();
|
||||
setTypes(loaded);
|
||||
if (loaded.length > 0) {
|
||||
try { setEnrichment(await getMasterEnrichmentStatus()); } catch { setEnrichment(null); }
|
||||
} else {
|
||||
setEnrichment(null);
|
||||
}
|
||||
const next = loaded.find((type) => type.id === preferId) ?? loaded[0];
|
||||
if (next) selectType(next);
|
||||
const load = async () => {
|
||||
const [loadedTypes, loadedFamilies] = await Promise.all([
|
||||
listAssetTypes(),
|
||||
listInventoryFamiliesAdmin(),
|
||||
]);
|
||||
setTypes(loadedTypes);
|
||||
setFamilies(loadedFamilies);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const toggleParent = (id: string) => setParentTypeIds((current) =>
|
||||
current.includes(id) ? current.filter((value) => value !== id) : [...current, id],
|
||||
);
|
||||
const selectedType = canonicalType(types, selectedKind);
|
||||
const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]);
|
||||
const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]);
|
||||
|
||||
const installDefaultMaster = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'¿Instalar la configuración inicial de Hidrocarburos?\n\nSe crearán tipos, jerarquías y atributos base. No se crearán empresas, áreas ni registros reales.',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const result = await bootstrapMasterDefaults();
|
||||
setTypes(result.data);
|
||||
const next = result.data.find((type) => type.code === 'area') ?? result.data[0];
|
||||
if (next) selectType(next);
|
||||
setSuccess(`Configuración inicial instalada: ${result.createdTypeCount} tipos, ${result.createdAttributeCount} atributos y ${result.createdParentRuleCount} reglas de jerarquía.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => {
|
||||
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName(''); setFamilyParentId(''); setFamilyActive(true);
|
||||
setError(''); setSuccess('');
|
||||
};
|
||||
const openFamily = (family: InventoryFamily) => {
|
||||
setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name);
|
||||
setFamilyParentId(family.parentFamilyId ?? ''); setFamilyActive(family.isActive !== false);
|
||||
setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const enrichTechnicalCatalog = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'¿Completar el catálogo técnico de Hidrocarburos?\n\nSólo se agregarán tipos, atributos y relaciones de jerarquía que falten. No se modificarán tipos existentes ni se crearán registros reales.',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const result = await enrichMasterDefaults();
|
||||
setTypes(result.data);
|
||||
setEnrichment(await getMasterEnrichmentStatus());
|
||||
const next = result.data.find((type) => type.id === selectedId) ?? result.data.find((type) => type.code === 'area') ?? result.data[0];
|
||||
if (next) selectType(next);
|
||||
setSuccess(`Catálogo técnico completado: ${result.createdTypeCount} tipos, ${result.createdAttributeCount} atributos y ${result.createdParentRuleCount} reglas nuevas.`);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const saveType = async (event: FormEvent) => {
|
||||
const saveFamily = async (event: FormEvent) => {
|
||||
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const saved = creating
|
||||
? await createAssetType({ code, name, description, canBeRoot, operationalRole, allowedParentTypeIds: parentTypeIds })
|
||||
: await updateAssetType(selected!.id, { name, description, canBeRoot, isActive, operationalRole, allowedParentTypeIds: parentTypeIds });
|
||||
await load(saved.id);
|
||||
setSuccess(creating ? 'Tipo de elemento creado correctamente' : 'Tipo de elemento actualizado');
|
||||
setCreating(false);
|
||||
if (familyEditor === 'new') {
|
||||
await createInventoryFamily({
|
||||
level: familyLevel,
|
||||
name: familyName.trim(),
|
||||
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
|
||||
});
|
||||
setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
|
||||
} else if (familyEditor) {
|
||||
await updateInventoryFamily(familyEditor.id, {
|
||||
name: familyName.trim(),
|
||||
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
|
||||
isActive: familyActive,
|
||||
});
|
||||
setSuccess('Clasificación actualizada.');
|
||||
}
|
||||
await load();
|
||||
setFamilyEditor(null);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const openAttribute = (attribute: AssetAttributeDefinition | 'new') => {
|
||||
setAttributeEditor(attribute);
|
||||
setAttributeEditor(attribute); setError(''); setSuccess('');
|
||||
if (attribute === 'new') {
|
||||
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT');
|
||||
setAttributeRequired(false); setAttributeActive(true); setAttributeUnit('');
|
||||
setAttributeOptions(''); setAttributeOrder(selected?.attributes.length ?? 0);
|
||||
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false);
|
||||
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions('');
|
||||
setAttributeOrder(selectedType?.attributes.length ?? 0);
|
||||
} else {
|
||||
setAttributeCode(attribute.code); setAttributeName(attribute.name);
|
||||
setAttributeType(attribute.dataType); setAttributeRequired(attribute.isRequired);
|
||||
setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? '');
|
||||
setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder);
|
||||
setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType);
|
||||
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive);
|
||||
setAttributeUnit(attribute.unit ?? ''); setAttributeOptions(attribute.options?.join('\n') ?? '');
|
||||
setAttributeOrder(attribute.sortOrder);
|
||||
}
|
||||
setError(''); setSuccess('');
|
||||
};
|
||||
|
||||
const saveAttribute = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selected || !attributeEditor) return;
|
||||
if (!selectedType || !attributeEditor) return;
|
||||
setSaving(true); setError(''); setSuccess('');
|
||||
const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
|
||||
try {
|
||||
const saved = attributeEditor === 'new'
|
||||
? await createAssetAttribute(selected.id, {
|
||||
code: attributeCode, name: attributeName, dataType: attributeType,
|
||||
isRequired: attributeRequired, unit: attributeUnit || null,
|
||||
...(attributeType === 'SELECT' ? { options } : {}), sortOrder: attributeOrder,
|
||||
})
|
||||
: await updateAssetAttribute(selected.id, attributeEditor.id, {
|
||||
name: attributeName, dataType: attributeType,
|
||||
isRequired: attributeRequired, isActive: attributeActive,
|
||||
unit: attributeUnit || null,
|
||||
options: attributeType === 'SELECT' ? options : null,
|
||||
sortOrder: attributeOrder,
|
||||
});
|
||||
await load(saved.id);
|
||||
setSuccess(attributeEditor === 'new' ? 'Atributo agregado correctamente' : 'Atributo actualizado');
|
||||
if (attributeEditor === 'new') {
|
||||
await createAssetAttribute(selectedType.id, {
|
||||
code: attributeCode,
|
||||
name: attributeName,
|
||||
dataType: attributeType,
|
||||
isRequired: attributeRequired,
|
||||
unit: attributeUnit || null,
|
||||
...(attributeType === 'SELECT' ? { options } : {}),
|
||||
sortOrder: attributeOrder,
|
||||
});
|
||||
setSuccess(`Nueva columna agregada a ${LEVELS.find((item) => item.kind === selectedKind)?.label}.`);
|
||||
} else {
|
||||
await updateAssetAttribute(selectedType.id, attributeEditor.id, {
|
||||
name: attributeName,
|
||||
dataType: attributeType,
|
||||
isRequired: attributeRequired,
|
||||
isActive: attributeActive,
|
||||
unit: attributeUnit || null,
|
||||
options: attributeType === 'SELECT' ? options : null,
|
||||
sortOrder: attributeOrder,
|
||||
});
|
||||
setSuccess('Columna actualizada.');
|
||||
}
|
||||
await load();
|
||||
setAttributeEditor(null);
|
||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Cargando tipos de inventario…" />;
|
||||
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
|
||||
|
||||
return <section>
|
||||
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Definí qué clases de elementos pueden formar parte de los inventarios y qué información necesita cada una. Las reglas técnicas quedan en configuración avanzada.</p></div>{canManage && <button className="button primary" onClick={startCreate}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
{types.length > 0 && enrichment && !enrichment.complete && <div className="panel master-bootstrap-panel"><div className="master-bootstrap-copy"><span className="eyebrow">CATÁLOGO TÉCNICO</span><h2>Completar nomenclatura de inspección</h2><p>La estructura base ya existe. Esta mejora agrega únicamente las familias técnicas que faltan: plantas, baterías, sistemas y equipos específicos, manteniendo un solo tipo Pozo con método/función configurable.</p><div className="master-bootstrap-notice"><strong>Es una ampliación no destructiva.</strong><span>No reemplaza configuraciones existentes ni crea operadoras, áreas o registros reales.</span></div></div><div className="master-bootstrap-types"><strong>Pendiente</strong><div className="bootstrap-type-grid"><span><Icon name="check" />{enrichment.missingTypeCodes.length} tipos técnicos</span><span><Icon name="check" />{enrichment.missingAttributeCount} atributos</span><span><Icon name="check" />{enrichment.missingParentRuleCount} reglas de jerarquía</span></div></div>{canManage && enrichment.canApply ? <div className="master-bootstrap-actions"><button className="button primary" onClick={enrichTechnicalCatalog} disabled={saving}><Icon name="check" />{saving ? 'Completando…' : 'Completar catálogo técnico'}</button></div> : <Alert>{enrichment.reason ?? 'No se puede aplicar automáticamente sobre esta configuración.'}</Alert>}</div>}
|
||||
{types.length === 0 && !creating ? <div className="panel master-bootstrap-panel"><div className="master-bootstrap-copy"><span className="eyebrow">CONFIGURACIÓN INICIAL</span><h2>Preparar inventarios de Hidrocarburos</h2><p>La configuración de inventarios está vacía. Podés instalar una estructura inicial segura con niveles territoriales, instalaciones, sistemas y familias técnicas de inspección.</p><div className="master-bootstrap-notice"><strong>No carga datos reales automáticamente.</strong><span>Las operadoras, áreas y registros concretos se cargarán después con fuente y vigencia.</span></div></div><div className="master-bootstrap-types"><strong>Incluye</strong><div className="bootstrap-type-grid">{['Área','Organización','Yacimiento / Locación','Planta / Batería / Estación','Sistemas técnicos','Pozo con método configurable','Tanques, bombas y otros equipos','Ducto / Cañería'].map((label) => <span key={label}><Icon name="check" />{label}</span>)}</div></div>{canManage ? <div className="master-bootstrap-actions"><button className="button primary" onClick={installDefaultMaster} disabled={saving}><Icon name="check" />{saving ? 'Instalando…' : 'Instalar configuración base'}</button><button className="button secondary" onClick={startCreate} disabled={saving}><Icon name="plus" />Configurar manualmente</button></div> : <Alert>Necesitás permiso para administrar tipos de inventario y ejecutar la configuración inicial.</Alert>}</div> : <div className="asset-types-layout">
|
||||
<aside className="panel role-list"><div className="role-list-heading"><strong>Tipos disponibles</strong><span>{types.length}</span></div>{types.map((type) => <button key={type.id} className={`role-list-item ${selectedId === type.id && !creating ? 'active' : ''}`} onClick={() => selectType(type)}><span><strong>{type.name}</strong><small>{type.code} · {OPERATIONAL_ROLES.find((role) => role.value === type.operationalRole)?.label ?? type.operationalRole}</small></span><span className="role-count">{type.assetCount} registro{Number(type.assetCount) === 1 ? '' : 's'}</span></button>)}</aside>
|
||||
|
||||
<div className="asset-type-workspace">
|
||||
<form className="panel form-panel" onSubmit={saveType}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">{creating ? 'NUEVO TIPO' : selected?.isActive ? 'TIPO DISPONIBLE' : 'TIPO NO DISPONIBLE'}</span><h2>{creating ? 'Crear tipo de elemento' : name}</h2></div>{!creating && <span className={`status-badge ${isActive ? 'active' : 'inactive'}`}>{isActive ? 'Disponible' : 'No disponible'}</span>}</div>
|
||||
<label className="field"><span>Nombre visible</span><input value={name} onChange={(event) => { setName(event.target.value); if (creating) setCode(typeCodeFromName(event.target.value)); }} disabled={!canManage} required maxLength={160} placeholder="Tanque, Bomba, Planta…" /></label>
|
||||
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canManage} maxLength={2000} rows={3} placeholder="Cuándo debe utilizarse este tipo de elemento" /></label>
|
||||
<div className="type-summary-grid"><div><small>Comportamiento</small><strong>{OPERATIONAL_ROLES.find((role) => role.value === operationalRole)?.label}</strong></div><div><small>Puede estar dentro de</small><strong>{parentTypeIds.length ? types.filter((type) => parentTypeIds.includes(type.id)).map((type) => type.name).slice(0,3).join(', ') + (parentTypeIds.length > 3 ? ` +${parentTypeIds.length-3}` : '') : canBeRoot ? 'Es raíz' : 'Sin configurar'}</strong></div><div><small>Campos técnicos</small><strong>{selected?.attributes.length ?? 0}</strong></div></div>
|
||||
<details className="advanced-config" open={creating}>
|
||||
<summary>Configuración avanzada</summary>
|
||||
<p>Estas opciones controlan reglas internas de los inventarios. La configuración inicial ya las deja preparadas para los tipos estándar.</p>
|
||||
<div className="form-grid"><label className="field"><span>Código interno</span><input value={code} onChange={(event) => setCode(event.target.value.toLowerCase())} disabled={!creating || !canManage} required minLength={2} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Comportamiento</span><SearchableSelect value={operationalRole} onChange={(event) => setOperationalRole(event.target.value as AssetTypeOperationalRole)} disabled={!canManage}>{OPERATIONAL_ROLES.map((role) => <option key={role.value} value={role.value}>{role.label}</option>)}</SearchableSelect><small>{OPERATIONAL_ROLES.find((role) => role.value === operationalRole)?.help}</small></label></div>
|
||||
<div className="type-flags"><label className="check-row"><input type="checkbox" checked={canBeRoot} onChange={(event) => setCanBeRoot(event.target.checked)} disabled={!canManage} /><span><strong>Puede ser raíz</strong><small>Permite crear registros de este tipo sin un registro padre.</small></span></label>{!creating && <label className="check-row"><input type="checkbox" checked={isActive} onChange={(event) => setIsActive(event.target.checked)} disabled={!canManage} /><span><strong>Tipo disponible</strong><small>Los tipos inactivos se conservan para el historial pero no aparecen en altas nuevas.</small></span></label>}</div>
|
||||
<div className="parent-type-section"><h3>¿Dónde puede estar contenido?</h3><p>Seleccioná sólo los tipos que pueden actuar como padre físico.</p><div className="choice-grid">{types.filter((type) => type.id !== selected?.id).map((type) => <label className={`choice-card compact ${parentTypeIds.includes(type.id) ? 'selected' : ''}`} key={type.id}><input type="checkbox" checked={parentTypeIds.includes(type.id)} onChange={() => toggleParent(type.id)} disabled={!canManage} /><span><strong>{type.name}</strong></span><Icon name="check" /></label>)}</div></div>
|
||||
</details>
|
||||
{canManage && <div className="form-actions">{creating && <button className="button secondary" type="button" onClick={() => types[0] && selectType(types[0])}>Cancelar</button>}<button className="button primary" disabled={saving}>{saving ? 'Guardando…' : creating ? 'Crear tipo' : 'Guardar configuración'}</button></div>}
|
||||
</form>
|
||||
|
||||
{!creating && selected && <div className="panel attributes-panel"><div className="panel-heading"><div><span className="eyebrow">CAMPOS DINÁMICOS</span><h2>Atributos</h2></div>{canManage && <button className="button secondary" onClick={() => openAttribute('new')}><Icon name="plus" />Agregar atributo</button>}</div>
|
||||
{selected.attributes.length === 0 ? <div className="inline-empty">No hay atributos configurados para este tipo.</div> : <div className="attribute-list">{selected.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => canManage && openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
|
||||
|
||||
{attributeEditor && <form className="attribute-editor" onSubmit={saveAttribute}><div className="attribute-editor-heading"><h3>{attributeEditor === 'new' ? 'Nuevo atributo' : `Editar ${attributeEditor.name}`}</h3><button type="button" className="button text" onClick={() => setAttributeEditor(null)}>Cerrar</button></div><div className="form-grid"><label className="field"><span>Código</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} required minLength={2} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Nombre</span><input value={attributeName} onChange={(event) => setAttributeName(event.target.value)} required maxLength={160} /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label><label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} placeholder="m, bar, °C…" /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} required /></label></div>{attributeType === 'SELECT' && <label className="field"><span>Opciones <em>una por línea</em></span><textarea value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} required rows={4} placeholder={'Opción A\nOpción B'} /></label>}<div className="type-flags"><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Todo registro de este tipo debe completar el valor.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Atributo activo</strong><small>Desactivarlo conserva los valores históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar atributo'}</button></div></form>}
|
||||
</div>}
|
||||
return <section className="inventory-config-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">ADMINISTRACIÓN</span>
|
||||
<h1>Configuración de Inventarios</h1>
|
||||
<p>Administrá la estructura, los tipos de Instalación/Subinstalación y las columnas que se completan en oficina o desde la APK.</p>
|
||||
</div>
|
||||
</div>}
|
||||
{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}
|
||||
</div>
|
||||
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
|
||||
|
||||
<div className="panel" style={{ marginBottom: 18 }}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">MODELO VIGENTE</span><h2>Estructura física</h2><p className="section-copy">Empresa no es padre del Área. La Operadora/Concesionaria se vincula al Área con vigencia temporal.</p></div></div>
|
||||
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
|
||||
<div><span>1</span><strong>Área</strong><small>raíz territorial</small></div><i>›</i>
|
||||
<div><span>2</span><strong>Yacimiento</strong><small>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 className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente. Puede cambiar la Operadora de un Área sin mover ni reescribir Yacimientos, Instalaciones o Subinstalaciones.</p></div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Precargados desde final_modelov2.xlsx y ampliables por Hidrocarburos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.sourceReference?.startsWith('F5:final_modelov2.xlsx') ? 'Precargado desde fuente autorizada' : 'Agregado por Hidrocarburos'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
|
||||
</article>
|
||||
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Cada tipo queda asociado a un tipo de Instalación.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
|
||||
<div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{subinstallationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilyName ?? 'Sin Instalación padre'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article className="panel" style={{ marginTop: 18 }}>
|
||||
<div className="panel-heading"><div><span className="eyebrow">COLUMNAS / CAMPOS</span><h2>Información configurable</h2><p className="section-copy">Agregá las columnas que deben completarse para cada nivel. No modifica la jerarquía.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nueva columna</button>}</div>
|
||||
<div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div>
|
||||
<p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>
|
||||
{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay columnas adicionales configuradas para {LEVELS.find((item) => item.kind === selectedKind)?.label}.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
|
||||
</article>
|
||||
|
||||
<div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo separado, relación clara</h2><p className="section-copy">Los Hallazgos se crean y editan en su catálogo, y allí se vinculan al tipo de Instalación/Subinstalación correspondiente. OTROS permanece siempre disponible.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div>
|
||||
|
||||
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN DE INVENTARIO</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentId(''); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required placeholder={familyLevel === 'INSTALLATION' ? 'Ej.: Estación, Planta…' : 'Ej.: Tanque, Bomba…'} /></label>{familyLevel === 'SUBINSTALLATION' && <label className="field"><span>Tipo de Instalación padre</span><SearchableSelect value={familyParentId} onChange={(event) => setFamilyParentId(event.target.value)} required><option value="">Seleccionar…</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option value={family.id} key={family.id}>{family.name}</option>)}</SearchableSelect></label>}{familyEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarlo deja de ofrecerse en nuevas altas, sin borrar registros históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && !familyParentId)}>{saving ? 'Guardando…' : 'Guardar tipo'}</button></div></form></div>}
|
||||
|
||||
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">COLUMNA DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nueva columna' : 'Editar columna'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Columna activa</strong><small>Desactivarla conserva datos históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar columna'}</button></div></form></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -15,14 +15,15 @@ import {
|
||||
ASSET_OPERATIONAL_STATUSES,
|
||||
ASSET_STATUSES,
|
||||
} from '../features/assets/assetPresentation';
|
||||
import { listAssets, listAssetTree, listAssetTypes } from '../lib/api';
|
||||
import { listAssetTypes } from '../lib/api';
|
||||
import type {
|
||||
AssetInformationStatus,
|
||||
AssetListItem,
|
||||
AssetOperationalStatus,
|
||||
AssetType,
|
||||
PageMeta,
|
||||
} from '../lib/api';
|
||||
import { listRealInventory } from '../lib/inventoryBrowserApi';
|
||||
import type { InventoryListItem, InventoryQuery } from '../lib/inventoryBrowserApi';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
const quickViews = [
|
||||
@@ -35,7 +36,7 @@ const quickViews = [
|
||||
export function AssetsPage() {
|
||||
const [urlParams, setUrlParams] = useSearchParams();
|
||||
const operationalContext = useOperationalContext();
|
||||
const [assets, setAssets] = useState<AssetListItem[]>([]);
|
||||
const [assets, setAssets] = useState<InventoryListItem[]>([]);
|
||||
const [types, setTypes] = useState<AssetType[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -59,25 +60,39 @@ export function AssetsPage() {
|
||||
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
|
||||
|
||||
useEffect(() => {
|
||||
listAssetTypes().then(setTypes).catch(() => undefined);
|
||||
listAssetTypes().then((data) => setTypes(data.filter((type) => ['instalacion','subinstalacion'].includes(type.code.toLowerCase())))).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== 'list') return;
|
||||
setLoading(true); setError('');
|
||||
listAssets({
|
||||
page, pageSize: 25, search, typeId, status,
|
||||
setLoading(true);
|
||||
setError('');
|
||||
listRealInventory({
|
||||
page,
|
||||
pageSize: 25,
|
||||
search,
|
||||
typeId,
|
||||
status,
|
||||
operationalStatus: effectiveOperationalStatus,
|
||||
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
needsValidation,
|
||||
hasGeometry,
|
||||
})
|
||||
.then((response) => { setAssets(response.data); setMeta(response.meta); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [view, page, search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
|
||||
|
||||
const filters: Parameters<typeof listAssetTree>[0] = useMemo(() => ({
|
||||
search, typeId, status, operationalStatus: effectiveOperationalStatus,
|
||||
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
|
||||
const filters: InventoryQuery = useMemo(() => ({
|
||||
search,
|
||||
typeId,
|
||||
status,
|
||||
operationalStatus: effectiveOperationalStatus,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
needsValidation,
|
||||
hasGeometry,
|
||||
}), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
|
||||
|
||||
const update = (changes: Record<string, string | null>) => {
|
||||
@@ -91,18 +106,25 @@ export function AssetsPage() {
|
||||
const clearFilters = () => {
|
||||
const next = new URLSearchParams();
|
||||
if (view === 'list') next.set('view', 'list');
|
||||
['section', 'companyId', 'parentId'].forEach((key) => {
|
||||
const value = urlParams.get(key);
|
||||
if (value) next.set(key, value);
|
||||
});
|
||||
setDraftSearch(''); setUrlParams(next);
|
||||
const parentId=urlParams.get('parentId');
|
||||
if (parentId) next.set('parentId',parentId);
|
||||
setDraftSearch('');
|
||||
setUrlParams(next);
|
||||
};
|
||||
const setPage = (value: number) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
const setPage = (value: number) => { const next = new URLSearchParams(urlParams); value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next); };
|
||||
const advancedActive = Boolean(typeId || status || rawOperationalStatus);
|
||||
|
||||
return <section>
|
||||
<div className="page-heading asset-center-heading">
|
||||
<div><span className="eyebrow">INVENTARIOS</span><h1>Inventarios</h1><p>Consultá y administrá el inventario operativo de cada empresa desde un solo lugar.</p></div>
|
||||
<div>
|
||||
<span className="eyebrow">INVENTARIOS</span>
|
||||
<h1>Inventarios</h1>
|
||||
<p>Instancias reales registradas en campo, organizadas por Área → Yacimiento → Instalación → Subinstalación.</p>
|
||||
</div>
|
||||
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
|
||||
</div>
|
||||
|
||||
@@ -111,15 +133,15 @@ export function AssetsPage() {
|
||||
<div className="asset-center-controls">
|
||||
<form className="asset-center-search" onSubmit={applySearch}>
|
||||
<Icon name="search" />
|
||||
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar registro, código o identificación…" />
|
||||
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar Inventario, código o identificación…" />
|
||||
<button className="button primary" type="submit">Buscar</button>
|
||||
</form>
|
||||
<div className="quick-view-row" aria-label="Vistas rápidas">
|
||||
{quickViews.map((item) => <button key={item.key} type="button" className={quick === item.key ? 'active' : ''} onClick={() => setQuick(item.key)}>{item.label}</button>)}
|
||||
<button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>
|
||||
{view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>}
|
||||
</div>
|
||||
{(advancedOpen || advancedActive) && <div className="advanced-filter-panel">
|
||||
<label className="field compact-field"><span>Tipo</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
|
||||
{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="">Instalaciones y Subinstalaciones</option>{types.map((type) => <option key={type.id} value={type.id}>{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>
|
||||
@@ -127,19 +149,25 @@ export function AssetsPage() {
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{view === 'hierarchy' ? <AssetHierarchyView filters={filters} /> : loading ? <LoadingBlock label="Cargando inventario…" /> : assets.length === 0 ? <EmptyState title="No encontramos registros" text="Probá con otra búsqueda o cambiá los filtros seleccionados." /> : <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>Tipo</th><th>Área / 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>
|
||||
<td>{asset.operationalArea || asset.operatorCompany ? <span><strong className="table-primary">{asset.operationalArea?.name ?? 'Sin área'}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td>
|
||||
<td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td>
|
||||
<td>{formatDate(asset.updatedAt)}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>)}
|
||||
</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
{view === 'hierarchy'
|
||||
? <AssetHierarchyView filters={filters} />
|
||||
: loading
|
||||
? <LoadingBlock label="Cargando Inventario real…" />
|
||||
: assets.length === 0
|
||||
? <EmptyState title="Todavía no hay Inventario real" text="Las Áreas y Yacimientos precargados son contexto. Las Instalaciones/Subinstalaciones aparecerán aquí cuando sean registradas realmente." />
|
||||
: <div className="table-panel compact-assets-table">
|
||||
<div className="table-summary"><strong>{meta.total} instancia{meta.total === 1 ? '' : 's'} real{meta.total === 1 ? '' : 'es'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Inventario</th><th>Nivel</th><th>Área / Operadora vigente</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>
|
||||
<td>{asset.operationalArea ? <span><strong className="table-primary">{asset.operationalArea.name}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora vigente'}</small></span> : <span className="muted">Sin Área</span>}</td>
|
||||
<td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td>
|
||||
<td>{formatDate(asset.updatedAt)}</td>
|
||||
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
|
||||
</tr>)}
|
||||
</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function DashboardPage() {
|
||||
|
||||
return <section>
|
||||
<div className="page-heading dashboard-heading">
|
||||
<div><span className="eyebrow">RESUMEN OPERATIVO</span><h1>Buen día, {user?.firstName}</h1><p>Lo que requiere atención en Inspecciones, Hallazgos, verificaciones e Informes.</p></div>
|
||||
<div><span className="eyebrow">RESUMEN OPERATIVO</span><h1>Buen día, {user?.firstName}</h1><p>Lo que requiere atención en Inspecciones, Actas, verificaciones e Informes.</p></div>
|
||||
<span className={`health-pill ${health?.status === 'ok' ? 'ok' : ''}`}><span />{health?.status === 'ok' ? 'Sistema operativo' : 'Verificando sistema'}</span>
|
||||
</div>
|
||||
|
||||
@@ -38,21 +38,21 @@ export function DashboardPage() {
|
||||
<div className="dashboard-section-heading"><div><span className="eyebrow">HOY</span><h2>Qué requiere atención</h2></div></div>
|
||||
<div className="stat-grid operational-stat-grid">
|
||||
<Link className="stat-card" to="/inspecciones?status=PLANNED"><div className="stat-icon blue"><Icon name="calendar" /></div><div><small>INSPECCIONES PLANIFICADAS</small><strong>{summary.counts.plannedInspections}</strong><span>Pendientes de iniciar</span></div></Link>
|
||||
<Link className="stat-card" to="/hallazgos?workflow=VERIFICATION_OVERDUE"><div className="stat-icon danger"><Icon name="alert" /></div><div><small>VERIFICACIONES VENCIDAS</small><strong>{summary.counts.overdueControls}</strong><span>Requieren control operativo</span></div></Link>
|
||||
<Link className="stat-card" to="/hallazgos"><div className="stat-icon observed"><Icon name="alert" /></div><div><small>HALLAZGOS ABIERTOS</small><strong>{summary.counts.openFindings}</strong><span>Seguimiento técnico activo</span></div></Link>
|
||||
<Link className="stat-card" to="/informes?view=pending"><div className="stat-icon violet"><Icon name="clipboard" /></div><div><small>ACTAS SIN INF</small><strong>{summary.counts.sealedActsWithoutReport}</strong><span>Actas selladas pendientes de Informe</span></div></Link>
|
||||
<Link className="stat-card" to="/hallazgos?workflow=VERIFICATION_OVERDUE"><div className="stat-icon danger"><Icon name="alert" /></div><div><small>VERIFICACIONES VENCIDAS</small><strong>{summary.counts.overdueControls}</strong><span>Hallazgos que requieren control técnico</span></div></Link>
|
||||
<Link className="stat-card" to="/seguimiento-actas"><div className="stat-icon observed"><Icon name="clipboard" /></div><div><small>ACTAS EN SEGUIMIENTO</small><strong>{summary.counts.actsInFollowUp}</strong><span>Respuestas, compromisos y regularización</span></div></Link>
|
||||
<Link className="stat-card" to="/informes?view=pending"><div className="stat-icon violet"><Icon name="clipboard" /></div><div><small>ACTAS SIN INFORME</small><strong>{summary.counts.sealedActsWithoutReport}</strong><span>Actas selladas que todavía no tienen Informe</span></div></Link>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section-heading compact"><div><span className="eyebrow">INVENTARIOS Y DOCUMENTOS</span><h2>Estado general</h2></div></div>
|
||||
<div className="stat-grid secondary-stat-grid">
|
||||
<Link className="stat-card compact" to="/inventarios"><div className="stat-icon blue"><Icon name="layers" /></div><div><small>INVENTARIO</small><strong>{summary.counts.totalAssets}</strong><span>Elementos registrados</span></div></Link>
|
||||
<Link className="stat-card compact" to="/inventarios?quick=location"><div className="stat-icon gray"><Icon name="map" /></div><div><small>SIN UBICACIÓN</small><strong>{summary.counts.assetsWithoutGeometry}</strong><span>Sin geometría registrada</span></div></Link>
|
||||
<Link className="stat-card compact" to="/informes"><div className="stat-icon gray"><Icon name="audit" /></div><div><small>INF EN PREPARACIÓN</small><strong>{summary.counts.reportsWorking}</strong><span>{summary.counts.reportsOfficialized} oficializados en GEDO</span></div></Link>
|
||||
<Link className="stat-card compact" to="/inventarios"><div className="stat-icon blue"><Icon name="layers" /></div><div><small>INVENTARIO REAL</small><strong>{summary.counts.totalAssets}</strong><span>Instancias físicas registradas</span></div></Link>
|
||||
<Link className="stat-card compact" to="/inventarios?quick=location"><div className="stat-icon gray"><Icon name="map" /></div><div><small>SIN UBICACIÓN</small><strong>{summary.counts.assetsWithoutGeometry}</strong><span>Inventarios reales sin geometría</span></div></Link>
|
||||
<Link className="stat-card compact" to="/informes"><div className="stat-icon gray"><Icon name="audit" /></div><div><small>INFORMES EN PREPARACIÓN</small><strong>{summary.counts.reportsWorking}</strong><span>Informe creado, todavía no oficializado · {summary.counts.reportsOfficialized} oficializados</span></div></Link>
|
||||
<Link className="stat-card compact" to="/hallazgos/planificacion"><div className="stat-icon green"><Icon name="calendar" /></div><div><small>PRÓXIMAS VERIFICACIONES</small><strong>{summary.counts.controlsNext30Days}</strong><span>{summary.counts.findingsWithoutControlDate} Hallazgos abiertos sin fecha de control</span></div></Link>
|
||||
</div>
|
||||
|
||||
{hasPermission('inspection_findings.read') && <article className="panel upcoming-controls-panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">AGENDA</span><h2>Próximos controles</h2><p className="section-copy">Hallazgos abiertos con una fecha de verificación definida.</p></div><Link className="text-link" to="/hallazgos/planificacion">Planificar verificaciones <Icon name="chevron" size={15} /></Link></div>
|
||||
<div className="panel-heading"><div><span className="eyebrow">AGENDA TÉCNICA</span><h2>Próximos controles</h2><p className="section-copy">Hallazgos abiertos con una fecha de verificación definida. Este seguimiento técnico permite verificar regularización y detectar reincidencias.</p></div><Link className="text-link" to="/hallazgos/planificacion">Planificar verificaciones <Icon name="chevron" size={15} /></Link></div>
|
||||
<div className="activity-list">
|
||||
{summary.upcomingControls.length === 0 && <p className="muted">Todavía no hay controles programados.</p>}
|
||||
{summary.upcomingControls.map((control) => <Link className="activity-item control-item" key={control.id} to={`/hallazgos/${control.id}`}><span className={`activity-dot ${control.nextControlOn < todayDate() ? 'overdue-dot' : ''}`} /><div><strong>{control.title}</strong><p>{control.assetCode} · {control.assetName} · {control.actCode}</p></div><time>{formatDateOnly(control.nextControlOn)}</time></Link>)}
|
||||
@@ -61,15 +61,16 @@ export function DashboardPage() {
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<article className="panel">
|
||||
<div className="panel-heading"><div><span className="eyebrow">ACTIVIDAD</span><h2>Últimos cambios</h2></div>{hasPermission('audit.read') && <Link className="text-link" to="/admin/audit">Ver auditoría <Icon name="chevron" size={15} /></Link>}</div>
|
||||
<div className="activity-list">{summary.recentAudit.length === 0 ? <p className="muted">Todavía no hay actividad registrada.</p> : summary.recentAudit.map((event) => <div className="activity-item" key={event.id}><span className="activity-dot" /><div><strong>{actionLabel(event.action)}</strong><p>{event.actorUsername ?? 'Sistema'}{event.entityType ? ` · ${event.entityType}` : ''}</p></div><time>{formatDate(event.occurredAt)}</time></div>)}</div>
|
||||
<div className="panel-heading"><div><span className="eyebrow">ACTIVIDAD DE CAMPO</span><h2>Últimas actividades de los inspectores</h2><p className="section-copy">Acciones operativas realizadas por inspectores asignados.</p></div></div>
|
||||
<div className="activity-list">{summary.recentInspectorActivity.length === 0 ? <p className="muted">Todavía no hay actividad operativa de inspectores registrada.</p> : summary.recentInspectorActivity.map((event) => <div className="activity-item" key={event.id}><span className="activity-dot" /><div><strong>{actionLabel(event.action)}</strong><p>{event.actorUsername ?? 'Inspector'}{event.entityType ? ` · ${event.entityType}` : ''}</p></div><time>{formatDate(event.occurredAt)}</time></div>)}</div>
|
||||
</article>
|
||||
|
||||
<article className="panel quick-panel">
|
||||
<span className="eyebrow">ACCESOS RÁPIDOS</span><h2>Administración</h2>
|
||||
<div className="quick-links">
|
||||
<Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Inventario operativo organizado por empresa y área</small></span><Icon name="chevron" /></Link>
|
||||
{hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Tipos y campos configurables</small></span><Icon name="chevron" /></Link>}
|
||||
<Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Instancias reales organizadas por empresa, área y jerarquía</small></span><Icon name="chevron" /></Link>
|
||||
{hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Jerarquía, tipos y campos configurables</small></span><Icon name="chevron" /></Link>}
|
||||
{hasPermission('finding_catalog.manage') && <Link to="/admin/finding-catalog"><Icon name="alert" /><span><strong>Catálogo de hallazgos</strong><small>Hallazgos aplicables según el tipo de Subinstalación</small></span><Icon name="chevron" /></Link>}
|
||||
{hasPermission('users.read') && <Link to="/admin/users"><Icon name="users" /><span><strong>Usuarios</strong><small>Accesos y roles</small></span><Icon name="chevron" /></Link>}
|
||||
</div>
|
||||
<div className="system-footnote">API {health?.version ?? '—'} · Base {health?.database === 'ok' ? 'operativa' : 'sin verificar'}</div>
|
||||
|
||||
@@ -100,8 +100,6 @@ export function InspectionVisitsPage() {
|
||||
{statusTabs.map((item) => <Link key={item.value || 'all'} className={status === item.value ? 'active' : ''} to={statusHref(item.value)}>{item.label}</Link>)}
|
||||
</nav>
|
||||
|
||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un recorrido, varias actas.</strong> La inspección representa la salida a un Área/Operadora y puede generar múltiples actas durante el recorrido. El inicio y cierre en campo siguen siendo exclusivos de la APK.</p></div>
|
||||
|
||||
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código" /><button>Buscar</button></label>
|
||||
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={updateFilter} />
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
InventoryStructureParent,
|
||||
} from '../lib/inventoryStructureApi';
|
||||
|
||||
const KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [
|
||||
const STRUCTURE_KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [
|
||||
{ kind: 'AREA', label: 'Área', help: 'Nivel territorial raíz.', step: 1 },
|
||||
{ kind: 'YACIMIENTO', label: 'Yacimiento', help: 'Debe pertenecer a un Área.', step: 2 },
|
||||
{ kind: 'INSTALACION', label: 'Instalación', help: 'Debe pertenecer a un Yacimiento.', step: 3 },
|
||||
@@ -32,7 +32,8 @@ const childKindByParentType: Record<string, InventoryStructureKind | undefined>
|
||||
};
|
||||
|
||||
function kindLabel(kind: InventoryStructureKind): string {
|
||||
return KINDS.find((item) => item.kind === kind)?.label ?? kind;
|
||||
if (kind === 'EMPRESA') return 'Empresa';
|
||||
return STRUCTURE_KINDS.find((item) => item.kind === kind)?.label ?? kind;
|
||||
}
|
||||
|
||||
export function InventoryCreatePage() {
|
||||
@@ -72,7 +73,7 @@ export function InventoryCreatePage() {
|
||||
}, [contextParentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind === 'AREA') {
|
||||
if (kind === 'AREA' || kind === 'EMPRESA') {
|
||||
setParents([]);
|
||||
setParentId('');
|
||||
return;
|
||||
@@ -130,7 +131,7 @@ export function InventoryCreatePage() {
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const requiresParent = kind !== 'AREA';
|
||||
const requiresParent = kind !== 'AREA' && kind !== 'EMPRESA';
|
||||
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
|
||||
if (requiresParent && !parentId) {
|
||||
setError(`Seleccioná el ${kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación'} padre.`);
|
||||
@@ -160,10 +161,13 @@ export function InventoryCreatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingBlock label="Preparando alta de Inventario…" />;
|
||||
if (loading) return <LoadingBlock label="Preparando alta…" />;
|
||||
|
||||
const isIndependentCompany = kind === 'EMPRESA';
|
||||
const parentLabel = kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación';
|
||||
const currentStep = KINDS.find((item) => item.kind === kind)?.step ?? 1;
|
||||
const currentStep = STRUCTURE_KINDS.find((item) => item.kind === kind)?.step ?? 1;
|
||||
const identificationStep = isIndependentCompany ? '2' : kind === 'AREA' ? '2' : kind === 'YACIMIENTO' ? '3' : '4';
|
||||
const requiresParent = kind !== 'AREA' && kind !== 'EMPRESA';
|
||||
|
||||
return <section className="narrow-section asset-detail-page">
|
||||
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
|
||||
@@ -172,9 +176,9 @@ export function InventoryCreatePage() {
|
||||
|
||||
<div className="page-heading asset-editor-heading">
|
||||
<div>
|
||||
<span className="eyebrow">INVENTARIO</span>
|
||||
<h1>Agregar a la estructura</h1>
|
||||
<p>La estructura oficial es Área → Yacimiento → Instalación → Subinstalación. Elegí el nivel y el sistema te guía con los vínculos válidos.</p>
|
||||
<span className="eyebrow">CONFIGURACIÓN E INVENTARIO</span>
|
||||
<h1>Agregar registro</h1>
|
||||
<p>Empresa es un maestro independiente. La estructura física es Área → Yacimiento → Instalación → Subinstalación.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,9 +186,18 @@ export function InventoryCreatePage() {
|
||||
|
||||
<div className="panel" style={{ marginBottom: 18 }}>
|
||||
<div className="form-section">
|
||||
<div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Sólo se pueden crear los cuatro niveles estructurales definidos para DH.</p></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 10 }}>
|
||||
{KINDS.map((item) => <button
|
||||
<div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Creá una Empresa independiente o agregá un nivel a la estructura física.</p></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`button ${kind === 'EMPRESA' ? 'primary' : 'secondary'}`}
|
||||
onClick={() => chooseKind('EMPRESA')}
|
||||
style={{ minHeight: 76, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 3 }}
|
||||
>
|
||||
<strong>Empresa</strong>
|
||||
<small>Maestro independiente.</small>
|
||||
</button>
|
||||
{STRUCTURE_KINDS.map((item) => <button
|
||||
key={item.kind}
|
||||
type="button"
|
||||
className={`button ${kind === item.kind ? 'primary' : 'secondary'}`}
|
||||
@@ -195,15 +208,18 @@ export function InventoryCreatePage() {
|
||||
<small>{item.help}</small>
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="temporal-notice" style={{ marginTop: 12 }}>
|
||||
{isIndependentCompany ? <div className="temporal-notice" style={{ marginTop: 12 }}>
|
||||
<Icon name="users" />
|
||||
<p><strong>Empresa:</strong> no forma parte de la jerarquía física. Su vínculo con un Área se administra como relación temporal de operación/concesión.</p>
|
||||
</div> : <div className="temporal-notice" style={{ marginTop: 12 }}>
|
||||
<Icon name="layers" />
|
||||
<p><strong>Ruta:</strong> {KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')}</p>
|
||||
</div>
|
||||
<p><strong>Ruta física:</strong> {STRUCTURE_KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')}</p>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="panel form-panel" onSubmit={save}>
|
||||
{kind !== 'AREA' && <div className="form-section">
|
||||
{requiresParent && <div className="form-section">
|
||||
<div><h2>2. Ubicación en la estructura</h2><p className="section-copy">Primero elegí el {parentLabel} al que pertenece este registro.</p></div>
|
||||
<label className="field">
|
||||
<span>Buscar {parentLabel.toLowerCase()}</span>
|
||||
@@ -220,24 +236,24 @@ export function InventoryCreatePage() {
|
||||
</div>}
|
||||
|
||||
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="form-section">
|
||||
<div><h2>3. Familia técnica</h2><p className="section-copy">La familia no crea otro nivel. Sirve para aplicar exactamente los Hallazgos del Excel que corresponden.</p></div>
|
||||
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación seleccionada todavía no tiene una familia técnica F3.1. Revisala antes de crear una Subinstalación.</Alert> : <label className="field">
|
||||
<span>Familia de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span>
|
||||
<div><h2>3. Clasificación técnica</h2><p className="section-copy">La clasificación no crea otro nivel: define el tipo de Instalación/Subinstalación y los Hallazgos aplicables.</p></div>
|
||||
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación seleccionada todavía no tiene una clasificación técnica. Revisala antes de crear una Subinstalación.</Alert> : <label className="field">
|
||||
<span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span>
|
||||
<select value={familyId} onChange={(event) => setFamilyId(event.target.value)} required>
|
||||
<option value="">Seleccionar familia…</option>
|
||||
<option value="">Seleccionar tipo…</option>
|
||||
{families.map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}
|
||||
</select>
|
||||
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && <small>Se muestran sólo las subinstalaciones válidas para {selectedParent.inventoryFamily.name}.</small>}
|
||||
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && <small>Se muestran sólo las Subinstalaciones válidas para {selectedParent.inventoryFamily.name}.</small>}
|
||||
</label>}
|
||||
|
||||
{selectedFamily && <div className="context-create-banner" style={{ alignItems: 'flex-start' }}>
|
||||
<Icon name="alert" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<strong>Hallazgos asociados automáticamente</strong>
|
||||
<span>{familyFindings ? `${familyFindings.count} controles del Excel para ${selectedFamily.name}` : 'Cargando catálogo asociado…'}</span>
|
||||
<span>{familyFindings ? `${familyFindings.count} Hallazgos aplicables para ${selectedFamily.name}` : 'Cargando catálogo asociado…'}</span>
|
||||
{familyFindings && familyFindings.items.length > 0 && <ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
||||
{familyFindings.items.slice(0, 7).map((item) => <li key={item.id}>{item.title}</li>)}
|
||||
{familyFindings.items.length > 7 && <li><strong>+ {familyFindings.items.length - 7} hallazgos más</strong></li>}
|
||||
{familyFindings.items.length > 7 && <li><strong>+ {familyFindings.items.length - 7} Hallazgos más</strong></li>}
|
||||
</ul>}
|
||||
</div>
|
||||
</div>}
|
||||
@@ -249,18 +265,18 @@ export function InventoryCreatePage() {
|
||||
</div>}
|
||||
|
||||
<div className="form-section">
|
||||
<div><h2>{kind === 'AREA' ? '2' : kind === 'YACIMIENTO' ? '3' : '4'}. Identificación</h2><p className="section-copy">Usá el nombre real de campo. El código DH puede generarse automáticamente.</p></div>
|
||||
<div><h2>{identificationStep}. Identificación</h2><p className="section-copy">{isIndependentCompany ? 'Registrá la denominación de la Empresa.' : 'Usá el nombre real de campo.'} El código DH puede generarse automáticamente.</p></div>
|
||||
<div className="form-grid">
|
||||
<label className="field"><span>Nombre <em>obligatorio</em></span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label>
|
||||
<label className="field"><span>Código DH <em>opcional</em></span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejar vacío para generar automáticamente" /><small>Si no lo informás, DH genera un código único.</small></label>
|
||||
<label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} placeholder="Nombre usado por los inspectores en campo" /></label>
|
||||
<label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} placeholder={isIndependentCompany ? 'Nombre habitual o abreviado' : 'Nombre usado por los inspectores en campo'} /></label>
|
||||
</div>
|
||||
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<Link className="button secondary" to="/inventarios">Cancelar</Link>
|
||||
<button className="button primary" disabled={saving || !name.trim() || (kind !== 'AREA' && !parentId) || ((kind === 'INSTALACION' || kind === 'SUBINSTALACION') && !familyId)}>
|
||||
<button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || ((kind === 'INSTALACION' || kind === 'SUBINSTALACION') && !familyId)}>
|
||||
<Icon name="check" />{saving ? 'Creando…' : `Crear ${kindLabel(kind)}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user