diff --git a/web-v2/src/pages/TerritorialInventoryPage.tsx b/web-v2/src/pages/TerritorialInventoryPage.tsx new file mode 100644 index 0000000..7700a70 --- /dev/null +++ b/web-v2/src/pages/TerritorialInventoryPage.tsx @@ -0,0 +1,247 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { useAuth } from '../auth/AuthContext'; +import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; +import { Icon } from '../components/Icon'; +import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel'; +import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation'; +import { + listAreaCompanyRelations, + listAssetTreeChildren, + listInspectionVisits, + updateAsset, +} from '../lib/api'; +import type { AreaCompanyRelation, AssetDetail, AssetListItem } from '../lib/api'; +import { + listInspectionActsGlobalF4, + type InspectionActStatusF4, +} from '../lib/inspectionActF4Api'; +import { listInspectionReportsF4 } from '../lib/reportWorkflowApi'; +import './territorialInventory.css'; + +type TerritoryTab = 'summary' | 'history'; + +function normalized(value: string) { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function typeTokens(asset: AssetDetail) { + return new Set(`${normalized(asset.type.code)} ${normalized(asset.type.name)}`.split(' ').filter(Boolean)); +} + +export function isTerritorialInventoryAsset(asset: AssetDetail) { + const tokens = typeTokens(asset); + return tokens.has('departamento') || tokens.has('area') || tokens.has('yacimiento'); +} + +function isOperationalArea(asset: AssetDetail) { + return typeTokens(asset).has('area'); +} + +function relationRoleLabel(role: AreaCompanyRelation['relationRole']) { + if (role === 'OPERATOR') return 'Operadora'; + if (role === 'TECHNICAL_OPERATOR') return 'Operadora técnica'; + if (role === 'CONCESSIONAIRE') return 'Concesionaria / titular'; + if (role === 'PERMIT_HOLDER') return 'Permisionaria'; + if (role === 'PARTICIPANT') return 'Participante'; + return 'Vinculada'; +} + +export function TerritorialInventoryPage({ initialAsset }: { initialAsset: AssetDetail }) { + const [params, setParams] = useSearchParams(); + const requestedTab = params.get('tab') as TerritoryTab | null; + const tab: TerritoryTab = requestedTab === 'history' ? 'history' : 'summary'; + const { hasPermission } = useAuth(); + const canEdit = hasPermission('assets.update'); + const canCreate = hasPermission('assets.create'); + const canReadHistory = hasPermission('assets.read_history'); + const canReadRelations = hasPermission('asset_relations.read'); + const canReadActs = hasPermission('inspection_acts.read'); + const canReadReports = hasPermission('inspection_reports.read'); + const canReadInspections = hasPermission('inspections.read'); + + const [asset, setAsset] = useState(initialAsset); + const [children, setChildren] = useState([]); + const [childrenHasMore, setChildrenHasMore] = useState(false); + const [relations, setRelations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [name, setName] = useState(initialAsset.name); + const [description, setDescription] = useState(initialAsset.description ?? ''); + const [metrics, setMetrics] = useState({ activeInspections: 0, acts: 0, reports: 0 }); + + const area = isOperationalArea(asset); + const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]); + + const loadSummary = async () => { + setLoading(true); + setError(''); + try { + const [childPage, loadedRelations] = await Promise.all([ + listAssetTreeChildren({ parentId: asset.id, limit: 100 }), + area && canReadRelations + ? listAreaCompanyRelations({ areaId: asset.id, includeHistory: true }) + : Promise.resolve([]), + ]); + setChildren(childPage.data); + setChildrenHasMore(childPage.meta.hasMore); + setRelations(loadedRelations); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void loadSummary(); }, [asset.id, area, canReadRelations]); + + useEffect(() => { + if (!area) { + setMetrics({ activeInspections: 0, acts: 0, reports: 0 }); + return; + } + let cancelled = false; + const loadMetrics = async () => { + try { + const actStatuses: InspectionActStatusF4[] = ['SEALED', 'CLOSED', 'RECTIFIED']; + const [planned, inProgress, actPages, reportPage] = await Promise.all([ + canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'PLANNED', pageSize: 1 }) : Promise.resolve(null), + canReadInspections ? listInspectionVisits({ areaId: asset.id, status: 'IN_PROGRESS', pageSize: 1 }) : Promise.resolve(null), + canReadActs + ? Promise.all(actStatuses.map((status) => listInspectionActsGlobalF4({ areaId: asset.id, status, pageSize: 1 }))) + : Promise.resolve([]), + canReadReports ? listInspectionReportsF4({ areaId: asset.id, pageSize: 1 }) : Promise.resolve(null), + ]); + if (cancelled) return; + setMetrics({ + activeInspections: (planned?.meta.total ?? 0) + (inProgress?.meta.total ?? 0), + acts: actPages.reduce((total, page) => total + page.meta.total, 0), + reports: reportPage?.meta.total ?? 0, + }); + } catch { + if (!cancelled) setMetrics({ activeInspections: 0, acts: 0, reports: 0 }); + } + }; + void loadMetrics(); + return () => { cancelled = true; }; + }, [asset.id, area, canReadActs, canReadReports, canReadInspections]); + + const save = async (event: FormEvent) => { + event.preventDefault(); + if (!canEdit || !name.trim()) return; + setSaving(true); setError(''); setSuccess(''); + try { + const saved = await updateAsset(asset.id, { + name: name.trim(), + description: description.trim() || null, + }); + setAsset(saved); + setName(saved.name); + setDescription(saved.description ?? ''); + setEditing(false); + setSuccess('Datos actualizados.'); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + const setTab = (next: TerritoryTab) => { + const nextParams = new URLSearchParams(params); + next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next); + nextParams.delete('advanced'); + setParams(nextParams); + }; + + return
+ + +
+
+ {asset.type.name.toUpperCase()} +

{asset.name}

+

{asset.code}

+
+
+ {assetStatusLabel(asset.informationStatus)} + {canEdit && tab === 'summary' && } + {canCreate && tab === 'summary' && Agregar elemento} +
+
+ +
Este nivel organiza el territorio y la navegación. Los hallazgos se cargan sobre elementos inspeccionables, no sobre {asset.type.name.toLowerCase()}s.
+ + + + {error && {error}}{success && {success}} + + {tab === 'summary' &&
+
+
+
DATOS BÁSICOS

Identificación

+
+ {editing ?
+ +