diff --git a/web-v2/src/pages/CompanyInventoryPage.tsx b/web-v2/src/pages/CompanyInventoryPage.tsx new file mode 100644 index 0000000..175e77f --- /dev/null +++ b/web-v2/src/pages/CompanyInventoryPage.tsx @@ -0,0 +1,437 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { FormEvent } 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 { SearchableSelect } from '../components/SearchableSelect'; +import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel'; +import { AssetMediaPanel } from '../features/assets/AssetMediaPanel'; +import { assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation'; +import { + createAreaCompanyRelation, + endAreaCompanyRelation, + getAssetRegistry, + listAreaCompanyRelations, + listInspectionVisits, + listOperationalAreas, + updateAsset, + upsertOrganizationProfile, +} from '../lib/api'; +import type { + AreaCompanyRelation, + AssetDetail, + AssetRegistry, + OperationalAssetSummary, + OrganizationKind, +} from '../lib/api'; +import { + listInspectionActsGlobalF4, + type InspectionActListItemF4, + type InspectionActStatusF4, +} from '../lib/inspectionActF4Api'; +import { + listInspectionReportsF4, + type InspectionReportDetailF4, +} from '../lib/reportWorkflowApi'; +import { formatDate } from '../lib/format'; +import './companyInventory.css'; + +type CompanyTab = 'summary' | 'issued' | 'documents' | 'history'; + +const organizationLabels: Record = { + COMPANY: 'Empresa', + UTE: 'UTE', + PUBLIC_ENTITY: 'Entidad pública', + OTHER: 'Otra organización', +}; + +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 'Otro vínculo'; +} + +function reportStatusLabel(report: InspectionReportDetailF4) { + if (report.status === 'OFFICIALIZED') return report.gedoIfIdentifier ? `GEDO · ${report.gedoIfIdentifier}` : 'Oficializado en GEDO'; + if (report.status === 'WORKING') return 'INF en preparación'; + if (report.status === 'CANCELLED') return 'Cancelado'; + return 'Informe histórico'; +} + +function reportStatusClass(report: InspectionReportDetailF4) { + if (report.status === 'OFFICIALIZED') return 'active'; + if (report.status === 'CANCELLED') return 'danger'; + return 'pending'; +} + +function areaLabel(items: Array<{ name: string }>) { + if (!items.length) return 'Área sin informar'; + const first = items[0]; + if (!first) return 'Área sin informar'; + return items.length === 1 ? first.name : `${first.name} +${items.length - 1}`; +} + +async function loadAllActs(companyId: string): Promise { + const statuses: InspectionActStatusF4[] = ['SEALED', 'CLOSED', 'RECTIFIED']; + const groups = await Promise.all(statuses.map(async (status) => { + const collected: InspectionActListItemF4[] = []; + let page = 1; + let totalPages = 1; + do { + const response = await listInspectionActsGlobalF4({ companyId, status, page, pageSize: 100 }); + collected.push(...response.data); + totalPages = response.meta.totalPages; + page += 1; + } while (page <= totalPages); + return collected; + })); + return groups.flat().sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime()); +} + +async function loadAllReports(companyId: string): Promise { + const collected: InspectionReportDetailF4[] = []; + let page = 1; + let totalPages = 1; + do { + const response = await listInspectionReportsF4({ companyId, page, pageSize: 100 }); + collected.push(...response.data); + totalPages = response.meta.totalPages; + page += 1; + } while (page <= totalPages); + return collected + .filter((item) => item.status !== 'CANCELLED') + .sort((a, b) => new Date(b.generatedAt).getTime() - new Date(a.generatedAt).getTime()); +} + +export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDetail }) { + const [params, setParams] = useSearchParams(); + const requestedTab = params.get('tab') as CompanyTab | null; + const tab: CompanyTab = requestedTab && ['summary', 'issued', 'documents', 'history'].includes(requestedTab) + ? requestedTab + : 'summary'; + const { hasPermission } = useAuth(); + const canEditAsset = hasPermission('assets.update'); + const canReadRegistry = hasPermission('asset_registry.read'); + const canManageRegistry = hasPermission('asset_registry.manage'); + const canReadRelations = hasPermission('asset_relations.read'); + const canManageRelations = hasPermission('asset_relations.manage'); + const canReadActs = hasPermission('inspection_acts.read'); + const canReadReports = hasPermission('inspection_reports.read'); + const canReadInspections = hasPermission('inspections.read'); + const canReadMedia = hasPermission('assets.read_media'); + const canManageMedia = hasPermission('assets.manage_media'); + const canReadHistory = hasPermission('assets.read_history'); + + const [asset, setAsset] = useState(initialAsset); + const [registry, setRegistry] = useState(null); + const [relations, setRelations] = useState([]); + const [areas, setAreas] = useState([]); + const [loadingContext, setLoadingContext] = useState(true); + const [contextError, setContextError] = useState(''); + const [success, setSuccess] = useState(''); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [companyForm, setCompanyForm] = useState({ + name: initialAsset.name, + commonName: initialAsset.commonName ?? '', + organizationKind: 'COMPANY' as OrganizationKind, + legalName: initialAsset.name, + taxId: '', + notificationEmail: '', + notes: '', + }); + const [relationForm, setRelationForm] = useState({ areaId: '', reason: '' }); + const [metrics, setMetrics] = useState({ acts: 0, reports: 0, activeInspections: 0 }); + const [acts, setActs] = useState([]); + const [reports, setReports] = useState([]); + const [documentsLoading, setDocumentsLoading] = useState(false); + const [documentsError, setDocumentsError] = useState(''); + const [documentSearch, setDocumentSearch] = useState(''); + + const activeRelations = useMemo(() => relations.filter((item) => item.active), [relations]); + + const loadContext = async () => { + setLoadingContext(true); + setContextError(''); + try { + const [loadedRegistry, loadedRelations, loadedAreas] = await Promise.all([ + canReadRegistry ? getAssetRegistry(asset.id) : Promise.resolve(null), + canReadRelations ? listAreaCompanyRelations({ companyId: asset.id, includeHistory: true }) : Promise.resolve([]), + canManageRelations ? listOperationalAreas() : Promise.resolve([]), + ]); + setRegistry(loadedRegistry); + setRelations(loadedRelations); + setAreas(loadedAreas); + if (loadedRegistry?.organizationProfile) { + const profile = loadedRegistry.organizationProfile; + setCompanyForm({ + name: asset.name, + commonName: asset.commonName ?? '', + organizationKind: profile.organizationKind, + legalName: profile.legalName ?? asset.name, + taxId: profile.taxId ?? '', + notificationEmail: profile.notificationEmail ?? '', + notes: profile.notes ?? '', + }); + } + } catch (requestError) { + setContextError(errorMessage(requestError)); + } finally { + setLoadingContext(false); + } + }; + + useEffect(() => { void loadContext(); }, [asset.id]); + + useEffect(() => { + let cancelled = false; + const loadMetrics = async () => { + try { + const [sealed, closed, rectified, reportPage, planned, inProgress] = await Promise.all([ + canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'SEALED', pageSize: 1 }) : Promise.resolve(null), + canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'CLOSED', pageSize: 1 }) : Promise.resolve(null), + canReadActs ? listInspectionActsGlobalF4({ companyId: asset.id, status: 'RECTIFIED', pageSize: 1 }) : Promise.resolve(null), + canReadReports ? listInspectionReportsF4({ companyId: asset.id, pageSize: 1 }) : Promise.resolve(null), + canReadInspections ? listInspectionVisits({ companyId: asset.id, status: 'PLANNED', pageSize: 1 }) : Promise.resolve(null), + canReadInspections ? listInspectionVisits({ companyId: asset.id, status: 'IN_PROGRESS', pageSize: 1 }) : Promise.resolve(null), + ]); + if (cancelled) return; + setMetrics({ + acts: (sealed?.meta.total ?? 0) + (closed?.meta.total ?? 0) + (rectified?.meta.total ?? 0), + reports: reportPage?.meta.total ?? 0, + activeInspections: (planned?.meta.total ?? 0) + (inProgress?.meta.total ?? 0), + }); + } catch { + if (!cancelled) setMetrics({ acts: 0, reports: 0, activeInspections: 0 }); + } + }; + void loadMetrics(); + return () => { cancelled = true; }; + }, [asset.id, canReadActs, canReadReports, canReadInspections]); + + useEffect(() => { + if (tab !== 'issued' || (!canReadActs && !canReadReports)) return; + let cancelled = false; + setDocumentsLoading(true); + setDocumentsError(''); + Promise.all([ + canReadActs ? loadAllActs(asset.id) : Promise.resolve([]), + canReadReports ? loadAllReports(asset.id) : Promise.resolve([]), + ]) + .then(([loadedActs, loadedReports]) => { + if (cancelled) return; + setActs(loadedActs); + setReports(loadedReports); + }) + .catch((requestError) => { if (!cancelled) setDocumentsError(errorMessage(requestError)); }) + .finally(() => { if (!cancelled) setDocumentsLoading(false); }); + return () => { cancelled = true; }; + }, [tab, asset.id, canReadActs, canReadReports]); + + const saveCompany = async (event: FormEvent) => { + event.preventDefault(); + setSaving(true); setContextError(''); setSuccess(''); + try { + let savedAsset = asset; + if (canEditAsset) { + savedAsset = await updateAsset(asset.id, { + name: companyForm.name.trim(), + commonName: companyForm.commonName.trim() || null, + }); + setAsset(savedAsset); + } + if (canManageRegistry) { + await upsertOrganizationProfile(asset.id, { + organizationKind: companyForm.organizationKind, + legalName: companyForm.legalName.trim() || null, + taxId: companyForm.taxId.trim() || null, + notificationEmail: companyForm.notificationEmail.trim() || null, + notes: companyForm.notes.trim() || null, + }); + } + setEditing(false); + setSuccess('Datos de la empresa actualizados.'); + await loadContext(); + } catch (requestError) { + setContextError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + const addRelation = async (event: FormEvent) => { + event.preventDefault(); + if (!relationForm.areaId || relationForm.reason.trim().length < 3) return; + setSaving(true); setContextError(''); setSuccess(''); + try { + await createAreaCompanyRelation({ + areaId: relationForm.areaId, + companyId: asset.id, + reason: relationForm.reason.trim(), + }); + setRelationForm({ areaId: '', reason: '' }); + setSuccess('Área vinculada a la empresa.'); + await loadContext(); + } catch (requestError) { + setContextError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + const endRelation = async (relation: AreaCompanyRelation) => { + const reason = window.prompt(`Motivo para finalizar el vínculo con ${relation.area.name}`); + if (!reason?.trim() || reason.trim().length < 3) return; + setSaving(true); setContextError(''); setSuccess(''); + try { + await endAreaCompanyRelation(relation.id, reason.trim()); + setSuccess('Vínculo finalizado. El historial quedó conservado.'); + await loadContext(); + } catch (requestError) { + setContextError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + const setTab = (next: CompanyTab) => { + const nextParams = new URLSearchParams(params); + next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next); + nextParams.delete('advanced'); + setParams(nextParams); + }; + + const reportByAct = useMemo(() => new Map(reports.map((report) => [report.actId, report])), [reports]); + const filteredActs = useMemo(() => { + const needle = documentSearch.trim().toLocaleLowerCase('es'); + if (!needle) return acts; + return acts.filter((act) => [ + act.code, + act.title, + act.visit.code, + ...act.areas.map((item) => item.name), + reportByAct.get(act.id)?.code ?? '', + reportByAct.get(act.id)?.gedoIfIdentifier ?? '', + ].join(' ').toLocaleLowerCase('es').includes(needle)); + }, [acts, documentSearch, reportByAct]); + + const usedAreaIds = new Set(activeRelations.map((relation) => relation.area.id)); + const availableAreas = areas.filter((item) => !usedAreaIds.has(item.id)); + const profile = registry?.organizationProfile ?? null; + + return
+ + +
+
+ EMPRESA +

{asset.name}

+

{asset.code}{profile?.taxId ? ` · CUIT ${profile.taxId}` : ''}

+
+
+ {assetStatusLabel(asset.informationStatus)} + {(canEditAsset || canManageRegistry) && tab === 'summary' && } +
+
+ + + + {contextError && {contextError}} + {success && {success}} + + {tab === 'summary' &&
+ {loadingContext ? : <> +
+
+
IDENTIDAD

Datos de la empresa

La información que se usa para identificarla y enviar documentación oficial.

+
+ {editing ?
+
+ + + + + + +
+