chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import { PermissionRoute, ProtectedRoute } from '../auth/ProtectedRoute';
import { AppLayout } from '../layout/AppLayout';
import { OperationalContextProvider } from '../context/OperationalContext';
import { AuditPage } from '../pages/AuditPage';
import { ChangePasswordPage } from '../pages/ChangePasswordPage';
import { DashboardPage } from '../pages/DashboardPage';
import { LoginPage } from '../pages/LoginPage';
import { NewUserPage } from '../pages/NewUserPage';
import { RolesPage } from '../pages/RolesPage';
import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages';
import { UserDetailPage } from '../pages/UserDetailPage';
import { UsersPage } from '../pages/UsersPage';
import { AssetsPage } from '../pages/AssetsPage';
import { AssetEditorPage } from '../pages/AssetEditorPage';
import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage';
import { AssetTypesPage } from '../pages/AssetTypesPage';
import { HistoryPage } from '../pages/HistoryPage';
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
import { SurveyCampaignsPage } from '../pages/SurveyCampaignsPage';
import { SurveyCampaignEditorPage } from '../pages/SurveyCampaignEditorPage';
import { SurveyExecutionPage } from '../pages/SurveyExecutionPage';
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
import { InspectionVisitEditorPage } from '../pages/InspectionVisitEditorPage';
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';
import { VerificationPlanningPage } from '../pages/VerificationPlanningPage';
const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage })));
const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage').then((module) => ({ default: module.DocumentDeliveryPage })));
export function App() {
return <Suspense fallback={<div className="loading-block"><span className="spinner" />Cargando módulo</div>}><Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route element={<OperationalContextProvider><AppLayout /></OperationalContextProvider>}>
<Route element={<PermissionRoute permission="dashboard.read" />}><Route index element={<DashboardPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read" />}>
<Route path="/inventarios" element={<AssetsPage />} />
<Route path="/inventarios/:id" element={<AssetEditorPage />} />
<Route path="/activos" element={<Navigate to="/inventarios" replace />} />
<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={<AssetEditorPage />} /><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="surveys.read" />}>
<Route path="/relevamiento" element={<SurveyCampaignsPage />} />
<Route path="/relevamiento/:id" element={<SurveyCampaignEditorPage />} />
</Route>
<Route element={<PermissionRoute permission="surveys.read_reports" />}><Route path="/relevamiento/objetivos/:targetId" element={<SurveyExecutionPage />} /></Route>
<Route element={<PermissionRoute permission="surveys.manage" />}><Route path="/relevamiento/nuevo" element={<SurveyCampaignEditorPage />} /></Route>
<Route element={<PermissionRoute permission="inspections.read" />}>
<Route path="/inspecciones" element={<InspectionVisitsPage />} />
<Route path="/inspecciones/:id" element={<InspectionVisitEditorPage />} />
</Route>
<Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitEditorPage />} /></Route>
<Route element={<PermissionRoute permission="inspection_acts.read" />}>
<Route path="/inspecciones/actas/:actId" element={<InspectionActEditorPage />} />
<Route path="/inspecciones/actas/nueva" element={<InspectionActEditorPage />} />
</Route>
<Route element={<PermissionRoute permission="inspection_findings.read" />}><Route path="/hallazgos" element={<FindingsPage />} /><Route path="/hallazgos/:id" element={<FindingDetailPage />} /></Route>
<Route element={<PermissionRoute permission="inspection_verifications.plan" />}><Route path="/hallazgos/planificacion" element={<VerificationPlanningPage />} /></Route>
<Route element={<PermissionRoute permission="inspection_acts.read" />}><Route path="/actas" element={<ActsPage />} /></Route>
<Route element={<PermissionRoute permission="inspection_reports.read" />}><Route path="/informes" element={<ReportsPage />} /><Route path="/informes/:id" element={<ReportDetailPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read_history" />}><Route path="/historial" element={<HistoryPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read_temporal" />}><Route path="/consulta-temporal" element={<TemporalAssetsPage />} /></Route>
<Route element={<PermissionRoute permission="users.read" />}>
<Route path="/admin/users" element={<UsersPage />} />
<Route path="/admin/users/:id" element={<UserDetailPage />} />
</Route>
<Route element={<PermissionRoute permission="users.create" />}><Route path="/admin/users/new" element={<NewUserPage />} /></Route>
<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="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 />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Route>
</Routes></Suspense>;
}
+66
View File
@@ -0,0 +1,66 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import type { PropsWithChildren } from 'react';
import {
changePassword as requestPasswordChange,
getMe,
login as requestLogin,
logout as requestLogout,
} from '../lib/api';
import type { AuthUser } from '../lib/api';
interface AuthContextValue {
loading: boolean;
user: AuthUser | null;
login: (identifier: string, password: string) => Promise<AuthUser>;
logout: () => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
hasPermission: (permission: string) => boolean;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: PropsWithChildren) {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
let active = true;
getMe()
.then((current) => { if (active) setUser(current); })
.catch(() => { if (active) setUser(null); })
.finally(() => { if (active) setLoading(false); });
const unauthorized = () => setUser(null);
window.addEventListener('dhv2:unauthorized', unauthorized);
return () => {
active = false;
window.removeEventListener('dhv2:unauthorized', unauthorized);
};
}, []);
const value = useMemo<AuthContextValue>(() => ({
loading,
user,
login: async (identifier, password) => {
const response = await requestLogin(identifier, password);
setUser(response.user);
return response.user;
},
logout: async () => {
try { await requestLogout(); } finally { setUser(null); }
},
changePassword: async (currentPassword, newPassword) => {
await requestPasswordChange(currentPassword, newPassword);
setUser((current) => current ? { ...current, mustChangePassword: false } : current);
},
hasPermission: (permission) => Boolean(user?.permissions.includes(permission)),
}), [loading, user]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth debe utilizarse dentro de AuthProvider');
return context;
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { useAuth } from './AuthContext';
export function PermissionGate({
permission,
children,
fallback = null,
}: {
permission: string;
children: ReactNode;
fallback?: ReactNode;
}) {
return useAuth().hasPermission(permission) ? children : fallback;
}
+21
View File
@@ -0,0 +1,21 @@
import { Navigate, Outlet, useLocation } from 'react-router';
import { useAuth } from './AuthContext';
export function ProtectedRoute() {
const { loading, user } = useAuth();
const location = useLocation();
if (loading) {
return <div className="full-screen-state"><span className="spinner" />Verificando sesión</div>;
}
if (!user) return <Navigate to="/login" replace state={{ from: location.pathname }} />;
if (user.mustChangePassword && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />;
}
return <Outlet />;
}
export function PermissionRoute({ permission }: { permission: string }) {
const { hasPermission } = useAuth();
return hasPermission(permission) ? <Outlet /> : <Navigate to="/sin-acceso" replace />;
}
+17
View File
@@ -0,0 +1,17 @@
import { ApiError } from '../lib/api';
export function errorMessage(error: unknown) {
return error instanceof ApiError ? error.message : 'No se pudo completar la operación';
}
export function LoadingBlock({ label = 'Cargando…' }: { label?: string }) {
return <div className="loading-block"><span className="spinner" />{label}</div>;
}
export function EmptyState({ title, text }: { title: string; text: string }) {
return <div className="empty-state"><strong>{title}</strong><p>{text}</p></div>;
}
export function Alert({ type = 'error', children }: { type?: 'error' | 'success' | 'info'; children: React.ReactNode }) {
return <div className={`alert-box ${type}`}>{children}</div>;
}
+33
View File
@@ -0,0 +1,33 @@
export type IconName =
| 'home' | 'map' | 'layers' | 'calendar' | 'clipboard' | 'alert'
| 'history' | 'users' | 'shield' | 'audit' | 'logout' | 'menu'
| 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload';
export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
const paths: Record<IconName, React.ReactNode> = {
home: <><path d="m3 10 9-7 9 7"/><path d="M5 9v11h14V9"/><path d="M9 20v-6h6v6"/></>,
map: <><path d="m3 6 6-3 6 3 6-3v15l-6 3-6-3-6 3Z"/><path d="M9 3v15M15 6v15"/></>,
layers: <><path d="m12 3-9 5 9 5 9-5Z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/></>,
calendar: <><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M16 3v4M8 3v4M3 10h18"/></>,
clipboard: <><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V2h6v2M9 12h6M9 16h5"/></>,
alert: <><path d="M12 3 2.5 20h19Z"/><path d="M12 9v5M12 17h.01"/></>,
history: <><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/></>,
users: <><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/></>,
shield: <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></>,
audit: <><path d="M4 4h16v16H4z"/><path d="M8 9h8M8 13h8M8 17h5M8 4V2M16 4V2"/></>,
logout: <><path d="M10 17l5-5-5-5M15 12H3"/><path d="M14 3h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5"/></>,
menu: <><path d="M4 6h16M4 12h16M4 18h16"/></>,
plus: <><path d="M12 5v14M5 12h14"/></>,
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></>,
edit: <><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"/></>,
chevron: <path d="m9 18 6-6-6-6"/>,
check: <path d="m5 12 4 4L19 6"/>,
key: <><circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M15 8l3 3M17 6l2 2"/></>,
upload: <><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M5 20h14"/></>,
};
return (
<svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{paths[name]}
</svg>
);
}
@@ -0,0 +1,91 @@
import { SearchableSelect } from './SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { useOperationalContext } from '../context/OperationalContext';
import { listCompaniesForArea, listOperationalAreas } from '../lib/api';
import type { OperationalAssetSummary } from '../lib/api';
import { Icon } from './Icon';
export function OperationalContextBar() {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const { areaId, companyId, setAreaId, setCompanyId, clearContext } = useOperationalContext();
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]);
const [search, setSearch] = useState('');
const canReadInventory = hasPermission('assets.read');
const canReadInspections = hasPermission('inspections.read');
useEffect(() => {
if (!canReadInventory && !canReadInspections) return;
listOperationalAreas().then(setAreas).catch(() => setAreas([]));
}, [canReadInventory, canReadInspections]);
useEffect(() => {
if (!areaId) {
setCompanies([]);
if (companyId) setCompanyId('');
return;
}
listCompaniesForArea(areaId)
.then((rows) => {
setCompanies(rows);
if (companyId && !rows.some((item) => item.id === companyId)) setCompanyId('');
})
.catch(() => setCompanies([]));
}, [areaId, companyId, setCompanyId]);
const selectedArea = useMemo(() => areas.find((item) => item.id === areaId) ?? null, [areas, areaId]);
const selectedCompany = useMemo(() => companies.find((item) => item.id === companyId) ?? null, [companies, companyId]);
const submitSearch = (event: FormEvent) => {
event.preventDefault();
const term = search.trim();
if (!term || !canReadInventory) return;
navigate(`/inventarios?view=list&search=${encodeURIComponent(term)}`);
};
if (!canReadInventory && !canReadInspections) return null;
return <section className="operational-context-bar" aria-label="Contexto de trabajo">
<div className="operational-context-summary">
<span className="operational-context-icon"><Icon name="layers" size={17} /></span>
<div>
<small>CONTEXTO DE TRABAJO</small>
<strong>{selectedArea ? selectedArea.name : 'Todas las Áreas'}{selectedCompany ? ` · ${selectedCompany.name}` : ''}</strong>
</div>
</div>
<div className="operational-context-selectors">
<label>
<span>Área</span>
<SearchableSelect value={areaId} onChange={(event) => setAreaId(event.target.value)}>
<option value="">Todas las Áreas</option>
{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}
</SearchableSelect>
</label>
<label>
<span>Operadora</span>
<SearchableSelect value={companyId} disabled={!areaId} onChange={(event) => setCompanyId(event.target.value)}>
<option value="">{areaId ? 'Todas las Operadoras' : 'Primero elegí un Área'}</option>
{companies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}
</SearchableSelect>
</label>
</div>
{canReadInventory && <form className="operational-context-search" onSubmit={submitSearch}>
<Icon name="search" size={16} />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar en el Inventario…" />
<button type="submit">Buscar</button>
</form>}
<div className="operational-context-actions">
{canReadInventory && <Link className="button secondary compact" to="/inventarios">Inventario</Link>}
{canReadInspections && <Link className="button secondary compact" to="/inspecciones">Inspecciones</Link>}
{(areaId || companyId) && <button type="button" className="button text compact" onClick={clearContext}>Ver todo</button>}
</div>
</section>;
}
+228
View File
@@ -0,0 +1,228 @@
import {
Children,
Fragment,
isValidElement,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import type {
ReactElement,
ReactNode,
SelectHTMLAttributes,
} from 'react';
import { createPortal } from 'react-dom';
import { Icon } from './Icon';
interface FlatOption {
value: string;
label: string;
disabled: boolean;
}
type SearchableSelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
searchPlaceholder?: string;
};
function textContent(node: ReactNode): string {
if (node == null || typeof node === 'boolean') return '';
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textContent).join('');
if (isValidElement(node)) {
return textContent((node.props as { children?: ReactNode }).children);
}
return '';
}
function flattenOptions(node: ReactNode, target: FlatOption[]): void {
Children.forEach(node, (child) => {
if (!isValidElement(child)) return;
const element = child as ReactElement<{
children?: ReactNode;
value?: string | number;
disabled?: boolean;
}>;
if (element.type === 'option') {
target.push({
value: element.props.value == null ? textContent(element.props.children) : String(element.props.value),
label: textContent(element.props.children).trim(),
disabled: Boolean(element.props.disabled),
});
return;
}
if (element.type === Fragment || element.props.children != null) {
flattenOptions(element.props.children, target);
}
});
}
function normalized(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLocaleLowerCase('es-AR')
.trim();
}
export function SearchableSelect({
children,
className,
disabled,
value,
defaultValue,
onChange,
onBlur,
onFocus,
searchPlaceholder = 'Buscar…',
id,
...selectProps
}: SearchableSelectProps) {
const selectRef = useRef<HTMLSelectElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const searchRef = useRef<HTMLInputElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const generatedId = useId();
const resolvedId = id || generatedId;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [popupStyle, setPopupStyle] = useState({ top: 0, left: 0, width: 240 });
const options = useMemo(() => {
const result: FlatOption[] = [];
flattenOptions(children, result);
return result;
}, [children]);
const selectedValue = value == null
? defaultValue == null ? '' : String(defaultValue)
: String(value);
const selected = options.find((option) => option.value === selectedValue);
const placeholder = options.find((option) => option.value === '')?.label || 'Seleccionar…';
const queryNormalized = normalized(query);
const filtered = queryNormalized
? options.filter((option) => normalized(option.label).includes(queryNormalized))
: options;
const positionPopup = () => {
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
const margin = 8;
const desiredWidth = Math.max(rect.width, 260);
const width = Math.min(desiredWidth, window.innerWidth - margin * 2);
const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
const roomBelow = window.innerHeight - rect.bottom;
const top = roomBelow >= 320
? rect.bottom + 5
: Math.max(margin, rect.top - Math.min(315, rect.top - margin));
setPopupStyle({ top, left, width });
};
const close = () => {
setOpen(false);
setQuery('');
};
const choose = (nextValue: string) => {
const native = selectRef.current;
if (!native) return;
const descriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
descriptor?.set?.call(native, nextValue);
native.dispatchEvent(new Event('change', { bubbles: true }));
close();
window.setTimeout(() => buttonRef.current?.focus(), 0);
};
useEffect(() => {
if (!open) return;
positionPopup();
const reposition = () => positionPopup();
const outside = (event: MouseEvent) => {
const node = event.target as Node;
if (popupRef.current?.contains(node) || buttonRef.current?.contains(node)) return;
close();
};
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, true);
document.addEventListener('mousedown', outside);
window.setTimeout(() => searchRef.current?.focus(), 0);
return () => {
window.removeEventListener('resize', reposition);
window.removeEventListener('scroll', reposition, true);
document.removeEventListener('mousedown', outside);
};
}, [open]);
return <div className={`searchable-select${className ? ` ${className}` : ''}`}>
<select
{...selectProps}
ref={selectRef}
id={`${resolvedId}-native`}
className="searchable-select-native"
disabled={disabled}
value={value}
defaultValue={defaultValue}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
tabIndex={-1}
aria-hidden="true"
>{children}</select>
<button
ref={buttonRef}
id={resolvedId}
type="button"
className="searchable-select-trigger"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => {
if (disabled) return;
positionPopup();
setOpen((current) => !current);
}}
>
<span className={!selectedValue ? 'placeholder' : ''}>{selected?.label || placeholder}</span>
<Icon name="chevron" size={14} />
</button>
{open && createPortal(<div
ref={popupRef}
className="searchable-select-popup"
style={{ top: popupStyle.top, left: popupStyle.left, width: popupStyle.width }}
>
<div className="searchable-select-search"><Icon name="search" size={14} /><input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
close();
buttonRef.current?.focus();
}
if (event.key === 'Enter') {
const first = filtered.find((option) => !option.disabled);
if (first) {
event.preventDefault();
choose(first.value);
}
}
}}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
/></div>
<div className="searchable-select-options" role="listbox">
{filtered.length === 0 ? <div className="searchable-select-empty">Sin coincidencias</div> : filtered.map((option, index) => <button
type="button"
role="option"
aria-selected={option.value === selectedValue}
className={option.value === selectedValue ? 'selected' : ''}
disabled={option.disabled}
key={`${option.value}-${index}`}
onClick={() => choose(option.value)}
><span>{option.label || '—'}</span>{option.value === selectedValue && <Icon name="check" size={14} />}</button>)}
</div>
</div>, document.body)}
</div>;
}
+2
View File
@@ -0,0 +1,2 @@
export const APP_VERSION = '0.19.6-4';
export const APP_PHASE = 'Fase D5.6.4 · Inspecciones simples y combobox buscables';
+64
View File
@@ -0,0 +1,64 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import type { PropsWithChildren } from 'react';
interface OperationalContextValue {
areaId: string;
companyId: string;
setAreaId: (areaId: string) => void;
setCompanyId: (companyId: string) => void;
setContext: (areaId: string, companyId?: string) => void;
clearContext: () => void;
}
const STORAGE_KEY = 'dhv2.operational-context';
const OperationalContext = createContext<OperationalContextValue | null>(null);
function readStoredContext(): { areaId: string; companyId: string } {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { areaId: '', companyId: '' };
const parsed = JSON.parse(raw) as { areaId?: unknown; companyId?: unknown };
return {
areaId: typeof parsed.areaId === 'string' ? parsed.areaId : '',
companyId: typeof parsed.companyId === 'string' ? parsed.companyId : '',
};
} catch {
return { areaId: '', companyId: '' };
}
}
export function OperationalContextProvider({ children }: PropsWithChildren) {
const initial = useMemo(readStoredContext, []);
const [areaId, setAreaState] = useState(initial.areaId);
const [companyId, setCompanyState] = useState(initial.companyId);
useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ areaId, companyId }));
}, [areaId, companyId]);
const value = useMemo<OperationalContextValue>(() => ({
areaId,
companyId,
setAreaId: (nextAreaId) => {
setAreaState(nextAreaId);
setCompanyState('');
},
setCompanyId: setCompanyState,
setContext: (nextAreaId, nextCompanyId = '') => {
setAreaState(nextAreaId);
setCompanyState(nextAreaId ? nextCompanyId : '');
},
clearContext: () => {
setAreaState('');
setCompanyState('');
},
}), [areaId, companyId]);
return <OperationalContext.Provider value={value}>{children}</OperationalContext.Provider>;
}
export function useOperationalContext() {
const value = useContext(OperationalContext);
if (!value) throw new Error('useOperationalContext debe usarse dentro de OperationalContextProvider');
return value;
}
@@ -0,0 +1,22 @@
import { NavLink } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Icon } from '../../components/Icon';
export function AssetCenterTabs({ active }: { active: 'navigate' | 'list' | 'field' | 'map' | 'history' | 'temporal' }) {
const { hasPermission } = useAuth();
const items = [
{ key: 'navigate', to: '/inventarios', label: 'Navegar', icon: 'layers' as const, show: true },
{ key: 'list', to: '/inventarios?view=list', label: 'Listado', icon: 'layers' as const, show: true },
{ key: 'field', to: '/inventarios/revision-campo', label: 'Altas de campo', icon: 'layers' as const, show: hasPermission('assets.change_status') },
{ key: 'map', to: '/mapa', label: 'Mapa', icon: 'map' as const, show: true },
{ key: 'history', to: '/historial', label: 'Historial', icon: 'history' as const, show: hasPermission('assets.read_history') },
{ key: 'temporal', to: '/consulta-temporal', label: 'Consulta temporal', icon: 'history' as const, show: hasPermission('assets.read_temporal') },
];
return <nav className="asset-center-tabs" aria-label="Vistas de Inventarios">
{items.filter((item) => item.show).map((item) => <NavLink
key={item.key}
to={item.to}
className={active === item.key ? 'active' : ''}
><Icon name={item.icon} size={15} />{item.label}</NavLink>)}
</nav>;
}
@@ -0,0 +1,180 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
changeAssetContext,
listAssetContextHistory,
listAssetParentOptions,
listCompaniesForArea,
listOperationalAreas,
} from '../../lib/api';
import type {
AssetContextHistoryItem,
AssetDetail,
AssetListItem,
AssetType,
OperationalAssetSummary,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
function localDateTimeNow(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function contextLabel(item: AssetContextHistoryItem) {
const parts = [
item.parent ? `Padre: ${item.parent.name}` : 'Sin padre',
item.operationalArea ? `Área: ${item.operationalArea.name}` : null,
item.operatorCompany ? `Operadora: ${item.operatorCompany.name}` : null,
].filter(Boolean);
return parts.join(' · ');
}
export function AssetContextHistoryPanel({
asset,
type,
canManage,
onChanged,
}: {
asset: AssetDetail;
type: AssetType;
canManage: boolean;
onChanged: (asset: AssetDetail) => void;
}) {
const [history, setHistory] = useState<AssetContextHistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [parentId, setParentId] = useState(asset.parent?.id ?? '');
const [parentSearch, setParentSearch] = useState('');
const [parents, setParents] = useState<AssetListItem[]>([]);
const [operationalAreaId, setOperationalAreaId] = useState(asset.operationalArea?.id ?? '');
const [operatorCompanyId, setOperatorCompanyId] = useState(asset.operatorCompany?.id ?? '');
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]);
const [effectiveAt, setEffectiveAt] = useState(localDateTimeNow());
const [reason, setReason] = useState('');
const genericContext = type.operationalRole === 'GENERIC';
const current = useMemo(() => history.find((item) => item.isCurrent) ?? history[0] ?? null, [history]);
const loadHistory = () => {
setLoading(true);
listAssetContextHistory(asset.id)
.then(setHistory)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
};
useEffect(loadHistory, [asset.id]);
useEffect(() => {
if (!editing) return;
const timer = window.setTimeout(() => {
listAssetParentOptions(type.id, asset.id, parentSearch)
.then(setParents)
.catch((requestError) => setError(errorMessage(requestError)));
}, 180);
return () => window.clearTimeout(timer);
}, [editing, type.id, asset.id, parentSearch]);
useEffect(() => {
if (!editing || !genericContext || !parentId) {
setAreas([]);
return;
}
listOperationalAreas(parentId)
.then((items) => {
setAreas(items);
if (operationalAreaId && !items.some((item) => item.id === operationalAreaId)) {
setOperationalAreaId('');
setOperatorCompanyId('');
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, genericContext, parentId]);
useEffect(() => {
if (!editing || !genericContext || !operationalAreaId) {
setCompanies([]);
return;
}
listCompaniesForArea(operationalAreaId)
.then((items) => {
setCompanies(items);
if (operatorCompanyId && !items.some((item) => item.id === operatorCompanyId)) setOperatorCompanyId('');
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, genericContext, operationalAreaId]);
const beginEdit = () => {
setParentId(asset.parent?.id ?? '');
setOperationalAreaId(asset.operationalArea?.id ?? '');
setOperatorCompanyId(asset.operatorCompany?.id ?? '');
setEffectiveAt(localDateTimeNow());
setReason('');
setError('');
setSuccess('');
setEditing(true);
};
const save = async (event: FormEvent) => {
event.preventDefault();
setSaving(true);
setError('');
setSuccess('');
try {
const saved = await changeAssetContext(asset.id, {
parentId: parentId || null,
operationalAreaId: genericContext ? operationalAreaId || null : asset.operationalArea?.id ?? null,
operatorCompanyId: genericContext ? operatorCompanyId || null : asset.operatorCompany?.id ?? null,
effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined,
reason,
});
onChanged(saved);
setEditing(false);
setSuccess('Contexto actualizado. La asignación anterior quedó preservada en el historial.');
loadHistory();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
return <article className="panel asset-history-panel">
<div className="panel-heading">
<div><span className="eyebrow">CONTEXTO TEMPORAL</span><h2>Jerarquía, Área y Operadora</h2></div>
{canManage && !editing && <button type="button" className="button secondary" onClick={beginEdit}><Icon name="edit" />Cambiar contexto</button>}
</div>
<p className="section-copy">Los cambios no reemplazan la historia. Cada asignación conserva desde cuándo fue válida y qué relación la reemplazó.</p>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{current && <div className="temporal-notice"><Icon name="layers" /><p><strong>Contexto vigente.</strong> {contextLabel(current)}</p></div>}
{editing && <form className="form-section" onSubmit={save}>
<div><h3>Cambiar contexto vigente</h3><p className="section-copy">Indicá el nuevo lugar dentro del Inventario y el motivo. La relación anterior se cierra automáticamente.</p></div>
<div className="form-grid">
<div className="field parent-picker"><span>Registro padre</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder="Buscar registro padre…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} required={!type.canBeRoot}><option value="">{type.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect></div>
<label className="field"><span>Vigente desde</span><input type="datetime-local" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} required /><small>Puede registrarse una vigencia pasada si corresponde a un cambio ya ocurrido.</small></label>
</div>
{genericContext && <div className="form-grid">
<label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); setOperatorCompanyId(''); }} required><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Operadora</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} required disabled={!operationalAreaId}><option value="">{operationalAreaId ? 'Seleccionar Operadora…' : 'Primero seleccioná un Área'}</option>{companies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label>
</div>}
<label className="field"><span>Motivo del cambio</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={5} maxLength={2000} rows={3} required placeholder="Ej.: transferencia operativa, corrección documental, reubicación física…" /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)} disabled={saving}>Cancelar</button><button className="button primary" disabled={saving || reason.trim().length < 5}><Icon name="check" />{saving ? 'Registrando…' : 'Registrar cambio'}</button></div>
</form>}
<div className="form-section"><div><h3>Historial de contexto</h3><p className="section-copy">Se muestra la secuencia completa de relaciones conocidas del elemento.</p></div>
{loading ? <LoadingBlock label="Cargando contexto…" /> : history.length === 0 ? <div className="inline-empty">Todavía no hay contexto histórico registrado.</div> : <div className="asset-timeline">{history.map((item) => <div className="timeline-entry" key={item.id}><span className={`timeline-dot ${item.isCurrent ? 'current' : ''}`} /><span><strong>{item.isCurrent ? 'Vigente' : 'Histórico'} · v{item.assetVersionNumber}</strong><small>{formatDate(item.validFrom)} {item.validUntil ? formatDate(item.validUntil) : 'actualidad'}{item.creator ? ` · ${item.creator.username}` : ''}</small><span>{contextLabel(item)}</span><small>{item.changeReason}</small></span></div>)}</div>}
</div>
</article>;
}
@@ -0,0 +1,144 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { formatDate, formatDateOnly } from '../../lib/format';
import { getAssetDossier } from '../../lib/api';
import type { AssetDossier, AssetDossierTimelineEvent } from '../../lib/api';
type View = 'timeline' | 'findings' | 'documents';
function findingStatusLabel(status: string) {
if (status === 'OPEN') return 'Abierto';
if (status === 'CLOSED') return 'Cerrado';
return 'Anulado';
}
function inspectionStatusLabel(status: string) {
const labels: Record<string, string> = {
DRAFT: 'Borrador', PLANNED: 'Planificada', IN_PROGRESS: 'En curso', CLOSED: 'Cerrada', CANCELLED: 'Cancelada',
};
return labels[status] ?? status;
}
function actStatusLabel(status: string) {
const labels: Record<string, string> = {
DRAFT: 'Borrador', READY: 'Lista', CLOSED: 'Cerrada', CANCELLED: 'Cancelada', RECTIFIED: 'Rectificada',
};
return labels[status] ?? status;
}
function timelineLabel(event: AssetDossierTimelineEvent) {
const labels: Record<AssetDossierTimelineEvent['kind'], string> = {
INVENTORY_CHANGE: 'Inventario', INSPECTION: 'Inspección', ACT: 'Acta', FINDING: 'Hallazgo', FINDING_CLOSED: 'Cierre',
COMMUNICATION: 'Seguimiento', PHOTO: 'Fotografía', DOCUMENT: 'Documento', REPORT: 'Informe', SOURCE_DOCUMENT: 'Documento fuente', VERIFICATION: 'Verificación',
};
return labels[event.kind];
}
function timelineIcon(event: AssetDossierTimelineEvent) {
if (event.kind === 'FINDING' || event.kind === 'FINDING_CLOSED') return 'alert' as const;
if (event.kind === 'INSPECTION' || event.kind === 'VERIFICATION') return 'calendar' as const;
if (event.kind === 'INVENTORY_CHANGE') return 'history' as const;
return 'clipboard' as const;
}
export function AssetDossierPanel({ assetId }: { assetId: string }) {
const [dossier, setDossier] = useState<AssetDossier | null>(null);
const [view, setView] = useState<View>('timeline');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
setError('');
getAssetDossier(assetId)
.then(setDossier)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const pendingVerification = useMemo(() => dossier?.findings.filter((finding) => finding.status === 'OPEN' && finding.companyResponseReceivedOn && finding.nextControlOn).length ?? 0, [dossier]);
if (loading) return <div className="panel"><LoadingBlock label="Armando expediente técnico…" /></div>;
if (!dossier) return <Alert>{error || 'No se pudo cargar el expediente.'}</Alert>;
return <div className="asset-dossier-stack">
{error && <Alert>{error}</Alert>}
<article className="panel dossier-overview">
<div className="panel-heading">
<div><span className="eyebrow">EXPEDIENTE TÉCNICO</span><h2>{dossier.asset.name}</h2><p className="section-copy">{dossier.asset.code}{dossier.asset.commonName ? ` · Nombre habitual: ${dossier.asset.commonName}` : ''} · Inspecciones, actas, hallazgos, respuestas, evidencias, documentos y cambios reunidos en un solo lugar.</p></div>
</div>
<div className="dossier-metrics">
<div><small>Inspecciones</small><strong>{dossier.counters.inspections}</strong></div>
<div><small>Actas</small><strong>{dossier.counters.acts}</strong></div>
<div className={dossier.counters.openFindings ? 'attention' : ''}><small>Hallazgos abiertos</small><strong>{dossier.counters.openFindings}</strong></div>
<div><small>Hallazgos cerrados</small><strong>{dossier.counters.closedFindings}</strong></div>
<div><small>Fotos</small><strong>{dossier.counters.photos}</strong></div>
<div><small>Informes</small><strong>{dossier.counters.reports}</strong></div>
<div><small>Verificaciones</small><strong>{dossier.counters.verifications}</strong></div>
</div>
{pendingVerification > 0 && <div className="temporal-notice"><Icon name="calendar" /><p><strong>{pendingVerification} hallazgo{pendingVerification === 1 ? '' : 's'} con verificación programada.</strong> Las fechas operativas pueden utilizarse para planificar próximos controles.</p></div>}
</article>
<nav className="dossier-subtabs" aria-label="Vistas del expediente">
<button type="button" className={view === 'timeline' ? 'active' : ''} onClick={() => setView('timeline')}>Cronología</button>
<button type="button" className={view === 'findings' ? 'active' : ''} onClick={() => setView('findings')}>Inspecciones y hallazgos</button>
<button type="button" className={view === 'documents' ? 'active' : ''} onClick={() => setView('documents')}>Documentos y evidencias</button>
</nav>
{view === 'timeline' && <article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CRONOLOGÍA</span><h2>Línea de tiempo</h2><p className="section-copy">Cada evento conserva su origen y enlaza con el registro que lo generó cuando corresponde.</p></div><span className="count-pill">{dossier.timeline.length}</span></div>
{dossier.timeline.length === 0 ? <EmptyState title="Sin actividad histórica" text="Los eventos aparecerán a medida que el elemento sea inspeccionado o actualizado." /> : <div className="dossier-timeline">
{dossier.timeline.map((event) => <div className="dossier-timeline-item" key={event.id}>
<span className="dossier-timeline-icon"><Icon name={timelineIcon(event)} size={16} /></span>
<div className="dossier-timeline-body">
<div className="dossier-timeline-head"><span>{timelineLabel(event)}</span><time>{formatDate(event.occurredAt)}</time></div>
{event.href ? <Link to={event.href}><strong>{event.title}</strong></Link> : <strong>{event.title}</strong>}
{event.description && <p>{event.description}</p>}
</div>
</div>)}
</div>}
</article>}
{view === 'findings' && <div className="asset-dossier-grid">
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">INSPECCIONES</span><h2>Visitas relacionadas</h2></div><span className="count-pill">{dossier.visits.length}</span></div>
{dossier.visits.length === 0 ? <EmptyState title="Sin inspecciones" text="Todavía no hay visitas asociadas a este elemento." /> : <div className="dossier-link-list">{dossier.visits.map((visit) => <Link key={visit.id} to={`/inspecciones/${visit.id}`}><div><strong>{visit.code}</strong><small>{inspectionStatusLabel(visit.status)}</small></div><span>{formatDate(visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">ACTAS</span><h2>Actas relacionadas</h2></div><span className="count-pill">{dossier.acts.length}</span></div>
{dossier.acts.length === 0 ? <EmptyState title="Sin actas" text="Todavía no hay actas asociadas a este elemento." /> : <div className="dossier-link-list">{dossier.acts.map((act) => <Link key={act.id} to={`/inspecciones/actas/${act.id}`}><div><strong>{act.code}</strong><small>{act.title} · {actStatusLabel(act.status)}</small></div><span>{formatDate(act.occurredAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Seguimiento del elemento</h2></div><Link className="text-link" to="/hallazgos">Ver bandeja general <Icon name="chevron" size={14} /></Link></div>
{dossier.findings.length === 0 ? <EmptyState title="Sin hallazgos" text="Este elemento todavía no registra hallazgos." /> : <div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Estado</th><th>Acta</th><th>Vencimiento empresa</th><th>Verificación</th><th /></tr></thead><tbody>{dossier.findings.map((finding) => <tr key={finding.id}><td><strong>{finding.title}</strong><small className="block-muted">{finding.code}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{findingStatusLabel(finding.status)}</span></td><td><Link className="text-link" to={`/inspecciones/actas/${finding.actId}`}>{finding.actCode}</Link></td><td>{formatDateOnly(finding.correctionDueOn)}</td><td>{formatDateOnly(finding.nextControlOn)}</td><td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div>}
</article>
</div>}
{view === 'documents' && <div className="asset-dossier-grid">
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">DOCUMENTOS</span><h2>Documentos vinculados</h2></div><span className="count-pill">{dossier.documents.length}</span></div>
{dossier.documents.length === 0 ? <EmptyState title="Sin documentos fuente" text="No hay documentos fuente vinculados directamente a este registro." /> : <div className="dossier-document-list">{dossier.documents.map((document) => <div key={document.id}><Icon name="clipboard" /><div><strong>{document.title}</strong><small>{document.documentNumber ?? document.documentType} · {formatDateOnly(document.documentDate)}</small></div></div>)}</div>}
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">EVIDENCIAS</span><h2>Fotos y archivos de hallazgos</h2></div><span className="count-pill">{dossier.evidence.length}</span></div>
{dossier.evidence.length === 0 ? <EmptyState title="Sin evidencias de inspección" text="Las fotos y documentos de hallazgos aparecerán aquí en orden histórico." /> : <div className="dossier-link-list">{dossier.evidence.map((item) => <Link key={item.id} to={`/hallazgos/${item.findingId}`}><div><strong>{item.title ?? item.originalName}</strong><small>{item.findingCode} · {item.kind === 'PHOTO' ? 'Fotografía' : 'Documento'}</small></div><span>{formatDate(item.capturedAt ?? item.createdAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">INFORMES DE INSPECCIÓN</span><h2>Informes relacionados</h2><p className="section-copy">Informes emitidos desde inspecciones que incluyeron este elemento o un hallazgo asociado.</p></div><Link className="text-link" to="/informes">Ver centro de informes <Icon name="chevron" size={14} /></Link></div>
{dossier.inspectionReports.length === 0 ? <EmptyState title="Sin informes de inspección" text="Todavía no hay informes emitidos para este elemento." /> : <div className="dossier-link-list">{dossier.inspectionReports.map((report) => <Link key={report.id} to={`/informes/${report.id}`}><div><strong>{report.code}</strong><small>{report.title} · {report.pdfStatus === 'READY' ? 'PDF disponible' : 'PDF pendiente'}</small></div><span>{formatDate(report.generatedAt)}</span><Icon name="chevron" size={15} /></Link>)}</div>}
</article>
<article className="panel dossier-wide">
<div className="panel-heading"><div><span className="eyebrow">DOCUMENTACIÓN TÉCNICA FUENTE</span><h2>Informes técnicos vinculados</h2></div><span className="count-pill">{dossier.reports.length}</span></div>
{dossier.reports.length === 0 ? <EmptyState title="Sin informes técnicos fuente" text="No hay documentos fuente clasificados como informe técnico para este elemento." /> : <div className="dossier-document-list">{dossier.reports.map((report) => <div key={report.id}><Icon name="clipboard" /><div><strong>{report.title}</strong><small>{report.documentNumber ?? 'Informe técnico'} · {formatDateOnly(report.documentDate)}</small></div></div>)}</div>}
</article>
</div>}
</div>;
}
@@ -0,0 +1,59 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getFindingCatalogAssetSelection, replaceFindingCatalogAssetSelection } from '../../lib/api';
import type { FindingCatalogAssetSelection } from '../../lib/api';
export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) {
const [selection, setSelection] = useState<FindingCatalogAssetSelection | null>(null);
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => getFindingCatalogAssetSelection(assetId).then((loaded) => {
setSelection(loaded);
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
});
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId]);
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 toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const save = async () => {
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetSelection(assetId, { enabledItemIds: [...enabled], reason });
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason('');
setSuccess('Subconjunto de hallazgos actualizado para este objeto.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando hallazgos aplicables…" /></div>;
if (!selection) return <Alert>{error || 'No se pudo cargar la configuración.'}</Alert>;
return <section className="panel asset-finding-catalog-panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>{selection.asset.name}</h2><p className="section-copy">Base: {selection.asset.assetTypeName}. Las excepciones de esta pantalla afectan sólo a este objeto del Inventario.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{!selection.typeConfigured && <div className="temporal-notice"><Icon name="alert" /><p><strong>El tipo técnico todavía no tiene un catálogo restringido.</strong> Por compatibilidad, su base actual incluye todo el catálogo activo. Podés configurar primero el tipo general en Catálogo de hallazgos.</p></div>}
<label className="search-field"><Icon name="search" /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar hallazgo aplicable…" /></label>
<div className="finding-selection-list">{visible.map((item) => <label className={`finding-selection-row ${item.assetOverride !== null ? 'override' : ''}`} key={item.id}><input type="checkbox" disabled={!canManage} checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code} · base del tipo: {item.typeDefaultEnabled ? 'sí' : 'no'}{item.assetOverride !== null ? ' · excepción de este objeto' : ''}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
{canManage && <><label className="field"><span>Motivo de la excepción</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Explicá por qué este objeto usa un subconjunto distinto…" /></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 excepciones'}</button></div></>}
</section>;
}
@@ -0,0 +1,256 @@
import { useEffect, useMemo, 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 {
getAsset,
getAssetLineage,
listAreasForCompany,
listCompaniesForArea,
listAssetTreeChildren,
listOperationalAreas,
listOperationalCompanies,
} from '../../lib/api';
import type { AssetDetail, AssetLineageItem, AssetListItem, OperationalAssetSummary } from '../../lib/api';
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 } = {}) {
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}`;
}
function AssetCard({ item, href }: { item: AssetListItem; 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-main">
<strong>{item.name}</strong>
<small>{item.code} · {item.type.name}{item.commonName ? ` · ${item.commonName}` : ''}</small>
</span>
<span className="asset-browser-item-status">
<span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span>
<small>{assetOperationalStatusLabel(item.operationalStatus)}</small>
</span>
<Icon name="chevron" size={16} />
</Link>;
}
function SummaryCard({ item, href, subtitle }: { item: OperationalAssetSummary; href: string; subtitle: 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-main">
<strong>{item.name}</strong>
<small>{item.code} · {subtitle}{item.commonName ? ` · ${item.commonName}` : ''}</small>
</span>
<Icon name="chevron" size={16} />
</Link>;
}
export function AssetHierarchyView({ filters }: { filters: TreeFilters }) {
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 [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
setLoading(true);
setError('');
setCompanies([]);
setAreas([]);
setCompany(null);
setLineage([]);
setChildren([]);
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)));
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);
}
};
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';
if (loading) return <LoadingBlock label="Cargando inventarios…" />;
if (!section && !companyId && !parentId) {
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>
{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>
</div>
</div>;
}
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>;
})}
</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>}
</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>
{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>;
}
return <>{error && <Alert>{error}</Alert>}<EmptyState title="No se pudo abrir la estructura" text="Volvé al inicio de Inventarios e intentá nuevamente." /></>;
}
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetVersion,
listAssetVersionTimeline,
} from '../../lib/api';
import type {
AssetVersionDetail,
AssetVersionSummary,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
import { AssetVersionDrawer } from './AssetVersionDrawer';
import {
assetVersionChangeLabel,
assetVersionFieldLabel,
} from './assetVersionPresentation';
export function AssetHistoryPanel({
assetId,
refreshKey,
}: {
assetId: string;
refreshKey: number;
}) {
const [versions, setVersions] = useState<AssetVersionSummary[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<AssetVersionDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
setLoading(true);
setError('');
listAssetVersionTimeline(assetId, { pageSize: 10 })
.then((response) => {
setVersions(response.data);
setTotal(response.meta.total);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId, refreshKey]);
const open = async (version: AssetVersionSummary) => {
setDetail(null);
setDetailLoading(true);
setError('');
try {
setDetail(await getAssetVersion(version.assetId, version.versionNumber));
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setDetailLoading(false);
}
};
return <article className="panel asset-history-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD TEMPORAL</span><h2>Historial del registro</h2></div><span className="count-pill">{total} versión{total === 1 ? '' : 'es'}</span></div>
<p className="section-copy">Cada cambio conserva una copia completa e inmutable del registro, sus atributos y su ubicación.</p>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando historial…" /> : <div className="asset-timeline">{versions.map((version) => <button type="button" className="timeline-entry" key={version.id} onClick={() => open(version)}><span className={`timeline-dot ${version.isCurrent ? 'current' : ''}`} /><span><strong>v{version.versionNumber} · {assetVersionChangeLabel(version.changeType)}</strong><small>{formatDate(version.occurredAt)} · {version.actorUsername ?? 'Sistema'}</small><span className="tag-list">{version.changedFields.slice(0, 4).map((field) => <em className="tag" key={field}>{assetVersionFieldLabel(field)}</em>)}</span></span><Icon name="chevron" size={16} /></button>)}</div>}
{!loading && total > versions.length && <p className="history-limit-note">Se muestran las 10 versiones más recientes. El historial completo está disponible en la sección Historial.</p>}
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
</article>;
}
@@ -0,0 +1,62 @@
import { EmptyState, LoadingBlock } from '../../components/Feedback';
import type { AssetImportReviewItem } from '../../lib/api';
const ENTITY: Record<AssetImportReviewItem['entityKind'], string> = {
DEPARTMENT: 'Departamento', ORGANIZATION: 'Organización', AREA: 'Área', AREA_DEPARTMENT_RELATION: 'Área / Departamento',
FIELD: 'Yacimiento', OPERATOR_RELATION: 'Área / Operadora', LEGAL_RIGHT: 'Derecho / concesión',
LEGAL_RIGHT_ORGANIZATION: 'Derecho / Organización', INSTALLATION: 'Instalación', LOCAL_STRUCTURE: 'Estructura local', TECHNICAL_ASSET: 'Elemento técnico',
};
const REASON: Record<string, string> = {
PLAN_TERRITORY_AMBIGUOUS: 'Área/Yacimiento ambiguo',
PLAN_TERRITORY_CONTEXT_REQUIRED: 'Falta definir Área/Yacimiento',
PLAN_CONTEXT_DECISION_REQUIRED: 'Depende de una decisión territorial',
PLAN_PARENT_NOT_RESOLVED: 'Padre físico pendiente',
PLAN_CONTAINER_REVIEW_REQUIRED: 'Instalación pendiente',
PLAN_MULTIPLE_NAMESPACE_MATCHES: 'Más de una coincidencia por identificador',
DUPLICATE_INVENTORY_ID_IN_BATCH: 'Identificador repetido en el archivo',
GROUPED_QUANTITY: 'La fila agrupa varias unidades',
SOURCE_ROW_WARNING: 'Advertencia en la fuente',
SOURCE_ROW_CONFLICT: 'Conflicto en la fuente',
};
function rowsLabel(rows: number[]) {
if (!rows.length) return 'Sin fila específica';
if (rows.length <= 4) return `Fila${rows.length === 1 ? '' : 's'} ${rows.join(', ')}`;
return `${rows.length.toLocaleString('es-AR')} filas`;
}
export function AssetImportReviewsPanel(props: {
data: AssetImportReviewItem[];
loading: boolean;
kind: 'ALL' | 'DIRECT' | 'DEPENDENCY';
meta: { page: number; pageSize: number; total: number; totalPages: number };
onKindChange: (kind: 'ALL' | 'DIRECT' | 'DEPENDENCY') => void;
onPageChange: (page: number) => void;
onOpenBatch: (batchId: string) => void;
}) {
const { data, loading, kind, meta, onKindChange, onPageChange, onOpenBatch } = props;
return <section className="panel import-review-inbox">
<div className="panel-heading">
<div><span className="eyebrow">IMPORTACIONES</span><h2>Revisiones pendientes</h2><p>Decisiones que pueden resolverse más adelante sin frenar los registros independientes.</p></div>
<span className="status-badge observed">{meta.total.toLocaleString('es-AR')} pendientes</span>
</div>
<div className="import-review-inbox-filters">
<button className={`button ${kind === 'ALL' ? 'primary' : 'secondary'}`} onClick={() => onKindChange('ALL')}>Todas</button>
<button className={`button ${kind === 'DIRECT' ? 'primary' : 'secondary'}`} onClick={() => onKindChange('DIRECT')}>Decisiones humanas</button>
<button className={`button ${kind === 'DEPENDENCY' ? 'primary' : 'secondary'}`} onClick={() => onKindChange('DEPENDENCY')}>Dependencias</button>
</div>
{loading ? <LoadingBlock label="Cargando revisiones…" /> : data.length === 0 ? <EmptyState title="No hay revisiones pendientes" text="Los lotes activos no tienen decisiones pendientes en este filtro." /> : <div className="import-review-inbox-list">
{data.map((item) => <article key={item.id} className={`import-review-inbox-item ${item.dependency ? 'dependency' : 'decision'}`}>
<div className="import-review-inbox-copy">
<div className="import-review-inbox-meta"><span>{item.dependency ? 'Dependencia automática' : 'Decisión pendiente'}</span><span>{ENTITY[item.entityKind]}</span><span>{rowsLabel(item.sourceRowNumbers)}</span></div>
<strong>{item.displayName}</strong>
<p>{item.reviewCodes.map((code) => REASON[code] ?? 'Revisión de datos').filter((value, index, values) => values.indexOf(value) === index).slice(0, 3).join(' · ')}</p>
<small>{item.batchName}{item.sourceLabel ? ` · ${item.sourceLabel}` : ''} · Plan rev. {item.planRevision}</small>
</div>
<button className="button secondary" onClick={() => onOpenBatch(item.batchId)}>Abrir lote</button>
</article>)}
</div>}
{meta.totalPages > 1 && <div className="pagination"><button className="button secondary" disabled={meta.page <= 1 || loading} onClick={() => onPageChange(meta.page - 1)}>Anterior</button><span>Página {meta.page} de {meta.totalPages}</span><button className="button secondary" disabled={meta.page >= meta.totalPages || loading} onClick={() => onPageChange(meta.page + 1)}>Siguiente</button></div>}
</section>;
}
@@ -0,0 +1,235 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetMediaBlob,
listAssetMedia,
removeAssetMedia,
updateAssetMedia,
uploadAssetMedia,
} from '../../lib/api';
import type { AssetMedia, AssetMediaKind } from '../../lib/api';
import { formatDate } from '../../lib/format';
function localDateTime(value: string | number | Date): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function fileSize(value: number) {
return value >= 1024 * 1024
? `${(value / (1024 * 1024)).toFixed(1)} MB`
: `${Math.max(1, Math.round(value / 1024))} KB`;
}
function AssetPhotoPreview({ media }: { media: AssetMedia }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getAssetMediaBlob(media.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [media.id]);
return url
? <img src={url} alt={media.title || media.originalName} />
: <div className="media-preview-loading"><span className="spinner" /></div>;
}
export function AssetMediaPanel({
assetId,
assetName,
canManage,
onChanged,
}: {
assetId: string;
assetName: string;
canManage: boolean;
onChanged: () => void;
}) {
const fileRef = useRef<HTMLInputElement | null>(null);
const [items, setItems] = useState<AssetMedia[]>([]);
const [kind, setKind] = useState<AssetMediaKind>('PHOTO');
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [capturedAt, setCapturedAt] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [accuracyM, setAccuracyM] = useState('');
const [editing, setEditing] = useState<AssetMedia | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editCapturedAt, setEditCapturedAt] = useState('');
const [editLatitude, setEditLatitude] = useState('');
const [editLongitude, setEditLongitude] = useState('');
const [editAccuracyM, setEditAccuracyM] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => listAssetMedia(assetId).then(setItems);
useEffect(() => {
setLoading(true);
load()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const resetUpload = () => {
setFile(null); setTitle(''); setDescription(''); setCapturedAt('');
setLatitude(''); setLongitude(''); setAccuracyM('');
if (fileRef.current) fileRef.current.value = '';
};
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setAccuracyM(position.coords.accuracy.toFixed(3));
if (!capturedAt) setCapturedAt(localDateTime(position.timestamp));
setLocating(false);
},
() => {
setError('No fue posible obtener la ubicación del dispositivo.');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
);
};
const upload = async (event: FormEvent) => {
event.preventDefault();
if (!file) return;
setSaving(true); setError(''); setSuccess('');
try {
await uploadAssetMedia(assetId, {
file,
kind,
title: title.trim() || undefined,
description: description.trim() || undefined,
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
accuracyM: accuracyM ? Number(accuracyM) : undefined,
});
await load();
resetUpload();
setSuccess('Archivo incorporado correctamente');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const openEdit = (media: AssetMedia) => {
setEditing(media);
setEditTitle(media.title ?? '');
setEditDescription(media.description ?? '');
setEditCapturedAt(media.capturedAt ? localDateTime(media.capturedAt) : '');
setEditLatitude(media.latitude == null ? '' : String(media.latitude));
setEditLongitude(media.longitude == null ? '' : String(media.longitude));
setEditAccuracyM(media.accuracyM == null ? '' : String(media.accuracyM));
};
const saveEdit = async (event: FormEvent) => {
event.preventDefault();
if (!editing) return;
setSaving(true); setError(''); setSuccess('');
try {
await updateAssetMedia(editing.id, {
title: editTitle.trim() || null,
description: editDescription.trim() || null,
capturedAt: editCapturedAt ? new Date(editCapturedAt).toISOString() : null,
latitude: editLatitude ? Number(editLatitude) : null,
longitude: editLongitude ? Number(editLongitude) : null,
accuracyM: editAccuracyM ? Number(editAccuracyM) : null,
});
await load();
setEditing(null);
setSuccess('Metadatos actualizados correctamente');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const remove = async (media: AssetMedia) => {
if (!window.confirm(`¿Retirar “${media.title || media.originalName}” de ${assetName}? El original se conservará en el almacenamiento protegido.`)) return;
setSaving(true); setError(''); setSuccess('');
try {
await removeAssetMedia(media.id);
await load();
setSuccess('Archivo retirado; el original quedó preservado');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const download = async (media: AssetMedia) => {
setError('');
try {
const blob = await getAssetMediaBlob(media.id, true);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = media.originalName;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (requestError) {
setError(errorMessage(requestError));
}
};
const photos = items.filter((item) => item.kind === 'PHOTO');
const documents = items.filter((item) => item.kind === 'DOCUMENT');
return <article className="panel asset-media-panel">
<div className="panel-heading"><div><span className="eyebrow">ARCHIVOS DEL INVENTARIO</span><h2>Fotografías y documentos</h2></div><span className="count-pill">{items.length} archivo{items.length === 1 ? '' : 's'}</span></div>
<p className="section-copy">Originales protegidos con hash SHA-256, autor, fecha y ubicación opcional. Formatos permitidos: JPG, PNG, WebP y PDF de hasta 15 MB.</p>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{canManage && <form className="media-upload-form" onSubmit={upload}>
<div className="form-grid"><label className="field"><span>Clase de archivo</span><SearchableSelect value={kind} onChange={(event) => { setKind(event.target.value as AssetMediaKind); setFile(null); if (fileRef.current) fileRef.current.value = ''; }}><option value="PHOTO">Fotografía</option><option value="DOCUMENT">Documento PDF</option></SearchableSelect></label><label className="field"><span>Archivo</span><input ref={fileRef} type="file" accept={kind === 'PHOTO' ? 'image/jpeg,image/png,image/webp' : 'application/pdf'} capture={kind === 'PHOTO' ? 'environment' : undefined} onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label></div>
<div className="form-grid"><label className="field"><span>Título <em>opcional</em></span><input value={title} onChange={(event) => setTitle(event.target.value)} maxLength={200} /></label><label className="field"><span>Fecha de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} /></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 className="media-location-row"><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Agregar ubicación actual'}</button>{latitude && longitude ? <span>{latitude}, {longitude}{accuracyM ? ` · ±${accuracyM} m` : ''}</span> : <span>Sin coordenadas</span>}<button type="button" className="button text" onClick={() => { setLatitude(''); setLongitude(''); setAccuracyM(''); }}>Limpiar GPS</button></div>
<div className="form-actions"><button className="button primary" disabled={!file || saving}><Icon name="plus" />{saving ? 'Subiendo…' : 'Incorporar archivo'}</button></div>
</form>}
{loading ? <LoadingBlock label="Cargando archivos…" /> : items.length === 0 ? <div className="inline-empty">Este registro todavía no tiene fotografías ni documentos.</div> : <>
{photos.length > 0 && <section className="media-section"><h3>Fotografías</h3><div className="media-photo-grid">{photos.map((media) => <article className="media-card photo" key={media.id}><div className="media-photo-preview"><AssetPhotoPreview media={media} /></div><div className="media-card-copy"><strong>{media.title || media.originalName}</strong><small>{fileSize(media.sizeBytes)} · {formatDate(media.capturedAt || media.createdAt)}</small>{media.description && <p>{media.description}</p>}<div className="media-card-actions"><button type="button" className="button text" onClick={() => download(media)}>Descargar</button>{canManage && <button type="button" className="button text" onClick={() => openEdit(media)}>Editar</button>}{canManage && <button type="button" className="button text danger-text" onClick={() => remove(media)} disabled={saving}>Retirar</button>}</div></div></article>)}</div></section>}
{documents.length > 0 && <section className="media-section"><h3>Documentos</h3><div className="media-document-list">{documents.map((media) => <article className="media-card document" key={media.id}><span className="asset-symbol"><Icon name="clipboard" /></span><div className="media-card-copy"><strong>{media.title || media.originalName}</strong><small>{media.originalName} · {fileSize(media.sizeBytes)} · {formatDate(media.createdAt)}</small>{media.description && <p>{media.description}</p>}</div><div className="media-card-actions"><button type="button" className="button secondary" onClick={() => download(media)}>Descargar</button>{canManage && <button type="button" className="button text" onClick={() => openEdit(media)}>Editar</button>}{canManage && <button type="button" className="button text danger-text" onClick={() => remove(media)} disabled={saving}>Retirar</button>}</div></article>)}</div></section>}
</>}
{editing && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) setEditing(null); }}><aside className="detail-drawer media-edit-drawer" role="dialog" aria-modal="true" aria-label="Editar metadatos"><form onSubmit={saveEdit}><div className="drawer-heading"><div><span className="eyebrow">METADATOS DEL ARCHIVO</span><h2>{editing.title || editing.originalName}</h2></div><button type="button" className="icon-button" onClick={() => setEditing(null)} aria-label="Cerrar">×</button></div><div className="media-edit-fields"><label className="field"><span>Título</span><input value={editTitle} onChange={(event) => setEditTitle(event.target.value)} maxLength={200} /></label><label className="field"><span>Descripción</span><textarea value={editDescription} onChange={(event) => setEditDescription(event.target.value)} rows={4} maxLength={4000} /></label><label className="field"><span>Fecha de captura</span><input type="datetime-local" value={editCapturedAt} onChange={(event) => setEditCapturedAt(event.target.value)} /></label><div className="form-grid"><label className="field"><span>Latitud</span><input type="number" min="-90" max="90" step="0.000001" value={editLatitude} onChange={(event) => setEditLatitude(event.target.value)} /></label><label className="field"><span>Longitud</span><input type="number" min="-180" max="180" step="0.000001" value={editLongitude} onChange={(event) => setEditLongitude(event.target.value)} /></label></div><label className="field"><span>Precisión GPS en metros</span><input type="number" min="0" max="100000" step="0.001" value={editAccuracyM} onChange={(event) => setEditAccuracyM(event.target.value)} /></label><div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(null)}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar metadatos'}</button></div></div></form></aside></div>}
</article>;
}
@@ -0,0 +1,138 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
createAreaCompanyRelation,
endAreaCompanyRelation,
listAreaCompanyRelations,
listOperationalAreas,
listOperationalCompanies,
} from '../../lib/api';
import type {
AreaCompanyRelation,
AssetTypeOperationalRole,
OperationalAssetSummary,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
export function AssetOperationalRelationsPanel({
assetId,
role,
canManage,
}: {
assetId: string;
role: AssetTypeOperationalRole;
canManage: boolean;
}) {
const [relations, setRelations] = useState<AreaCompanyRelation[]>([]);
const [candidates, setCandidates] = useState<OperationalAssetSummary[]>([]);
const [candidateId, setCandidateId] = useState('');
const [reason, setReason] = useState('');
const [endingId, setEndingId] = useState<string | null>(null);
const [endReason, setEndReason] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = async () => {
const [loadedRelations, loadedCandidates] = await Promise.all([
listAreaCompanyRelations({
...(role === 'AREA' ? { areaId: assetId } : { companyId: assetId }),
includeHistory: true,
}),
role === 'AREA' ? listOperationalCompanies() : listOperationalAreas(),
]);
setRelations(loadedRelations);
setCandidates(loadedCandidates.filter((item) => item.id !== assetId));
};
useEffect(() => {
setLoading(true);
load()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId, role]);
const activeCounterpartIds = useMemo(() => new Set(
relations.filter((item) => item.active && item.relationRole === 'OPERATOR').map((item) => (
role === 'AREA' ? item.company.id : item.area.id
)),
), [relations, role]);
const available = candidates.filter((item) => !activeCounterpartIds.has(item.id));
const createRelation = async (event: FormEvent) => {
event.preventDefault();
if (!candidateId || reason.trim().length < 3) return;
setBusy(true); setError(''); setSuccess('');
try {
await createAreaCompanyRelation({
areaId: role === 'AREA' ? assetId : candidateId,
companyId: role === 'COMPANY' ? assetId : candidateId,
reason: reason.trim(),
});
setCandidateId(''); setReason('');
await load();
setSuccess(role === 'AREA' ? 'Organización vinculada al área.' : 'Área vinculada a la organización.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const endRelation = async (relationId: string) => {
if (endReason.trim().length < 3) return;
setBusy(true); setError(''); setSuccess('');
try {
await endAreaCompanyRelation(relationId, endReason.trim());
setEndingId(null); setEndReason('');
await load();
setSuccess('Relación finalizada. El historial quedó conservado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
if (role === 'GENERIC') return null;
if (loading) return <div className="panel"><LoadingBlock label="Cargando relaciones operativas…" /></div>;
return <article className="panel form-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">RELACIONES OPERATIVAS</span>
<h2>{role === 'AREA' ? 'Organizaciones vinculadas con esta área' : 'Áreas vinculadas con esta organización'}</h2>
<p className="section-copy">Esta pantalla administra la operadora usada para asignar registros de inventario. Titularidad, concesiones y UTE se gestionarán en la ficha jurídica del Inventarios.</p>
</div>
<span className="count-pill">{relations.filter((item) => item.active).length}</span>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{canManage && <form className="operational-relation-form" onSubmit={createRelation}>
<div className="form-grid">
<label className="field"><span>{role === 'AREA' ? 'Organización' : 'Área'}</span><SearchableSelect value={candidateId} onChange={(event) => setCandidateId(event.target.value)} required><option value="">Seleccionar</option>{available.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.code}</option>)}</SearchableSelect><small>{available.length} opción{available.length === 1 ? '' : 'es'} disponible{available.length === 1 ? '' : 's'}</small></label>
<label className="field"><span>Motivo del vínculo</span><input value={reason} onChange={(event) => setReason(event.target.value)} required minLength={3} maxLength={1000} placeholder="Ej.: operación vigente según instrumento informado" /></label>
</div>
<div className="form-actions"><button className="button secondary" disabled={busy || !candidateId || reason.trim().length < 3}><Icon name="plus" />{busy ? 'Guardando…' : 'Vincular'}</button></div>
</form>}
{relations.length === 0 ? <EmptyState title="Sin relaciones registradas" text={role === 'AREA' ? 'Todavía no hay organizaciones vinculadas con esta área.' : 'Todavía no hay áreas vinculadas con esta organización.'} /> : <div className="table-scroll"><table><thead><tr><th>{role === 'AREA' ? 'Organización' : 'Área'}</th><th>Rol</th><th>Vigencia</th><th>Motivo</th><th>Registros asignados</th><th>Estado</th><th /></tr></thead><tbody>{relations.map((relation) => {
const counterpart = role === 'AREA' ? relation.company : relation.area;
return <tr key={relation.id}>
<td><strong className="table-primary">{counterpart.name}</strong><small className="cell-subtext">{counterpart.code} · {counterpart.typeName}</small></td>
<td><span>{relation.relationRole === 'OPERATOR' ? 'Operadora' : relation.relationRole === 'TECHNICAL_OPERATOR' ? 'Operadora técnica' : relation.relationRole === 'CONCESSIONAIRE' ? 'Concesionaria / titular' : relation.relationRole === 'PERMIT_HOLDER' ? 'Permisionaria' : relation.relationRole === 'PARTICIPANT' ? 'Participante' : 'Otro'}</span>{relation.participationPercent != null && <small className="cell-subtext">{relation.participationPercent}%</small>}</td><td><span>{formatDate(relation.validFrom)}</span><small className="cell-subtext">{relation.validUntil ? `hasta ${formatDate(relation.validUntil)}` : 'vigente'}</small></td>
<td><span>{relation.startReason}</span>{relation.endReason && <small className="cell-subtext">Cierre: {relation.endReason}</small>}</td>
<td><span className="count-pill">{relation.assignedAssetCount}</span></td>
<td><span className={`status-badge ${relation.active ? 'active' : 'inactive'}`}>{relation.active ? 'Activa' : 'Histórica'}</span></td>
<td className="action-cell">{canManage && relation.active && (endingId === relation.id
? <div className="relation-end-inline"><input value={endReason} onChange={(event) => setEndReason(event.target.value)} placeholder="Motivo de finalización" minLength={3} maxLength={1000} /><button type="button" className="button danger-outline compact" disabled={busy || endReason.trim().length < 3} onClick={() => endRelation(relation.id)}>Confirmar</button><button type="button" className="button text compact" onClick={() => { setEndingId(null); setEndReason(''); }}>Cancelar</button></div>
: <button type="button" className="button danger-outline compact" onClick={() => { setEndingId(relation.id); setEndReason(''); }}>Finalizar</button>)}</td>
</tr>;
})}</tbody></table></div>}
</article>;
}
@@ -0,0 +1,120 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetProvenance,
updateAssetProvenance,
verifyAssetProvenance,
} from '../../lib/api';
import type { AssetDataOrigin, AssetProvenance } from '../../lib/api';
import { formatDate } from '../../lib/format';
import {
ASSET_DATA_ORIGINS,
assetOriginLabel,
} from './assetProvenancePresentation';
function localDateTime(value: string | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
export function AssetProvenancePanel({
assetId,
canManage,
canVerify,
onChanged,
}: {
assetId: string;
canManage: boolean;
canVerify: boolean;
onChanged: () => void;
}) {
const [provenance, setProvenance] = useState<AssetProvenance | null>(null);
const [origin, setOrigin] = useState<AssetDataOrigin>('MANUAL');
const [sourceName, setSourceName] = useState('');
const [sourceReference, setSourceReference] = useState('');
const [observedAt, setObservedAt] = useState('');
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const apply = (value: AssetProvenance) => {
setProvenance(value);
setOrigin(value.origin);
setSourceName(value.sourceName ?? '');
setSourceReference(value.sourceReference ?? '');
setObservedAt(localDateTime(value.observedAt));
setNotes(value.notes ?? '');
};
useEffect(() => {
setLoading(true);
getAssetProvenance(assetId)
.then(apply)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const save = async (event: FormEvent) => {
event.preventDefault();
setSaving(true); setError(''); setSuccess('');
try {
const updated = await updateAssetProvenance(assetId, {
origin,
sourceName: sourceName.trim() || null,
sourceReference: sourceReference.trim() || null,
observedAt: observedAt ? new Date(observedAt).toISOString() : null,
notes: notes.trim() || null,
});
apply(updated);
setSuccess('Procedencia actualizada. La verificación anterior, si existía, fue reiniciada.');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const verify = async () => {
if (!window.confirm('¿Confirmás que la procedencia registrada fue revisada?')) return;
setSaving(true); setError(''); setSuccess('');
try {
const verified = await verifyAssetProvenance(assetId);
apply(verified);
setSuccess('Procedencia verificada correctamente');
onChanged();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando procedencia…" /></div>;
if (!provenance) return <div className="panel"><Alert>{error || 'No fue posible cargar la procedencia.'}</Alert></div>;
const requiresSource = origin === 'PROVIDED_DOCUMENT' || origin === 'IMPORT';
return <article className="panel provenance-panel">
<div className="panel-heading"><div><span className="eyebrow">CALIDAD Y TRAZABILIDAD</span><h2>Procedencia de datos</h2></div><span className={`provenance-status ${provenance.verifiedAt ? 'verified' : 'pending'}`}><Icon name={provenance.verifiedAt ? 'check' : 'history'} />{provenance.verifiedAt ? 'Verificada' : 'Pendiente de verificación'}</span></div>
<p className="section-copy">Identifica de dónde proviene la información principal del registro. Cada actualización queda versionada y reinicia su verificación.</p>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{canManage ? <form className="provenance-form" onSubmit={save}>
<div className="form-grid"><label className="field"><span>Origen</span><SearchableSelect value={origin} onChange={(event) => setOrigin(event.target.value as AssetDataOrigin)}>{ASSET_DATA_ORIGINS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label><label className="field"><span>Fecha observada <em>opcional</em></span><input type="datetime-local" value={observedAt} onChange={(event) => setObservedAt(event.target.value)} /></label></div>
<div className="form-grid"><label className="field"><span>Fuente {requiresSource ? <em>obligatoria</em> : <em>opcional</em>}</span><input value={sourceName} onChange={(event) => setSourceName(event.target.value)} maxLength={160} required={requiresSource} placeholder="Ej.: padrón técnico, planilla o expediente" /></label><label className="field"><span>Referencia <em>opcional</em></span><input value={sourceReference} onChange={(event) => setSourceReference(event.target.value)} maxLength={255} placeholder="Número, código o vínculo documental" /></label></div>
<label className="field"><span>Notas de procedencia <em>opcional</em></span><textarea value={notes} onChange={(event) => setNotes(event.target.value)} maxLength={4000} rows={3} /></label>
<div className="form-actions provenance-actions"><span className="provenance-updated">Última carga: {formatDate(provenance.updatedAt)} · {provenance.updatedByUsername ?? 'Sistema'}</span><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar procedencia'}</button></div>
</form> : <dl className="detail-list provenance-readonly"><div><dt>Origen</dt><dd>{assetOriginLabel(provenance.origin)}</dd></div><div><dt>Fuente</dt><dd>{provenance.sourceName || 'Sin fuente identificada'}</dd></div><div><dt>Referencia</dt><dd>{provenance.sourceReference || '—'}</dd></div><div><dt>Fecha observada</dt><dd>{formatDate(provenance.observedAt)}</dd></div><div><dt>Notas</dt><dd>{provenance.notes || 'Sin notas'}</dd></div></dl>}
<div className="provenance-verification"><div><strong>Verificación de procedencia</strong>{provenance.verifiedAt ? <p>Revisada por {provenance.verifiedByUsername ?? 'usuario'} el {formatDate(provenance.verifiedAt)}.</p> : <p>Todavía no fue confirmada por un rol autorizado.</p>}</div>{canVerify && !provenance.verifiedAt && <button type="button" className="button secondary" onClick={verify} disabled={saving}><Icon name="shield" />Verificar procedencia</button>}</div>
</article>;
}
@@ -0,0 +1,98 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
addAreaLegalRightOrganization,
addAssetExternalIdentifier,
addOrganizationMembership,
createAreaLegalRight,
createSourceDocument,
endAreaLegalRightOrganization,
endAssetExternalIdentifier,
endOrganizationMembership,
getAssetRegistry,
linkAssetSourceDocument,
listOperationalCompanies,
upsertOrganizationProfile,
} from '../../lib/api';
import type {
AreaLegalRightOrganizationRole,
AreaLegalRightType,
AssetRegistry,
AssetSourceDocumentRelationType,
AssetTypeOperationalRole,
OrganizationKind,
OrganizationMembershipRole,
OperationalAssetSummary,
SourceDocumentType,
} from '../../lib/api';
const documentLabels: Record<SourceDocumentType, string> = {
NOTE: 'Nota', TECHNICAL_REPORT: 'Informe técnico', INSPECTION_ACT: 'Acta de inspección', INVENTORY: 'Inventario',
RESOLUTION: 'Resolución', DECREE: 'Decreto', CONTRACT: 'Contrato', SPREADSHEET: 'Planilla', OTHER: 'Otro',
};
const orgLabels: Record<OrganizationKind, string> = { COMPANY: 'Empresa', UTE: 'UTE', PUBLIC_ENTITY: 'Entidad pública', OTHER: 'Otra' };
const rightLabels: Record<AreaLegalRightType, string> = { EXPLOITATION_CONCESSION: 'Concesión de explotación', EXPLORATION_PERMIT: 'Permiso de exploración', TRANSPORT_CONCESSION: 'Concesión de transporte', OTHER: 'Otro derecho' };
export function AssetRegistryPanel({ assetId, assetName, role, canManage, onChanged }: { assetId: string; assetName: string; role: AssetTypeOperationalRole; canManage: boolean; onChanged?: () => void }) {
const [data, setData] = useState<AssetRegistry | null>(null);
const [organizations, setOrganizations] = useState<OperationalAssetSummary[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [identifier, setIdentifier] = useState({ namespace: 'MENDOZA', value: '', validFrom: '', notes: '' });
const [document, setDocument] = useState({ documentType: 'NOTE' as SourceDocumentType, documentNumber: '', title: '', issuer: '', documentDate: '', externalReference: '', notes: '' });
const [profile, setProfile] = useState({ organizationKind: 'COMPANY' as OrganizationKind, legalName: '', taxId: '', notificationEmail: '', notes: '' });
const [membership, setMembership] = useState({ memberOrganizationId: '', role: 'MEMBER' as OrganizationMembershipRole, participationPercent: '', validFrom: '', notes: '' });
const [right, setRight] = useState({ rightType: 'EXPLOITATION_CONCESSION' as AreaLegalRightType, name: '', instrumentNumber: '', validFrom: '', validUntil: '', notes: '' });
const [rightParticipant, setRightParticipant] = useState<Record<string, { organizationId: string; role: AreaLegalRightOrganizationRole; participationPercent: string }>>({});
const load = async () => {
const [registry, orgs] = await Promise.all([getAssetRegistry(assetId), role === 'AREA' || role === 'COMPANY' ? listOperationalCompanies() : Promise.resolve([])]);
setData(registry); setOrganizations(orgs.filter((item) => item.id !== assetId));
if (registry.organizationProfile) setProfile({ organizationKind: registry.organizationProfile.organizationKind, legalName: registry.organizationProfile.legalName ?? '', taxId: registry.organizationProfile.taxId ?? '', notificationEmail: registry.organizationProfile.notificationEmail ?? '', notes: registry.organizationProfile.notes ?? '' });
};
useEffect(() => { setLoading(true); load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId, role]);
const run = async (action: () => Promise<unknown>, message: string) => {
setSaving(true); setError(''); setSuccess('');
try { await action(); await load(); onChanged?.(); setSuccess(message); }
catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando registro documental…" /></div>;
if (!data) return <Alert>No se pudo cargar el registro del inventario.</Alert>;
const activeMemberships = data.organizationMemberships.filter((item) => !item.validUntil);
return <div className="registry-stack">
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<article className="panel registry-panel">
<div className="panel-heading"><div><span className="eyebrow">IDENTIFICACIÓN OFICIAL</span><h2>Identificadores externos</h2><p className="section-copy">Conservá códigos provinciales, nacionales, SEN o de sistemas anteriores sin cambiar el código DH.</p></div></div>
{data.externalIdentifiers.length === 0 ? <div className="inline-empty">Todavía no hay identificadores externos.</div> : <div className="simple-record-list">{data.externalIdentifiers.map((item) => <div className="simple-record" key={item.id}><div><strong>{item.namespace}</strong><span>{item.value}</span><small>{item.validUntil ? `Finalizado ${item.validUntil}` : 'Vigente'}</small></div>{canManage && !item.validUntil && <button className="button text" type="button" onClick={() => { const reason=window.prompt('Motivo de finalización'); if (reason?.trim()) run(() => endAssetExternalIdentifier(item.id, reason.trim()), 'Identificador finalizado'); }}>Finalizar</button>}</div>)}</div>}
{canManage && <details className="inline-create"><summary><Icon name="plus" size={15} />Agregar identificador</summary><div className="form-grid"><label className="field"><span>Sistema / namespace</span><input value={identifier.namespace} onChange={(e)=>setIdentifier({...identifier,namespace:e.target.value.toUpperCase()})} placeholder="SEN, MENDOZA, NACION…" /></label><label className="field"><span>Valor</span><input value={identifier.value} onChange={(e)=>setIdentifier({...identifier,value:e.target.value})} /></label><label className="field"><span>Vigente desde <em>opcional</em></span><input type="date" value={identifier.validFrom} onChange={(e)=>setIdentifier({...identifier,validFrom:e.target.value})} /></label></div><label className="field"><span>Nota <em>opcional</em></span><input value={identifier.notes} onChange={(e)=>setIdentifier({...identifier,notes:e.target.value})} /></label><button className="button primary" type="button" disabled={saving || !identifier.namespace || !identifier.value} onClick={() => run(() => addAssetExternalIdentifier(assetId,{ namespace:identifier.namespace, value:identifier.value, validFrom:identifier.validFrom || null, notes:identifier.notes || null }), 'Identificador agregado').then(()=>setIdentifier({namespace:'MENDOZA',value:'',validFrom:'',notes:''}))}>Agregar</button></details>}
</article>
<article className="panel registry-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Documentos fuente</h2><p className="section-copy">Notas, informes, inventarios y actos administrativos que respaldan este registro.</p></div></div>
{data.sourceDocuments.length === 0 ? <div className="inline-empty">Todavía no hay documentos vinculados.</div> : <div className="simple-record-list">{data.sourceDocuments.map((doc) => <div className="simple-record" key={doc.linkId}><div><strong>{doc.title}</strong><span>{documentLabels[doc.documentType]}{doc.documentNumber ? ` · ${doc.documentNumber}` : ''}</span><small>{[doc.issuer, doc.documentDate].filter(Boolean).join(' · ')}</small></div><span className="tag">{doc.relationType}</span></div>)}</div>}
{canManage && <details className="inline-create"><summary><Icon name="plus" size={15} />Crear y vincular documento</summary><div className="form-grid"><label className="field"><span>Tipo</span><SearchableSelect value={document.documentType} onChange={(e)=>setDocument({...document,documentType:e.target.value as SourceDocumentType})}>{Object.entries(documentLabels).map(([value,label])=><option key={value} value={value}>{label}</option>)}</SearchableSelect></label><label className="field"><span>Número <em>opcional</em></span><input value={document.documentNumber} onChange={(e)=>setDocument({...document,documentNumber:e.target.value})} placeholder="NO-2026-…" /></label><label className="field"><span>Fecha <em>opcional</em></span><input type="date" value={document.documentDate} onChange={(e)=>setDocument({...document,documentDate:e.target.value})} /></label><label className="field"><span>Emisor <em>opcional</em></span><input value={document.issuer} onChange={(e)=>setDocument({...document,issuer:e.target.value})} /></label></div><label className="field"><span>Título</span><input value={document.title} onChange={(e)=>setDocument({...document,title:e.target.value})} /></label><label className="field"><span>Referencia externa <em>opcional</em></span><input value={document.externalReference} onChange={(e)=>setDocument({...document,externalReference:e.target.value})} /></label><button className="button primary" type="button" disabled={saving || document.title.trim().length < 3} onClick={() => run(async () => { const created=await createSourceDocument({ ...document, documentNumber:document.documentNumber||null, issuer:document.issuer||null, documentDate:document.documentDate||null, externalReference:document.externalReference||null, notes:document.notes||null }); await linkAssetSourceDocument(assetId,created.id,{relationType:'SOURCE' as AssetSourceDocumentRelationType}); }, 'Documento creado y vinculado').then(()=>setDocument({documentType:'NOTE',documentNumber:'',title:'',issuer:'',documentDate:'',externalReference:'',notes:''}))}>Guardar documento</button></details>}
</article>
{role === 'COMPANY' && <article className="panel registry-panel">
<div className="panel-heading"><div><span className="eyebrow">ORGANIZACIÓN</span><h2>Datos institucionales</h2><p className="section-copy">Definí si el registro representa una empresa, UTE u otra entidad.</p></div></div>
<div className="form-grid"><label className="field"><span>Tipo de organización</span><SearchableSelect value={profile.organizationKind} disabled={!canManage} onChange={(e)=>setProfile({...profile,organizationKind:e.target.value as OrganizationKind})}>{Object.entries(orgLabels).map(([value,label])=><option value={value} key={value}>{label}</option>)}</SearchableSelect></label><label className="field"><span>Razón social</span><input value={profile.legalName} disabled={!canManage} onChange={(e)=>setProfile({...profile,legalName:e.target.value})} placeholder={assetName} /></label><label className="field"><span>CUIT / identificación fiscal <em>opcional</em></span><input value={profile.taxId} disabled={!canManage} onChange={(e)=>setProfile({...profile,taxId:e.target.value})} /></label><label className="field"><span>Email oficial para actas <em>opcional</em></span><input type="email" value={profile.notificationEmail} disabled={!canManage} onChange={(e)=>setProfile({...profile,notificationEmail:e.target.value})} placeholder="inspecciones@empresa.com" /><small>Destinatario institucional del acta cerrada.</small></label></div><label className="field"><span>Notas <em>opcional</em></span><textarea rows={2} value={profile.notes} disabled={!canManage} onChange={(e)=>setProfile({...profile,notes:e.target.value})} /></label>{canManage && <button className="button primary" type="button" disabled={saving} onClick={()=>run(()=>upsertOrganizationProfile(assetId,{organizationKind:profile.organizationKind,legalName:profile.legalName||null,taxId:profile.taxId||null,notificationEmail:profile.notificationEmail||null,notes:profile.notes||null}),'Datos de organización actualizados')}>Guardar datos institucionales</button>}
{profile.organizationKind === 'UTE' && <div className="registry-subsection"><h3>Composición de la UTE</h3>{activeMemberships.length===0?<div className="inline-empty">Todavía no hay integrantes activos.</div>:<div className="simple-record-list">{activeMemberships.map((item)=><div className="simple-record" key={item.id}><div><strong>{item.member.id===assetId?item.parent.name:item.member.name}</strong><span>{item.role==='LEAD_MEMBER'?'Integrante principal':'Integrante'}{item.participationPercent!=null?` · ${item.participationPercent}%`:''}</span><small>Desde {item.validFrom}</small></div>{canManage&&item.parent.id===assetId&&<button className="button text" type="button" onClick={()=>{const reason=window.prompt('Motivo de finalización');if(reason?.trim())run(()=>endOrganizationMembership(item.id,reason.trim()),'Participación finalizada');}}>Finalizar</button>}</div>)}</div>}{canManage&&<details className="inline-create"><summary><Icon name="plus" size={15}/>Agregar integrante</summary><div className="form-grid"><label className="field"><span>Organización</span><SearchableSelect value={membership.memberOrganizationId} onChange={(e)=>setMembership({...membership,memberOrganizationId:e.target.value})}><option value="">Seleccionar</option>{organizations.map((org)=><option key={org.id} value={org.id}>{org.name}</option>)}</SearchableSelect></label><label className="field"><span>Rol</span><SearchableSelect value={membership.role} onChange={(e)=>setMembership({...membership,role:e.target.value as OrganizationMembershipRole})}><option value="MEMBER">Integrante</option><option value="LEAD_MEMBER">Integrante principal</option><option value="OTHER">Otro</option></SearchableSelect></label><label className="field"><span>Participación % <em>opcional</em></span><input type="number" min="0" max="100" step="0.0001" value={membership.participationPercent} onChange={(e)=>setMembership({...membership,participationPercent:e.target.value})}/></label><label className="field"><span>Desde <em>opcional</em></span><input type="date" value={membership.validFrom} onChange={(e)=>setMembership({...membership,validFrom:e.target.value})}/></label></div><button className="button primary" type="button" disabled={saving||!membership.memberOrganizationId} onClick={()=>run(()=>addOrganizationMembership(assetId,{memberOrganizationId:membership.memberOrganizationId,role:membership.role,participationPercent:membership.participationPercent?Number(membership.participationPercent):null,validFrom:membership.validFrom||null,notes:membership.notes||null}),'Integrante agregado').then(()=>setMembership({memberOrganizationId:'',role:'MEMBER',participationPercent:'',validFrom:'',notes:''}))}>Agregar integrante</button></details>}</div>}
</article>}
{role === 'AREA' && <article className="panel registry-panel">
<div className="panel-heading"><div><span className="eyebrow">MARCO LEGAL</span><h2>Permisos y concesiones</h2><p className="section-copy">El Área es territorial. Los derechos legales se administran por separado y conservan su vigencia histórica.</p></div></div>
{data.legalRights.length===0?<div className="inline-empty">Todavía no hay derechos registrados para esta área.</div>:<div className="legal-right-list">{data.legalRights.map((item)=><div className="legal-right-card" key={item.id}><div className="legal-right-heading"><div><strong>{item.name}</strong><span>{rightLabels[item.rightType]}{item.instrumentNumber?` · ${item.instrumentNumber}`:''}</span></div><span className={`status-badge ${item.status==='ACTIVE'?'active':item.status==='PENDING'?'pending':'inactive'}`}>{item.status==='ACTIVE'?'Activo':item.status==='PENDING'?'Pendiente':item.status==='EXPIRED'?'Vencido':'Revocado'}</span></div>{item.organizations.length>0&&<div className="right-organizations">{item.organizations.map((org)=><div key={org.id}><span><strong>{org.organizationName}</strong><small>{org.role}{org.participationPercent!=null?` · ${org.participationPercent}%`:''}{org.validUntil?` · hasta ${org.validUntil}`:''}</small></span>{canManage&&!org.validUntil&&<button className="button text" type="button" onClick={()=>{const reason=window.prompt('Motivo de finalización');if(reason?.trim())run(()=>endAreaLegalRightOrganization(org.id,reason.trim()),'Participación legal finalizada');}}>Finalizar</button>}</div>)}</div>}{canManage&&<details className="inline-create compact"><summary>Agregar organización al derecho</summary>{(()=>{const f=rightParticipant[item.id]??{organizationId:'',role:'HOLDER' as AreaLegalRightOrganizationRole,participationPercent:''};return <div className="form-grid"><label className="field"><span>Organización</span><SearchableSelect value={f.organizationId} onChange={(e)=>setRightParticipant({...rightParticipant,[item.id]:{...f,organizationId:e.target.value}})}><option value="">Seleccionar</option>{organizations.map((org)=><option key={org.id} value={org.id}>{org.name}</option>)}</SearchableSelect></label><label className="field"><span>Rol</span><SearchableSelect value={f.role} onChange={(e)=>setRightParticipant({...rightParticipant,[item.id]:{...f,role:e.target.value as AreaLegalRightOrganizationRole}})}><option value="HOLDER">Titular</option><option value="PARTICIPANT">Participante</option><option value="OPERATOR">Operador</option><option value="OTHER">Otro</option></SearchableSelect></label><label className="field"><span>Participación %</span><input type="number" min="0" max="100" step="0.0001" value={f.participationPercent} onChange={(e)=>setRightParticipant({...rightParticipant,[item.id]:{...f,participationPercent:e.target.value}})}/></label><div className="field action-field"><span>&nbsp;</span><button className="button primary" type="button" disabled={!f.organizationId||saving} onClick={()=>run(()=>addAreaLegalRightOrganization(item.id,{organizationId:f.organizationId,role:f.role,participationPercent:f.participationPercent?Number(f.participationPercent):null}),'Organización vinculada al derecho').then(()=>setRightParticipant({...rightParticipant,[item.id]:{organizationId:'',role:'HOLDER',participationPercent:''}}))}>Agregar</button></div></div>})()}</details>}</div>)}</div>}
{canManage&&<details className="inline-create"><summary><Icon name="plus" size={15}/>Agregar permiso o concesión</summary><div className="form-grid"><label className="field"><span>Tipo</span><SearchableSelect value={right.rightType} onChange={(e)=>setRight({...right,rightType:e.target.value as AreaLegalRightType})}>{Object.entries(rightLabels).map(([value,label])=><option key={value} value={value}>{label}</option>)}</SearchableSelect></label><label className="field"><span>Instrumento <em>opcional</em></span><input value={right.instrumentNumber} onChange={(e)=>setRight({...right,instrumentNumber:e.target.value})}/></label><label className="field"><span>Desde <em>opcional</em></span><input type="date" value={right.validFrom} onChange={(e)=>setRight({...right,validFrom:e.target.value})}/></label><label className="field"><span>Hasta <em>opcional</em></span><input type="date" value={right.validUntil} onChange={(e)=>setRight({...right,validUntil:e.target.value})}/></label></div><label className="field"><span>Nombre</span><input value={right.name} onChange={(e)=>setRight({...right,name:e.target.value})} placeholder="Concesión de explotación…"/></label><button className="button primary" type="button" disabled={saving||right.name.trim().length<3} onClick={()=>run(()=>createAreaLegalRight(assetId,{rightType:right.rightType,name:right.name,instrumentNumber:right.instrumentNumber||null,validFrom:right.validFrom||null,validUntil:right.validUntil||null,status:'ACTIVE',notes:right.notes||null}),'Derecho registrado').then(()=>setRight({rightType:'EXPLOITATION_CONCESSION',name:'',instrumentNumber:'',validFrom:'',validUntil:'',notes:''}))}>Guardar derecho</button></details>}
</article>}
</div>;
}
@@ -0,0 +1,58 @@
import { assetOperationalStatusLabel, assetStatusLabel } from './assetPresentation';
import {
assetVersionChangeLabel,
assetVersionFieldLabel,
} from './assetVersionPresentation';
import type { AssetVersionDetail } from '../../lib/api';
import { formatDate } from '../../lib/format';
import { assetOriginLabel } from './assetProvenancePresentation';
function displayValue(value: unknown): string {
if (value === null || value === undefined || value === '') return 'Sin dato';
if (typeof value === 'boolean') return value ? 'Sí' : 'No';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function fileSize(value: number) {
return value >= 1024 * 1024
? `${(value / (1024 * 1024)).toFixed(1)} MB`
: `${Math.max(1, Math.round(value / 1024))} KB`;
}
export function AssetVersionDrawer({
detail,
loading,
onClose,
}: {
detail: AssetVersionDetail | null;
loading: boolean;
onClose: () => void;
}) {
return <div className="modal-backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget && !loading) onClose();
}}>
<aside className="detail-drawer asset-version-drawer" role="dialog" aria-modal="true" aria-label="Detalle de versión">
{loading ? <div className="loading-block"><span className="spinner" />Cargando versión</div> : detail && <>
<div className="drawer-heading"><div><span className="eyebrow">VERSIÓN INMUTABLE</span><h2>{detail.assetName} · v{detail.versionNumber}</h2><p>{assetVersionChangeLabel(detail.changeType)}</p></div><button className="icon-button" onClick={onClose} aria-label="Cerrar">×</button></div>
<div className="version-heading-meta"><span className={`version-badge ${detail.isCurrent ? 'current' : ''}`}>{detail.isCurrent ? 'Versión actual' : `Versión ${detail.versionNumber}`}</span><span>{formatDate(detail.occurredAt)}</span><span>{detail.actorUsername ?? 'Sistema'} · {detail.source}</span></div>
<dl className="detail-list"><div><dt>Código</dt><dd><code>{detail.snapshot.code}</code></dd></div><div><dt>Nombre</dt><dd>{detail.snapshot.name}</dd></div><div><dt>Tipo</dt><dd>{detail.snapshot.type.name}</dd></div><div><dt>Registro padre</dt><dd>{detail.snapshot.parent ? `${detail.snapshot.parent.name} · ${detail.snapshot.parent.code}` : 'Registro raíz'}</dd></div><div><dt>Área operativa</dt><dd>{detail.snapshot.operationalArea ? `${detail.snapshot.operationalArea.name} · ${detail.snapshot.operationalArea.code}` : 'Sin asignar'}</dd></div><div><dt>Organización operadora</dt><dd>{detail.snapshot.operatorCompany ? `${detail.snapshot.operatorCompany.name} · ${detail.snapshot.operatorCompany.code}` : 'Sin asignar'}</dd></div><div><dt>Estado de información</dt><dd>{assetStatusLabel(detail.snapshot.informationStatus)}</dd></div><div><dt>Estado operativo</dt><dd>{assetOperationalStatusLabel(detail.snapshot.operationalStatus)}</dd></div><div><dt>Guardada</dt><dd>{formatDate(detail.occurredAt)}</dd></div></dl>
<section className="snapshot-section"><h3>Campos modificados</h3><div className="tag-list">{detail.changedFields.length ? detail.changedFields.map((field) => <span className="tag" key={field}>{assetVersionFieldLabel(field)}</span>) : <span className="muted">Sin diferencias funcionales</span>}</div></section>
<section className="snapshot-section"><h3>Descripción</h3><p>{detail.snapshot.description || 'Sin descripción en esta versión.'}</p></section>
<section className="snapshot-section"><h3>Atributos configurables</h3>{detail.snapshot.attributes.length === 0 ? <p className="muted">Sin atributos en esta versión.</p> : <div className="snapshot-attributes">{detail.snapshot.attributes.map((attribute) => <div key={attribute.definitionId}><small>{attribute.name}{attribute.unit ? ` · ${attribute.unit}` : ''}</small><strong>{displayValue(attribute.value)}</strong><code>{attribute.code}</code></div>)}</div>}</section>
<section className="snapshot-section"><h3>Ubicación geográfica</h3>{detail.snapshot.geometry ? <dl className="detail-list compact"><div><dt>Tipo</dt><dd>{detail.snapshot.geometry.geometryType}</dd></div><div><dt>Fuente</dt><dd>{detail.snapshot.geometry.source}</dd></div><div><dt>Capturada</dt><dd>{formatDate(detail.snapshot.geometry.capturedAt)}</dd></div><div><dt>Precisión</dt><dd>{detail.snapshot.geometry.accuracyM == null ? '—' : `${detail.snapshot.geometry.accuracyM} m`}</dd></div></dl> : <p className="muted">Esta versión no tenía una geometría asociada.</p>}</section>
<section className="snapshot-section"><h3>Fotografías y documentos</h3>{(detail.snapshot.media ?? []).length === 0 ? <p className="muted">Esta versión no tenía archivos activos.</p> : <div className="snapshot-attributes">{(detail.snapshot.media ?? []).map((media) => <div key={media.id}><small>{media.kind === 'PHOTO' ? 'Fotografía' : 'Documento'} · {fileSize(media.sizeBytes)}</small><strong>{media.title || media.originalName}</strong><code>{media.sha256.slice(0, 16)}</code></div>)}</div>}</section>
<section className="snapshot-section"><h3>Procedencia de datos</h3>{detail.snapshot.provenance ? <dl className="detail-list compact"><div><dt>Origen</dt><dd>{assetOriginLabel(detail.snapshot.provenance.origin)}</dd></div><div><dt>Fuente</dt><dd>{detail.snapshot.provenance.sourceName || 'Sin fuente identificada'}</dd></div><div><dt>Referencia</dt><dd>{detail.snapshot.provenance.sourceReference || '—'}</dd></div><div><dt>Fecha observada</dt><dd>{formatDate(detail.snapshot.provenance.observedAt)}</dd></div><div><dt>Verificación</dt><dd>{detail.snapshot.provenance.verifiedAt ? formatDate(detail.snapshot.provenance.verifiedAt) : 'Pendiente'}</dd></div></dl> : <p className="muted">Esta versión es anterior al registro de procedencia.</p>}</section>
<details className="json-detail"><summary>Snapshot técnico exacto</summary><pre>{JSON.stringify(detail.snapshot, null, 2)}</pre></details>
</>}
</aside>
</div>;
}
@@ -0,0 +1,39 @@
import type { AssetInformationStatus, AssetOperationalStatus } from '../../lib/api';
export const ASSET_STATUSES: Array<{
value: AssetInformationStatus;
label: string;
}> = [
{ value: 'DRAFT', label: 'Borrador' },
{ value: 'PENDING_SURVEY', label: 'Pendiente de relevamiento' },
{ value: 'SURVEYED', label: 'Relevado en campo' },
{ value: 'VALIDATED', label: 'Validado' },
{ value: 'OBSERVED', label: 'Observado' },
{ value: 'OUTDATED', label: 'Desactualizado' },
{ value: 'INACTIVE', label: 'Inactivo' },
];
export function assetStatusLabel(status: AssetInformationStatus): string {
return ASSET_STATUSES.find((item) => item.value === status)?.label ?? status;
}
export function assetStatusClass(status: AssetInformationStatus): string {
if (status === 'VALIDATED' || status === 'SURVEYED') return 'active';
if (status === 'INACTIVE' || status === 'OUTDATED') return 'inactive';
if (status === 'OBSERVED') return 'observed';
return 'pending';
}
export const ASSET_OPERATIONAL_STATUSES: Array<{ value: AssetOperationalStatus; label: string }> = [
{ value: 'UNKNOWN', label: 'Sin informar' },
{ value: 'IN_SERVICE', label: 'En servicio' },
{ value: 'TEMPORARILY_OUT_OF_SERVICE', label: 'Fuera de servicio temporal' },
{ value: 'OUT_OF_SERVICE', label: 'Fuera de servicio' },
{ value: 'DECOMMISSIONED', label: 'Desafectado' },
{ value: 'ABANDONED', label: 'Abandonado' },
];
export function assetOperationalStatusLabel(status: AssetOperationalStatus): string {
return ASSET_OPERATIONAL_STATUSES.find((item) => item.value === status)?.label ?? status;
}
@@ -0,0 +1,17 @@
import type { AssetDataOrigin } from '../../lib/api';
const originLabels: Record<AssetDataOrigin, string> = {
MANUAL: 'Carga manual',
FIELD_SURVEY: 'Relevamiento de campo',
PROVIDED_DOCUMENT: 'Documentación recibida',
IMPORT: 'Importación',
SYSTEM: 'Generado por el sistema',
};
export function assetOriginLabel(origin: AssetDataOrigin) {
return originLabels[origin];
}
export const ASSET_DATA_ORIGINS = (
Object.keys(originLabels) as AssetDataOrigin[]
).map((value) => ({ value, label: originLabels[value] }));
@@ -0,0 +1,53 @@
import type { AssetVersionChangeType } from '../../lib/api';
const changeLabels: Record<AssetVersionChangeType, string> = {
BASELINE: 'Versión base',
CREATED: 'Registro creado',
UPDATED: 'Datos actualizados',
CONTEXT_CHANGED: 'Contexto operativo modificado',
STATUS_CHANGED: 'Estado de información modificado',
OPERATIONAL_STATUS_CHANGED: 'Estado operativo modificado',
REGISTRY_UPDATED: 'Registro maestro actualizado',
GEOMETRY_UPDATED: 'Ubicación actualizada',
GEOMETRY_REMOVED: 'Ubicación retirada',
MEDIA_UPLOADED: 'Archivo incorporado',
MEDIA_UPDATED: 'Archivo actualizado',
MEDIA_REMOVED: 'Archivo retirado',
PROVENANCE_BASELINE: 'Procedencia inicial',
PROVENANCE_UPDATED: 'Procedencia actualizada',
PROVENANCE_VERIFIED: 'Procedencia verificada',
};
const fieldLabels: Record<string, string> = {
baseline: 'Versión base',
code: 'Código',
name: 'Nombre',
description: 'Descripción',
type: 'Tipo',
parent: 'Jerarquía',
operationalArea: 'Área operativa',
operatorCompany: 'Organización operadora',
informationStatus: 'Estado de información',
operationalStatus: 'Estado operativo',
organizationProfile: 'Perfil de organización',
organizationMemberships: 'Composición de UTE',
externalIdentifiers: 'Identificadores externos',
sourceDocuments: 'Documentos fuente',
legalRights: 'Derechos sobre el área',
attributes: 'Atributos',
geometry: 'Ubicación',
media: 'Fotografías y documentos',
provenance: 'Procedencia de datos',
};
export function assetVersionChangeLabel(changeType: AssetVersionChangeType) {
return changeLabels[changeType];
}
export function assetVersionFieldLabel(field: string) {
return fieldLabels[field] ?? field;
}
export const ASSET_VERSION_CHANGES = (
Object.keys(changeLabels) as AssetVersionChangeType[]
).map((value) => ({ value, label: changeLabels[value] }));
@@ -0,0 +1,8 @@
import { NavLink } from 'react-router';
export function DocumentCenterTabs() {
return <nav className="document-center-tabs" aria-label="Centro documental">
<NavLink to="/actas" className={({ isActive }) => isActive ? 'active' : ''}>Actas</NavLink>
<NavLink to="/informes" className={({ isActive }) => isActive ? 'active' : ''}>Informes</NavLink>
</nav>;
}
@@ -0,0 +1,48 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listFindingCatalogProposals, reviewFindingCatalogProposal } from '../../lib/api';
import type { FindingAdminCatalog, FindingCatalogProposal } from '../../lib/api';
export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdminCatalog }) {
const [items, setItems] = useState<FindingCatalogProposal[]>([]);
const [matches, setMatches] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => listFindingCatalogProposals({ status: 'PENDING' }).then(setItems);
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, []);
const review = async (proposal: FindingCatalogProposal, decision: 'MATCH' | 'REJECT') => {
setBusyId(proposal.id); setError(''); setSuccess('');
try {
if (decision === 'MATCH' && !matches[proposal.id]) {
setError('Seleccioná primero el tipo de hallazgo del catálogo que corresponde.');
return;
}
await reviewFindingCatalogProposal(proposal.id, {
decision,
catalogItemId: decision === 'MATCH' ? matches[proposal.id] : undefined,
notes: decision === 'REJECT' ? 'Propuesta descartada desde administración de catálogo.' : 'Propuesta vinculada a un tipo existente del catálogo.',
});
await load();
setSuccess(decision === 'MATCH' ? 'Propuesta incorporada al criterio de catálogo futuro.' : 'Propuesta descartada sin modificar el hallazgo histórico.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusyId('');
}
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando propuestas OTROS…" /></div>;
return <section className="panel finding-proposals-panel">
<div className="panel-heading"><div><span className="eyebrow">PROPUESTAS DESDE CAMPO</span><h2>Hallazgos cargados como OTROS</h2><p className="section-copy">El hallazgo original queda intacto. Esta bandeja sirve para decidir si conviene sumar esa opción al catálogo para inspecciones futuras.</p></div><span className="count-pill">{items.length} pendientes</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{items.length === 0 ? <EmptyState title="Sin propuestas pendientes" text="Los nuevos hallazgos cargados como OTROS aparecerán acá." /> : <div className="finding-proposal-list">{items.map((proposal) => <article className="finding-proposal-card" key={proposal.id}><div><span className="eyebrow">{proposal.assetTypeName} · {proposal.assetCode}</span><h3>{proposal.proposedTitle}</h3><p>{proposal.description}</p><small><Link to={`/hallazgos/${proposal.findingId}`}>{proposal.findingCode}</Link> · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}</small></div><div className="finding-proposal-actions"><label className="field"><span>Coincide con</span><SearchableSelect value={matches[proposal.id] ?? ''} onChange={(event) => setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}><option value="">Seleccionar tipo existente</option>{catalog.items.filter((item) => item.isActive && item.categoryActive).map((item) => <option value={item.id} key={item.id}>{item.title} · {item.code}</option>)}</SearchableSelect></label><div className="form-actions"><button type="button" className="button secondary" disabled={busyId === proposal.id} onClick={() => review(proposal, 'REJECT')}>Descartar</button><button type="button" className="button primary" disabled={busyId === proposal.id || !matches[proposal.id]} onClick={() => review(proposal, 'MATCH')}><Icon name="check" />Vincular</button></div><small>Si todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.</small></div></article>)}</div>}
</section>;
}
@@ -0,0 +1,95 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getFindingCatalogAssetTypeSelection,
listAssetTypes,
replaceFindingCatalogAssetTypeSelection,
} from '../../lib/api';
import type { AssetType, FindingCatalogAssetTypeSelection } from '../../lib/api';
export function FindingCatalogTypeApplicabilityPanel() {
const [types, setTypes] = useState<AssetType[]>([]);
const [typeId, setTypeId] = useState('');
const [selection, setSelection] = useState<FindingCatalogAssetTypeSelection | null>(null);
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
listAssetTypes()
.then((loaded) => {
const technical = loaded.filter((type) => type.isActive && type.operationalRole === 'GENERIC');
setTypes(technical);
setTypeId(technical[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 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 toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const save = async () => {
if (!selection) return;
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetTypeSelection(selection.assetType.id, {
enabledItemIds: [...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}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading && types.length === 0) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (types.length === 0) return null;
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>
{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>
</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>
</section>;
}
@@ -0,0 +1,370 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router';
import { Alert, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
closeInspectionFinding,
createInspectionFindingCommunication,
updateInspectionFindingFollowUp,
uploadInspectionFindingEvidence,
} from '../../lib/api';
import type {
InspectionFinding,
InspectionFindingCommunication,
InspectionFindingOfficeWorkflow,
} from '../../lib/api';
import { formatDate, formatDateOnly } from '../../lib/format';
function localDateTime(value: string | number | Date): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function workflowFallback(finding: InspectionFinding): InspectionFindingOfficeWorkflow {
if (finding.status === 'CLOSED') {
return { stage: 'CLOSED', label: 'Hallazgo cerrado', action: 'El seguimiento está finalizado.', dueOn: null, overdue: false };
}
if (!finding.companyResponseReceivedOn) {
return { stage: 'WAITING_COMPANY', label: 'Esperando respuesta de empresa', action: 'Registrar la respuesta cuando sea recibida.', dueOn: finding.correctionDueOn, overdue: false };
}
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') {
return { stage: 'READY_TO_CLOSE', label: 'Listo para cierre', action: 'Revisar la evidencia y cerrar administrativamente.', dueOn: null, overdue: false };
}
if (!finding.nextControlOn) {
return { stage: 'DEFINE_VERIFICATION', label: 'Definir fecha de verificación', action: 'Establecer cuándo debe volver a controlarse.', dueOn: null, overdue: false };
}
if (finding.verificationVisit) {
return { stage: 'VERIFICATION_PLANNED', label: 'Verificación planificada', action: 'Consultar la visita vinculada.', dueOn: finding.nextControlOn, overdue: false };
}
return { stage: 'PLAN_VERIFICATION', label: 'Verificación pendiente de planificación', action: 'Crear la visita de verificación.', dueOn: finding.nextControlOn, overdue: false };
}
function verificationEventLabel(eventType: NonNullable<InspectionFinding['verificationHistory']>[number]['eventType']): string {
if (eventType === 'CONTROL_DATE_DEFINED') return 'Fecha de verificación definida';
if (eventType === 'CONTROL_DATE_CHANGED') return 'Fecha de verificación reprogramada';
if (eventType === 'CONTROL_DATE_CLEARED') return 'Fecha de verificación consumida o retirada';
if (eventType === 'VISIT_PLANNED') return 'Visita de verificación planificada';
return 'Resultado de verificación registrado';
}
function verificationOutcomeLabel(outcome: NonNullable<InspectionFinding['latestVerification']>['outcome']): string {
if (outcome === 'RESOLVED') return 'Solucionado';
if (outcome === 'NOT_RESOLVED') return 'No solucionado';
if (outcome === 'REQUIRES_NEW_DATE') return 'Requiere nueva fecha';
return '';
}
export function FindingOfficeWorkspace({
finding,
canFollowUp,
canClose,
canCreateEvidence,
canCreateCommunications,
onFindingChanged,
onEvidenceChanged,
}: {
finding: InspectionFinding;
canFollowUp: boolean;
canClose: boolean;
canCreateEvidence: boolean;
canCreateCommunications: boolean;
onFindingChanged: (finding: InspectionFinding) => void;
onEvidenceChanged: () => void;
}) {
const responseFileRef = useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [responseText, setResponseText] = useState('');
const [responseOccurredAt, setResponseOccurredAt] = useState(localDateTime(new Date()));
const [responseChannel, setResponseChannel] = useState<InspectionFindingCommunication['channel']>('EMAIL');
const [responseContactName, setResponseContactName] = useState('');
const [responseContactEmail, setResponseContactEmail] = useState('');
const [responseFile, setResponseFile] = useState<File | null>(null);
const [responseDueBasis, setResponseDueBasis] = useState<'FINDING_DATE' | 'REPORT_NOTIFICATION' | ''>(finding.responseDueBasis ?? '');
const [responseDueDays, setResponseDueDays] = useState(finding.responseDueDays == null ? '' : String(finding.responseDueDays));
const [reportNotifiedOn, setReportNotifiedOn] = useState(finding.reportNotifiedOn ?? '');
const [committedOn, setCommittedOn] = useState(finding.companyCommittedCorrectionOn ?? '');
const [nextControlOn, setNextControlOn] = useState(finding.nextControlOn ?? '');
const [closureNotes, setClosureNotes] = useState('');
useEffect(() => {
setResponseText('');
setResponseDueBasis(finding.responseDueBasis ?? '');
setResponseDueDays(finding.responseDueDays == null ? '' : String(finding.responseDueDays));
setReportNotifiedOn(finding.reportNotifiedOn ?? '');
setCommittedOn(finding.companyCommittedCorrectionOn ?? '');
setNextControlOn(finding.nextControlOn ?? '');
}, [finding.id, finding.responseDueBasis, finding.responseDueDays, finding.reportNotifiedOn, finding.companyCommittedCorrectionOn, finding.nextControlOn]);
const workflow = finding.officeWorkflow ?? workflowFallback(finding);
const hasResponse = Boolean(finding.companyResponseReceivedOn);
const verificationDone = Boolean(finding.latestVerification?.outcome);
const closed = finding.status === 'CLOSED';
const verificationReadyToClose = finding.latestVerification?.outcome === 'RESOLVED'
&& finding.latestVerification.visitStatus === 'CLOSED';
const verificationActive = Boolean(finding.verificationVisit);
const verificationHistory = finding.verificationHistory ?? [];
const saveResponseDeadline = async (event: FormEvent) => {
event.preventDefault();
if (!responseDueBasis || responseDueDays === '') return;
if (responseDueBasis === 'REPORT_NOTIFICATION' && !reportNotifiedOn) return;
setBusy(true);
setError('');
setSuccess('');
try {
const updated = await updateInspectionFindingFollowUp(finding.id, {
responseDueBasis,
responseDueDays: Number(responseDueDays),
reportNotifiedOn: responseDueBasis === 'REPORT_NOTIFICATION' ? reportNotifiedOn : null,
});
onFindingChanged(updated);
setSuccess('Plazo administrativo calculado y guardado con su fecha base.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const registerResponse = async (event: FormEvent) => {
event.preventDefault();
if (!responseText.trim() || !responseOccurredAt) return;
const receivedOn = responseOccurredAt.slice(0, 10);
const firstPresentation = !hasResponse;
setBusy(true);
setError('');
setSuccess('');
try {
let updated = finding;
if (firstPresentation) {
updated = await updateInspectionFindingFollowUp(finding.id, {
companyResponse: responseText.trim(),
companyResponseReceivedOn: receivedOn,
companyCommittedCorrectionOn: committedOn || null,
nextControlOn: nextControlOn || null,
});
onFindingChanged(updated);
} else if (
committedOn !== (finding.companyCommittedCorrectionOn ?? '')
|| nextControlOn !== (finding.nextControlOn ?? '')
) {
updated = await updateInspectionFindingFollowUp(finding.id, {
companyCommittedCorrectionOn: committedOn || null,
nextControlOn: nextControlOn || null,
});
onFindingChanged(updated);
}
let communicationId = '';
let traceWarning = '';
if (canCreateCommunications) {
try {
const communication = await createInspectionFindingCommunication(finding.id, {
type: 'COMPANY_RESPONSE',
direction: 'INBOUND',
channel: responseChannel,
occurredAt: new Date(responseOccurredAt).toISOString(),
subject: `${firstPresentation ? 'Respuesta' : 'Nueva presentación'} de empresa · ${finding.code}`,
details: responseText.trim(),
contactName: responseContactName.trim() || null,
contactEmail: responseContactEmail.trim() || null,
});
communicationId = communication.id;
onEvidenceChanged();
} catch (requestError) {
traceWarning = `${firstPresentation ? 'La primera respuesta' : 'La actualización de seguimiento'} quedó registrada, pero la presentación histórica no pudo incorporarse: ${errorMessage(requestError)}`;
}
} else {
traceWarning = 'No tenés permiso para incorporar la presentación a la trazabilidad histórica.';
}
if (responseFile && canCreateEvidence) {
if (!communicationId) {
traceWarning = traceWarning || 'El documento debe incorporarse desde Documentos y trazabilidad porque no existe una presentación histórica vinculada.';
} else {
try {
await uploadInspectionFindingEvidence(finding.id, {
file: responseFile,
kind: 'DOCUMENT',
purpose: 'COMPANY_RESPONSE',
communicationId,
title: responseFile.name,
description: `Documento presentado por la empresa para ${finding.code}`,
capturedAt: new Date(responseOccurredAt).toISOString(),
deviceLabel: navigator.userAgent.slice(0, 200),
});
onEvidenceChanged();
} catch (requestError) {
traceWarning = `La presentación quedó registrada, pero el documento no pudo incorporarse: ${errorMessage(requestError)}`;
}
}
}
setResponseText('');
setResponseContactName('');
setResponseContactEmail('');
setResponseOccurredAt(localDateTime(new Date()));
setResponseFile(null);
if (responseFileRef.current) responseFileRef.current.value = '';
setSuccess(firstPresentation
? 'Primera respuesta registrada. Las presentaciones posteriores se agregarán sin modificar esta respuesta original.'
: 'Nueva presentación agregada al historial del hallazgo. La respuesta original permanece inmutable.');
if (traceWarning) setError(traceWarning);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const saveOperationalFollowUp = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError('');
setSuccess('');
try {
const updated = await updateInspectionFindingFollowUp(finding.id, {
companyCommittedCorrectionOn: committedOn || null,
nextControlOn: nextControlOn || null,
});
onFindingChanged(updated);
setSuccess('Seguimiento operativo actualizado. El cambio de fecha quedó agregado al historial sin borrar los controles anteriores.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const closeFinding = async (event: FormEvent) => {
event.preventDefault();
if (!closureNotes.trim()) return;
setBusy(true);
setError('');
setSuccess('');
try {
const updated = await closeInspectionFinding(finding.id, closureNotes.trim());
onFindingChanged(updated);
setClosureNotes('');
setSuccess('Hallazgo cerrado. La conclusión quedó incorporada al expediente histórico.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
return <article className="panel finding-office-workspace">
<div className="panel-heading">
<div><span className="eyebrow">EXPEDIENTE OPERATIVO</span><h2>Dar curso al hallazgo</h2><p className="section-copy">Respuesta, documentación, vencimientos, verificación y cierre desde una sola ficha.</p></div>
<span className={`status-badge large ${workflow.overdue ? 'observed' : workflow.stage === 'CLOSED' ? 'active' : 'pending'}`}>{workflow.label}</span>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<div className="finding-case-progress" aria-label="Etapas del seguimiento">
<div className="done"><span>1</span><strong>Hallazgo</strong><small>Registrado</small></div>
<i></i>
<div className={hasResponse ? 'done' : 'current'}><span>2</span><strong>Empresa</strong><small>{hasResponse ? 'Respuesta recibida' : 'Esperando respuesta'}</small></div>
<i></i>
<div className={verificationDone ? 'done' : hasResponse ? 'current' : 'pending'}><span>3</span><strong>Verificación</strong><small>{verificationDone ? 'Resultado registrado' : finding.verificationVisit ? 'Visita planificada' : finding.nextControlOn ? 'Fecha definida' : 'Pendiente'}</small></div>
<i></i>
<div className={closed ? 'done' : verificationReadyToClose ? 'current' : 'pending'}><span>4</span><strong>Cierre</strong><small>{closed ? 'Finalizado' : verificationReadyToClose ? 'Listo para cerrar' : 'Pendiente'}</small></div>
</div>
{!closed && <div className={`finding-recommended-action ${workflow.overdue ? 'overdue' : ''}`}>
<div><span className="eyebrow">ACCIÓN RECOMENDADA</span><strong>{workflow.label}</strong><p>{workflow.action}{workflow.dueOn ? ` Fecha: ${formatDateOnly(workflow.dueOn)}.` : ''}</p></div>
<div className="form-actions">
{(workflow.stage === 'WAITING_COMPANY' || workflow.stage === 'COMPANY_OVERDUE') && <a className="button primary" href="#respuesta-empresa">Registrar respuesta</a>}
{(workflow.stage === 'DEFINE_VERIFICATION' || workflow.stage === 'RESCHEDULE_VERIFICATION') && <a className="button primary" href="#seguimiento-operativo">Definir fecha</a>}
{workflow.stage === 'PLAN_VERIFICATION' && <Link className="button primary" to="/hallazgos/planificacion">Planificar visita <Icon name="chevron" /></Link>}
{workflow.stage === 'VERIFICATION_PLANNED' && finding.verificationVisit && <Link className="button primary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir verificación <Icon name="chevron" /></Link>}
{workflow.stage === 'READY_TO_CLOSE' && <a className="button primary" href="#cierre-hallazgo">Revisar y cerrar</a>}
</div>
</div>}
<div className="finding-office-sections">
<section id="respuesta-empresa" className={`finding-office-step ${hasResponse ? 'complete' : ''}`}>
<div className="subsection-heading"><div><span className="eyebrow">1 · ADMINISTRATIVO</span><h3>Respuesta de la empresa</h3></div><span className={`status-badge ${hasResponse ? 'active' : workflow.stage === 'COMPANY_OVERDUE' ? 'observed' : 'pending'}`}>{hasResponse ? 'Recibida' : workflow.stage === 'COMPANY_OVERDUE' ? 'Vencida' : 'Pendiente'}</span></div>
{!hasResponse && canFollowUp && <form className="finding-office-form finding-deadline-form" onSubmit={saveResponseDeadline}>
<div className="form-grid">
<label className="field"><span>Inicio del plazo de respuesta</span><SearchableSelect value={responseDueBasis} onChange={(event) => setResponseDueBasis(event.target.value as 'FINDING_DATE' | 'REPORT_NOTIFICATION' | '')}><option value="">Seleccionar</option><option value="FINDING_DATE">Desde el día del hallazgo</option><option value="REPORT_NOTIFICATION">Desde la notificación del informe</option></SearchableSelect></label>
<label className="field"><span>Cantidad de días</span><input type="number" min="0" max="3650" value={responseDueDays} onChange={(event) => setResponseDueDays(event.target.value)} placeholder="Ej.: 5" /></label>
{responseDueBasis === 'REPORT_NOTIFICATION' && <label className="field"><span>Informe notificado el</span><input type="date" value={reportNotifiedOn} onChange={(event) => setReportNotifiedOn(event.target.value)} /></label>}
</div>
<p className="field-help">DH guarda la regla, la fecha base y el vencimiento calculado. Para casos críticos puede computarse desde el hallazgo; para los demás, desde la notificación del informe.</p>
<div className="form-actions"><button className="button secondary" disabled={busy || !responseDueBasis || responseDueDays === '' || (responseDueBasis === 'REPORT_NOTIFICATION' && !reportNotifiedOn)}><Icon name="calendar" />Calcular vencimiento</button></div>
</form>}
<div className="finding-office-deadline-row"><div><small>Vencimiento empresa</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong>{finding.responseDueBasis && <span>{finding.responseDueBasis === 'FINDING_DATE' ? 'Desde hallazgo' : 'Desde notificación'} · {finding.responseDueDays ?? 0} día{finding.responseDueDays === 1 ? '' : 's'} · base {formatDateOnly(finding.responseDueBaseOn)}</span>}</div>{hasResponse && <div><small>Respuesta recibida</small><strong>{formatDateOnly(finding.companyResponseReceivedOn)}</strong></div>}</div>
{!closed && canFollowUp && <form className="finding-office-form" onSubmit={registerResponse}>
<label className="field"><span>{hasResponse ? 'Nueva presentación / ampliación de la empresa' : 'Respuesta o descargo recibido'}</span><textarea required rows={5} maxLength={20000} value={responseText} onChange={(event) => setResponseText(event.target.value)} placeholder={hasResponse ? 'Registrar sólo la nueva presentación; la respuesta original no se modifica…' : 'Resumir la presentación, acción correctiva o descargo recibido…'} /></label>
<div className="form-grid finding-response-grid">
<label className="field"><span>Recibida el</span><input type="datetime-local" required value={responseOccurredAt} onChange={(event) => setResponseOccurredAt(event.target.value)} /></label>
<label className="field"><span>Medio</span><SearchableSelect value={responseChannel} onChange={(event) => setResponseChannel(event.target.value as InspectionFindingCommunication['channel'])}><option value="EMAIL">Email</option><option value="LETTER">Nota formal</option><option value="IN_PERSON">Presencial</option><option value="PHONE">Teléfono</option><option value="SYSTEM">Sistema</option><option value="OTHER">Otro medio</option></SearchableSelect></label>
<label className="field"><span>Fecha informada por la empresa <em>opcional</em></span><input type="date" value={committedOn} onChange={(event) => setCommittedOn(event.target.value)} /></label>
<label className="field"><span>Fecha de verificación <em>opcional</em></span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label>
</div>
{canCreateCommunications && <div className="form-grid"><label className="field"><span>Contacto <em>opcional</em></span><input maxLength={200} value={responseContactName} onChange={(event) => setResponseContactName(event.target.value)} /></label><label className="field"><span>Email de contacto <em>opcional</em></span><input type="email" maxLength={320} value={responseContactEmail} onChange={(event) => setResponseContactEmail(event.target.value)} /></label></div>}
{canCreateCommunications && canCreateEvidence && <label className="field"><span>Documento presentado por la empresa <em>opcional</em></span><input ref={responseFileRef} type="file" accept="application/pdf" onChange={(event) => setResponseFile(event.target.files?.[0] ?? null)} /><small className="field-help">Si adjuntás un PDF, queda vinculado a la respuesta recibida y protegido con SHA-256.</small></label>}
<p className="field-help">Registrar la respuesta cumple el vencimiento administrativo. La fecha informada por la empresa es sólo un compromiso declarado y la fecha de verificación sigue siendo una decisión operativa de DH.</p>
<div className="form-actions"><button className="button primary" disabled={busy || !responseText.trim() || !responseOccurredAt}><Icon name="check" />{busy ? 'Registrando…' : hasResponse ? 'Agregar presentación' : 'Registrar respuesta'}</button></div>
</form>}
{hasResponse && <div className="finding-response-record">
<div><small>Primera respuesta registrada · inmutable</small><p>{finding.companyResponse}</p></div>
<div><small>Fecha informada por la empresa</small><strong>{formatDateOnly(finding.companyCommittedCorrectionOn)}</strong></div>
</div>}
</section>
<section id="seguimiento-operativo" className={`finding-office-step ${finding.nextControlOn || verificationDone ? 'complete' : ''}`}>
<div className="subsection-heading"><div><span className="eyebrow">2 · OPERATIVO</span><h3>Verificación del hallazgo</h3></div><span className={`status-badge ${verificationDone ? 'active' : finding.nextControlOn ? 'pending' : 'draft'}`}>{verificationDone ? 'Verificado' : finding.nextControlOn ? 'Fecha definida' : 'Sin fecha'}</span></div>
{hasResponse && canFollowUp && !closed && <form className="finding-office-form" onSubmit={saveOperationalFollowUp}>
<div className="form-grid finding-follow-up-dates">
<label className="field"><span>Fecha informada por la empresa</span><input type="date" value={committedOn} onChange={(event) => setCommittedOn(event.target.value)} /></label>
<label className="field"><span>Fecha de verificación / resolución</span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label>
</div>
<p className="field-help">El vencimiento administrativo original permanece intacto. Esta segunda fecha alimenta la planificación de verificaciones e inspecciones.</p>
<div className="form-actions"><button className="button secondary" disabled={busy}><Icon name="check" />Guardar seguimiento</button>{nextControlOn && !finding.verificationVisit && finding.latestVerification?.outcome !== 'RESOLVED' && <Link className="button primary" to="/hallazgos/planificacion">Planificar verificación <Icon name="chevron" /></Link>}{finding.verificationVisit && <Link className="button secondary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir visita <Icon name="chevron" /></Link>}</div>
</form>}
{!hasResponse && <div className="inline-empty">La planificación operativa se habilita cuando se registra la respuesta de la empresa.</div>}
{finding.latestVerification?.outcome && <div className="finding-verification-record"><small>Último resultado de campo</small><strong>{verificationOutcomeLabel(finding.latestVerification.outcome)}</strong><span>{formatDate(finding.latestVerification.verifiedAt)} · {finding.latestVerification.visitCode}</span><p>{finding.latestVerification.resultNotes || 'Sin observaciones adicionales.'}</p></div>}
{verificationHistory.length > 0 && <div className="finding-verification-history">
<div className="finding-verification-history-heading"><div><small>TRAZABILIDAD APPEND-ONLY</small><strong>Historial completo de verificaciones</strong></div><span>{verificationHistory.length} evento{verificationHistory.length === 1 ? '' : 's'}</span></div>
<div className="finding-verification-timeline">{verificationHistory.map((event) => <div className="finding-verification-event" key={event.id}>
<div className="finding-verification-event-dot" />
<div className="finding-verification-event-body">
<div className="finding-verification-event-title"><strong>{verificationEventLabel(event.eventType)}</strong><span>{formatDate(event.occurredAt)}</span></div>
{event.outcome && <span className="status-badge pending">{verificationOutcomeLabel(event.outcome)}</span>}
{event.eventType.startsWith('CONTROL_DATE_') && (event.previousControlOn || event.nextControlOn) && <div className="finding-verification-date-change"><small>Fecha anterior</small><strong>{formatDateOnly(event.previousControlOn)}</strong><i></i><small>Nueva fecha</small><strong>{formatDateOnly(event.nextControlOn)}</strong></div>}
{(event.eventType === 'VISIT_PLANNED' || event.eventType === 'RESULT_RECORDED') && event.targetControlOn && <div className="finding-verification-target-date"><small>{event.eventType === 'VISIT_PLANNED' ? 'Control previsto' : 'Fecha que se verificó'}</small><strong>{formatDateOnly(event.targetControlOn)}</strong></div>}
{event.verificationVisit && <Link className="finding-verification-visit-link" to={`/inspecciones/${event.verificationVisit.id}`}><span>{event.verificationVisit.code}</span><Icon name="chevron" /></Link>}
{event.notes && <p>{event.notes}</p>}
<div className="finding-verification-event-meta"><span>{event.actorUsername || 'Sistema'}</span>{event.evidenceCount > 0 && <span>{event.evidenceCount} evidencia{event.evidenceCount === 1 ? '' : 's'}</span>}</div>
</div>
</div>)}</div>
</div>}
</section>
<section id="cierre-hallazgo" className={`finding-office-step ${closed ? 'complete' : ''}`}>
<div className="subsection-heading"><div><span className="eyebrow">3 · CIERRE</span><h3>{closed ? 'Hallazgo cerrado' : verificationReadyToClose ? 'Listo para cierre' : 'Decisión administrativa'}</h3></div><span className={`status-badge ${closed ? 'active' : verificationReadyToClose ? 'active' : 'draft'}`}>{closed ? 'Cerrado' : verificationReadyToClose ? 'Revisar' : 'Pendiente'}</span></div>
{closed ? <div className="finding-response-record"><div><small>Cerrado el</small><strong>{formatDate(finding.closedAt)}</strong><p>{finding.closureNotes}</p></div></div> : canClose ? <form className="finding-office-form" onSubmit={closeFinding}>
{verificationReadyToClose && <Alert type="success">La verificación de campo indicó que el hallazgo está solucionado. Revisá fotos y documentos antes de registrar el cierre.</Alert>}
<label className="field"><span>Conclusión de cierre</span><textarea rows={4} maxLength={5000} value={closureNotes} onChange={(event) => setClosureNotes(event.target.value)} placeholder="Indicar qué se revisó, qué respaldo existe y por qué corresponde cerrar el hallazgo…" /></label>
<p className="field-help">{verificationActive ? 'Existe una visita de verificación activa. Debe finalizar antes del cierre administrativo.' : 'El cierre queda versionado y auditado. Los documentos y fotos continúan disponibles en el expediente.'}</p>
<div className="form-actions"><a className="button text" href="#documentos-hallazgo">Revisar documentos</a><button className="button danger-outline" disabled={busy || verificationActive || closureNotes.trim().length < 3}><Icon name="check" />Cerrar hallazgo</button></div>
</form> : <div className="inline-empty">El cierre está reservado a Inspector, Supervisor o Director.</div>}
</section>
</div>
{hasResponse && <div className="finding-office-footnote"><Icon name="history" /><span>Respuesta recibida {formatDateOnly(finding.companyResponseReceivedOn)} · Las presentaciones sucesivas, medios y documentos quedan en la trazabilidad sin modificar la respuesta original. {finding.companyCommittedCorrectionOn ? `La empresa informó ${formatDateOnly(finding.companyCommittedCorrectionOn)}.` : 'La empresa no informó una fecha de corrección.'}</span></div>}
</article>;
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listInspectionActs } from '../../lib/api';
import type { InspectionActListItem, InspectionVisit } from '../../lib/api';
import { formatDate } from '../../lib/format';
import { inspectionActStatusClass, inspectionActStatusLabel } from './inspectionActPresentation';
export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
const { hasPermission } = useAuth();
const canRead = hasPermission('inspection_acts.read');
const [acts, setActs] = useState<InspectionActListItem[]>([]);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
useEffect(() => {
if (!canRead) return;
setLoading(true);
setError('');
listInspectionActs(visit.id, { pageSize: 100 })
.then((response) => setActs(response.data))
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [canRead, visit.id]);
if (!canRead) return null;
return <section className="inspection-acts-panel">
<div className="panel-heading">
<div><span className="eyebrow">ACTA DE LA VISITA</span><h2>Acta única</h2><p className="section-copy">Cada visita genera una única acta, con numeración anual oficial y versiones inmutables.</p></div>
<span className="status-badge pending">Sólo lectura</span>
</div>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>El acta se crea en la APK.</strong> El dashboard permite consultar el acta, sus hallazgos, firmas e informe después de la sincronización.</p></div>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0
? <EmptyState title="Sin acta sincronizada" text={visit.status === 'IN_PROGRESS' ? 'El inspector debe crearla desde la APK.' : 'Aparecerá aquí cuando el inspector la genere desde la APK.'} />
: <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Acta de inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div></div>}
</section>;
}
@@ -0,0 +1,85 @@
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getInspectionClosure, getInspectionSignatureBlob } from '../../lib/api';
import type { InspectionAct, InspectionActSignature, InspectionClosure } from '../../lib/api';
import { formatDate } from '../../lib/format';
function signatureStatusLabel(value: InspectionActSignature['status']): string {
if (value === 'SIGNED') return 'Firmada';
if (value === 'REFUSED') return 'Se negó a firmar';
return 'Ausente';
}
function signerTypeLabel(value: InspectionActSignature['signerType']): string {
return value === 'INSPECTOR' ? 'Inspector/a' : 'Responsable de la empresa';
}
export function InspectionClosurePanel({ act }: { act: InspectionAct }) {
const { hasPermission } = useAuth();
const canRead = hasPermission('inspection_closure.read');
const [closure, setClosure] = useState<InspectionClosure | null>(null);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
useEffect(() => {
if (!canRead) return;
setLoading(true);
getInspectionClosure(act.id)
.then(setClosure)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [act.id, canRead]);
const inspectorSignatures = useMemo(
() => closure?.signatures.filter((item) => item.signerType === 'INSPECTOR') ?? [],
[closure],
);
const companyOutcome = useMemo(
() => closure?.signatures.find((item) => item.signerType === 'COMPANY_RESPONSIBLE') ?? null,
[closure],
);
const viewSignature = async (signature: InspectionActSignature) => {
const tab = window.open('about:blank', '_blank');
if (tab) tab.opener = null;
try {
const blob = await getInspectionSignatureBlob(signature.id);
const url = URL.createObjectURL(blob);
if (tab) tab.location.href = url;
else window.open(url, '_blank', 'noopener,noreferrer');
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (requestError) {
tab?.close();
setError(errorMessage(requestError));
}
};
if (!canRead) return null;
if (loading || !closure) return <section className="panel inspection-closure-panel"><LoadingBlock label="Cargando cierre del acta…" /></section>;
const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome);
return <section className="panel inspection-closure-panel">
<div className="panel-heading"><div><span className="eyebrow">CIERRE DE LA INSPECCIÓN · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">La preparación, las firmas y el cierre son operaciones exclusivas de la APK para inspectores.</p></div><span className={`status-badge large ${act.status === 'CLOSED' ? 'active' : act.status === 'READY' ? 'observed' : 'pending'}`}>{act.status === 'CLOSED' ? 'Cierre sellado' : act.status === 'READY' ? 'Esperando firmas' : 'Pendiente en APK'}</span></div>
{error && <Alert>{error}</Alert>}
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Operación exclusiva en APK.</strong> El dashboard muestra las constancias sincronizadas, pero no permite identificar al responsable, firmar, reabrir ni cerrar el acta.</p></div>
<div className="closure-step-grid">
<div className={closure.responsible ? 'complete' : ''}><span>1</span><strong>Responsable</strong><small>{closure.responsible ? 'Registrado' : 'Pendiente'}</small></div>
<div className={closure.closure?.isCurrent ? 'complete' : ''}><span>2</span><strong>Congelado</strong><small>{closure.closure?.isCurrent ? 'SHA-256 listo' : 'Pendiente'}</small></div>
<div className={constanciasCompletas ? 'complete' : ''}><span>3</span><strong>Constancias</strong><small>{inspectorSignatures.length} inspector · {companyOutcome ? 'empresa OK' : 'empresa pendiente'}</small></div>
<div className={act.status === 'CLOSED' ? 'complete' : ''}><span>4</span><strong>Cierre</strong><small>{act.status === 'CLOSED' ? 'Sellado' : 'Pendiente'}</small></div>
</div>
{closure.responsible && <div className="responsible-summary"><div><small>Situación</small><strong>{closure.responsible.attendanceStatus === 'PRESENT' ? 'Presente' : 'Ausente'}</strong></div><div><small>Responsable</small><strong>{closure.responsible.fullName ?? 'No estuvo presente'}</strong></div><div><small>Documento / cargo</small><strong>{closure.responsible.documentNumber ? `${closure.responsible.documentType} ${closure.responsible.documentNumber}` : 'No informado'}{closure.responsible.position ? ` · ${closure.responsible.position}` : ''}</strong></div><div><small>Contacto</small><strong>{closure.responsible.email ?? closure.responsible.phone ?? 'No informado'}</strong></div></div>}
{closure.closure?.isCurrent && <div className="closure-hash-card"><div><span className="eyebrow">CONTENIDO CONGELADO</span><strong>{closure.closure.schemaVersion}</strong><small>Preparado {formatDate(closure.closure.preparedAt)}</small></div><code>{closure.closure.preparedSha256}</code></div>}
{closure.signatures.length > 0 && <div className="signature-records">{closure.signatures.map((signature) => <article key={signature.id}><div><span className={`status-badge ${signature.status === 'SIGNED' ? 'active' : 'observed'}`}>{signatureStatusLabel(signature.status)}</span><strong>{signature.signerName}</strong><small>{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}</small></div><code title={signature.signaturePayloadSha256}>{signature.signaturePayloadSha256}</code>{signature.status === 'SIGNED' ? <button type="button" className="button secondary" onClick={() => viewSignature(signature)}>Ver firma</button> : <p>{signature.reason}</p>}</article>)}</div>}
{act.status === 'CLOSED' && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA Y VISITA CERRADAS</span><strong>{formatDate(closure.closure.serverClosedAt)}</strong><p>Los hallazgos continúan abiertos para la respuesta de la empresa y el próximo control.</p><code>{closure.closure.finalSha256}</code></div></div>}
</section>;
}
@@ -0,0 +1,330 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
createInspectionFindingCommunication,
getInspectionFindingEvidenceBlob,
listInspectionFindingCommunications,
listInspectionFindingEvidence,
uploadInspectionFindingEvidence,
} from '../../lib/api';
import type {
InspectionEvidenceKind,
InspectionEvidencePurpose,
InspectionFinding,
InspectionFindingCommunication,
InspectionFindingEvidence,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
function localDateTime(value: string | number | Date): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function fileSize(value: number): string {
return value >= 1024 * 1024
? `${(value / (1024 * 1024)).toFixed(1)} MB`
: `${Math.max(1, Math.round(value / 1024))} KB`;
}
const purposeLabels: Record<InspectionEvidencePurpose, string> = {
OBSERVATION: 'Evidencia del hallazgo',
VERIFICATION: 'Evidencia de verificación',
COMPANY_RESPONSE: 'PDF de respuesta de la empresa',
COMMUNICATION_ATTACHMENT: 'Adjunto de comunicación',
OTHER_DOCUMENT: 'Otro documento',
};
const typeLabels: Record<InspectionFindingCommunication['type'], string> = {
COMPANY_RESPONSE: 'Respuesta de la empresa',
AUTHORITY_NOTICE: 'Notificación del organismo',
FOLLOW_UP: 'Seguimiento',
OTHER: 'Otra comunicación',
};
const channelLabels: Record<InspectionFindingCommunication['channel'], string> = {
EMAIL: 'Email',
IN_PERSON: 'Presencial',
PHONE: 'Teléfono',
LETTER: 'Nota formal',
SYSTEM: 'Sistema',
OTHER: 'Otro medio',
};
function EvidencePhotoPreview({ evidence }: { evidence: InspectionFindingEvidence }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getInspectionFindingEvidenceBlob(evidence.id)
.then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
})
.catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [evidence.id]);
return url
? <img src={url} alt={evidence.title || evidence.originalName} />
: <div className="media-preview-loading"><span className="spinner" /></div>;
}
export function InspectionEvidencePanel({
finding,
canReadEvidence,
canCreateEvidence,
canCreateFieldEvidence,
canReadCommunications,
canCreateCommunications,
refreshKey = 0,
managedCompanyResponse = false,
}: {
finding: InspectionFinding;
canReadEvidence: boolean;
canCreateEvidence: boolean;
canCreateFieldEvidence: boolean;
canReadCommunications: boolean;
canCreateCommunications: boolean;
refreshKey?: number;
managedCompanyResponse?: boolean;
}) {
const fileRef = useRef<HTMLInputElement | null>(null);
const [evidence, setEvidence] = useState<InspectionFindingEvidence[]>([]);
const [communications, setCommunications] = useState<InspectionFindingCommunication[]>([]);
const [loading, setLoading] = useState(canReadEvidence || canReadCommunications);
const [busy, setBusy] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [kind, setKind] = useState<InspectionEvidenceKind>(
canCreateFieldEvidence ? 'PHOTO' : 'DOCUMENT',
);
const [purpose, setPurpose] = useState<InspectionEvidencePurpose>(
canCreateFieldEvidence ? 'OBSERVATION' : 'OTHER_DOCUMENT',
);
const [file, setFile] = useState<File | null>(null);
const [communicationId, setCommunicationId] = useState('');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [capturedAt, setCapturedAt] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [accuracyM, setAccuracyM] = useState('');
const [communicationType, setCommunicationType] = useState<InspectionFindingCommunication['type']>(managedCompanyResponse ? 'FOLLOW_UP' : 'COMPANY_RESPONSE');
const [direction, setDirection] = useState<InspectionFindingCommunication['direction']>('INBOUND');
const [channel, setChannel] = useState<InspectionFindingCommunication['channel']>('EMAIL');
const [occurredAt, setOccurredAt] = useState(localDateTime(new Date()));
const [subject, setSubject] = useState('');
const [details, setDetails] = useState('');
const [contactName, setContactName] = useState('');
const [contactEmail, setContactEmail] = useState('');
const load = async () => {
const [evidenceValue, communicationValue] = await Promise.all([
canReadEvidence ? listInspectionFindingEvidence(finding.id) : Promise.resolve([]),
canReadCommunications
? listInspectionFindingCommunications(finding.id)
: Promise.resolve([]),
]);
setEvidence(evidenceValue);
setCommunications(communicationValue);
};
useEffect(() => {
if (!canReadEvidence && !canReadCommunications) return;
setLoading(true);
load()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [finding.id, canReadEvidence, canReadCommunications, refreshKey]);
const resetEvidenceForm = () => {
setFile(null); setCommunicationId(''); setTitle(''); setDescription('');
setCapturedAt(''); setLatitude(''); setLongitude(''); setAccuracyM('');
if (fileRef.current) fileRef.current.value = '';
};
const chooseKind = (value: InspectionEvidenceKind) => {
setKind(value);
setPurpose(value === 'PHOTO' ? 'OBSERVATION' : 'OTHER_DOCUMENT');
setCommunicationId(''); setFile(null);
if (fileRef.current) fileRef.current.value = '';
};
const choosePurpose = (value: InspectionEvidencePurpose) => {
setPurpose(value);
if (value === 'COMPANY_RESPONSE') {
const response = communications.find((item) => item.type === 'COMPANY_RESPONSE');
setCommunicationId(response?.id ?? '');
} else if (value !== 'COMMUNICATION_ATTACHMENT') {
setCommunicationId('');
}
};
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setAccuracyM(position.coords.accuracy.toFixed(3));
if (!capturedAt) setCapturedAt(localDateTime(position.timestamp));
setLocating(false);
},
() => {
setError('No fue posible obtener la ubicación del dispositivo.');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
);
};
const upload = async (event: FormEvent) => {
event.preventDefault();
if (!file) return;
setBusy(true); setError(''); setSuccess('');
try {
await uploadInspectionFindingEvidence(finding.id, {
file,
kind,
purpose,
communicationId: communicationId || undefined,
title: title.trim() || undefined,
description: description.trim() || undefined,
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : undefined,
latitude: latitude ? Number(latitude) : undefined,
longitude: longitude ? Number(longitude) : undefined,
accuracyM: accuracyM ? Number(accuracyM) : undefined,
deviceLabel: navigator.userAgent.slice(0, 200),
});
await load();
resetEvidenceForm();
setSuccess('Evidencia incorporada y sellada con SHA-256.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const createCommunication = async (event: FormEvent) => {
event.preventDefault();
setBusy(true); setError(''); setSuccess('');
try {
const created = await createInspectionFindingCommunication(finding.id, {
type: communicationType,
direction: communicationType === 'COMPANY_RESPONSE' ? 'INBOUND' : direction,
channel,
occurredAt: new Date(occurredAt).toISOString(),
subject,
details: details.trim() || null,
contactName: contactName.trim() || null,
contactEmail: contactEmail.trim() || null,
});
await load();
setCommunicationId(created.id);
setSubject(''); setDetails(''); setContactName(''); setContactEmail('');
setOccurredAt(localDateTime(new Date()));
setSuccess(
created.type === 'COMPANY_RESPONSE'
? 'Respuesta registrada. Ya podés vincular y subir el PDF recibido.'
: 'Comunicación incorporada al historial inmutable.',
);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const download = async (item: InspectionFindingEvidence) => {
setError('');
try {
const blob = await getInspectionFindingEvidenceBlob(item.id, true);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = item.originalName;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (requestError) {
setError(errorMessage(requestError));
}
};
const preview = async (item: InspectionFindingEvidence) => {
setError('');
try {
const blob = await getInspectionFindingEvidenceBlob(item.id);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener,noreferrer');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (requestError) {
setError(errorMessage(requestError));
}
};
if (!canReadEvidence && !canReadCommunications) return null;
const responseCommunications = communications.filter((item) => item.type === 'COMPANY_RESPONSE');
const requiresCommunication = purpose === 'COMPANY_RESPONSE'
|| purpose === 'COMMUNICATION_ATTACHMENT';
const canUploadSelected = kind === 'PHOTO' ? canCreateFieldEvidence : canCreateEvidence;
return <details className="finding-evidence-panel">
<summary>
<span><strong>{managedCompanyResponse ? 'Documentos y trazabilidad' : 'Evidencias y comunicaciones'}</strong><small>{managedCompanyResponse ? 'Adjuntos adicionales, comunicaciones y respaldo histórico' : 'Fotos, GPS, PDFs y trazabilidad protegida'}</small></span>
<span className="count-pill">{evidence.length + communications.length}</span>
</summary>
<div className="finding-evidence-body">
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{loading ? <LoadingBlock label="Cargando evidencias…" /> : <>
{canCreateCommunications && finding.status === 'OPEN' && <form className="communication-form" onSubmit={createCommunication}>
<div className="subsection-heading"><div><span className="eyebrow">NUEVO REGISTRO</span><h4>{managedCompanyResponse ? 'Comunicación adicional' : 'Comunicación'}</h4></div></div>
<div className="form-grid">
<label className="field"><span>Tipo</span><SearchableSelect value={communicationType} onChange={(event) => { const value = event.target.value as InspectionFindingCommunication['type']; setCommunicationType(value); if (value === 'COMPANY_RESPONSE') setDirection('INBOUND'); }}>{!managedCompanyResponse && <option value="COMPANY_RESPONSE">Respuesta de la empresa</option>}<option value="AUTHORITY_NOTICE">Notificación del organismo</option><option value="FOLLOW_UP">Seguimiento</option><option value="OTHER">Otra comunicación</option></SearchableSelect></label>
<label className="field"><span>Sentido</span><SearchableSelect value={communicationType === 'COMPANY_RESPONSE' ? 'INBOUND' : direction} disabled={communicationType === 'COMPANY_RESPONSE'} onChange={(event) => setDirection(event.target.value as InspectionFindingCommunication['direction'])}><option value="INBOUND">Recibida</option><option value="OUTBOUND">Enviada</option><option value="INTERNAL">Interna</option></SearchableSelect></label>
<label className="field"><span>Medio</span><SearchableSelect value={channel} onChange={(event) => setChannel(event.target.value as InspectionFindingCommunication['channel'])}>{Object.entries(channelLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</SearchableSelect></label>
<label className="field"><span>Fecha y hora</span><input type="datetime-local" required value={occurredAt} onChange={(event) => setOccurredAt(event.target.value)} /></label>
</div>
<label className="field"><span>Asunto</span><input required maxLength={250} value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="Ej.: Respuesta al hallazgo y compromiso de corrección" /></label>
<label className="field"><span>Detalle <em>opcional</em></span><textarea rows={3} maxLength={10000} value={details} onChange={(event) => setDetails(event.target.value)} /></label>
<div className="form-grid"><label className="field"><span>Contacto <em>opcional</em></span><input maxLength={200} value={contactName} onChange={(event) => setContactName(event.target.value)} /></label><label className="field"><span>Email <em>opcional</em></span><input type="email" maxLength={320} value={contactEmail} onChange={(event) => setContactEmail(event.target.value)} /></label></div>
<div className="form-actions"><button className="button secondary" disabled={busy || !subject.trim() || !occurredAt}><Icon name="plus" />Registrar comunicación</button></div>
</form>}
{canCreateEvidence && finding.status === 'OPEN' && <form className="evidence-upload-form" onSubmit={upload}>
<div className="subsection-heading"><div><span className="eyebrow">ARCHIVO PROTEGIDO</span><h4>Incorporar evidencia</h4></div></div>
<div className="form-grid">
<label className="field"><span>Clase</span><SearchableSelect value={kind} onChange={(event) => chooseKind(event.target.value as InspectionEvidenceKind)}><option value="PHOTO" disabled={!canCreateFieldEvidence}>Fotografía de campo</option><option value="DOCUMENT">Documento PDF</option></SearchableSelect></label>
<label className="field"><span>Finalidad</span><SearchableSelect value={purpose} onChange={(event) => choosePurpose(event.target.value as InspectionEvidencePurpose)}>{kind === 'PHOTO' ? <option value="OBSERVATION">Evidencia del hallazgo</option> : <><option value="COMPANY_RESPONSE">PDF de respuesta de la empresa</option><option value="COMMUNICATION_ATTACHMENT">Adjunto de comunicación</option>{canCreateFieldEvidence && <option value="OBSERVATION">Documento de campo</option>}<option value="OTHER_DOCUMENT">Otro documento</option></>}</SearchableSelect></label>
</div>
{requiresCommunication && <label className="field"><span>Comunicación vinculada</span><SearchableSelect required value={communicationId} onChange={(event) => setCommunicationId(event.target.value)}><option value="">Seleccionar</option>{(purpose === 'COMPANY_RESPONSE' ? responseCommunications : communications).map((item) => <option key={item.id} value={item.id}>{formatDate(item.occurredAt)} · {item.subject}</option>)}</SearchableSelect><small className="field-help">{purpose === 'COMPANY_RESPONSE' && responseCommunications.length === 0 ? 'Primero registrá arriba la respuesta recibida de la empresa.' : 'El vínculo queda preservado y no puede modificarse.'}</small></label>}
<div className="form-grid"><label className="field"><span>Archivo</span><input ref={fileRef} type="file" accept={kind === 'PHOTO' ? 'image/jpeg,image/png,image/webp' : 'application/pdf'} capture={kind === 'PHOTO' ? 'environment' : undefined} onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label><label className="field"><span>Fecha de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} /></label></div>
<div className="form-grid"><label className="field"><span>Título <em>opcional</em></span><input maxLength={200} value={title} onChange={(event) => setTitle(event.target.value)} /></label><label className="field"><span>Descripción <em>opcional</em></span><input maxLength={4000} value={description} onChange={(event) => setDescription(event.target.value)} /></label></div>
{kind === 'PHOTO' && <div className="media-location-row"><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Capturar GPS actual'}</button>{latitude && longitude ? <span>{latitude}, {longitude}{accuracyM ? ` · ±${accuracyM} m` : ''}</span> : <span>Sin coordenadas</span>}<button type="button" className="button text" onClick={() => { setLatitude(''); setLongitude(''); setAccuracyM(''); }}>Limpiar</button></div>}
<p className="field-help">El original, autor, dispositivo, fecha, ubicación y hash SHA-256 quedan inmutables. Máximo 15 MB.</p>
<div className="form-actions"><button className="button primary" disabled={busy || !file || !canUploadSelected || (requiresCommunication && !communicationId)}><Icon name="plus" />{busy ? 'Subiendo…' : 'Incorporar evidencia'}</button></div>
</form>}
<section className="evidence-list-section"><div className="subsection-heading"><div><span className="eyebrow">ARCHIVOS</span><h4>Evidencias protegidas</h4></div><span>{evidence.length}</span></div>{evidence.length === 0 ? <div className="inline-empty">Este hallazgo todavía no tiene fotos ni documentos.</div> : <div className="finding-evidence-grid">{evidence.map((item) => <article className={`finding-evidence-card ${item.kind.toLowerCase()}`} key={item.id}>{item.kind === 'PHOTO' ? <div className="finding-evidence-preview"><EvidencePhotoPreview evidence={item} /></div> : <span className="asset-symbol"><Icon name="clipboard" /></span>}<div className="finding-evidence-copy"><small>{purposeLabels[item.purpose]}</small><strong>{item.title || item.originalName}</strong><span>{fileSize(item.sizeBytes)} · {formatDate(item.capturedAt || item.createdAt)}</span>{item.description && <p>{item.description}</p>}{item.communication && <span>Vinculado a: {item.communication.subject}</span>}{item.latitude != null && item.longitude != null && <span>GPS {item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}{item.accuracyM != null ? ` · ±${item.accuracyM} m` : ''}</span>}<code>SHA-256 {item.sha256.slice(0, 16)}</code><span>Autor: {item.uploadedByUsername ?? 'sistema'} · {item.deviceLabel ?? item.source}</span></div><div className="media-card-actions"><button type="button" className="button text" onClick={() => preview(item)}>Ver</button><button type="button" className="button text" onClick={() => download(item)}>Descargar</button></div></article>)}</div>}</section>
<section className="communication-timeline"><div className="subsection-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h4>Historial de comunicaciones</h4></div><span>{communications.length}</span></div>{communications.length === 0 ? <div className="inline-empty">Todavía no hay comunicaciones registradas.</div> : <div className="communication-list">{communications.map((item) => <article key={item.id}><span className={`communication-direction ${item.direction.toLowerCase()}`}>{item.direction === 'INBOUND' ? 'Recibida' : item.direction === 'OUTBOUND' ? 'Enviada' : 'Interna'}</span><div><strong>{item.subject}</strong><small>{typeLabels[item.type]} · {channelLabels[item.channel]} · {formatDate(item.occurredAt)}</small>{item.details && <p>{item.details}</p>}{(item.contactName || item.contactEmail) && <span>{item.contactName}{item.contactName && item.contactEmail ? ' · ' : ''}{item.contactEmail}</span>}<span>{item.attachmentCount} adjunto{item.attachmentCount === 1 ? '' : 's'} · Registró {item.createdByUsername ?? 'sistema'}</span></div></article>)}</div>}</section>
</>}
</div>
</details>;
}
@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listInspectionFindings, updateInspectionFindingFollowUp } from '../../lib/api';
import type { InspectionAct, InspectionFinding } from '../../lib/api';
import { formatDateOnly } from '../../lib/format';
import { InspectionEvidencePanel } from './InspectionEvidencePanel';
function todayInput(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
function FindingCard({
finding,
canFollowUp,
canReadEvidence,
canCreateEvidence,
canReadCommunications,
canCreateCommunications,
onSaved,
}: {
finding: InspectionFinding;
canFollowUp: boolean;
canReadEvidence: boolean;
canCreateEvidence: boolean;
canReadCommunications: boolean;
canCreateCommunications: boolean;
onSaved: (value: InspectionFinding) => void;
}) {
const [companyResponse, setCompanyResponse] = useState(finding.companyResponse ?? '');
const [responseReceivedOn, setResponseReceivedOn] = useState(
finding.companyResponseReceivedOn ?? todayInput(),
);
const [committedOn, setCommittedOn] = useState(
finding.companyCommittedCorrectionOn ?? '',
);
const [nextControlOn, setNextControlOn] = useState(finding.nextControlOn ?? '');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
setCompanyResponse(finding.companyResponse ?? '');
setResponseReceivedOn(finding.companyResponseReceivedOn ?? todayInput());
setCommittedOn(finding.companyCommittedCorrectionOn ?? '');
setNextControlOn(finding.nextControlOn ?? '');
}, [finding]);
const saveFollowUp = async (event: FormEvent) => {
event.preventDefault();
setBusy(true); setError(''); setSuccess('');
try {
const hasResponse = Boolean(companyResponse.trim());
const updated = await updateInspectionFindingFollowUp(finding.id, {
companyResponse: hasResponse ? companyResponse : null,
companyResponseReceivedOn: hasResponse ? responseReceivedOn : null,
companyCommittedCorrectionOn: hasResponse && committedOn ? committedOn : null,
nextControlOn: nextControlOn || null,
});
onSaved(updated);
setSuccess('Seguimiento actualizado; el hallazgo continúa abierto hasta su verificación y cierre.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
return <article className="inspection-finding-card">
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.catalog?.categoryName ?? 'Hallazgo personalizado'} · {finding.asset.code} · {finding.asset.name}</p></div><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : 'Cerrado'}</span></div>
<p className="inspection-finding-description">{finding.description}</p>{finding.severity && <span className="tag">Gravedad {finding.severity}/10{finding.suggestedSeverity ? ` · sugerida ${finding.suggestedSeverity}/10` : ''}</span>}
<div className="inspection-finding-dates"><span><small>Vence respuesta empresa</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong></span><span><small>Fecha informada por empresa</small><strong>{formatDateOnly(finding.companyCommittedCorrectionOn)}</strong></span><span className={finding.nextControlOn && finding.nextControlOn < todayInput() ? 'overdue' : ''}><small>Verificación / resolución</small><strong>{formatDateOnly(finding.nextControlOn)}</strong></span><span><small>Versión</small><strong>v{finding.currentVersion}</strong></span></div>
{finding.legalBasis && <details className="finding-detail"><summary>Normativa aplicable</summary><p>{finding.legalBasis}</p>{finding.glossary && <small>{finding.glossary}</small>}</details>}
{finding.companyResponse && <div className="company-response-summary"><small>RESPUESTA RECIBIDA {formatDateOnly(finding.companyResponseReceivedOn)}</small><p>{finding.companyResponse}</p></div>}
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{canFollowUp && finding.status === 'OPEN' && <details className="finding-editor follow-up-editor" open={!finding.nextControlOn}><summary>Respuesta de la empresa y verificación</summary><form onSubmit={saveFollowUp}><label className="field"><span>Respuesta recibida de la empresa</span><textarea rows={4} value={companyResponse} maxLength={20000} onChange={(event) => setCompanyResponse(event.target.value)} placeholder="Compromiso, explicación o acción correctiva informada…" /></label><div className="form-grid finding-follow-up-dates"><label className="field"><span>Respuesta recibida el</span><input type="date" value={responseReceivedOn} disabled={!companyResponse.trim()} onChange={(event) => setResponseReceivedOn(event.target.value)} /></label><label className="field"><span>Fecha informada por la empresa</span><input type="date" value={committedOn} disabled={!companyResponse.trim()} onChange={(event) => setCommittedOn(event.target.value)} /></label><label className="field"><span>Fecha de verificación / resolución</span><input type="date" value={nextControlOn} onChange={(event) => setNextControlOn(event.target.value)} /></label></div><p className="field-help">El vencimiento de respuesta es administrativo. La fecha de verificación se usa para planificar el control posterior. Adjuntá la presentación en Evidencias y comunicaciones.</p><div className="form-actions"><button className="button primary" disabled={busy || (Boolean(companyResponse.trim()) && !responseReceivedOn)}><Icon name="check" />Guardar seguimiento</button></div></form></details>}
<InspectionEvidencePanel finding={finding} canReadEvidence={canReadEvidence} canCreateEvidence={canCreateEvidence} canCreateFieldEvidence={false} canReadCommunications={canReadCommunications} canCreateCommunications={canCreateCommunications} />
</article>;
}
export function InspectionFindingsPanel({ act }: { act: InspectionAct }) {
const { hasPermission } = useAuth();
const canRead = hasPermission('inspection_findings.read');
const [findings, setFindings] = useState<InspectionFinding[]>([]);
const [loading, setLoading] = useState(canRead);
const [error, setError] = useState('');
const canFollowUp = hasPermission('inspection_findings.follow_up');
const canReadEvidence = hasPermission('inspection_evidence.read');
const canCreateEvidence = hasPermission('inspection_evidence.create');
const canReadCommunications = hasPermission('inspection_communications.read');
const canCreateCommunications = hasPermission('inspection_communications.create');
useEffect(() => {
if (!canRead) return;
setLoading(true); setError('');
listInspectionFindings(act.id)
.then(setFindings)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [act.id, canRead]);
const replaceFinding = (value: InspectionFinding) => {
setFindings((current) => current.map((item) => item.id === value.id ? value : item));
};
if (!canRead) return null;
if (loading) return <LoadingBlock label="Cargando hallazgos…" />;
return <section className="inspection-findings-panel panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS DEL ACTA · SÓLO LECTURA DE CAMPO</span><h2>Múltiples hallazgos</h2><p className="section-copy">Permanecen abiertos hasta que una inspección posterior verifique la corrección.</p></div><span className="count-pill">{findings.filter((finding) => finding.status === 'OPEN').length} abiertos</span></div>
{error && <Alert>{error}</Alert>}
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Actas y hallazgos se registran desde la APK.</strong> En el dashboard se consultan y se carga únicamente el seguimiento posterior: respuesta de la empresa, documentación y fecha de verificación.</p></div>
<div className="inspection-finding-list">{findings.length === 0 ? <EmptyState title="Sin hallazgos sincronizados" text="Los hallazgos que registre el inspector en la APK aparecerán aquí." /> : findings.map((finding) => <FindingCard key={finding.id} finding={finding} canFollowUp={canFollowUp} canReadEvidence={canReadEvidence} canCreateEvidence={canCreateEvidence} canReadCommunications={canReadCommunications} canCreateCommunications={canCreateCommunications} onSaved={replaceFinding} />)}</div>
</section>;
}
@@ -0,0 +1,28 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { listInspectionAssignees } from '../../lib/api';
import type { InspectionPerson } from '../../lib/api';
export function OperationalFilters({
inspectorId,
dateFrom,
dateTo,
onChange,
}: {
inspectorId: string;
dateFrom: string;
dateTo: string;
onChange: (key: string, value: string) => void;
}) {
const [inspectors, setInspectors] = useState<InspectionPerson[]>([]);
useEffect(() => {
void listInspectionAssignees().then(setInspectors).catch(() => setInspectors([]));
}, []);
return <>
<label className="select-field"><span>Inspector</span><SearchableSelect value={inspectorId} onChange={(event) => onChange('inspectorId', event.target.value)}><option value="">Todos</option>{inspectors.map((item) => <option key={item.id} value={item.id}>{item.lastName}, {item.firstName}</option>)}</SearchableSelect></label>
<label className="select-field"><span>Desde</span><input type="date" value={dateFrom} onChange={(event) => onChange('dateFrom', event.target.value)} /></label>
<label className="select-field"><span>Hasta</span><input type="date" value={dateTo} onChange={(event) => onChange('dateTo', event.target.value)} /></label>
</>;
}
@@ -0,0 +1,101 @@
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
export interface SignaturePadHandle {
clear: () => void;
isEmpty: () => boolean;
toBlob: () => Promise<Blob>;
}
export const SignaturePad = forwardRef<SignaturePadHandle, { disabled?: boolean }>(
function SignaturePad({ disabled = false }, ref) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef(false);
const [empty, setEmpty] = useState(true);
const point = (event: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = event.currentTarget;
const bounds = canvas.getBoundingClientRect();
return {
x: (event.clientX - bounds.left) * (canvas.width / bounds.width),
y: (event.clientY - bounds.top) * (canvas.height / bounds.height),
};
};
const start = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (disabled) return;
const context = event.currentTarget.getContext('2d');
if (!context) return;
event.currentTarget.setPointerCapture(event.pointerId);
const current = point(event);
context.beginPath();
context.moveTo(current.x, current.y);
context.lineCap = 'round';
context.lineJoin = 'round';
context.lineWidth = 5;
context.strokeStyle = '#172033';
drawingRef.current = true;
};
const move = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current || disabled) return;
const context = event.currentTarget.getContext('2d');
if (!context) return;
const current = point(event);
context.lineTo(current.x, current.y);
context.stroke();
setEmpty(false);
};
const stop = (event: ReactPointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current) return;
drawingRef.current = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const clear = () => {
const canvas = canvasRef.current;
canvas?.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height);
setEmpty(true);
};
useImperativeHandle(ref, () => ({
clear,
isEmpty: () => empty,
toBlob: () => new Promise<Blob>((resolve, reject) => {
const canvas = canvasRef.current;
if (!canvas || empty) {
reject(new Error('La firma está vacía'));
return;
}
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('No se pudo generar la firma'));
}, 'image/png');
}),
}), [empty]);
return <div className={`signature-pad-shell ${disabled ? 'disabled' : ''}`}>
<canvas
ref={canvasRef}
width={1000}
height={300}
className="signature-pad"
aria-label="Área para firma manuscrita"
onPointerDown={start}
onPointerMove={move}
onPointerUp={stop}
onPointerCancel={stop}
/>
<div className="signature-pad-line" />
<div className="signature-pad-footer">
<span>{empty ? 'Firmá dentro del recuadro' : 'Firma capturada'}</span>
<button type="button" className="button text" onClick={clear} disabled={disabled || empty}>
Limpiar
</button>
</div>
</div>;
},
);
@@ -0,0 +1,33 @@
import type { InspectionActStatus, InspectionActVersionEvent } from '../../lib/api';
const statusLabels: Record<InspectionActStatus, string> = {
DRAFT: 'Borrador',
READY: 'Lista para cerrar',
CLOSED: 'Cerrada',
CANCELLED: 'Cancelada',
RECTIFIED: 'Rectificada',
};
const eventLabels: Record<InspectionActVersionEvent, string> = {
CREATED: 'Creación',
UPDATED: 'Actualización',
READY: 'Preparación para firmas',
REOPENED: 'Vuelta a borrador',
CLOSED: 'Cierre y sellado',
CANCELLED: 'Cancelación',
};
export function inspectionActStatusLabel(value: InspectionActStatus): string {
return statusLabels[value];
}
export function inspectionActStatusClass(value: InspectionActStatus): string {
if (value === 'CLOSED') return 'active';
if (value === 'READY' || value === 'RECTIFIED') return 'observed';
if (value === 'CANCELLED') return 'inactive';
return 'pending';
}
export function inspectionActVersionEventLabel(value: InspectionActVersionEvent): string {
return eventLabels[value];
}
@@ -0,0 +1,23 @@
import type { InspectionVisitStatus } from '../../lib/api';
export const INSPECTION_VISIT_STATUSES: Array<{
value: InspectionVisitStatus;
label: string;
}> = [
{ value: 'DRAFT', label: 'Borrador' },
{ value: 'PLANNED', label: 'Planificada' },
{ value: 'IN_PROGRESS', label: 'En curso' },
{ value: 'CLOSED', label: 'Cerrada' },
{ value: 'CANCELLED', label: 'Cancelada' },
];
export function inspectionVisitStatusLabel(value: InspectionVisitStatus): string {
return INSPECTION_VISIT_STATUSES.find((item) => item.value === value)?.label ?? value;
}
export function inspectionStatusClass(value: InspectionVisitStatus): string {
if (value === 'CLOSED') return 'active';
if (value === 'IN_PROGRESS') return 'observed';
if (value === 'CANCELLED') return 'inactive';
return 'pending';
}
@@ -0,0 +1,291 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Map,
NavigationControl,
type GeoJSONSource,
} from 'maplibre-gl';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import {
getAssetGeometry,
removeAssetGeometry,
upsertAssetGeometry,
} from '../../lib/api';
import type {
AssetGeometry,
AssetGeometryType,
GeoJsonGeometry,
Position,
} from '../../lib/api';
import { formatDate } from '../../lib/format';
const osmStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
};
function localDateTime(value: string | number | Date): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
if (!geometry) return [];
if (geometry.type === 'POINT') return [geometry.coordinates];
if (geometry.type === 'LINESTRING') return geometry.coordinates;
return geometry.coordinates[0]?.slice(0, -1) ?? [];
}
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null;
if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null;
if (vertices.length < 3) return null;
return { type, coordinates: [[...vertices, vertices[0]!]] };
}
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
const features: unknown[] = [];
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
if (geometry?.type !== 'POINT') {
vertices.forEach((coordinates, index) => features.push({
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
geometry: { type: 'Point', coordinates },
}));
}
return { type: 'FeatureCollection', features };
}
function geometryBounds(geometry: GeoJsonGeometry | null) {
const points = geometryVertices(geometry);
if (!points.length) return null;
return points.reduce<[number, number, number, number]>((result, point) => [
Math.min(result[0], point[0]), Math.min(result[1], point[1]),
Math.max(result[2], point[0]), Math.max(result[3], point[1]),
], [points[0]![0], points[0]![1], points[0]![0], points[0]![1]]);
}
function GeometryDrawingMap({
geometry,
vertices,
editable,
onAddVertex,
}: {
geometry: GeoJsonGeometry | null;
vertices: Position[];
editable: boolean;
onAddVertex: (position: Position) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const addVertexRef = useRef(onAddVertex);
const dataRef = useRef(drawingCollection(geometry, vertices));
addVertexRef.current = onAddVertex;
dataRef.current = drawingCollection(geometry, vertices);
useEffect(() => {
if (!containerRef.current) return;
const bounds = geometryBounds(geometry);
const map = new Map({
container: containerRef.current,
style: osmStyle,
center: bounds ? [bounds[0], bounds[1]] : [-68.8458, -32.8895],
zoom: bounds ? 12 : 6,
});
mapRef.current = map;
map.addControl(new NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('drawing', { type: 'geojson', data: dataRef.current as never });
map.addLayer({
id: 'drawing-fill', type: 'fill', source: 'drawing',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.2 },
});
map.addLayer({
id: 'drawing-line', type: 'line', source: 'drawing',
filter: ['in', ['geometry-type'], ['literal', ['LineString', 'Polygon']]],
paint: { 'line-color': '#2864dc', 'line-width': 3 },
});
map.addLayer({
id: 'drawing-points', type: 'circle', source: 'drawing',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': ['case', ['==', ['get', 'kind'], 'vertex'], 5, 8],
'circle-color': ['case', ['==', ['get', 'kind'], 'vertex'], '#f59e0b', '#2864dc'],
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2,
},
});
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 14 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 45, maxZoom: 16 });
}
}
});
if (editable) {
map.getCanvas().style.cursor = 'crosshair';
map.on('click', (event) => addVertexRef.current([
Number(event.lngLat.lng.toFixed(7)),
Number(event.lngLat.lat.toFixed(7)),
]));
}
return () => {
mapRef.current = null;
map.remove();
};
}, [editable]);
useEffect(() => {
const source = mapRef.current?.getSource('drawing') as GeoJSONSource | undefined;
source?.setData(drawingCollection(geometry, vertices) as never);
}, [geometry, vertices]);
return <div ref={containerRef} className="geometry-map" />;
}
const geometryNames: Record<AssetGeometryType, string> = {
POINT: 'Punto',
LINESTRING: 'Línea',
POLYGON: 'Polígono',
};
export function AssetGeometryEditor({
assetId,
assetName,
canEdit,
onChanged,
}: {
assetId: string;
assetName: string;
canEdit: boolean;
onChanged?: () => void;
}) {
const [stored, setStored] = useState<AssetGeometry | null>(null);
const [type, setType] = useState<AssetGeometryType>('POINT');
const [vertices, setVertices] = useState<Position[]>([]);
const [accuracyM, setAccuracyM] = useState('');
const [capturedAt, setCapturedAt] = useState('');
const [deviceLabel, setDeviceLabel] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const geometry = useMemo(() => draftGeometry(type, vertices), [type, vertices]);
const applyStored = (value: AssetGeometry | null) => {
setStored(value);
if (value) {
setType(value.geometry.type);
setVertices(geometryVertices(value.geometry));
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
setDeviceLabel(value.deviceLabel ?? '');
}
};
useEffect(() => {
getAssetGeometry(assetId)
.then(applyStored)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
const changeType = (next: AssetGeometryType) => {
setType(next); setVertices([]); setSuccess(''); setError('');
};
const addVertex = (position: Position) => {
if (!canEdit) return;
setVertices((current) => type === 'POINT' ? [position] : [...current, position]);
setSuccess('');
};
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setType('POINT');
setVertices([[
Number(position.coords.longitude.toFixed(7)),
Number(position.coords.latitude.toFixed(7)),
]]);
setAccuracyM(Number(position.coords.accuracy.toFixed(3)).toString());
setCapturedAt(localDateTime(position.timestamp));
setDeviceLabel('Navegador web');
setLocating(false);
},
(locationError) => {
setError(locationError.code === 1
? 'No se otorgó permiso para acceder a la ubicación.'
: 'No fue posible obtener una ubicación precisa.');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
);
};
const save = async () => {
if (!geometry) return;
setSaving(true); setError(''); setSuccess('');
try {
const updated = await upsertAssetGeometry(assetId, {
geometry,
accuracyM: accuracyM ? Number(accuracyM) : null,
capturedAt: capturedAt ? new Date(capturedAt).toISOString() : null,
deviceLabel: deviceLabel.trim() || null,
});
applyStored(updated);
setSuccess('Ubicación guardada correctamente');
onChanged?.();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const remove = async () => {
if (!window.confirm(`¿Quitar la geometría actual de ${assetName}? El cambio quedará auditado.`)) return;
setSaving(true); setError(''); setSuccess('');
try {
await removeAssetGeometry(assetId);
setStored(null); setVertices([]); setAccuracyM(''); setCapturedAt(''); setDeviceLabel('');
setSuccess('La geometría fue retirada y el cambio quedó auditado');
onChanged?.();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>;
const minimum = type === 'POINT' ? 1 : type === 'LINESTRING' ? 2 : 3;
return <article className="panel geometry-editor">
<div className="panel-heading"><div><span className="eyebrow">POSTGIS · WGS 84</span><h2>Ubicación geográfica</h2></div>{stored ? <span className="tag">{geometryNames[stored.geometryType]}</span> : <span className="tag">Sin geometría</span>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<p className="section-copy">Elegí el tipo y marcá {type === 'POINT' ? 'la posición' : 'los vértices'} directamente sobre el mapa. Las coordenadas se guardan en EPSG:4326.</p>
{canEdit && <div className="geometry-toolbar"><div className="geometry-type-buttons">{(['POINT', 'LINESTRING', 'POLYGON'] as AssetGeometryType[]).map((item) => <button type="button" key={item} className={`button ${type === item ? 'primary' : 'secondary'}`} onClick={() => changeType(item)}>{geometryNames[item]}</button>)}</div><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={locating}><Icon name="map" />{locating ? 'Ubicando…' : 'Usar mi ubicación'}</button></div>}
<GeometryDrawingMap geometry={geometry} vertices={vertices} editable={canEdit} onAddVertex={addVertex} />
<div className="geometry-progress"><strong>{vertices.length} vértice{vertices.length === 1 ? '' : 's'}</strong><span>{geometry ? 'Geometría lista para guardar' : `Faltan ${Math.max(0, minimum - vertices.length)} vértices`}</span>{canEdit && vertices.length > 0 && <div><button type="button" className="button text" onClick={() => setVertices((current) => current.slice(0, -1))}>Deshacer último</button><button type="button" className="button text" onClick={() => setVertices([])}>Limpiar</button></div>}</div>
<div className="form-grid geometry-metadata"><label className="field"><span>Precisión GPS <em>metros · opcional</em></span><input type="number" min="0" max="100000" step="0.001" value={accuracyM} onChange={(event) => setAccuracyM(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Fecha y hora de captura <em>opcional</em></span><input type="datetime-local" value={capturedAt} onChange={(event) => setCapturedAt(event.target.value)} disabled={!canEdit} /></label><label className="field"><span>Dispositivo <em>opcional</em></span><input value={deviceLabel} onChange={(event) => setDeviceLabel(event.target.value)} disabled={!canEdit} maxLength={255} placeholder="Tablet, navegador, GPS…" /></label></div>
{stored && <div className="geometry-audit-note"><span>Fuente: {stored.source}</span><span>Actualizada: {formatDate(stored.updatedAt)}</span>{stored.accuracyM != null && <span>Precisión: {stored.accuracyM} m</span>}</div>}
{canEdit && <div className="form-actions">{stored && <button type="button" className="button danger-outline" onClick={remove} disabled={saving}>Quitar geometría</button>}<button type="button" className="button primary" onClick={save} disabled={!geometry || saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar ubicación'}</button></div>}
</article>;
}
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useRef } from 'react';
import {
Map,
NavigationControl,
type GeoJSONSource,
type MapGeoJSONFeature,
} from 'maplibre-gl';
import type { MapAssetFeatureCollection } from '../../lib/api';
const osmStyle = {
version: 8 as const,
sources: {
osm: {
type: 'raster' as const,
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster' as const, source: 'osm' }],
};
const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons'];
function boundsFromFeatures(collection: MapAssetFeatureCollection) {
const positions: Array<[number, number]> = [];
collection.features.forEach((feature) => {
if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates);
if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates);
if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
});
if (!positions.length) return null;
return positions.reduce<[number, number, number, number]>((result, point) => [
Math.min(result[0], point[0]),
Math.min(result[1], point[1]),
Math.max(result[2], point[0]),
Math.max(result[3], point[1]),
], [positions[0]![0], positions[0]![1], positions[0]![0], positions[0]![1]]);
}
export function DhMap({
data,
selectedId,
onSelect,
}: {
data: MapAssetFeatureCollection;
selectedId: string | null;
onSelect: (id: string | null) => void;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const onSelectRef = useRef(onSelect);
const dataRef = useRef(data);
onSelectRef.current = onSelect;
dataRef.current = data;
useEffect(() => {
if (!containerRef.current) return;
const map = new Map({
container: containerRef.current,
style: osmStyle,
center: [-68.8458, -32.8895],
zoom: 6,
});
mapRef.current = map;
map.addControl(new NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('assets', { type: 'geojson', data: dataRef.current as never });
map.addLayer({
id: 'assets-polygons', type: 'fill', source: 'assets',
filter: ['==', ['geometry-type'], 'Polygon'],
paint: { 'fill-color': '#2864dc', 'fill-opacity': 0.22, 'fill-outline-color': '#184caf' },
});
map.addLayer({
id: 'assets-lines', type: 'line', source: 'assets',
filter: ['==', ['geometry-type'], 'LineString'],
paint: { 'line-color': '#2864dc', 'line-width': 3 },
});
map.addLayer({
id: 'assets-points', type: 'circle', source: 'assets',
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 7, 'circle-color': '#2864dc',
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2,
},
});
map.addLayer({
id: 'assets-selected-polygons', type: 'line', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Polygon'], ['==', ['get', 'id'], '']],
paint: { 'line-color': '#f59e0b', 'line-width': 4 },
});
map.addLayer({
id: 'assets-selected-lines', type: 'line', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'LineString'], ['==', ['get', 'id'], '']],
paint: { 'line-color': '#f59e0b', 'line-width': 6 },
});
map.addLayer({
id: 'assets-selected-points', type: 'circle', source: 'assets',
filter: ['all', ['==', ['geometry-type'], 'Point'], ['==', ['get', 'id'], '']],
paint: {
'circle-radius': 10, 'circle-color': '#f59e0b',
'circle-stroke-color': '#ffffff', 'circle-stroke-width': 3,
},
});
const click = (event: { features?: MapGeoJSONFeature[] }) => {
const id = event.features?.[0]?.properties?.id;
onSelectRef.current(typeof id === 'string' ? id : null);
};
interactiveLayers.forEach((layer) => {
map.on('click', layer, click);
map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; });
});
map.on('click', (event) => {
const hits = map.queryRenderedFeatures(event.point, { layers: interactiveLayers });
if (!hits.length) onSelectRef.current(null);
});
const bounds = boundsFromFeatures(dataRef.current);
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
}
}
});
return () => {
mapRef.current = null;
map.remove();
};
}, []);
useEffect(() => {
const map = mapRef.current;
if (!map?.isStyleLoaded()) return;
(map.getSource('assets') as GeoJSONSource | undefined)?.setData(data as never);
const bounds = boundsFromFeatures(data);
if (bounds) {
if (bounds[0] === bounds[2] && bounds[1] === bounds[3]) {
map.flyTo({ center: [bounds[0], bounds[1]], zoom: 13 });
} else {
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 55, maxZoom: 15 });
}
}
}, [data]);
useEffect(() => {
const map = mapRef.current;
if (!map?.isStyleLoaded()) return;
const id = selectedId ?? '__none__';
const filters: Array<[string, string]> = [
['assets-selected-points', 'Point'],
['assets-selected-lines', 'LineString'],
['assets-selected-polygons', 'Polygon'],
];
filters.forEach(([layer, geometryType]) => {
map.setFilter(layer, ['all', ['==', ['geometry-type'], geometryType], ['==', ['get', 'id'], id]]);
});
}, [selectedId]);
return <div ref={containerRef} className="map-canvas" />;
}
@@ -0,0 +1,32 @@
import type { SurveyCampaignStatus, SurveyTargetStatus } from '../../lib/api';
export const SURVEY_CAMPAIGN_STATUSES: Array<{ value: SurveyCampaignStatus; label: string }> = [
{ value: 'DRAFT', label: 'Borrador' },
{ value: 'PLANNED', label: 'Planificada' },
{ value: 'IN_PROGRESS', label: 'En curso' },
{ value: 'COMPLETED', label: 'Completada' },
{ value: 'CANCELLED', label: 'Cancelada' },
];
export const SURVEY_TARGET_STATUSES: Array<{ value: SurveyTargetStatus; label: string }> = [
{ value: 'PENDING', label: 'Pendiente' },
{ value: 'IN_PROGRESS', label: 'En ejecución' },
{ value: 'SUBMITTED', label: 'En revisión' },
{ value: 'COMPLETED', label: 'Completado' },
{ value: 'SKIPPED', label: 'Omitido' },
];
export function surveyCampaignStatusLabel(value: SurveyCampaignStatus): string {
return SURVEY_CAMPAIGN_STATUSES.find((item) => item.value === value)?.label ?? value;
}
export function surveyTargetStatusLabel(value: SurveyTargetStatus): string {
return SURVEY_TARGET_STATUSES.find((item) => item.value === value)?.label ?? value;
}
export function surveyStatusClass(value: SurveyCampaignStatus | SurveyTargetStatus): string {
if (value === 'COMPLETED') return 'active';
if (value === 'CANCELLED' || value === 'SKIPPED') return 'inactive';
if (value === 'IN_PROGRESS' || value === 'SUBMITTED') return 'observed';
return 'pending';
}
+130
View File
@@ -0,0 +1,130 @@
import { useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { APP_PHASE, APP_VERSION } from '../config/version';
import { Icon } from '../components/Icon';
import { OperationalContextBar } from '../components/OperationalContextBar';
import type { IconName } from '../components/Icon';
interface NavItem {
to: string;
label: string;
icon: IconName;
permission?: string;
}
const operational: NavItem[] = [
{ to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' },
{ to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' },
];
const followUp: NavItem[] = [
{ to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' },
];
const documents: NavItem[] = [
{ to: '/actas', label: 'Actas', icon: 'clipboard', permission: 'inspection_acts.read' },
{ to: '/informes', label: 'Informes', icon: 'audit', permission: 'inspection_reports.read' },
];
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' },
{ to: '/relevamiento', label: 'Relevamientos', icon: 'clipboard', permission: 'surveys.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/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' },
];
const system: NavItem[] = [
{ to: '/admin/audit', label: 'Auditoría', icon: 'audit', permission: 'audit.read' },
];
export function AppLayout() {
const { user, hasPermission, logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const canSee = (item: NavItem) => !item.permission || hasPermission(item.permission);
const close = () => setMobileOpen(false);
const handleLogout = async () => {
await logout().catch(() => undefined);
navigate('/login', { replace: true });
};
const renderItems = (items: NavItem[]) => items.filter(canSee).map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={close}
className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
>
<Icon name={item.icon} />
<span>{item.label}</span>
</NavLink>
));
const hasFollowUp = followUp.some(canSee);
const hasDocuments = documents.some(canSee);
const hasMaster = master.some(canSee);
const hasAdmin = administration.some(canSee);
const hasSystem = system.some(canSee);
return (
<div className="shell">
{mobileOpen && <button className="sidebar-backdrop" aria-label="Cerrar menú" onClick={close} />}
<aside className={`sidebar ${mobileOpen ? 'open' : ''}`}>
<div className="brand">
<span className="brand-mark">DH</span>
<div><strong>DH Inspección</strong><small>Plataforma V2</small></div>
</div>
<nav className="sidebar-nav" aria-label="Navegación principal">
<span className="nav-heading">OPERACIÓN</span>
{renderItems(operational)}
{hasFollowUp && <span className="nav-heading spaced">SEGUIMIENTO</span>}
{renderItems(followUp)}
{hasDocuments && <span className="nav-heading spaced">DOCUMENTOS</span>}
{renderItems(documents)}
{hasMaster && <span className="nav-heading spaced">INVENTARIOS</span>}
{renderItems(master)}
{hasAdmin && <span className="nav-heading spaced">ADMINISTRACIÓN</span>}
{renderItems(administration)}
{hasSystem && <span className="nav-heading spaced">SISTEMA</span>}
{renderItems(system)}
</nav>
<div className="sidebar-user">
<div className="user-avatar">{user?.firstName?.[0]}{user?.lastName?.[0]}</div>
<div className="user-copy">
<strong>{user?.firstName} {user?.lastName}</strong>
<small>{user?.roles.join(' · ') || user?.username}</small>
</div>
<button className="icon-button dark" onClick={handleLogout} title="Cerrar sesión" aria-label="Cerrar sesión">
<Icon name="logout" />
</button>
</div>
</aside>
<div className="main-column">
<header className="mobile-header">
<button className="icon-button" onClick={() => setMobileOpen(true)} aria-label="Abrir menú"><Icon name="menu" /></button>
<strong>DH Inspección</strong>
<span className="mobile-version">v{APP_VERSION}</span>
</header>
<OperationalContextBar />
<main className="content"><Outlet /></main>
<footer className="app-footer" aria-label="Versión de la aplicación">
<span>DH Inspección V2</span><span>·</span><strong>v{APP_VERSION}</strong><span>·</span><span>{APP_PHASE}</span>
</footer>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
const dateTime = new Intl.DateTimeFormat('es-AR', {
dateStyle: 'short',
timeStyle: 'short',
});
export function formatDate(value: string | null | undefined) {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '—' : dateTime.format(date);
}
export function formatDateOnly(value: string | null | undefined) {
if (!value) return '—';
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.slice(0, 10));
return match ? `${match[3]}/${match[2]}/${match[1]}` : '—';
}
const actions: Record<string, string> = {
AUTH_LOGIN_SUCCESS: 'Inicio de sesión',
AUTH_LOGIN_FAILED: 'Acceso rechazado',
AUTH_LOGOUT: 'Cierre de sesión',
AUTH_REFRESH: 'Sesión renovada',
AUTH_REFRESH_REUSE_DETECTED: 'Reutilización de sesión detectada',
AUTH_PASSWORD_CHANGED: 'Contraseña modificada',
SYSTEM_BOOTSTRAP_ADMIN_CREATED: 'Administrador inicial creado',
USER_CREATED: 'Usuario creado',
USER_UPDATED: 'Usuario actualizado',
USER_STATUS_CHANGED: 'Estado de usuario modificado',
USER_ROLES_CHANGED: 'Roles de usuario modificados',
ROLE_CREATED: 'Rol creado',
ROLE_UPDATED: 'Rol actualizado',
ROLE_PERMISSIONS_CHANGED: 'Permisos de rol modificados',
ASSET_TYPE_CREATED: 'Tipo de elemento creado',
ASSET_MASTER_BOOTSTRAPPED: 'Configuración inicial de Inventarios',
ASSET_TYPE_UPDATED: 'Tipo de elemento actualizado',
ASSET_ATTRIBUTE_CREATED: 'Atributo de inventario creado',
ASSET_ATTRIBUTE_UPDATED: 'Atributo de inventario actualizado',
ASSET_CREATED: 'Registro de inventario creado',
ASSET_FIELD_DISCOVERY_CREATED: 'Alta de campo registrada',
ASSET_FIELD_DISCOVERY_APPROVED: 'Alta de campo aprobada',
ASSET_FIELD_DISCOVERY_MATCHED: 'Alta de campo conciliada',
ASSET_FIELD_DISCOVERY_REJECTED: 'Alta de campo rechazada',
ASSET_UPDATED: 'Registro de inventario actualizado',
ASSET_INFORMATION_STATUS_CHANGED: 'Estado de información modificado',
ASSET_GEOMETRY_UPDATED: 'Ubicación de registro actualizada',
ASSET_GEOMETRY_REMOVED: 'Ubicación de registro retirada',
ASSET_MEDIA_UPLOADED: 'Archivo de inventario incorporado',
ASSET_MEDIA_UPDATED: 'Archivo de inventario actualizado',
ASSET_MEDIA_REMOVED: 'Archivo de inventario retirado',
ASSET_PROVENANCE_UPDATED: 'Procedencia de registro actualizada',
ASSET_PROVENANCE_VERIFIED: 'Procedencia de registro verificada',
ASSET_AREA_COMPANY_RELATION_CREATED: 'Relación ÁreaOrganización creada',
ASSET_AREA_COMPANY_RELATION_ENDED: 'Relación ÁreaOrganización finalizada',
SURVEY_CAMPAIGN_CREATED: 'Campaña de relevamiento creada',
SURVEY_CAMPAIGN_UPDATED: 'Campaña de relevamiento actualizada',
SURVEY_CAMPAIGN_STATUS_CHANGED: 'Estado de campaña modificado',
SURVEY_TARGET_ADDED: 'Objetivo de relevamiento agregado',
SURVEY_TARGET_UPDATED: 'Plan de objetivo actualizado',
SURVEY_TARGET_ASSIGNED: 'Objetivo de relevamiento asignado',
SURVEY_TARGET_STATUS_CHANGED: 'Avance de relevamiento actualizado',
SURVEY_REPORT_SAVED: 'Borrador de relevamiento guardado',
SURVEY_REPORT_SUBMITTED: 'Relevamiento enviado a revisión',
SURVEY_REPORT_APPROVED: 'Relevamiento aprobado',
SURVEY_REPORT_REJECTED: 'Relevamiento rechazado',
INSPECTION_VISIT_CREATED: 'Visita de inspección creada',
INSPECTION_VISIT_UPDATED: 'Plan de visita actualizado',
INSPECTION_VISIT_STATUS_CHANGED: 'Estado de visita modificado',
INSPECTION_VISIT_ASSETS_UPDATED: 'Registros de visita actualizados',
INSPECTION_VISIT_TEAM_UPDATED: 'Equipo de visita actualizado',
INSPECTION_VISIT_STARTED: 'Visita de inspección iniciada',
INSPECTION_ACT_CREATED: 'Acta de inspección creada',
INSPECTION_ACT_UPDATED: 'Acta de inspección actualizada',
INSPECTION_ACT_CANCELLED: 'Acta de inspección cancelada',
INSPECTION_ACT_RESPONSIBLE_UPDATED: 'Responsable del acta actualizado',
INSPECTION_ACT_READY: 'Contenido del acta congelado',
INSPECTION_ACT_REOPENED: 'Acta devuelta a borrador',
INSPECTION_ACT_SIGNATURE_RECORDED: 'Firma manuscrita incorporada al acta',
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED: 'Resultado de recepción empresarial registrado',
INSPECTION_ACT_CLOSED: 'Acta y visita cerradas',
INSPECTION_REPORT_GENERATED: 'Informe de inspección generado',
INSPECTION_VERIFICATION_PLANNED: 'Visita de verificación preparada',
INSPECTION_FINDING_CREATED: 'Hallazgo abierto',
INSPECTION_FINDING_UPDATED: 'Hallazgo actualizado',
INSPECTION_FINDING_FOLLOW_UP_UPDATED: 'Seguimiento de hallazgo actualizado',
INSPECTION_FINDING_CLOSED: 'Hallazgo cerrado',
INSPECTION_FINDING_EVIDENCE_UPLOADED: 'Evidencia de hallazgo incorporada',
INSPECTION_FINDING_COMMUNICATION_CREATED: 'Comunicación de hallazgo registrada',
};
export function actionLabel(action: string) {
return actions[action] ?? action.replaceAll('_', ' ').toLocaleLowerCase('es-AR');
}
export function initials(firstName: string, lastName: string) {
return `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import 'maplibre-gl/dist/maplibre-gl.css';
import './styles.css';
import { App } from './app/App';
import { AuthProvider } from './auth/AuthContext';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider><App /></AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);
+117
View File
@@ -0,0 +1,117 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { useOperationalContext } from '../context/OperationalContext';
import { DocumentCenterTabs } from '../features/documents/DocumentCenterTabs';
import { inspectionActStatusClass, inspectionActStatusLabel } from '../features/inspections/inspectionActPresentation';
import { OperationalFilters } from '../features/inspections/OperationalFilters';
import { listInspectionActsGlobal } from '../lib/api';
import type { InspectionActListItem, InspectionActStatus, PageMeta } from '../lib/api';
import { formatDate } from '../lib/format';
const statuses: Array<{ value: InspectionActStatus | ''; label: string }> = [
{ value: '', label: 'Todos los estados' },
{ value: 'DRAFT', label: 'Borrador' },
{ value: 'READY', label: 'Lista para cierre' },
{ value: 'CLOSED', label: 'Cerrada' },
{ value: 'RECTIFIED', label: 'Rectificada' },
{ value: 'CANCELLED', label: 'Cancelada' },
];
function contextLabel(items: Array<{ name: string }>, empty: string): string {
if (items.length === 0) return empty;
const first = items[0];
if (!first) return empty;
if (items.length === 1) return first.name;
return `${first.name} +${items.length - 1}`;
}
export function ActsPage() {
const [params, setParams] = useSearchParams();
const operationalContext = useOperationalContext();
const [items, setItems] = useState<InspectionActListItem[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const search = params.get('search') ?? '';
const status = (params.get('status') ?? '') as InspectionActStatus | '';
const year = params.get('year') ?? '';
const companyId = operationalContext.companyId || params.get('companyId') || '';
const areaId = operationalContext.areaId || params.get('areaId') || '';
const inspectorId = params.get('inspectorId') ?? '';
const dateFrom = params.get('dateFrom') ?? '';
const dateTo = params.get('dateTo') ?? '';
const page = Math.max(1, Number(params.get('page') ?? 1) || 1);
const [draftSearch, setDraftSearch] = useState(search);
useEffect(() => {
setLoading(true);
setError('');
listInspectionActsGlobal({ page, pageSize: 25, search, status, year: year ? Number(year) : '', companyId, areaId, inspectorId, dateFrom, dateTo })
.then((response) => {
setItems(response.data);
setMeta(response.meta);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [page, search, status, year, companyId, areaId, inspectorId, dateFrom, dateTo]);
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (key === 'areaId') {
operationalContext.setAreaId(value);
next.delete('areaId');
next.delete('companyId');
} else if (key === 'companyId') {
operationalContext.setCompanyId(value);
next.delete('companyId');
} else {
value ? next.set(key, value) : next.delete(key);
}
next.delete('page');
setParams(next);
};
const applySearch = (event: FormEvent) => {
event.preventDefault();
setFilter('search', draftSearch.trim());
};
const setPage = (value: number) => {
const next = new URLSearchParams(params);
value > 1 ? next.set('page', String(value)) : next.delete('page');
setParams(next);
};
return <section>
<div className="page-heading">
<div><span className="eyebrow">CENTRO DOCUMENTAL</span><h1>Actas</h1><p>Consulta global de actas con acceso a la inspección, hallazgos, inventario e informe relacionado.</p></div>
</div>
<DocumentCenterTabs />
<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 acta, inspección, empresa, área o elemento" /><button>Buscar</button></label>
<label className="select-field"><span>Estado</span><SearchableSelect value={status} onChange={(event) => setFilter('status', event.target.value)}>{statuses.map((item) => <option key={item.value || 'all'} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="select-field"><span>Año</span><input inputMode="numeric" value={year} onChange={(event) => setFilter('year', event.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="Todos" /></label>
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={setFilter} />
</form>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando actas…" /> : items.length === 0 ? <EmptyState title="Sin actas" text="No hay actas para los filtros seleccionados." /> : <div className="table-panel document-table">
<div className="table-summary"><strong>{meta.total} acta{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Acta</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Estado</th><th>Informe</th><th /></tr></thead><tbody>{items.map((act) => <tr key={act.id}>
<td><div className="document-primary"><strong>{act.code}</strong><small>{act.title} · {formatDate(act.occurredAt)}</small></div></td>
<td><div className="document-primary"><strong>{contextLabel(act.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(act.areas, 'Área sin asignar')}</small></div></td>
<td><Link className="text-link" to={`/inspecciones/${act.visitId}`}>{act.visit.code}</Link></td>
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(act.code)}`}>{act.findingCount}</Link></td>
<td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td>
<td>{act.report ? <Link className="text-link" to={`/informes/${act.report.id}`}>{act.report.code}<small className="block-muted">{act.report.pdfStatus === 'READY' ? 'PDF disponible' : 'PDF pendiente'}</small></Link> : act.status === 'CLOSED' ? <span className="status-badge pending">Pendiente de emisión</span> : <span className="muted"></span>}</td>
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><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>;
}
+250
View File
@@ -0,0 +1,250 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { ASSET_OPERATIONAL_STATUSES, ASSET_STATUSES, assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
import { AssetProvenancePanel } from '../features/assets/AssetProvenancePanel';
import { AssetOperationalRelationsPanel } from '../features/assets/AssetOperationalRelationsPanel';
import { AssetRegistryPanel } from '../features/assets/AssetRegistryPanel';
import { AssetContextHistoryPanel } from '../features/assets/AssetContextHistoryPanel';
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
import {
createAsset, getAsset, getAssetLineage, listAssetParentOptions, listAssetTypes, listCompaniesForArea,
listOperationalAreas, updateAsset, updateAssetInformationStatus, updateAssetOperationalStatus,
} from '../lib/api';
import type {
AssetAttributeDefinition, AssetDetail, AssetInformationStatus, AssetOperationalStatus,
AssetLineageItem, AssetListItem, AssetType, OperationalAssetSummary,
} from '../lib/api';
const AssetGeometryEditor = lazy(() => import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })));
type DetailTab = 'summary' | 'dossier' | 'findings' | 'location' | 'registry' | 'files' | 'history';
function localDateTime(value: unknown): string {
if (typeof value !== 'string' || !value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function normalizeAttributeValues(definitions: AssetAttributeDefinition[], values: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
definitions.filter((item) => item.isActive).forEach((definition) => {
const raw = values[definition.id];
if (definition.dataType === 'BOOLEAN') result[definition.id] = Boolean(raw);
else if (raw !== undefined && raw !== null && raw !== '') result[definition.id] = definition.dataType === 'NUMBER' ? Number(raw) : raw;
});
return result;
}
export function AssetEditorPage() {
const { id } = useParams();
const editing = Boolean(id);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const contextParentId = !editing ? searchParams.get('parentId') : null;
const requestedTab = searchParams.get('tab') as DetailTab | null;
const tab: DetailTab = requestedTab && ['summary','dossier','findings','location','registry','files','history'].includes(requestedTab) ? requestedTab : 'summary';
const { hasPermission } = useAuth();
const canEdit = editing ? hasPermission('assets.update') : hasPermission('assets.create');
const canCreate = hasPermission('assets.create');
const canChangeStatus = hasPermission('assets.change_status');
const canChangeOperationalStatus = hasPermission('assets.change_operational_status');
const canEditGeometry = hasPermission('assets.update_geometry');
const canReadHistory = hasPermission('assets.read_history');
const canReadMedia = hasPermission('assets.read_media');
const canManageMedia = hasPermission('assets.manage_media');
const canReadProvenance = hasPermission('assets.read_provenance');
const canManageProvenance = hasPermission('assets.manage_provenance');
const canVerifyProvenance = hasPermission('assets.verify_provenance');
const canReadRelations = hasPermission('asset_relations.read');
const canManageRelations = hasPermission('asset_relations.manage');
const canManageContext = hasPermission('assets.manage_context');
const canReadFindingCatalog = hasPermission('finding_catalog.read');
const canManageFindingCatalog = hasPermission('finding_catalog.manage');
const canReadDossier = hasPermission('inspections.read')
&& hasPermission('inspection_acts.read')
&& hasPermission('inspection_findings.read')
&& hasPermission('inspection_evidence.read')
&& hasPermission('inspection_communications.read');
const canReadRegistry = hasPermission('asset_registry.read');
const canManageRegistry = hasPermission('asset_registry.manage');
const canSave = canEdit || (editing && (canChangeStatus || canChangeOperationalStatus));
const [types, setTypes] = useState<AssetType[]>([]);
const [asset, setAsset] = useState<AssetDetail | null>(null);
const [contextParent, setContextParent] = useState<AssetDetail | null>(null);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [parents, setParents] = useState<AssetListItem[]>([]);
const [parentSearch, setParentSearch] = useState('');
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [commonName, setCommonName] = useState('');
const [typeId, setTypeId] = useState('');
const [parentId, setParentId] = useState('');
const [operationalAreaId, setOperationalAreaId] = useState('');
const [operatorCompanyId, setOperatorCompanyId] = useState('');
const [operationalAreas, setOperationalAreas] = useState<OperationalAssetSummary[]>([]);
const [operationalCompanies, setOperationalCompanies] = useState<OperationalAssetSummary[]>([]);
const [description, setDescription] = useState('');
const [status, setStatus] = useState<AssetInformationStatus>('DRAFT');
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>('UNKNOWN');
const [attributeValues, setAttributeValues] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const canDirectContextEdit = !editing || Boolean(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT');
const selectedType = types.find((type) => type.id === typeId) ?? null;
const definitions = useMemo(() => selectedType?.attributes.filter((item) => item.isActive) ?? [], [selectedType]);
useEffect(() => {
Promise.all([
listAssetTypes(),
id ? getAsset(id) : Promise.resolve(null),
id ? getAssetLineage(id) : Promise.resolve([] as AssetLineageItem[]),
])
.then(([loadedTypes, loadedAsset, loadedLineage]) => {
setTypes(loadedTypes);
setLineage(loadedLineage);
if (loadedAsset) {
setAsset(loadedAsset); setCode(loadedAsset.code); setName(loadedAsset.name); setCommonName(loadedAsset.commonName ?? ''); setTypeId(loadedAsset.type.id);
setParentId(loadedAsset.parent?.id ?? ''); setOperationalAreaId(loadedAsset.operationalArea?.id ?? '');
setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? ''); setDescription(loadedAsset.description ?? '');
setStatus(loadedAsset.informationStatus); setOperationalStatus(loadedAsset.operationalStatus);
setAttributeValues(Object.fromEntries(loadedAsset.attributes.map((attribute) => [attribute.definitionId, attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? ''])));
} else if (!contextParentId) {
const first = loadedTypes.find((type) => type.isActive && type.canBeRoot) ?? loadedTypes.find((type) => type.isActive);
if (first) setTypeId(first.id);
}
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (editing || !contextParentId || types.length === 0) return;
Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)]).then(([parent, parentLineage]) => {
setContextParent(parent);
setLineage(parentLineage);
const compatible = types.find((type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id));
if (compatible) {
setTypeId(compatible.id); setParentId(parent.id);
const parentType = types.find((type) => type.id === parent.type.id);
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
if (parent.operatorCompany) setOperatorCompanyId(parent.operatorCompany.id);
}
}).catch((requestError) => setError(errorMessage(requestError)));
}, [editing, contextParentId, types]);
useEffect(() => {
if (!typeId) { setParents([]); return; }
const timer = window.setTimeout(() => listAssetParentOptions(typeId, id, parentSearch).then(setParents).catch((requestError) => setError(errorMessage(requestError))), 220);
return () => window.clearTimeout(timer);
}, [typeId, id, parentSearch]);
useEffect(() => {
if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) {
setOperationalAreas([]);
if (!parentId) { setOperationalAreaId(''); setOperatorCompanyId(''); }
return;
}
listOperationalAreas(parentId).then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) { setOperationalAreaId(''); setOperatorCompanyId(''); }
}).catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, selectedType?.operationalRole, parentId]);
useEffect(() => {
if (!canReadRelations || !operationalAreaId || selectedType?.operationalRole !== 'GENERIC') { setOperationalCompanies([]); return; }
listCompaniesForArea(operationalAreaId).then(setOperationalCompanies).catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, operationalAreaId, selectedType?.operationalRole]);
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value }));
const save = async (event: FormEvent) => {
event.preventDefault(); if (!selectedType) return;
setSaving(true); setError(''); setSuccess('');
try {
const attributes = normalizeAttributeValues(definitions, attributeValues);
let saved: AssetDetail;
if (editing && id) {
if (canEdit) saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, operatorCompanyId: canDirectContextEdit ? operatorCompanyId || null : undefined, description: description.trim() || null, attributes });
else if (asset) saved = asset; else return;
if (canChangeStatus && saved.informationStatus !== status) saved = await updateAssetInformationStatus(id, status);
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) saved = await updateAssetOperationalStatus(id, operationalStatus);
setAsset(saved); setSuccess('Registro actualizado correctamente'); setHistoryRefreshKey((current) => current + 1);
} else {
saved = await createAsset({ code, name, commonName: commonName.trim() || null, typeId, parentId: parentId || null, operationalAreaId: operationalAreaId || null, operatorCompanyId: operatorCompanyId || null, description: description.trim() || null, informationStatus: canChangeStatus ? status : 'DRAFT', attributes });
navigate(`/inventarios/${saved.id}`, { replace: true });
}
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <LoadingBlock label="Cargando registro…" />;
const detailTabs = [
{ key: 'summary', label: 'Resumen', show: true },
{ key: 'dossier', label: 'Expediente', show: editing && canReadDossier },
{ key: 'findings', label: 'Hallazgos aplicables', show: editing && canReadFindingCatalog },
{ key: 'location', label: 'Ubicación', show: editing },
{ key: 'registry', label: 'Documentos y registro', show: editing && (canReadRegistry || canReadProvenance) },
{ key: 'files', label: 'Archivos', show: editing && canReadMedia },
{ key: 'history', label: 'Historial', show: editing && canReadHistory },
] as const;
const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa' ? 'companies' : 'territory';
return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
<Link to="/inventarios">Inventarios</Link><span></span>
<Link to={breadcrumbSection === 'companies' ? '/inventarios?section=companies' : '/inventarios?section=territory'}>{breadcrumbSection === 'companies' ? 'Empresas' : 'Áreas y yacimientos'}</Link>
{lineage.map((item, index) => {
const isLast = index === lineage.length - 1;
const showAsCurrent = editing ? isLast : false;
return <span className="asset-detail-crumb-part" key={item.id}><span></span>{showAsCurrent ? <strong>{item.name}</strong> : <Link to={`/inventarios?section=${breadcrumbSection}&parentId=${item.id}`}>{item.name}</Link>}</span>;
})}
{!editing && <><span></span><strong>Nuevo registro</strong></>}
</nav>
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">INVENTARIO</span><h1>{editing ? asset?.name ?? 'Registro' : contextParent ? `Agregar en ${contextParent.name}` : 'Nuevo registro'}</h1><p>{editing ? `${asset?.type.name} · ${asset?.code}` : contextParent ? `El sistema heredará el contexto disponible de ${contextParent.code}.` : 'Creá una entidad con identidad, jerarquía y atributos propios.'}</p></div><div className="asset-heading-actions">{editing && asset && <div className="heading-statuses"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div>}{editing && id && canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${id}`}><Icon name="plus" />Agregar registro aquí</Link>}</div></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{editing && <nav className="asset-detail-tabs">{detailTabs.filter((item) => item.show).map((item) => <Link key={item.key} className={tab === item.key ? 'active' : ''} to={`/inventarios/${id}${item.key === 'summary' ? '' : `?tab=${item.key}`}`}>{item.label}</Link>)}</nav>}
{(!editing || tab === 'summary') && <>
{contextParent && !editing && <div className="context-create-banner"><Icon name="layers" /><div><strong>Alta contextual</strong><span>{contextParent.name} · {contextParent.code}</span></div><Link to={`/inventarios/${contextParent.id}`}>Ver padre</Link></div>}
<form className="panel form-panel" onSubmit={save}>
<div className="form-section"><div><h2>Identificación</h2><p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p></div><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} disabled={!canEdit} required maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" /></label><label className="field"><span>Nombre técnico</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} /></label><label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Ej.: tanque grande, ET vieja, batería norte…" /><small>También se usa en las búsquedas del Inventario.</small></label></div><label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canEdit} maxLength={4000} rows={2} /></label></div>
<div className="form-section"><div><h2>Ubicación en la estructura</h2><p className="section-copy">Elegí qué es y dónde está contenido. El contexto ÁreaOperadora se completa a partir de esa ubicación cuando es posible. Los registros ya consolidados cambian de contexto desde el bloque histórico inferior.</p></div><div className="form-grid"><label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => changeType(event.target.value)} disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit} required><option value="">Seleccionar</option>{types.filter((type) => type.isActive || type.id === typeId).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label><div className="field parent-picker"><span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} disabled={!canEdit || !canDirectContextEdit} placeholder="Buscar planta, batería, estación…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit || !canDirectContextEdit} required={!selectedType?.canBeRoot}><option value="">{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect><small>Escribí para buscar entre los registros compatibles.</small></div></div><div className="form-grid">{canChangeStatus && <label className="field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)} disabled={!canEdit && !canChangeStatus}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Calidad y validación del registro.</small></label>}{editing && canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><SearchableSelect value={operationalStatus} onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Situación física u operativa del elemento.</small></label>}</div></div>
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section"><div><h2>Área y operadora</h2><p className="section-copy">Sólo necesitás revisar estos campos. La jerarquía física sigue siendo independiente.</p></div><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); setOperatorCompanyId(''); }} disabled={!canEdit || !canDirectContextEdit}><option value="">Sin asignación operativa</option>{operationalAreas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect></label><label className="field"><span>Operadora {operationalAreaId && <em>obligatoria</em>}</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} disabled={!canEdit || !canDirectContextEdit || !operationalAreaId} required={Boolean(operationalAreaId)}><option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>{operationalCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label></div>{operationalAreaId && operatorCompanyId && <div className="temporal-notice"><Icon name="check" /><p><strong>Contexto confirmado.</strong> El registro pertenece a esta Área y tiene una única Operadora activa.</p></div>}</div>}
<div className="form-section"><div><h2>Datos técnicos</h2><p className="section-copy">Campos definidos para el tipo seleccionado.</p></div>{definitions.length === 0 ? <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div> : <div className="dynamic-attributes">{definitions.map((definition) => { const value = attributeValues[definition.id]; const label = <span>{definition.name}{definition.unit ? ` (${definition.unit})` : ''}{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}</span>; if (definition.dataType === 'BOOLEAN') return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event) => setAttribute(definition.id, event.target.checked)} disabled={!canEdit} /><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>; if (definition.dataType === 'SELECT') return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option) => <option key={option} value={option}>{option}</option>)}</SearchableSelect></label>; const inputType = definition.dataType === 'NUMBER' ? 'number' : definition.dataType === 'DATE' ? 'date' : definition.dataType === 'DATETIME' ? 'datetime-local' : 'text'; return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired} step={definition.dataType === 'NUMBER' ? 'any' : undefined} maxLength={definition.dataType === 'TEXT' ? 4000 : undefined} /></label>; })}</div>}</div>
{canSave && <div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}</button></div>}
</form>
{editing && id && asset && selectedType && canReadHistory && <AssetContextHistoryPanel asset={asset} type={selectedType} canManage={canManageContext} onChanged={(saved) => { setAsset(saved); setParentId(saved.parent?.id ?? ''); setOperationalAreaId(saved.operationalArea?.id ?? ''); setOperatorCompanyId(saved.operatorCompany?.id ?? ''); setLineage([]); getAssetLineage(saved.id).then(setLineage).catch(() => undefined); setHistoryRefreshKey((current) => current + 1); }} />}
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC' && <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
</>}
{editing && id && asset && tab === 'findings' && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
{editing && id && asset && tab === 'dossier' && canReadDossier && <AssetDossierPanel assetId={id} />}
{editing && id && asset && tab === 'location' && <Suspense fallback={<div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>}><AssetGeometryEditor assetId={id} assetName={asset.name} canEdit={canEditGeometry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} /></Suspense>}
{editing && id && asset && tab === 'registry' && <div className="asset-tab-stack">{canReadRegistry && selectedType && <AssetRegistryPanel assetId={id} assetName={asset.name} role={selectedType.operationalRole} canManage={canManageRegistry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}{canReadProvenance && <AssetProvenancePanel assetId={id} canManage={canManageProvenance} canVerify={canVerifyProvenance} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}</div>}
{editing && id && asset && tab === 'files' && canReadMedia && <AssetMediaPanel assetId={id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}
{editing && id && tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />}
</section>;
}
+785
View File
@@ -0,0 +1,785 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { useAuth } from '../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetImportReviewsPanel } from '../features/assets/AssetImportReviewsPanel';
import {
applyAssetImportPlan,
applySafeAssetImportPlan,
cancelAssetImport,
generateAssetImportPlan,
getAssetImportBatch,
getAssetImportPlan,
listAssetImportBatches,
listAssetImportReviews,
listAssetImportOrganizations,
listAssetImportPlanItems,
listAssetImportRows,
listAssets,
reconcileAssetImport,
resolveAssetImportPlanItem,
rollbackAssetImportPlan,
uploadAssetImport,
} from '../lib/api';
import type {
AssetImportBatch,
AssetImportBatchDetail,
AssetImportOrganizationOption,
AssetImportPlan,
AssetImportPlanAction,
AssetImportPlanEntityKind,
AssetImportPlanItem,
AssetImportProfileCode,
AssetImportRow,
AssetImportRowStatus,
AssetImportReviewItem,
AssetListItem,
} from '../lib/api';
const ISSUE_LABELS: Record<string, string> = {
MISSING_AREA_OR_YACIMIENTO: 'Falta Área/Yacimiento',
MISSING_EQUIPMENT_DESCRIPTION: 'Falta descripción del equipo',
MISSING_INVENTORY_ID: 'Sin ID de inventario',
INVALID_QUANTITY: 'Cantidad inválida',
GROUPED_QUANTITY: 'Registro agrupado: requiere decisión humana',
SOURCE_STATUS_REQUIRES_MAPPING: 'Estado de fuente ambiguo',
INVENTORY_ID_MULTIPLE_LOCATIONS: 'ID repetido en ubicaciones diferentes',
DUPLICATE_INVENTORY_ID_IN_BATCH: 'ID repetido en el archivo',
MISSING_FIELD: 'Falta Yacimiento',
MISSING_AREA: 'Falta Área',
MISSING_OPERATOR: 'Falta Operadora',
PROFILE_NOT_RECOGNIZED: 'Formato no reconocido',
MATCH_MULTIPLE_ASSETS: 'Varias coincidencias exactas en los inventarios',
MATCH_MULTIPLE_ORGANIZATIONS: 'Varias organizaciones coinciden con la operadora',
SOURCE_ROW_WARNING: 'La fila de origen tiene una advertencia',
SOURCE_ROW_CONFLICT: 'La fila de origen tiene un conflicto',
PLAN_MULTIPLE_ORGANIZATIONS: 'Varias organizaciones posibles',
PLAN_MULTIPLE_AREAS: 'Varias áreas posibles',
PLAN_MULTIPLE_DEPARTMENTS: 'Varios Departamentos coinciden',
PLAN_DEPARTMENT_REVIEW_REQUIRED: 'El Departamento requiere revisión',
PLAN_LEGAL_RIGHT_TYPE_MISSING: 'Falta tipo de derecho/concesión',
PLAN_LEGAL_RIGHT_TYPE_UNRECOGNIZED: 'Tipo de derecho no reconocido',
PLAN_MULTIPLE_LEGAL_RIGHTS: 'Varios derechos vigentes coinciden',
PLAN_LEGAL_RIGHT_REVIEW_REQUIRED: 'El derecho/concesión requiere revisión',
PLAN_MULTIPLE_FIELDS: 'Varios yacimientos posibles',
PLAN_MULTIPLE_CONTAINERS: 'Varias instalaciones posibles',
PLAN_MULTIPLE_LOCAL_STRUCTURES: 'Varias estructuras locales posibles',
PLAN_MULTIPLE_NAMESPACE_MATCHES: 'El ID externo coincide con varios registros',
PLAN_GLOBAL_MATCH_REQUIRES_NAMESPACE: 'Coincidencia previa sin namespace confirmado',
PLAN_AREA_REVIEW_REQUIRED: 'El Área requiere revisión',
PLAN_ORGANIZATION_REVIEW_REQUIRED: 'La Operadora requiere revisión',
PLAN_FIELD_OPERATOR_AMBIGUOUS: 'La operadora del Yacimiento es ambigua',
PLAN_FIELD_OPERATOR_MISSING: 'No hay una operadora identificable para el Yacimiento',
PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH: 'El Yacimiento existente tiene otra asignación operativa; corregir el inventario o la fuente',
PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH: 'El registro identificado existe pero pertenece a otra Área/Operadora',
PLAN_TERRITORY_AMBIGUOUS: 'Área/Yacimiento ambiguo para este inventario',
PLAN_TERRITORY_CONTEXT_REQUIRED: 'Primero hay que elegir Área/Yacimiento',
PLAN_TERRITORY_OVERRIDE_STALE: 'La elección territorial guardada ya no es válida',
PLAN_CONTEXT_DECISION_REQUIRED: 'Depende de elegir el contexto territorial',
PLAN_TERRITORY_NOT_FOUND: 'Área/Yacimiento no existe en los inventarios para esta operadora',
PLAN_CONTAINER_REVIEW_REQUIRED: 'La instalación padre requiere revisión',
PLAN_CONTAINER_NOT_IDENTIFIED: 'No se identificó una instalación física segura',
PLAN_LOCAL_STRUCTURE_NOT_IDENTIFIED: 'No se pudo formar una estructura local desde la fuente',
PLAN_PARENT_NOT_RESOLVED: 'No se pudo resolver el padre físico',
PLAN_PROFILE_UNSUPPORTED: 'Perfil todavía no soportado para aplicación',
PLAN_SOURCE_GROUP_REQUIRES_DECISION: 'La fuente sólo informa una categoría/sector; confirmar contenedor',
PLAN_INVENTORY_ID_TOO_LONG: 'ID de inventario supera el máximo permitido',
};
const ENTITY_LABELS: Record<AssetImportPlanEntityKind, string> = {
DEPARTMENT: 'Departamentos',
ORGANIZATION: 'Organizaciones',
AREA: 'Áreas',
AREA_DEPARTMENT_RELATION: 'Relaciones Área ↔ Departamento',
FIELD: 'Yacimientos',
OPERATOR_RELATION: 'Relaciones Área ↔ Operadora',
LEGAL_RIGHT: 'Derechos / concesiones',
LEGAL_RIGHT_ORGANIZATION: 'Derecho ↔ Organización',
INSTALLATION: 'Instalaciones',
LOCAL_STRUCTURE: 'Estructura local según fuente',
TECHNICAL_ASSET: 'Elementos técnicos',
};
const PLAN_GROUPS: Array<{ label: string; kinds: AssetImportPlanEntityKind[] }> = [
{ label: 'Territorio', kinds: ['DEPARTMENT','AREA','AREA_DEPARTMENT_RELATION','FIELD'] },
{ label: 'Organizaciones', kinds: ['ORGANIZATION','OPERATOR_RELATION'] },
{ label: 'Marco jurídico', kinds: ['LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION'] },
{ label: 'Inventario técnico', kinds: ['LOCAL_STRUCTURE','INSTALLATION','TECHNICAL_ASSET'] },
];
const PLAN_ACTION_LABELS: Record<AssetImportPlanAction, string> = {
CREATE: 'Crear nuevo', MATCH: 'Usar existente', REVIEW: 'Requiere revisión', IGNORE: 'Ignorar',
};
const MATCH_CRITERIA: Record<AssetImportPlanEntityKind, string> = {
DEPARTMENT: 'Mismo Departamento normalizado dentro de Mendoza.',
ORGANIZATION: 'Mismo nombre o razón social normalizada.',
AREA: 'Mismo nombre de Área en los inventarios.',
AREA_DEPARTMENT_RELATION: 'La relación Área ↔ Departamento ya está vigente.',
FIELD: 'Mismo Yacimiento dentro de la misma Área y contexto operativo.',
OPERATOR_RELATION: 'La relación Área ↔ Operadora ya está vigente.',
LEGAL_RIGHT: 'Mismo derecho/concesión vigente para el Área.',
LEGAL_RIGHT_ORGANIZATION: 'La relación Derecho ↔ Organización ya está vigente.',
INSTALLATION: 'Mismo tipo y nombre bajo el mismo padre físico.',
LOCAL_STRUCTURE: 'Misma nomenclatura local bajo el mismo Área/Yacimiento y la misma operadora.',
TECHNICAL_ASSET: 'ID externo exacto dentro del namespace seleccionado y mismo contexto operativo.',
};
type ReviewGuidance = { title: string; detail: string; decision: string; dependency?: boolean };
const REVIEW_GUIDANCE: Record<string, ReviewGuidance> = {
MISSING_AREA_OR_YACIMIENTO:{title:'Falta ubicación territorial',detail:'La fila no informa Área o Yacimiento suficiente para ubicar el registro.',decision:'Completá la ubicación en la fuente o vinculala con el contexto correcto.'},
MISSING_EQUIPMENT_DESCRIPTION:{title:'Falta identificar el equipo',detail:'No hay una descripción suficiente para saber qué registro representa la fila.',decision:'Completá la descripción antes de crear un registro.'},
MISSING_INVENTORY_ID:{title:'Falta ID de inventario',detail:'La fuente no aporta un identificador estable para este equipo.',decision:'Confirmá si debe crearse sin ID externo o corregí la fuente.'},
INVALID_QUANTITY:{title:'Cantidad inválida',detail:'La cantidad informada no puede interpretarse con seguridad.',decision:'Corregí o confirmá la cantidad antes de importar.'},
GROUPED_QUANTITY:{title:'La fila agrupa varias unidades',detail:'La fuente representa más de una unidad en una sola fila y el sistema no debe separarlas automáticamente.',decision:'Confirmá si se conserva como registro agrupado o si la fuente debe dividirse en registros individuales.'},
SOURCE_STATUS_REQUIRES_MAPPING:{title:'Estado de fuente ambiguo',detail:'El texto de estado no permite decidir automáticamente entre estado operativo y condición.',decision:'Revisá qué significa el estado antes de importar.'},
INVENTORY_ID_MULTIPLE_LOCATIONS:{title:'El mismo ID aparece en ubicaciones distintas',detail:'Un identificador de inventario se repite con contextos territoriales diferentes.',decision:'Verificá si es el mismo registro trasladado o un error de identificación.'},
DUPLICATE_INVENTORY_ID_IN_BATCH:{title:'ID repetido dentro del archivo',detail:'El mismo ID de inventario aparece más de una vez en este lote.',decision:'Confirmá si las filas describen el mismo registro o corregí el identificador.'},
MISSING_FIELD:{title:'Falta Yacimiento',detail:'No se pudo identificar el Yacimiento de la fila.',decision:'Completá o confirmá el Yacimiento correcto.'},
MISSING_AREA:{title:'Falta Área',detail:'No se pudo identificar el Área operativa de la fila.',decision:'Completá o confirmá el Área correcta.'},
MISSING_OPERATOR:{title:'Falta Operadora',detail:'La fuente no permite determinar una organización operadora.',decision:'Confirmá la operadora o mantené explícitamente el registro sin operadora cuando corresponda.'},
PROFILE_NOT_RECOGNIZED:{title:'Formato no reconocido',detail:'El archivo no coincide con un perfil de importación conocido.',decision:'Usá un perfil soportado o ajustá el archivo.'},
MATCH_MULTIPLE_ASSETS:{title:'Hay varios registros posibles',detail:'Más de un registro cumple los criterios exactos disponibles.',decision:'Elegí manualmente cuál registro del inventario corresponde.'},
MATCH_MULTIPLE_ORGANIZATIONS:{title:'Hay varias organizaciones posibles',detail:'La operadora informada coincide con más de una organización.',decision:'Seleccioná la organización legal correcta.'},
SOURCE_ROW_WARNING:{title:'Advertencia en la fila de origen',detail:'La fila contiene información válida pero con una condición que requiere control humano.',decision:'Revisá las observaciones de origen y confirmá la decisión.'},
SOURCE_ROW_CONFLICT:{title:'Conflicto en la fila de origen',detail:'La fila contiene datos que se contradicen o no pueden resolverse automáticamente.',decision:'Corregí la fuente o resolvé manualmente identidad y contexto.'},
PLAN_MULTIPLE_ORGANIZATIONS:{title:'Varias organizaciones coinciden',detail:'No existe una única organización segura para reutilizar.',decision:'Elegí la organización correcta o corregí el inventario.'},
PLAN_MULTIPLE_AREAS:{title:'Varias Áreas coinciden',detail:'El nombre normalizado no identifica una sola Área.',decision:'Elegí el Área correcta o corregí la duplicidad en el inventario.'},
PLAN_MULTIPLE_DEPARTMENTS:{title:'Varios Departamentos coinciden',detail:'El Departamento no queda identificado de manera única.',decision:'Elegí o corregí el Departamento correspondiente.'},
PLAN_DEPARTMENT_REVIEW_REQUIRED:{title:'Depende de un Departamento pendiente',detail:'Esta relación no puede resolverse hasta decidir el Departamento relacionado.',decision:'Resolvé primero el Departamento y luego regenerá el plan.',dependency:true},
PLAN_LEGAL_RIGHT_TYPE_MISSING:{title:'Falta tipo de derecho',detail:'La fuente no informa el tipo de derecho/concesión necesario.',decision:'Completá o confirmá el tipo jurídico.'},
PLAN_LEGAL_RIGHT_TYPE_UNRECOGNIZED:{title:'Tipo de derecho no reconocido',detail:'El valor de fuente no puede mapearse con seguridad al catálogo jurídico.',decision:'Elegí el tipo correcto o corregí la fuente.'},
PLAN_MULTIPLE_LEGAL_RIGHTS:{title:'Varios derechos vigentes coinciden',detail:'Hay más de un derecho/concesión posible para el mismo contexto.',decision:'Seleccioná el derecho correcto.'},
PLAN_LEGAL_RIGHT_REVIEW_REQUIRED:{title:'Depende de un derecho pendiente',detail:'Este elemento depende de un derecho/concesión todavía no resuelto.',decision:'Resolvé primero el derecho y regenerá el plan.',dependency:true},
PLAN_MULTIPLE_FIELDS:{title:'Varios Yacimientos posibles',detail:'El nombre y contexto no identifican un único Yacimiento.',decision:'Elegí el Yacimiento correcto o corregí duplicidades.'},
PLAN_MULTIPLE_CONTAINERS:{title:'Varias instalaciones posibles',detail:'Más de una instalación física coincide bajo el mismo contexto.',decision:'Elegí la instalación correcta.'},
PLAN_MULTIPLE_LOCAL_STRUCTURES:{title:'Varias estructuras locales posibles',detail:'La misma nomenclatura local aparece más de una vez dentro del mismo contexto.',decision:'Revisá si son duplicados o estructuras distintas antes de continuar.'},
PLAN_MULTIPLE_NAMESPACE_MATCHES:{title:'El ID externo apunta a varios registros',detail:'Dentro del namespace seleccionado el mismo ID externo está asociado a más de un registro.',decision:'Corregí la duplicidad del identificador.'},
PLAN_GLOBAL_MATCH_REQUIRES_NAMESPACE:{title:'Coincidencia previa sin namespace seguro',detail:'Existe una coincidencia histórica, pero no está respaldada por el namespace externo seleccionado.',decision:'Verificá manualmente el registro o completá el identificador externo.'},
PLAN_AREA_REVIEW_REQUIRED:{title:'Depende de un Área pendiente',detail:'Este elemento no puede resolverse hasta definir el Área relacionada.',decision:'Resolvé primero el Área y regenerá el plan.',dependency:true},
PLAN_ORGANIZATION_REVIEW_REQUIRED:{title:'Depende de una organización pendiente',detail:'Este elemento necesita una organización que todavía no está resuelta.',decision:'Resolvé primero la organización y regenerá el plan.',dependency:true},
PLAN_FIELD_OPERATOR_AMBIGUOUS:{title:'Operadora del Yacimiento ambigua',detail:'La fuente permite más de una interpretación para la operadora del Yacimiento.',decision:'Confirmá la organización operadora correcta.'},
PLAN_FIELD_OPERATOR_MISSING:{title:'Yacimiento sin operadora identificable',detail:'No se pudo obtener una operadora segura para el Yacimiento.',decision:'Confirmá si realmente está sin operadora o completá la organización.'},
PLAN_FIELD_OPERATOR_CONTEXT_MISMATCH:{title:'Yacimiento con otro contexto operativo',detail:'El Yacimiento existente está asociado a una operadora diferente de la fuente.',decision:'Corregí el inventario o la fuente; no fuerces una coincidencia incorrecta.'},
PLAN_MATCH_OPERATIONAL_CONTEXT_MISMATCH:{title:'El registro existe en otro contexto',detail:'El identificador coincide, pero Área u Operadora no coinciden con la fuente.',decision:'Verificá traslado, error de fuente o contexto del inventario.'},
PLAN_TERRITORY_AMBIGUOUS:{title:'Elegir Área/Yacimiento',detail:'El mismo nombre existe en más de un contexto territorial válido. El sistema no va a adivinar cuál corresponde.',decision:'Elegí una de las alternativas detectadas. La decisión se guardará para este lote y luego se regenera el plan.'},
PLAN_TERRITORY_CONTEXT_REQUIRED:{title:'Depende del contexto territorial',detail:'No se analiza todavía la estructura local porque primero hay que decidir a qué Área/Yacimiento pertenece la fila.',decision:'Resolvé la tarjeta “Elegir Área/Yacimiento” y regenerá el plan.',dependency:true},
PLAN_CONTEXT_DECISION_REQUIRED:{title:'Esperando Área/Yacimiento',detail:'La instalación y el padre no son conflictos separados: se recalcularán cuando se elija el contexto territorial.',decision:'No resuelvas este registro individualmente; resolvé primero el contexto y regenerá.',dependency:true},
PLAN_TERRITORY_OVERRIDE_STALE:{title:'La elección territorial quedó obsoleta',detail:'El inventario cambió y el contexto elegido anteriormente ya no está entre las alternativas válidas.',decision:'Elegí nuevamente el Área/Yacimiento correcto.'},
PLAN_TERRITORY_NOT_FOUND:{title:'Área/Yacimiento no encontrado para esta operadora',detail:'La ubicación de la fila no existe dentro de las Áreas operadas por la organización seleccionada.',decision:'Importá/configurá primero el territorio correcto o corregí la fuente.'},
PLAN_CONTAINER_REVIEW_REQUIRED:{title:'Depende de una instalación pendiente',detail:'El registro necesita una instalación padre que todavía requiere decisión.',decision:'Resolvé primero la instalación y regenerá el plan.',dependency:true},
PLAN_CONTAINER_NOT_IDENTIFIED:{title:'No se identificó instalación física',detail:'La fila no aporta una instalación concreta y el equipo necesita un padre físico.',decision:'Identificá la instalación real o corregí la estructura de la fuente.'},
PLAN_LOCAL_STRUCTURE_NOT_IDENTIFIED:{title:'Falta estructura local utilizable',detail:'La fuente no aporta un nivel local suficiente para ubicar el registro debajo del Área/Yacimiento.',decision:'Revisá instalación, subinstalación o ubicación y confirmá el nivel estructural correcto.'},
PLAN_PARENT_NOT_RESOLVED:{title:'Padre físico sin resolver',detail:'El sistema no puede incorporar el registro sin un padre físico seguro.',decision:'Resolvé Área/Yacimiento/instalación padre y luego regenerá el plan.',dependency:true},
PLAN_PROFILE_UNSUPPORTED:{title:'Perfil no soportado para incorporación',detail:'El análisis puede leerse, pero este perfil todavía no tiene una incorporación segura al inventario.',decision:'No apliques el lote hasta contar con soporte específico.'},
PLAN_SOURCE_GROUP_REQUIRES_DECISION:{title:'Nomenclatura local de la fuente',detail:'Este plan utiliza un criterio anterior para clasificar la estructura local.',decision:'Regenerá el plan con la versión actual antes de resolver este caso.'},
PLAN_INVENTORY_ID_TOO_LONG:{title:'ID de inventario demasiado largo',detail:'El identificador supera el máximo permitido para persistirlo de forma segura.',decision:'Corregí o normalizá el ID en la fuente.'},
};
function reviewGuidance(code:string):ReviewGuidance{return REVIEW_GUIDANCE[code]??{title:ISSUE_LABELS[code]??code,detail:'El sistema no puede tomar esta decisión automáticamente.',decision:'Revisá el registro y resolvelo manualmente antes de aplicar.'};}
function isDependencyReview(item:AssetImportPlanItem){return item.reviewCodes.length>0&&item.reviewCodes.every((code)=>reviewGuidance(code).dependency===true);}
function provisionalDisplayName(name:string){return name.replace(/^\[A VALIDAR\]\s*/i,'').trim()||name;}
function isProvisionalName(name:string){return /^\[A VALIDAR\]/i.test(name.trim());}
function matchedObjectId(item:AssetImportPlanItem){if(item.matchedAssetId)return item.matchedAssetId;for(const key of ['matchedDepartmentId','matchedRelationId','matchedLegalRightId']){const current=item.payload[key];if(typeof current==='string'&&current)return current;}return null;}
function reviewFacts(item:AssetImportPlanItem){const p=item.payload;const entries:Array<[string,unknown]>=[['Área / Yacimiento',p.areaOrField],['Instalación',p.installation??p.sourceInstallation],['Sector / subinstalación',p.subInstallation??p.sourceSubInstallation],['Equipo',p.equipment],['Familia normalizada',p.normalizedFamily],['Subtipo',p.normalizedSubtype],['ID inventario',p.inventoryId],['Cantidad',p.quantity],['Estado fuente',p.sourceStatus]];return entries.filter(([,v])=>v!=null&&v!=='').slice(0,7).map(([label,v])=>[label,String(v)] as const);}
type InventoryContextCandidate = { id:string; code:string; name:string; typeCode:string; parentName?:string|null };
function inventoryContextCandidates(item:AssetImportPlanItem):InventoryContextCandidate[]{
if(item.payload.inventoryContextDecision!==true||!Array.isArray(item.payload.candidateOptions))return [];
return item.payload.candidateOptions.flatMap((value)=>{
const current=record(value);if(!current)return [];
const id=String(current.id??'');const code=String(current.code??'');const name=String(current.name??'');const typeCode=String(current.typeCode??'');
if(!id||!name||!typeCode)return [];
return [{id,code,name,typeCode,parentName:current.parentName?String(current.parentName):null}];
});
}
function profileLabel(code: AssetImportProfileCode) {
if (code === 'MENDOZA_INVENTORY_V1') return 'Inventario de instalaciones · Mendoza';
if (code === 'MENDOZA_YACIMIENTOS_V1') return 'Tabla Área / Yacimiento · Mendoza';
return 'Formato no reconocido';
}
function record(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function executionStatus(batch: AssetImportBatch) {
return record(batch.analysis.importExecution)?.status;
}
function batchStatus(batch: AssetImportBatch) {
const execution = executionStatus(batch);
if (execution === 'APPLIED') return { label: 'Importado', className: 'active' };
if (execution === 'PARTIAL') return { label: 'Importado parcialmente', className: 'pending' };
if (execution === 'ROLLED_BACK') return { label: 'Revertido', className: 'pending' };
if (batch.status === 'CANCELLED') return { label: 'Cancelado', className: 'inactive' };
if (batch.status === 'REVIEW_REQUIRED') return { label: 'Requiere revisión', className: 'observed' };
if (batch.status === 'FAILED') return { label: 'Fallido', className: 'inactive' };
return { label: 'Analizado', className: 'active' };
}
function rowStatus(status: AssetImportRowStatus) {
if (status === 'READY') return { label: 'Listo', className: 'active' };
if (status === 'WARNING') return { label: 'Advertencia', className: 'pending' };
if (status === 'CONFLICT') return { label: 'Conflicto', className: 'observed' };
return { label: 'Ignorado', className: 'inactive' };
}
function planState(plan: AssetImportPlan) {
if (plan.status === 'READY') return { label: 'Listo para confirmar', className: 'active' };
if (plan.status === 'REVIEW_REQUIRED' && summaryNumber(plan, 'appliedCreateItems') > 0) return { label: 'Importado parcialmente', className: 'pending' };
if (plan.status === 'REVIEW_REQUIRED') return { label: 'Decisiones pendientes', className: 'observed' };
if (plan.status === 'APPLIED') return { label: 'Aplicado', className: 'active' };
if (plan.status === 'ROLLED_BACK') return { label: 'Revertido', className: 'pending' };
if (plan.status === 'FAILED') return { label: 'Fallido', className: 'inactive' };
return { label: plan.status, className: 'inactive' };
}
function value(source: Record<string, unknown>, key: string) {
const current = source[key];
return current == null || current === '' ? '—' : String(current);
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function summaryNumber(plan: AssetImportPlan, key: string) {
return Number(plan.summary[key] ?? 0);
}
function directReviewCount(plan: AssetImportPlan) {
const value = plan.summary.directReviewItems;
return value == null ? plan.reviewItems.filter((item) => !isDependencyReview(item)).length : Number(value);
}
function dependencyReviewCount(plan: AssetImportPlan) {
const value = plan.summary.dependencyReviewItems;
return value == null ? plan.reviewItems.filter((item) => isDependencyReview(item)).length : Number(value);
}
function sourceRowsLabel(rows: number[]) {
if (!rows.length) return 'Contexto del inventario';
if (rows.length <= 4) return `Fila${rows.length === 1 ? '' : 's'} ${rows.join(', ')}`;
return `${rows.length.toLocaleString('es-AR')} filas · ${rows.slice(0, 3).join(', ')}`;
}
export function AssetImportsPage() {
const { hasPermission } = useAuth();
const canManage = hasPermission('asset_imports.manage');
const canApply = hasPermission('asset_imports.apply');
const [batches, setBatches] = useState<AssetImportBatch[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<AssetImportBatchDetail | null>(null);
const [plan, setPlan] = useState<AssetImportPlan | null>(null);
const [organizations, setOrganizations] = useState<AssetImportOrganizationOption[]>([]);
const [operatorAssetId, setOperatorAssetId] = useState('');
const [externalNamespace, setExternalNamespace] = useState('');
const [rows, setRows] = useState<AssetImportRow[]>([]);
const [rowMeta, setRowMeta] = useState({ page: 1, pageSize: 50, total: 0, totalPages: 0 });
const [rowStatusFilter, setRowStatusFilter] = useState<AssetImportRowStatus | ''>('');
const [rowSearch, setRowSearch] = useState('');
const [file, setFile] = useState<File | null>(null);
const [sourceLabel, setSourceLabel] = useState('');
const [notes, setNotes] = useState('');
const [profileCode, setProfileCode] = useState<'' | Exclude<AssetImportProfileCode, 'UNKNOWN'>>('');
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [matchItem, setMatchItem] = useState<AssetImportPlanItem | null>(null);
const [matchSearch, setMatchSearch] = useState('');
const [matchResults, setMatchResults] = useState<AssetListItem[]>([]);
const [matchLoading, setMatchLoading] = useState(false);
const [planDetailKind, setPlanDetailKind] = useState<AssetImportPlanEntityKind | null>(null);
const [planDetailAction, setPlanDetailAction] = useState<AssetImportPlanAction | null>(null);
const [planDetailItems, setPlanDetailItems] = useState<AssetImportPlanItem[]>([]);
const [planDetailMeta, setPlanDetailMeta] = useState({ page: 1, pageSize: 50, total: 0, totalPages: 0 });
const [planDetailLoading, setPlanDetailLoading] = useState(false);
const [section, setSection] = useState<'BATCHES' | 'REVIEWS'>('BATCHES');
const [reviews, setReviews] = useState<AssetImportReviewItem[]>([]);
const [reviewKind, setReviewKind] = useState<'ALL' | 'DIRECT' | 'DEPENDENCY'>('ALL');
const [reviewMeta, setReviewMeta] = useState({ page: 1, pageSize: 30, total: 0, totalPages: 0 });
const [reviewsLoading, setReviewsLoading] = useState(false);
const [scrollToReviews, setScrollToReviews] = useState(false);
const loadPlanDetail = async (kind: AssetImportPlanEntityKind | null, page = 1, action: AssetImportPlanAction | null = null) => {
if (!detail) return;
setPlanDetailKind(kind); setPlanDetailAction(action); setPlanDetailLoading(true);
try {
const response = await listAssetImportPlanItems(detail.id, { entityKind: kind ?? undefined, action: action ?? undefined, page, pageSize: 50 });
setPlanDetailItems(response.data); setPlanDetailMeta(response.meta);
} catch (current) { setError(errorMessage(current)); }
finally { setPlanDetailLoading(false); }
};
const loadReviews = async (page = 1, kind = reviewKind) => {
setReviewsLoading(true);
try {
const response = await listAssetImportReviews({ page, pageSize: 30, kind });
setReviews(response.data); setReviewMeta(response.meta);
} catch (current) { setError(errorMessage(current)); }
finally { setReviewsLoading(false); }
};
const loadBatches = async (preferId?: string) => {
const response = await listAssetImportBatches({ pageSize: 100 });
setBatches(response.data);
const next = preferId ?? selectedId ?? response.data[0]?.id ?? null;
setSelectedId(next);
};
const loadOrganizations = async () => {
const response = await listAssetImportOrganizations();
setOrganizations(response.data);
};
const loadDetail = async (id: string, page = 1) => {
setDetailLoading(true); setError('');
try {
const [batch, rowResponse, currentPlan] = await Promise.all([
getAssetImportBatch(id),
listAssetImportRows(id, { page, pageSize: 50, status: rowStatusFilter, search: rowSearch.trim() || undefined }),
getAssetImportPlan(id),
]);
setDetail(batch); setRows(rowResponse.data); setRowMeta(rowResponse.meta); setPlan(currentPlan); setPlanDetailKind(null); setPlanDetailAction(null); setPlanDetailItems([]);
if (currentPlan?.operatorAssetId) setOperatorAssetId(currentPlan.operatorAssetId);
if (currentPlan?.externalIdNamespace) setExternalNamespace(currentPlan.externalIdNamespace);
if (batch.profileCode === 'MENDOZA_INVENTORY_V1') await loadOrganizations();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setDetailLoading(false); }
};
useEffect(() => {
Promise.all([loadBatches(), loadReviews(1, 'ALL')]).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []);
useEffect(() => {
setOperatorAssetId(''); setExternalNamespace(''); setMatchItem(null); setMatchResults([]); setPlanDetailAction(null);
if (selectedId) void loadDetail(selectedId, 1);
else { setDetail(null); setPlan(null); setRows([]); }
}, [selectedId, rowStatusFilter]);
useEffect(() => {
if (!scrollToReviews || section !== 'BATCHES' || !detail || detail.id !== selectedId || plan?.status !== 'REVIEW_REQUIRED') return;
const target = document.getElementById('import-review-panel');
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
setScrollToReviews(false);
}, [scrollToReviews, section, detail, selectedId, plan]);
const analyze = async (event: FormEvent) => {
event.preventDefault();
if (!file) { setError('Seleccioná un archivo XLSX o CSV'); return; }
setSaving(true); setError(''); setSuccess('');
try {
const created = await uploadAssetImport({ file, sourceLabel, notes, profileCode: profileCode || undefined });
setFile(null); setSourceLabel(''); setNotes(''); setProfileCode('');
await loadBatches(created.id); setSelectedId(created.id);
setSuccess(`Archivo analizado: ${created.totalRows.toLocaleString('es-AR')} filas. Los inventarios no fueron modificados.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const reconcile = async () => {
if (!detail || detail.status === 'CANCELLED' || plan?.status === 'APPLIED' || executionStatus(detail) === 'PARTIAL') return;
setSaving(true); setError(''); setSuccess('');
try {
await reconcileAssetImport(detail.id);
await loadBatches(detail.id); await loadDetail(detail.id, rowMeta.page); await loadReviews(1, reviewKind);
setSuccess('Conciliación completada. Las advertencias quedan para revisión y no se autoaprueban.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const generatePlan = async () => {
if (!detail || !detail.analysis.reconciliation) return;
if (detail.profileCode === 'MENDOZA_INVENTORY_V1' && !operatorAssetId) { setError('Seleccioná la organización operadora antes de generar el plan.'); return; }
setSaving(true); setError(''); setSuccess('');
try {
const created = await generateAssetImportPlan(detail.id, {
operatorAssetId: detail.profileCode === 'MENDOZA_INVENTORY_V1' ? operatorAssetId : undefined,
externalIdNamespace: detail.profileCode === 'MENDOZA_INVENTORY_V1' && externalNamespace.trim() ? externalNamespace.trim() : undefined,
});
setPlan(created); await loadBatches(detail.id); await loadDetail(detail.id, rowMeta.page); await loadReviews(1, reviewKind);
setSuccess(created.status === 'READY'
? 'Plan generado por entidades únicas. Está listo para confirmación.'
: `Revisión obligatoria: quedan ${directReviewCount(created).toLocaleString('es-AR')} decisión/es humana/s. ${dependencyReviewCount(created).toLocaleString('es-AR')} dependencia/s se recalcularán automáticamente.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const resolveReview = async (item: AssetImportPlanItem, action: 'CREATE' | 'IGNORE', reasonLabel: string) => {
if (!detail) return;
const reason = window.prompt(`Motivo para ${reasonLabel.toLowerCase()}${item.displayName}”:`, reasonLabel);
if (!reason || reason.trim().length < 5) return;
setSaving(true); setError(''); setSuccess('');
try {
const updated = await resolveAssetImportPlanItem(detail.id, item.id, { action, reason: reason.trim() });
setPlan(updated); await loadReviews(1, reviewKind); setSuccess(`Revisión resuelta: ${item.displayName}.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const searchMatch = async (item: AssetImportPlanItem, query?: string) => {
const search = (query ?? item.displayName).replace(/ · .+$/, '').trim();
setMatchItem(item); setMatchSearch(search); setMatchResults([]); setMatchLoading(true); setError('');
try {
const response = await listAssets({ pageSize: 20, search: search || undefined });
setMatchResults(response.data.filter((asset) => !item.assetTypeCode || asset.type.code === item.assetTypeCode));
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setMatchLoading(false); }
};
const chooseMatch = async (asset: AssetListItem) => {
if (!detail || !matchItem) return;
if (!window.confirm(`¿Vincular “${matchItem.displayName}” con ${asset.code} · ${asset.name}?`)) return;
const reason = window.prompt('Motivo de la vinculación manual:', 'Vinculación con existente verificada manualmente');
if (!reason || reason.trim().length < 5) return;
setSaving(true); setError('');
try {
const updated = await resolveAssetImportPlanItem(detail.id, matchItem.id, { action: 'MATCH', matchedAssetId: asset.id, reason: reason.trim() });
setPlan(updated); await loadReviews(1, reviewKind); setMatchItem(null); setMatchResults([]); setSuccess(`Se usará el registro existente ${asset.code} · ${asset.name}.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const chooseContextCandidate = async (item: AssetImportPlanItem, candidate: InventoryContextCandidate) => {
if (!detail) return;
const label = `${candidate.name}${candidate.parentName ? ` · Área ${candidate.parentName}` : ''}`;
if (!window.confirm(`¿Confirmar “${label}” como contexto territorial para este inventario?
La elección se guarda sólo para este lote. Al confirmar, el sistema recalculará automáticamente la estructura local, padres y equipos dependientes.`)) return;
const reason = window.prompt('Motivo de la elección territorial:', 'Contexto territorial verificado contra la fuente');
if (!reason || reason.trim().length < 5) return;
setSaving(true); setError(''); setSuccess('');
try {
await resolveAssetImportPlanItem(detail.id, item.id, { action: 'MATCH', matchedAssetId: candidate.id, reason: reason.trim() });
const regenerated = await generateAssetImportPlan(detail.id, {
operatorAssetId: detail.profileCode === 'MENDOZA_INVENTORY_V1' ? operatorAssetId : undefined,
externalIdNamespace: detail.profileCode === 'MENDOZA_INVENTORY_V1' && externalNamespace.trim() ? externalNamespace.trim() : undefined,
});
setPlan(regenerated);
await loadBatches(detail.id);
await loadDetail(detail.id, rowMeta.page);
await loadReviews(1, reviewKind);
setSuccess(regenerated.status === 'READY'
? `Contexto confirmado: ${label}. El sistema recalculó las dependencias y el plan quedó listo para confirmar.`
: `Contexto confirmado: ${label}. El sistema recalculó las dependencias; quedan ${directReviewCount(regenerated).toLocaleString('es-AR')} decisión/es humana/s.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const applySafePlan = async () => {
if (!detail || !plan || plan.status !== 'REVIEW_REQUIRED' || !canApply || plan.masterStateStale || summaryNumber(plan, 'safeCreateItems') <= 0) return;
const confirmation = window.prompt(`Se incorporarán únicamente los registros independientes que ya son seguros.
Listos para crear ahora: ${summaryNumber(plan, 'safeCreateItems').toLocaleString('es-AR')}
Ramas que esperan una revisión: ${summaryNumber(plan, 'blockedCreateItems').toLocaleString('es-AR')}
Decisiones pendientes: ${directReviewCount(plan).toLocaleString('es-AR')}
Dependencias pendientes: ${dependencyReviewCount(plan).toLocaleString('es-AR')}
Las ramas pendientes no se modificarán.
Escribí IMPORTAR para continuar:`);
if (confirmation !== 'IMPORTAR') return;
setSaving(true); setError(''); setSuccess('');
try {
const updated = await applySafeAssetImportPlan(detail.id, plan.planHash);
setPlan(updated); await loadBatches(detail.id); await loadDetail(detail.id, 1); await loadOrganizations(); await loadReviews(1, reviewKind);
setSuccess(`Importación parcial completada. Los registros seguros se incorporaron y las revisiones pendientes quedaron guardadas para resolver más adelante.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const applyPlan = async () => {
if (!detail || !plan || plan.status !== 'READY' || !canApply || plan.masterStateStale) return;
const confirmation = window.prompt(`Esta acción incorporará registros al inventario en una única transacción.\n\nCrear nuevos: ${summaryNumber(plan, 'createItems').toLocaleString('es-AR')}\nUsar existentes: ${summaryNumber(plan, 'matchItems').toLocaleString('es-AR')}\nIgnorar: ${summaryNumber(plan, 'ignoreItems').toLocaleString('es-AR')}\n\nEscribí APLICAR para confirmar:`);
if (confirmation !== 'APLICAR') return;
setSaving(true); setError(''); setSuccess('');
try {
const applied = await applyAssetImportPlan(detail.id, plan.planHash);
setPlan(applied); await loadBatches(detail.id); await loadDetail(detail.id, 1); await loadOrganizations(); await loadReviews(1, reviewKind);
setSuccess('Importación aplicada transaccionalmente. Los registros y relaciones quedaron vinculados al lote de origen.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const rollbackPlan = async () => {
if (!detail || !plan || (plan.status !== 'APPLIED' && executionStatus(detail) !== 'PARTIAL') || !canApply) return;
const reason = window.prompt('Motivo de la reversión lógica del lote:');
if (!reason || reason.trim().length < 8) return;
if (!window.confirm('La reversión inactivará únicamente entidades creadas por este lote y se bloqueará si ya tienen uso posterior. ¿Continuar?')) return;
setSaving(true); setError(''); setSuccess('');
try {
const rolledBack = await rollbackAssetImportPlan(detail.id, reason.trim());
setPlan(rolledBack); await loadBatches(detail.id); await loadDetail(detail.id, 1); await loadOrganizations(); await loadReviews(1, reviewKind);
setSuccess('Lote revertido lógicamente. No se eliminaron registros históricos.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const cancel = async () => {
if (!detail || detail.status === 'CANCELLED' || plan?.status === 'APPLIED' || executionStatus(detail) === 'PARTIAL') return;
const reason = window.prompt('Motivo de cancelación del lote:');
if (!reason || reason.trim().length < 5) return;
setSaving(true); setError('');
try {
await cancelAssetImport(detail.id, reason.trim());
await loadBatches(detail.id); await loadDetail(detail.id);
setSuccess('Lote cancelado. El archivo queda conservado para auditoría.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const filteredIssueCount = useMemo(() => detail?.topIssues.reduce((sum, item) => sum + item.count, 0) ?? 0, [detail]);
const reconciliation = detail ? record(detail.analysis.reconciliation) : null;
const planByKind = plan ? record(plan.summary.byKind) : null;
const applied = plan?.status === 'APPLIED';
const planReady = plan?.status === 'READY';
const planMasterStale = Boolean(plan?.masterStateStale);
const directReviewItems = plan?.reviewItems.filter((item) => !isDependencyReview(item)) ?? [];
const dependencyReviewItems = plan?.reviewItems.filter((item) => isDependencyReview(item)) ?? [];
const execution = detail ? record(detail.analysis.importExecution) : null;
const partiallyApplied = execution?.status === 'PARTIAL';
const appliedCreateItems = Number(execution?.appliedCreateItems ?? plan?.summary.appliedCreateItems ?? 0);
const safeCreateItems = plan ? summaryNumber(plan, 'safeCreateItems') : 0;
const blockedCreateItems = plan ? summaryNumber(plan, 'blockedCreateItems') : 0;
if (loading) return <LoadingBlock label="Cargando importaciones…" />;
return <div className="page-stack import-center-page">
<div className="page-heading">
<div><span className="eyebrow">INVENTARIOS · DATOS REALES</span><h1>Centro de importaciones</h1><p>Convertí inventarios masivos en un plan auditable antes de modificar los inventarios.</p></div>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<div className="import-section-tabs">
<button type="button" className={section === 'BATCHES' ? 'active' : ''} onClick={() => setSection('BATCHES')}><span>Lotes</span><b>{batches.length}</b></button>
<button type="button" className={section === 'REVIEWS' ? 'active' : ''} onClick={() => setSection('REVIEWS')}><span>Revisiones</span><b>{reviewMeta.total}</b></button>
</div>
{section === 'BATCHES' ? <>
<Alert type="info"><strong>Proceso de importación:</strong> las decisiones pendientes no frenan las ramas independientes. Podés importar lo seguro y dejar el resto en <strong>Revisiones</strong> para resolverlo más adelante.</Alert>
<div className="import-workflow import-workflow-six">
<span className="done">1 · Análisis</span>
<span className="done">2 · Normalización</span>
<span className={reconciliation ? 'done' : 'current'}>3 · Conciliación</span>
<span className={plan ? (plan.status === 'REVIEW_REQUIRED' ? 'current' : 'done') : reconciliation ? 'current' : ''}>4 · Revisiones</span>
<span className={partiallyApplied ? 'current' : applied ? 'done' : planReady ? 'current' : ''}>5 · Aplicación segura</span>
<span className={applied ? 'done' : ''}>6 · Cierre</span>
</div>
{canManage && <form className="panel import-upload-panel" onSubmit={analyze}>
<div className="panel-heading"><div><span className="eyebrow">NUEVO LOTE</span><h2>Analizar XLSX / CSV</h2></div><Icon name="layers" /></div>
<div className="import-upload-grid">
<label className="field"><span>Archivo</span><input type="file" accept=".xlsx,.csv" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></label>
<label className="field"><span>Fuente / empresa <em>opcional</em></span><input value={sourceLabel} onChange={(event) => setSourceLabel(event.target.value)} maxLength={200} placeholder="Phoenix, PSEnergy, EMESA…" /></label>
<label className="field"><span>Perfil <em>detección automática</em></span><SearchableSelect value={profileCode} onChange={(event) => setProfileCode(event.target.value as typeof profileCode)}><option value="">Detectar automáticamente</option><option value="MENDOZA_INVENTORY_V1">Inventario de instalaciones · Mendoza</option><option value="MENDOZA_YACIMIENTOS_V1">Tabla Área / Yacimiento · Mendoza</option></SearchableSelect></label>
<label className="field import-notes"><span>Notas <em>opcional</em></span><input value={notes} onChange={(event) => setNotes(event.target.value)} maxLength={2000} placeholder="Ej.: inventario recibido por Nota DH…" /></label>
</div>
<div className="form-actions"><button className="button primary" disabled={saving || !file}>{saving ? 'Analizando…' : 'Analizar archivo'}</button></div>
</form>}
<div className="import-center-grid">
<section className="table-panel import-batches-panel">
<div className="table-summary"><strong>Lotes analizados</strong><span>{batches.length} visibles</span></div>
{batches.length === 0 ? <EmptyState title="Todavía no hay importaciones" text="Subí uno de los inventarios reales para validar su estructura antes de incorporarlo al inventario correspondiente." /> : <div className="import-batch-list">
{batches.map((batch) => {
const state = batchStatus(batch);
return <button key={batch.id} className={`import-batch-item ${selectedId === batch.id ? 'selected' : ''}`} onClick={() => setSelectedId(batch.id)}>
<div><strong>{batch.originalName}</strong><span>{profileLabel(batch.profileCode)} · {batch.totalRows.toLocaleString('es-AR')} filas</span><small>{batch.sourceLabel || 'Fuente sin identificar'} · {new Date(batch.createdAt).toLocaleString('es-AR')}</small></div>
<span className={`status-badge ${state.className}`}>{state.label}</span>
</button>;
})}
</div>}
</section>
<section className="import-detail-column">
{!selectedId ? <EmptyState title="Seleccioná un lote" text="Elegí un archivo analizado para revisar su normalización y plan de importación." /> : detailLoading && !detail ? <LoadingBlock label="Cargando lote…" /> : detail && <>
<div className="panel import-summary-panel">
<div className="panel-heading"><div><span className="eyebrow">ANÁLISIS</span><h2>{detail.originalName}</h2></div><div className="form-actions">{canManage && detail.status !== 'CANCELLED' && !applied && !partiallyApplied && <button className="button secondary" disabled={saving} onClick={() => void reconcile()}>{saving ? 'Procesando…' : 'Conciliar con inventarios'}</button>}{canManage && detail.status !== 'CANCELLED' && !applied && !partiallyApplied && <button className="button danger-outline" disabled={saving} onClick={cancel}>Cancelar lote</button>}</div></div>
<div className="import-kpis">
<div><small>Filas</small><strong>{detail.totalRows.toLocaleString('es-AR')}</strong></div>
<div className="ok"><small>Analizables</small><strong>{detail.readyRows.toLocaleString('es-AR')}</strong></div>
<div className="warn"><small>Advertencias</small><strong>{detail.warningRows.toLocaleString('es-AR')}</strong></div>
<div className="bad"><small>Conflictos</small><strong>{detail.conflictRows.toLocaleString('es-AR')}</strong></div>
</div>
<div className="import-meta-grid">
<div><small>Perfil detectado</small><strong>{profileLabel(detail.profileCode)}</strong><span>Confianza {detail.profileConfidence}%</span></div>
<div><small>Hoja / encabezado</small><strong>{detail.worksheetName ?? '—'}</strong><span>Fila {detail.headerRow ?? '—'}</span></div>
<div><small>Archivo</small><strong>{formatBytes(detail.sizeBytes)}</strong><span>SHA {detail.sha256.slice(0, 12)}</span></div>
</div>
{reconciliation && <div className="reconciliation-summary"><strong>Conciliación por filas</strong><div><span>Nuevas <b>{Number(reconciliation.createRows ?? 0).toLocaleString('es-AR')}</b></span><span>Ya existentes <b>{Number(reconciliation.matchRows ?? 0).toLocaleString('es-AR')}</b></span><span>Revisar <b>{Number(reconciliation.reviewRows ?? 0).toLocaleString('es-AR')}</b></span><span>Ignorar <b>{Number(reconciliation.ignoreRows ?? 0).toLocaleString('es-AR')}</b></span></div><small>Es una señal preliminar. El Plan siguiente deduplica Áreas, Yacimientos, Organizaciones, instalaciones y elementos antes de cualquier incorporación.</small></div>}
{detail.duplicateFiles.length > 0 && <Alert type="info">Este mismo archivo (mismo SHA-256) ya fue analizado {detail.duplicateFiles.length} vez/veces anteriormente.</Alert>}
{detail.topIssues.length > 0 && <div className="issue-cloud"><strong>Principales observaciones · {filteredIssueCount.toLocaleString('es-AR')}</strong><div>{detail.topIssues.map((issue) => <span key={issue.code}>{ISSUE_LABELS[issue.code] ?? issue.code} <b>{issue.count}</b></span>)}</div></div>}
</div>
{reconciliation && detail.status !== 'CANCELLED' && <div className="panel import-plan-panel">
<div className="panel-heading"><div><span className="eyebrow">PLAN DE IMPORTACIÓN</span><h2>Entidades únicas y operaciones</h2><p>Las filas repetidas se agrupan antes de crear Áreas, Yacimientos, relaciones e instalaciones.</p></div>{plan && (() => { const state = planState(plan); return <span className={`status-badge ${state.className}`}>{state.label}</span>; })()}</div>
{detail.profileCode === 'MENDOZA_INVENTORY_V1' && !applied && <div className="import-plan-context">
<label className="field"><span>Operadora del inventario <strong>obligatorio</strong></span><SearchableSelect value={operatorAssetId} onChange={(event) => setOperatorAssetId(event.target.value)}><option value="">Seleccionar organización</option>{organizations.map((organization) => <option key={organization.id} value={organization.id}>{organization.name} · {organization.code}{organization.legalName && organization.legalName !== organization.name ? ` · ${organization.legalName}` : ''}</option>)}</SearchableSelect><small>Sólo se consideran Áreas con relación OPERATOR activa para esta organización.</small></label>
<label className="field"><span>Namespace de ID externo <em>opcional</em></span><input value={externalNamespace} onChange={(event) => setExternalNamespace(event.target.value.toUpperCase())} maxLength={80} placeholder="Ej.: PSENERGY" /><small>Evita tratar IDs de inventario de distintas fuentes como si fueran globales.</small></label>
</div>}
{!applied && canManage && <div className="form-actions import-plan-actions"><button className="button secondary" disabled={saving || (detail.profileCode === 'MENDOZA_INVENTORY_V1' && !operatorAssetId)} onClick={() => void generatePlan()}>{saving ? 'Generando…' : plan ? 'Regenerar plan' : 'Generar plan por entidades'}</button></div>}
{plan && <>
<div className="import-plan-kpis import-plan-kpi-actions">
<button type="button" className="ok" disabled={summaryNumber(plan, 'createItems') === 0} onClick={() => void loadPlanDetail(null, 1, 'CREATE')}><small>Crear nuevos</small><strong>{summaryNumber(plan, 'createItems').toLocaleString('es-AR')}</strong><span>No existen en los inventarios</span></button>
<button type="button" disabled={summaryNumber(plan, 'matchItems') === 0} onClick={() => void loadPlanDetail(null, 1, 'MATCH')}><small>Usar existentes</small><strong>{summaryNumber(plan, 'matchItems').toLocaleString('es-AR')}</strong><span>Ya existen; no se duplican</span></button>
<button type="button" className="bad" disabled={summaryNumber(plan, 'reviewItems') === 0} onClick={() => document.getElementById('import-review-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })}><small>Decisiones obligatorias</small><strong>{directReviewCount(plan).toLocaleString('es-AR')}</strong><span>{dependencyReviewCount(plan) > 0 ? `${dependencyReviewCount(plan).toLocaleString('es-AR')} dependencias se recalculan solas` : 'Necesitan una decisión humana'}</span></button>
<button type="button" disabled={summaryNumber(plan, 'ignoreItems') === 0} onClick={() => void loadPlanDetail(null, 1, 'IGNORE')}><small>Ignorar</small><strong>{summaryNumber(plan, 'ignoreItems').toLocaleString('es-AR')}</strong><span>No se incorporan al inventario</span></button>
</div>
<div className="import-plan-meta"><span>Revisión <b>#{plan.revision}</b></span>{plan.externalIdNamespace && <span>Namespace <b>{plan.externalIdNamespace}</b></span>}<span>Hash <code>{plan.planHash.slice(0, 12)}</code></span></div>
{summaryNumber(plan, 'explicitUnassignedOperatorFields') > 0 && <Alert>La fuente declara <strong>{summaryNumber(plan, 'explicitUnassignedOperatorFields').toLocaleString('es-AR')} Yacimientos sin empresa operadora</strong>. Se conservarán como borradores dentro de su Área, sin inventar una organización ni asignar contexto operativo.</Alert>}
{planByKind && <div className="import-plan-breakdown">
<div className="table-summary"><strong>Plan relacional por entidad</strong><span>Las filas se convierten en entidades únicas y relaciones; no se duplican catálogos.</span></div>
{summaryNumber(plan, 'relationalTerritoryPlan') > 0 && <div className="import-plan-kpis compact">
<div><small>Departamentos únicos</small><strong>{summaryNumber(plan, 'normalizedDepartments').toLocaleString('es-AR')}</strong></div>
<div><small>Tipos de derecho</small><strong>{summaryNumber(plan, 'normalizedLegalRightTypes').toLocaleString('es-AR')}</strong></div>
<div><small>Área Departamento</small><strong>{summaryNumber(plan, 'areaDepartmentRelations').toLocaleString('es-AR')}</strong></div>
<div><small>Derechos / concesiones</small><strong>{summaryNumber(plan, 'legalRights').toLocaleString('es-AR')}</strong></div>
</div>}
{PLAN_GROUPS.map((group) => {
const visibleKinds = group.kinds.filter((kind) => Number(record(planByKind[kind])?.total ?? 0) > 0);
if (!visibleKinds.length) return null;
return <section key={group.label} className="import-plan-group"><h4>{group.label}</h4><div className="table-scroll"><table><thead><tr><th>Entidad</th><th>Crear nuevos</th><th>Usar existentes</th><th>Revisar</th><th>Ignorar</th><th>Total</th><th></th></tr></thead><tbody>{visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return <tr key={kind}><td><strong>{ENTITY_LABELS[kind]}</strong></td><td>{Number(counts.create ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.match ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.review ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.ignore ?? 0).toLocaleString('es-AR')}</td><td>{Number(counts.total ?? 0).toLocaleString('es-AR')}</td><td><button type="button" className="button secondary small" onClick={() => void loadPlanDetail(kind, 1, null)}>Ver detalle</button></td></tr>; })}</tbody></table></div></section>;
})}
{(planDetailKind || planDetailAction) && <div className="import-plan-detail"><div className="table-summary"><strong>{planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'}</strong><span>{planDetailMeta.total.toLocaleString('es-AR')} elementos únicos</span></div>{planDetailLoading ? <LoadingBlock label="Cargando detalle del plan…" /> : <><div className="table-scroll"><table><thead><tr><th>Decisión</th><th>Entidad / relación</th><th>Qué hará el sistema</th><th>Filas fuente</th></tr></thead><tbody>{planDetailItems.map((item) => { const objectId = matchedObjectId(item); return <tr key={item.id}><td><span className={`status-badge ${item.action === 'REVIEW' ? 'observed' : item.action === 'CREATE' ? 'active' : item.action === 'MATCH' ? 'pending' : 'inactive'}`}>{PLAN_ACTION_LABELS[item.action]}</span></td><td><strong>{item.displayName}</strong><small className="cell-subtle">{ENTITY_LABELS[item.entityKind]}</small>{item.entityKind === 'DEPARTMENT' && <small className="cell-subtle">Código {String(item.payload.departmentCode ?? '—')}</small>}{item.entityKind === 'LEGAL_RIGHT' && <small className="cell-subtle">{String(item.payload.rightType ?? '—')} · estado inicial PENDING</small>}</td><td>{item.action === 'MATCH' ? <div className="import-match-explanation"><strong>Se reutiliza un registro del inventario</strong><span>{MATCH_CRITERIA[item.entityKind]}</span>{objectId && <code>ID {objectId.slice(0, 8)}</code>}</div> : item.action === 'CREATE' ? <div className="import-match-explanation"><strong>Se creará un registro nuevo</strong><span>No se encontró una coincidencia exacta y segura con las reglas de este tipo.</span></div> : item.action === 'REVIEW' ? <div className="row-issue-list">{item.reviewCodes.slice(0, 3).map((code) => <span key={code}>{reviewGuidance(code).title}</span>)}</div> : <span className="cell-subtle">No se incorporará al inventario.</span>}</td><td>{sourceRowsLabel(item.sourceRowNumbers)}</td></tr>; })}</tbody></table></div>{planDetailMeta.totalPages > 1 && <div className="pagination"><button type="button" disabled={planDetailMeta.page <= 1} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page - 1, planDetailAction)}>Anterior</button><span>Página {planDetailMeta.page} de {planDetailMeta.totalPages}</span><button type="button" disabled={planDetailMeta.page >= planDetailMeta.totalPages} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page + 1, planDetailAction)}>Siguiente</button></div>}</>}</div>}
</div>}
{plan.status === 'REVIEW_REQUIRED' && <div id="import-review-panel" className="import-review-panel">
<div className="import-review-mandatory-banner">
<div><span className="eyebrow">REVISIONES PENDIENTES</span><strong>Podés continuar con los registros independientes y decidir esto más adelante</strong><p>Las ramas que dependan de una decisión quedan pendientes. El resto puede incorporarse al inventario sin asumir datos ni jerarquías.</p></div>
<div className="import-review-lock"><Icon name="shield" /><span>Ramas pendientes protegidas</span><small>Las decisiones no se aplican hasta resolverlas</small></div>
</div>
<div className="import-review-steps">
<div className="current"><span>1</span><strong>Resolver decisiones</strong><small>{directReviewCount(plan).toLocaleString('es-AR')} pendientes</small></div>
<div><span>2</span><strong>Recalcular dependencias</strong><small>{dependencyReviewCount(plan).toLocaleString('es-AR')} automáticas</small></div>
<div><span>3</span><strong>Confirmar importación</strong><small>Sólo con plan limpio</small></div>
</div>
<Alert type="info"><strong>No resuelvas instalaciones o padres derivados.</strong> Si varias filas dependen del mismo Área/Yacimiento, elegís ese contexto una sola vez. El sistema vuelve a generar el plan y recalcula estructura local, padres y equipos.</Alert>
<Alert><strong>Nomenclatura de la operadora preservada.</strong> BATERIA, SET, PTC y otros nombres propios no se convierten en categorías universales de DH. Se conservan como <em>Estructura local según fuente</em>.</Alert>
<div className="import-review-overview">
<div className="human"><small>Vos tenés que decidir</small><strong>{directReviewCount(plan).toLocaleString('es-AR')}</strong><span>Decisiones humanas obligatorias</span></div>
<div className="automatic"><small>El sistema resuelve después</small><strong>{dependencyReviewCount(plan).toLocaleString('es-AR')}</strong><span>Dependencias automáticas</span></div>
<div className="import-review-reason-summary"><small>Motivos detectados</small><div>{Array.from(plan.reviewItems.reduce((map, item) => { item.reviewCodes.forEach((code) => map.set(code, (map.get(code) ?? 0) + 1)); return map; }, new Map<string, number>()).entries()).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([code, count]) => <span key={code}>{reviewGuidance(code).title} <b>{count}</b></span>)}</div></div>
</div>
{directReviewItems.length === 0 ? <Alert type="info"><strong>No quedan decisiones humanas visibles en este plan.</strong> Si todavía aparecen dependencias, regenerá el plan para que el sistema las recalcule sobre las decisiones ya tomadas.</Alert> : <>
<div className="import-review-section-heading"><div><span className="eyebrow">ACCIÓN REQUERIDA</span><strong>Estas son las únicas tarjetas que tenés que resolver</strong></div><span>{directReviewItems.length.toLocaleString('es-AR')} mostradas</span></div>
<div className="import-review-list direct-only">{directReviewItems.map((item) => {
const facts = reviewFacts(item);
const provisional = isProvisionalName(item.displayName);
const contextCandidates = inventoryContextCandidates(item);
const contextDecision = item.payload.inventoryContextDecision === true;
return <article key={item.id} className="import-review-item decision">
<div className="import-review-copy">
<div className="import-review-header">
<span className="status-badge observed">{contextDecision ? 'Elegir contexto territorial' : 'Decisión humana requerida'}</span>
<span className="eyebrow">{ENTITY_LABELS[item.entityKind]} · {sourceRowsLabel(item.sourceRowNumbers)}</span>
</div>
<strong className="import-review-title">{provisionalDisplayName(item.displayName)}</strong>
{provisional && <span className="import-review-provisional">Nombre provisional de un plan anterior. Regenerá con la versión actual para preservar la nomenclatura local.</span>}
{facts.length > 0 && <div className="import-review-source-box"><strong>1 · Qué informa el archivo</strong><dl className="import-review-facts">{facts.map(([label, current]) => <div key={label}><dt>{label}</dt><dd>{current}</dd></div>)}</dl></div>}
<div className="import-review-reasons"><div><strong>2 · Por qué el sistema no decide solo</strong>{item.reviewCodes.map((code) => { const guidance = reviewGuidance(code); return <p key={code}><b>{guidance.title}.</b> {guidance.detail}</p>; })}</div></div>
</div>
<div className="import-review-resolution">
<strong>3 · Qué tenés que decidir</strong>
{contextDecision ? <>
<p>El nombre de la fuente puede corresponder a más de un lugar. Compará el Área padre y elegí <b>una sola alternativa</b>. No se modifica el inventario al elegir.</p>
<div className="import-context-candidates">{contextCandidates.map((candidate) => <button type="button" key={candidate.id} className="import-context-candidate" disabled={saving} onClick={() => void chooseContextCandidate(item, candidate)}><span className="import-context-choice-copy"><strong>{candidate.name}</strong><span>{candidate.parentName ? `Área: ${candidate.parentName}` : candidate.typeCode === 'area' ? 'Área' : 'Yacimiento'} · Código ${candidate.code}</span></span><span className="import-context-choice-action">Elegir este</span></button>)}</div>
{contextCandidates.length === 0 && <p className="cell-subtle">No quedaron alternativas válidas. Regenerá el plan o revisá el inventario.</p>}
<small className="import-review-after-choice"><b>Después:</b> el plan se regenera automáticamente y recalcula todos los registros dependientes.</small>
</> : <>
<p>{item.reviewCodes.map((code) => reviewGuidance(code).decision).join(' ')}</p>
<div className="form-actions">{item.payload.manualMatchAllowed !== false && item.assetTypeCode && item.entityKind !== 'OPERATOR_RELATION' && <button className="button secondary" disabled={saving} onClick={() => void searchMatch(item)}>Usar un registro existente</button>}{item.payload.manualCreateAllowed === true && Boolean(item.assetTypeCode) && <button className="button secondary" disabled={saving} onClick={() => void resolveReview(item, 'CREATE', 'Creación aceptada manualmente')}>Crear uno nuevo</button>}{item.entityKind === 'TECHNICAL_ASSET' && <button className="button danger-outline" disabled={saving} onClick={() => void resolveReview(item, 'IGNORE', 'Registro excluido manualmente de la importación')}>No importar esta fila</button>}</div>
</>}
</div>
</article>;
})}</div>
</>}
{dependencyReviewItems.length > 0 && <details className="import-review-dependencies">
<summary><span><strong>{dependencyReviewCount(plan).toLocaleString('es-AR')} dependencias automáticas</strong><small>No requieren una decisión individual</small></span><span>Ver detalle</span></summary>
<div className="import-dependency-list">{dependencyReviewItems.map((item) => <div key={item.id}><div><strong>{provisionalDisplayName(item.displayName)}</strong><span>{ENTITY_LABELS[item.entityKind]} · {sourceRowsLabel(item.sourceRowNumbers)}</span></div><small>{item.reviewCodes.map((code) => reviewGuidance(code).title).join(' · ')}</small></div>)}</div>
</details>}
</div>}
{matchItem && <div className="import-match-picker"><div className="table-summary"><strong>Vincular {provisionalDisplayName(matchItem.displayName)} con un registro existente</strong><button className="button secondary" onClick={() => { setMatchItem(null); setMatchResults([]); }}>Cerrar</button></div><div className="search-field"><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void searchMatch(matchItem, matchSearch); } }} placeholder="Código o nombre en inventarios…" /><button onClick={() => void searchMatch(matchItem, matchSearch)}>Buscar</button></div>{matchLoading ? <LoadingBlock label="Buscando registros…" /> : matchResults.length === 0 ? <p className="cell-subtle">No se encontraron registros compatibles en los primeros resultados. Probá otro código o nombre.</p> : <div className="import-match-results">{matchResults.map((asset) => <button key={asset.id} onClick={() => void chooseMatch(asset)}><strong>{asset.code} · {asset.name}</strong><span>{asset.type.name}{asset.parent ? ` · ${asset.parent.name}` : ''}</span></button>)}</div>}</div>}
{partiallyApplied && <Alert type="info"><strong>Importación parcial en curso.</strong> Ya se incorporaron <strong>{appliedCreateItems.toLocaleString('es-AR')}</strong> entidades del lote. Las revisiones y sus dependencias siguen pendientes y pueden resolverse desde la pestaña <strong>Revisiones</strong>.</Alert>}
{planReady && !planMasterStale && <Alert type="success">Plan listo: no quedan decisiones pendientes. La confirmación valida tanto el hash del plan como el estado de los inventarios antes de escribir.</Alert>}
{planReady && planMasterStale && <Alert type="info"><strong>Plan obsoleto.</strong> El inventario cambió desde que se generó esta revisión. Conciliá y regenerá el plan antes de aplicar para evitar duplicados.</Alert>}
{plan.status === 'APPLIED' && <Alert type="success">Importación aplicada el {plan.appliedAt ? new Date(plan.appliedAt).toLocaleString('es-AR') : '—'}. El lote permanece trazable y puede revertirse lógicamente mientras no existan dependencias posteriores.</Alert>}
{plan.status === 'ROLLED_BACK' && <Alert type="info">La importación fue revertida lógicamente. Las entidades creadas quedaron inactivas y se conservó su historial.</Alert>}
<div className="form-actions import-apply-actions">
{plan.status === 'REVIEW_REQUIRED' && canApply && safeCreateItems > 0 && <button className="button primary" disabled={saving || planMasterStale} onClick={() => void applySafePlan()}>{saving ? 'Importando…' : planMasterStale ? 'Regenerá el plan antes de importar' : 'Importar registros seguros'}</button>}
{plan.status === 'REVIEW_REQUIRED' && canApply && safeCreateItems === 0 && <span className="cell-subtle">No quedan ramas independientes para importar. {blockedCreateItems.toLocaleString('es-AR')} registros esperan una revisión.</span>}
{planReady && canApply && <button className="button primary" disabled={saving || planMasterStale} onClick={() => void applyPlan()}>{saving ? 'Aplicando…' : planMasterStale ? 'Regenerá el plan antes de aplicar' : 'Confirmar y completar importación'}</button>}
{(planReady || plan.status === 'REVIEW_REQUIRED') && !canApply && <span className="cell-subtle">Sólo Director/Admin con permiso <code>asset_imports.apply</code> puede aplicar.</span>}
{(plan.status === 'APPLIED' || partiallyApplied) && canApply && <button className="button danger-outline" disabled={saving} onClick={() => void rollbackPlan()}>{partiallyApplied ? 'Revertir importación parcial' : 'Revertir lote'}</button>}
</div>
</>}
</div>}
<div className="toolbar import-row-toolbar">
<label className="select-field"><span>Resultado</span><SearchableSelect value={rowStatusFilter} onChange={(event) => setRowStatusFilter(event.target.value as AssetImportRowStatus | '')}><option value="">Todos</option><option value="READY">Listos</option><option value="WARNING">Advertencias</option><option value="CONFLICT">Conflictos</option><option value="IGNORED">Ignorados</option></SearchableSelect></label>
<div className="search-field"><Icon name="search" /><input value={rowSearch} onChange={(event) => setRowSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void loadDetail(detail.id, 1); } }} placeholder="Buscar ID, equipo, ubicación…" /><button onClick={() => void loadDetail(detail.id, 1)}>Buscar</button></div>
</div>
<div className="table-panel import-rows-table">
<div className="table-summary"><strong>Vista previa normalizada</strong><span>{rowMeta.total.toLocaleString('es-AR')} filas</span></div>
<div className="table-scroll"><table>
{detail.profileCode === 'MENDOZA_YACIMIENTOS_V1' ? <>
<thead><tr><th>Fila</th><th>Resultado</th><th>Área</th><th>Yacimiento</th><th>Departamento</th><th>Derecho</th><th>Operadora</th><th>Conciliación</th><th>Observaciones</th></tr></thead>
<tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}>
<td>{row.rowNumber}</td><td><span className={`status-badge ${state.className}`}>{state.label}</span></td>
<td><strong>{value(row.normalizedData, 'area')}</strong></td><td>{value(row.normalizedData, 'field')}</td><td>{value(row.normalizedData, 'department')}</td><td>{value(row.normalizedData, 'rightType')}</td><td>{value(row.normalizedData, 'operator')}</td>
<td>{row.importedAssetId ? <span className="ok-text">Importado</span> : row.matchedAssetId ? <span className="ok-text">Ya existe</span> : rec ? <span>{row.suggestedAction === 'REVIEW' || Number(rec.candidateCount ?? 0) > 1 ? 'Revisar' : 'Nuevo'}</span> : <span className="cell-subtle">Sin conciliar</span>}</td>
<td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}</div></td>
</tr>; })}</tbody>
</> : <>
<thead><tr><th>Fila</th><th>Resultado</th><th>Área / Yacimiento</th><th>Instalación / sector</th><th>Equipo</th><th>Normalización</th><th>ID inventario</th><th>Cant.</th><th>Estado fuente</th><th>Estado sugerido</th><th>Conciliación</th><th>Observaciones</th></tr></thead>
<tbody>{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return <tr key={row.id}>
<td>{row.rowNumber}</td><td><span className={`status-badge ${state.className}`}>{state.label}</span></td>
<td>{value(row.normalizedData, 'areaOrField')}</td>
<td><strong>{value(row.normalizedData, 'installation')}</strong><small className="cell-subtle">{value(row.normalizedData, 'subInstallation')}</small></td>
<td><strong>{value(row.normalizedData, 'equipment')}</strong><small className="cell-subtle">{value(row.normalizedData, 'sourceClassification')}</small></td>
<td><strong>{value(row.normalizedData, 'normalizedFamily')}</strong><small className="cell-subtle">{value(row.normalizedData, 'normalizedSubtype')}</small></td>
<td>{value(row.normalizedData, 'inventoryId')}</td><td>{value(row.normalizedData, 'quantity')}</td><td>{value(row.normalizedData, 'sourceStatus')}</td>
<td><strong>{value(row.normalizedData, 'operationalStatusSuggestion')}</strong><small className="cell-subtle">{value(row.normalizedData, 'conditionStatusSuggestion')}</small></td>
<td>{row.importedAssetId ? <span className="ok-text">Importado</span> : row.matchedAssetId ? <span className="ok-text">Ya existe</span> : rec ? <span>{row.suggestedAction === 'REVIEW' || Number(rec.candidateCount ?? 0) > 1 ? 'Revisar' : 'Nuevo'}</span> : <span className="cell-subtle">Sin conciliar</span>}</td>
<td><div className="row-issue-list">{row.issues.length ? row.issues.slice(0, 3).map((issue) => <span key={issue}>{ISSUE_LABELS[issue] ?? issue}</span>) : <span className="ok-text">Sin observaciones</span>}{row.issues.length > 3 && <small>+{row.issues.length - 3}</small>}</div></td>
</tr>; })}</tbody>
</>}
</table></div>
<div className="pagination"><button className="button secondary" disabled={rowMeta.page <= 1 || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page - 1)}>Anterior</button><span>Página {rowMeta.page} de {Math.max(rowMeta.totalPages, 1)}</span><button className="button secondary" disabled={rowMeta.page >= rowMeta.totalPages || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page + 1)}>Siguiente</button></div>
</div>
</>}
</section>
</div>
</> : <AssetImportReviewsPanel
data={reviews}
loading={reviewsLoading}
kind={reviewKind}
meta={reviewMeta}
onKindChange={(kind) => { setReviewKind(kind); void loadReviews(1, kind); }}
onPageChange={(page) => void loadReviews(page, reviewKind)}
onOpenBatch={(batchId) => { setSection('BATCHES'); setScrollToReviews(true); setSelectedId(batchId); }}
/>}
</div>;
}
+232
View File
@@ -0,0 +1,232 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
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.' },
];
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
{ value: 'TEXT', label: 'Texto' },
{ value: 'NUMBER', label: 'Número' },
{ value: 'BOOLEAN', label: 'Sí / No' },
{ value: 'DATE', label: 'Fecha' },
{ value: 'DATETIME', label: 'Fecha y hora' },
{ value: 'SELECT', label: 'Lista de opciones' },
];
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);
}
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 [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
const [attributeCode, setAttributeCode] = useState('');
const [attributeName, setAttributeName] = useState('');
const [attributeType, setAttributeType] = useState<AssetAttributeDataType>('TEXT');
const [attributeRequired, setAttributeRequired] = useState(false);
const [attributeActive, setAttributeActive] = useState(true);
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);
};
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 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 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) => {
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);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const openAttribute = (attribute: AssetAttributeDefinition | 'new') => {
setAttributeEditor(attribute);
if (attribute === 'new') {
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT');
setAttributeRequired(false); setAttributeActive(true); setAttributeUnit('');
setAttributeOptions(''); setAttributeOrder(selected?.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);
}
setError(''); setSuccess('');
};
const saveAttribute = async (event: FormEvent) => {
event.preventDefault();
if (!selected || !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');
setAttributeEditor(null);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <LoadingBlock label="Cargando tipos de inventario…" />;
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>}
</div>
</div>}
</section>;
}
+145
View File
@@ -0,0 +1,145 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { PermissionGate } from '../auth/PermissionGate';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { useOperationalContext } from '../context/OperationalContext';
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
import { AssetHierarchyView } from '../features/assets/AssetHierarchyView';
import {
assetOperationalStatusLabel,
assetStatusClass,
assetStatusLabel,
ASSET_OPERATIONAL_STATUSES,
ASSET_STATUSES,
} from '../features/assets/assetPresentation';
import { listAssets, listAssetTree, listAssetTypes } from '../lib/api';
import type {
AssetInformationStatus,
AssetListItem,
AssetOperationalStatus,
AssetType,
PageMeta,
} from '../lib/api';
import { formatDate } from '../lib/format';
const quickViews = [
{ key: 'all', label: 'Todos' },
{ key: 'validation', label: 'Pendientes de validar' },
{ key: 'location', label: 'Sin ubicación' },
{ key: 'out', label: 'Fuera de servicio' },
] as const;
export function AssetsPage() {
const [urlParams, setUrlParams] = useSearchParams();
const operationalContext = useOperationalContext();
const [assets, setAssets] = useState<AssetListItem[]>([]);
const [types, setTypes] = useState<AssetType[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
const view = urlParams.get('view') === 'list' ? 'list' : 'hierarchy';
const quick = (urlParams.get('quick') ?? 'all') as typeof quickViews[number]['key'];
const search = urlParams.get('search') ?? '';
const typeId = urlParams.get('typeId') ?? '';
const rawStatus = urlParams.get('status') ?? '';
const rawOperationalStatus = urlParams.get('operationalStatus') ?? '';
const operationalAreaId = operationalContext.areaId || urlParams.get('operationalAreaId') || '';
const operatorCompanyId = operationalContext.companyId || urlParams.get('operatorCompanyId') || '';
const status = ASSET_STATUSES.some((item) => item.value === rawStatus) ? rawStatus as AssetInformationStatus : '';
const operationalStatus = ASSET_OPERATIONAL_STATUSES.some((item) => item.value === rawOperationalStatus) ? rawOperationalStatus as AssetOperationalStatus : '';
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
const needsValidation = quick === 'validation' ? true : undefined;
const hasGeometry = quick === 'location' ? false : undefined;
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
useEffect(() => {
listAssetTypes().then(setTypes).catch(() => undefined);
}, []);
useEffect(() => {
if (view !== 'list') return;
setLoading(true); setError('');
listAssets({
page, pageSize: 25, search, typeId, status,
operationalStatus: effectiveOperationalStatus,
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,
}), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
const update = (changes: Record<string, string | null>) => {
const next = new URLSearchParams(urlParams);
Object.entries(changes).forEach(([key, value]) => value ? next.set(key, value) : next.delete(key));
next.delete('page');
setUrlParams(next);
};
const applySearch = (event: FormEvent) => { event.preventDefault(); update({ search: draftSearch.trim() || null }); };
const setQuick = (key: string) => update({ quick: key === 'all' ? null : key, operationalStatus: key === 'out' ? null : rawOperationalStatus || null });
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 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>
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div>
<AssetCenterTabs active={view === 'hierarchy' ? 'navigate' : 'list'} />
<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…" />
<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>
</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>
<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>
</div>}
</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>}
</section>;
}
+74
View File
@@ -0,0 +1,74 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { getAuditEvent, listAudit } from '../lib/api';
import type { AuditEventDetail, AuditEventSummary, PageMeta } from '../lib/api';
import { actionLabel, formatDate } from '../lib/format';
interface AuditFilters {
search: string;
action: string;
source: string;
from: string;
to: string;
}
const emptyFilters: AuditFilters = { search: '', action: '', source: '', from: '', to: '' };
export function AuditPage() {
const [events, setEvents] = useState<AuditEventSummary[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [draft, setDraft] = useState<AuditFilters>(emptyFilters);
const [filters, setFilters] = useState<AuditFilters>(emptyFilters);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<AuditEventDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
setLoading(true); setError('');
listAudit({
page, pageSize: 25, search: filters.search, action: filters.action,
source: filters.source,
from: filters.from ? `${filters.from}T00:00:00.000Z` : undefined,
to: filters.to ? `${filters.to}T23:59:59.999Z` : undefined,
}).then((response) => { setEvents(response.data); setMeta(response.meta); })
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [filters, page]);
const apply = (event: FormEvent) => { event.preventDefault(); setPage(1); setFilters({ ...draft }); };
const clear = () => { setDraft(emptyFilters); setFilters(emptyFilters); setPage(1); };
const set = (key: keyof AuditFilters, value: string) => setDraft((current) => ({ ...current, [key]: value }));
const openDetail = async (id: string) => {
setDetail(null); setDetailLoading(true); setError('');
try { setDetail(await getAuditEvent(id)); }
catch (requestError) { setError(errorMessage(requestError)); }
finally { setDetailLoading(false); }
};
return <section>
<div className="page-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h1>Auditoría</h1><p>Registro central de accesos y cambios administrativos.</p></div><span className="count-pill large">{meta.total} eventos</span></div>
<form className="audit-filters panel" onSubmit={apply}>
<label className="search-field"><Icon name="search" /><input value={draft.search} onChange={(event) => set('search', event.target.value)} placeholder="Usuario, entidad, acción o request ID" /></label>
<label className="field compact-field"><span>Acción</span><input value={draft.action} onChange={(event) => set('action', event.target.value)} placeholder="Ej. USER_UPDATED" /></label>
<label className="field compact-field"><span>Origen</span><SearchableSelect value={draft.source} onChange={(event) => set('source', event.target.value)}><option value="">Todos</option><option>WEB</option><option>ANDROID</option><option>SYSTEM</option><option>IMPORT</option></SearchableSelect></label>
<label className="field compact-field"><span>Desde</span><input type="date" value={draft.from} onChange={(event) => set('from', event.target.value)} /></label>
<label className="field compact-field"><span>Hasta</span><input type="date" value={draft.to} onChange={(event) => set('to', event.target.value)} /></label>
<div className="filter-actions"><button className="button text" type="button" onClick={clear}>Limpiar</button><button className="button primary">Aplicar filtros</button></div>
</form>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Consultando auditoría…" /> : events.length === 0 ? <EmptyState title="Sin eventos" text="No hay registros para los filtros seleccionados." /> : <div className="table-panel audit-table"><div className="table-scroll"><table><thead><tr><th>Fecha y hora</th><th>Usuario</th><th>Acción</th><th>Entidad</th><th>Origen</th><th>Request ID</th><th /></tr></thead><tbody>{events.map((event) => <tr key={event.id}><td>{formatDate(event.occurredAt)}</td><td><strong>{event.actorUsername ?? 'Sistema'}</strong><small className="cell-subtext">{event.ip ?? 'IP no registrada'}</small></td><td><span className={`event-badge ${event.action.includes('FAILED') || event.action.includes('REUSE') ? 'warning' : ''}`}>{actionLabel(event.action)}</span><small className="cell-subtext code-text">{event.action}</small></td><td>{event.entityType ?? '—'}<small className="cell-subtext code-text">{event.entityId ?? ''}</small></td><td><span className="tag">{event.source}</span></td><td><code>{event.requestId ? `${event.requestId.slice(0, 12)}` : '—'}</code></td><td><button className="icon-button" onClick={() => openDetail(event.id)} aria-label="Ver detalle"><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>Página {page} de {Math.max(meta.totalPages, 1)}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
{(detailLoading || detail) && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !detailLoading) setDetail(null); }}><aside className="detail-drawer" role="dialog" aria-modal="true" aria-label="Detalle de auditoría">{detailLoading ? <LoadingBlock label="Cargando detalle…" /> : detail && <><div className="drawer-heading"><div><span className="eyebrow">EVENTO DE AUDITORÍA</span><h2>{actionLabel(detail.action)}</h2></div><button className="icon-button" onClick={() => setDetail(null)} aria-label="Cerrar">×</button></div><dl className="detail-list"><div><dt>Fecha</dt><dd>{formatDate(detail.occurredAt)}</dd></div><div><dt>Usuario</dt><dd>{detail.actorUsername ?? 'Sistema'}</dd></div><div><dt>Origen / IP</dt><dd>{detail.source} · {detail.ip ?? '—'}</dd></div><div><dt>Entidad</dt><dd>{detail.entityType ?? '—'} {detail.entityId ? `· ${detail.entityId}` : ''}</dd></div><div><dt>Request ID</dt><dd><code>{detail.requestId ?? '—'}</code></dd></div><div><dt>User agent</dt><dd>{detail.userAgent ?? '—'}</dd></div></dl><JsonDetail title="Datos anteriores" value={detail.beforeData} /><JsonDetail title="Datos posteriores" value={detail.afterData} /><JsonDetail title="Metadatos" value={detail.metadata} /></>}</aside></div>}
</section>;
}
function JsonDetail({ title, value }: { title: string; value: Record<string, unknown> | null }) {
if (!value) return null;
return <details className="json-detail" open><summary>{title}</summary><pre>{JSON.stringify(value, null, 2)}</pre></details>;
}
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
export function ChangePasswordPage() {
const { user, changePassword, logout } = useAuth();
const navigate = useNavigate();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmation, setConfirmation] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const submit = async (event: FormEvent) => {
event.preventDefault();
setError('');
if (newPassword !== confirmation) return setError('Las contraseñas nuevas no coinciden');
if (newPassword.length < 12) return setError('La nueva contraseña debe tener al menos 12 caracteres');
setSubmitting(true);
try {
await changePassword(currentPassword, newPassword);
navigate('/', { replace: true });
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSubmitting(false);
}
};
const leave = async () => {
await logout().catch(() => undefined);
navigate('/login', { replace: true });
};
return (
<main className="centered-page">
<form className="login-card password-card" onSubmit={submit}>
<div className="round-icon"><Icon name="key" size={24} /></div>
<span className="eyebrow">SEGURIDAD DE CUENTA</span>
<h2>{user?.mustChangePassword ? 'Cambiá tu contraseña temporal' : 'Cambiar contraseña'}</h2>
<p className="muted">Usá al menos 12 caracteres. Las demás sesiones abiertas se cerrarán automáticamente.</p>
{error && <Alert>{error}</Alert>}
<label className="field"><span>Contraseña actual</span><input type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} required /></label>
<label className="field"><span>Nueva contraseña</span><input type="password" autoComplete="new-password" minLength={12} value={newPassword} onChange={(event) => setNewPassword(event.target.value)} required /></label>
<label className="field"><span>Repetir nueva contraseña</span><input type="password" autoComplete="new-password" minLength={12} value={confirmation} onChange={(event) => setConfirmation(event.target.value)} required /></label>
<button className="button primary wide" disabled={submitting}>{submitting ? 'Guardando…' : 'Guardar nueva contraseña'}</button>
<button className="button text wide" type="button" onClick={leave}>Cerrar sesión</button>
</form>
</main>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useState } 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 { getDashboardSummary, getHealth } from '../lib/api';
import type { DashboardSummary, HealthResponse } from '../lib/api';
import { actionLabel, formatDate, formatDateOnly } from '../lib/format';
function todayDate(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
export function DashboardPage() {
const { user, hasPermission } = useAuth();
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [health, setHealth] = useState<HealthResponse | null>(null);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([getDashboardSummary(), getHealth()])
.then(([dashboard, service]) => { setSummary(dashboard); setHealth(service); })
.catch((requestError) => setError(errorMessage(requestError)));
}, []);
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 y las próximas tareas de inspección.</p></div>
<span className={`health-pill ${health?.status === 'ok' ? 'ok' : ''}`}><span />{health?.status === 'ok' ? 'Sistema operativo' : 'Verificando sistema'}</span>
</div>
{error && <Alert>{error}</Alert>}
{!summary ? <LoadingBlock label="Cargando panel…" /> : <>
<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 activo</span></div></Link>
<Link className="stat-card" to="/inventarios?quick=validation"><div className="stat-icon violet"><Icon name="layers" /></div><div><small>REGISTROS A VALIDAR</small><strong>{summary.counts.assetsNeedValidation}</strong><span>Datos pendientes de revisión</span></div></Link>
</div>
<div className="dashboard-section-heading compact"><div><span className="eyebrow">INVENTARIOS Y SEGUIMIENTO</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 en los inventarios</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="/hallazgos?workflow=WAITING_COMPANY"><div className="stat-icon gray"><Icon name="clipboard" /></div><div><small>ESPERANDO RESPUESTA</small><strong>{summary.counts.awaitingCompanyResponse}</strong><span>{summary.counts.overdueCompanyResponses} vencidas · {summary.counts.companyResponsesDueNext7Days} vencen en 7 días</span></div></Link>
<Link className="stat-card compact" to="/hallazgos?workflow=TO_VERIFY"><div className="stat-icon green"><Icon name="calendar" /></div><div><small>PRÓXIMAS VERIFICACIONES</small><strong>{summary.counts.controlsNext30Days}</strong><span>{summary.counts.awaitingVerificationSchedule} pendientes de programar</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 que tienen una fecha de verificación programada.</p></div><Link className="text-link" to="/hallazgos">Ver hallazgos <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={`/inspecciones/actas/${control.actId}`}><span className={`activity-dot ${control.nextControlOn < todayDate() ? 'overdue-dot' : ''}`} /><div><strong>{control.title}</strong><p>{control.assetCode} · {control.assetName}</p></div><time>{formatDateOnly(control.nextControlOn)}</time></Link>)}
</div>
</article>}
<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>
</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</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>}
{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>
</article>
</div>
</>}
</section>;
}
+16
View File
@@ -0,0 +1,16 @@
import { FormEvent, useEffect, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { getDocumentDeliverySettings, listDocumentDeliveries, retryDocumentDelivery, retryPendingDocumentDeliveries, updateDocumentDeliverySettings } from '../lib/api';
import type { DocumentDeliveryItem, DocumentDeliverySettings } from '../lib/api';
import { useAuth } from '../auth/AuthContext';
const statusLabel:Record<DocumentDeliveryItem['status'],string>={PENDING:'Pendiente',WAITING_RECIPIENT:'Falta destinatario',WAITING_TRANSPORT:'SMTP sin configurar',WAITING_ARTIFACT:'Documento pendiente',SENT:'Enviado',FAILED:'Error'};
export function DocumentDeliveryPage(){ const {hasPermission}=useAuth(); const canManage=hasPermission('document_delivery.manage'); const [settings,setSettings]=useState<DocumentDeliverySettings|null>(null); const [items,setItems]=useState<DocumentDeliveryItem[]>([]); const [officeEmail,setOfficeEmail]=useState(''); const [directorEmail,setDirectorEmail]=useState(''); const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState('');
const load=async()=>{const [s,o]=await Promise.all([getDocumentDeliverySettings(),listDocumentDeliveries()]);setSettings(s);setOfficeEmail(s.officeEmail??'');setDirectorEmail(s.directorEmail??'');setItems(o.data);}; useEffect(()=>{load().catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[]);
const save=async(e:FormEvent)=>{e.preventDefault();setSaving(true);setError('');setSuccess('');try{const s=await updateDocumentDeliverySettings({officeEmail:officeEmail||null,directorEmail:directorEmail||null});setSettings(s);setSuccess('Destinatarios institucionales actualizados');}catch(err){setError(errorMessage(err));}finally{setSaving(false);}};
const retryAll=async()=>{setSaving(true);setError('');setSuccess('');try{const r=await retryPendingDocumentDeliveries();await load();setSuccess(`Se procesaron ${r.processed} entrega/s pendientes.`);}catch(err){setError(errorMessage(err));}finally{setSaving(false);}};
if(loading)return <LoadingBlock label="Cargando entrega documental…"/>; return <section className="narrow-section"><div className="page-heading"><div><span className="eyebrow">DOCUMENTOS</span><h1>Entrega documental</h1><p>Configurá los destinatarios institucionales y controlá el envío automático de Actas e Informes.</p></div>{canManage&&<button className="button secondary" onClick={retryAll} disabled={saving}><Icon name="history"/>Reintentar pendientes</button>}</div>{error&&<Alert>{error}</Alert>}{success&&<Alert type="success">{success}</Alert>}
<form className="panel form-panel" onSubmit={save}><div className="form-section"><div><h2>Destinatarios institucionales</h2><p className="section-copy">El email de cada empresa se configura dentro de su ficha de Inventario. Las credenciales SMTP permanecen fuera de la base de datos.</p></div><div className="form-grid"><label className="field"><span>Email de oficina</span><input type="email" value={officeEmail} onChange={e=>setOfficeEmail(e.target.value)} disabled={!canManage}/></label><label className="field"><span>Email del Director de Hidrocarburos</span><input type="email" value={directorEmail} onChange={e=>setDirectorEmail(e.target.value)} disabled={!canManage}/></label></div><div className="temporal-notice"><Icon name={settings?.smtpConfigured?'check':'alert'}/><p><strong>{settings?.smtpConfigured?'Servidor de correo configurado':'Servidor de correo pendiente'}</strong>{settings?.mailFrom?` · Remitente ${settings.mailFrom}`:' · Falta configurar SMTP_HOST y MAIL_FROM en el servidor.'}</p></div>{canManage&&<div className="form-actions"><button className="button primary" disabled={saving}><Icon name="check"/>{saving?'Guardando…':'Guardar destinatarios'}</button></div>}</div></form>
<div className="panel"><div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Últimas entregas</h2></div><span>{items.length}</span></div>{items.length===0?<div className="inline-empty">Todavía no existen entregas documentales.</div>:<div className="table-wrap"><table><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th></th></tr></thead><tbody>{items.map(item=><tr key={item.id}><td><strong>{item.documentKind==='ACT_PDF'?item.actCode:item.reportCode}</strong><small className="block-muted">{item.documentKind==='ACT_PDF'?'Acta PDF':'Informe Word'}</small></td><td>{item.recipientAssetName??(item.recipientKind==='COMPANY'?'Empresa sin identificar':item.recipientKind==='OFFICE'?'Oficina':'Director')}<small className="block-muted">{item.recipientEmail??'Sin email configurado'}</small></td><td><span className={`status-badge ${item.status==='SENT'?'active':item.status==='FAILED'?'inactive':'pending'}`}>{statusLabel[item.status]}</span>{item.lastError&&<small className="block-muted">{item.lastError}</small>}</td><td>{item.attempts}</td><td>{canManage&&item.status!=='SENT'&&<button className="button text" type="button" disabled={saving} onClick={async()=>{setSaving(true);try{await retryDocumentDelivery(item.id);await load();}catch(err){setError(errorMessage(err));}finally{setSaving(false);}}}>Reintentar</button>}</td></tr>)}</tbody></table></div>}</div>
</section>; }
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
import {
approveFieldDiscovery,
listAssets,
listFieldDiscoveries,
matchFieldDiscovery,
rejectFieldDiscovery,
} from '../lib/api';
import type { AssetListItem, FieldDiscovery, FieldDiscoveryStatus, PageMeta } from '../lib/api';
import { formatDate } from '../lib/format';
const statuses: Array<{ value: FieldDiscoveryStatus | ''; label: string }> = [
{ value: 'PENDING', label: 'Pendientes' },
{ value: 'APPROVED', label: 'Aprobadas' },
{ value: 'MATCHED', label: 'Conciliadas' },
{ value: 'REJECTED', label: 'Rechazadas' },
{ value: '', label: 'Todas' },
];
function statusLabel(status: FieldDiscoveryStatus) {
if (status === 'APPROVED') return 'Aprobada';
if (status === 'MATCHED') return 'Conciliada';
if (status === 'REJECTED') return 'Rechazada';
return 'Pendiente';
}
export function FieldDiscoveriesPage() {
const [items, setItems] = useState<FieldDiscovery[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [status, setStatus] = useState<FieldDiscoveryStatus | ''>('PENDING');
const [search, setSearch] = useState('');
const [draftSearch, setDraftSearch] = useState('');
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState('');
const [error, setError] = useState('');
const [matchFor, setMatchFor] = useState<FieldDiscovery | null>(null);
const [matchSearch, setMatchSearch] = useState('');
const [candidates, setCandidates] = useState<AssetListItem[]>([]);
const [matchLoading, setMatchLoading] = useState(false);
const load = () => {
setLoading(true); setError('');
listFieldDiscoveries({ page, pageSize: 25, status: status || undefined, search })
.then((response) => { setItems(response.data); setMeta(response.meta); })
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
};
useEffect(load, [page, status, search]);
const applySearch = (event: FormEvent) => {
event.preventDefault();
setPage(1); setSearch(draftSearch.trim());
};
const approve = async (item: FieldDiscovery) => {
if (!window.confirm(`¿Validar ${item.asset.name} e incorporarlo al Inventario?`)) return;
setBusyId(item.id); setError('');
try { await approveFieldDiscovery(item.id); load(); }
catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusyId(''); }
};
const reject = async (item: FieldDiscovery) => {
const reason = window.prompt('Motivo del rechazo (queda registrado en el historial):', '');
if (!reason || reason.trim().length < 5) return;
setBusyId(item.id); setError('');
try { await rejectFieldDiscovery(item.id, reason.trim()); load(); }
catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusyId(''); }
};
const searchMatches = async (event: FormEvent) => {
event.preventDefault();
if (!matchFor || matchSearch.trim().length < 2) return;
setMatchLoading(true); setError('');
try {
const response = await listAssets({
page: 1,
pageSize: 12,
search: matchSearch.trim(),
operationalAreaId: matchFor.asset.operationalAreaId ?? undefined,
operatorCompanyId: matchFor.asset.operatorCompanyId ?? undefined,
});
setCandidates(response.data.filter((candidate) => candidate.id !== matchFor.asset.id && candidate.informationStatus !== 'INACTIVE'));
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setMatchLoading(false); }
};
const chooseMatch = async (candidate: AssetListItem) => {
if (!matchFor) return;
const reason = window.prompt(`Confirmá por qué ${matchFor.asset.name} corresponde a ${candidate.name}:`, 'Coincidencia verificada en oficina');
if (!reason || reason.trim().length < 5) return;
setBusyId(matchFor.id); setError('');
try {
await matchFieldDiscovery(matchFor.id, candidate.id, reason.trim());
setMatchFor(null); setMatchSearch(''); setCandidates([]); load();
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusyId(''); }
};
return <section>
<div className="page-heading asset-center-heading">
<div><span className="eyebrow">INVENTARIOS</span><h1>Altas encontradas en campo</h1><p>Revisá elementos creados por inspectores durante una visita antes de incorporarlos definitivamente al Inventario.</p></div>
</div>
<AssetCenterTabs active="field" />
<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 nombre, código, inspección o inspector…" />
<button className="button primary" type="submit">Buscar</button>
</form>
<div className="quick-view-row">
{statuses.map((option) => <button key={option.label} type="button" className={status === option.value ? 'active' : ''} onClick={() => { setPage(1); setStatus(option.value); }}>{option.label}</button>)}
</div>
</div>
{error && <Alert>{error}</Alert>}
<Alert>Una alta de campo queda en borrador. Podés corregir su ficha, aprobarla, conciliarla con un registro existente o rechazarla. Las Actas y Hallazgos que ya la referencien no se reescriben.</Alert>
{loading ? <LoadingBlock label="Cargando altas de campo…" /> : items.length === 0 ? <EmptyState title="No hay altas en esta bandeja" text="Cuando un inspector registre un elemento nuevo durante una visita aparecerá aquí." /> : <div className="table-panel">
<div className="table-summary"><strong>{meta.total} alta{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>Elemento</th><th>Inspección</th><th>Inspector</th><th>Estado</th><th>Acciones</th></tr></thead><tbody>
{items.map((item) => <tr key={item.id}>
<td><Link className="table-primary" to={`/inventarios/${item.asset.id}`}>{item.asset.name}</Link><small className="cell-subtext">{item.asset.code} · {item.asset.typeName}{item.asset.commonName ? ` · ${item.asset.commonName}` : ''}</small></td>
<td><Link to={`/inspecciones/${item.visit.id}`}>{item.visit.code}</Link><small className="cell-subtext">{formatDate(item.observedAt)}</small></td>
<td><strong className="table-primary">{item.creator.firstName} {item.creator.lastName}</strong><small className="cell-subtext">{item.creator.username}</small></td>
<td><span className={`status-badge ${item.status === 'PENDING' ? 'warning' : item.status === 'APPROVED' ? 'success' : item.status === 'MATCHED' ? 'info' : 'muted'}`}>{statusLabel(item.status)}</span>{item.matchedAsset && <small className="cell-subtext"> {item.matchedAsset.name}</small>}</td>
<td className="action-cell">
<Link className="button secondary compact" to={`/inventarios/${item.asset.id}`}>Corregir ficha</Link>
{item.status === 'PENDING' && <>
<button className="button primary compact" disabled={busyId === item.id} onClick={() => approve(item)}>Aprobar</button>
<button className="button secondary compact" disabled={busyId === item.id} onClick={() => { setMatchFor(item); setMatchSearch(''); setCandidates([]); }}>Conciliar</button>
<button className="button danger-outline compact" disabled={busyId === item.id} onClick={() => reject(item)}>Rechazar</button>
</>}
</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>}
{matchFor && <div className="panel field-discovery-match-panel">
<div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Empresa y Área. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div>
<form className="asset-center-search" onSubmit={searchMatches}><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /><button className="button primary" disabled={matchLoading}>{matchLoading ? 'Buscando…' : 'Buscar'}</button></form>
{candidates.length > 0 && <div className="table-scroll"><table><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></table></div>}
</div>}
</section>;
}
+183
View File
@@ -0,0 +1,183 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { FindingCatalogProposalsPanel } from '../features/inspections/FindingCatalogProposalsPanel';
import { FindingCatalogTypeApplicabilityPanel } from '../features/inspections/FindingCatalogTypeApplicabilityPanel';
import {
createFindingCatalogItem,
createFindingCategory,
getFindingCatalogAdmin,
updateFindingCatalogItem,
updateFindingCategory,
} from '../lib/api';
import type {
FindingAdminCatalog,
FindingAdminCatalogItem,
FindingAdminCategory,
} from '../lib/api';
type CategoryEditor = FindingAdminCategory | 'new' | null;
type ItemEditor = FindingAdminCatalogItem | 'new' | null;
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
export function FindingCatalogPage() {
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [selectedCategoryId, setSelectedCategoryId] = useState<string>('');
const [search, setSearch] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [categoryEditor, setCategoryEditor] = useState<CategoryEditor>(null);
const [categoryCode, setCategoryCode] = useState('');
const [categoryName, setCategoryName] = useState('');
const [categoryOrder, setCategoryOrder] = useState(0);
const [categoryActive, setCategoryActive] = useState(true);
const [itemEditor, setItemEditor] = useState<ItemEditor>(null);
const [itemCategoryId, setItemCategoryId] = useState('');
const [itemCode, setItemCode] = useState('');
const [itemNumber, setItemNumber] = useState(1);
const [itemTitle, setItemTitle] = useState('');
const [legalBasis, setLegalBasis] = useState('');
const [glossary, setGlossary] = useState('');
const [importNote, setImportNote] = useState('');
const [suggestedSeverity, setSuggestedSeverity] = useState<number | ''>('');
const [itemActive, setItemActive] = useState(true);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = async (preferCategoryId?: string) => {
const loaded = await getFindingCatalogAdmin();
setCatalog(loaded);
setSelectedCategoryId((current) => {
const preferred = preferCategoryId ?? current;
return loaded.categories.some((category) => category.id === preferred)
? preferred
: loaded.categories[0]?.id ?? '';
});
};
useEffect(() => {
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []);
const categories = useMemo(() => catalog.categories.filter((category) =>
showInactive || category.isActive,
), [catalog.categories, showInactive]);
const items = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return catalog.items.filter((item) =>
item.categoryId === selectedCategoryId
&& (showInactive || item.isActive)
&& (!needle || [item.code, item.title, item.legalBasis, item.glossary]
.some((value) => value?.toLocaleLowerCase().includes(needle))),
);
}, [catalog.items, search, selectedCategoryId, showInactive]);
const selectedCategory = catalog.categories.find((category) => category.id === selectedCategoryId) ?? null;
const openCategory = (value: FindingAdminCategory | 'new') => {
setCategoryEditor(value);
if (value === 'new') {
setCategoryCode(''); setCategoryName('');
setCategoryOrder(catalog.categories.length ? Math.max(...catalog.categories.map((item) => item.sortOrder)) + 10 : 10);
setCategoryActive(true);
} else {
setCategoryCode(value.code); setCategoryName(value.name);
setCategoryOrder(value.sortOrder); setCategoryActive(value.isActive);
}
setError(''); setSuccess('');
};
const saveCategory = async (event: FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
try {
const saved = categoryEditor === 'new'
? await createFindingCategory({ code: categoryCode, name: categoryName, sortOrder: categoryOrder })
: await updateFindingCategory(categoryEditor!.id, {
name: categoryName, sortOrder: categoryOrder, isActive: categoryActive,
});
await load(saved.id);
setCategoryEditor(null);
setSuccess(categoryEditor === 'new' ? 'Categoría creada correctamente' : 'Categoría actualizada');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const openItem = (value: FindingAdminCatalogItem | 'new') => {
setItemEditor(value);
if (value === 'new') {
const nextNumber = items.length ? Math.max(...items.map((item) => item.sourceNumber)) + 1 : 1;
setItemCategoryId(selectedCategoryId); setItemCode(''); setItemNumber(nextNumber);
setItemTitle(''); setLegalBasis(''); setGlossary(''); setImportNote(''); setSuggestedSeverity(''); setItemActive(true);
} else {
setItemCategoryId(value.categoryId); setItemCode(value.code); setItemNumber(value.sourceNumber);
setItemTitle(value.title); setLegalBasis(value.legalBasis ?? ''); setGlossary(value.glossary ?? '');
setImportNote(value.importNote ?? ''); setSuggestedSeverity(value.suggestedSeverity ?? ''); setItemActive(value.isActive);
}
setError(''); setSuccess('');
};
const saveItem = async (event: FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
const input = {
categoryId: itemCategoryId,
sourceNumber: itemNumber,
title: itemTitle,
legalBasis: legalBasis || null,
glossary: glossary || null,
importNote: importNote || null,
suggestedSeverity: suggestedSeverity === '' ? null : suggestedSeverity,
};
try {
const saved = itemEditor === 'new'
? await createFindingCatalogItem({ ...input, code: itemCode })
: await updateFindingCatalogItem(itemEditor!.id, { ...input, isActive: itemActive });
await load(saved.categoryId);
setItemEditor(null);
setSuccess(itemEditor === 'new'
? 'Tipo de hallazgo creado correctamente'
: `Tipo actualizado: ahora está en revisión ${saved.revision}`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <LoadingBlock label="Cargando catálogo de hallazgos…" />;
return <section className="finding-catalog-page">
<div className="page-heading">
<div><span className="eyebrow">CONFIGURACIÓN PARA LA APK</span><h1>Catálogo de hallazgos</h1><p>Administrá las opciones que el inspector podrá seleccionar durante una inspección.</p></div>
<button className="button primary" onClick={() => openCategory('new')}><Icon name="plus" />Nueva categoría</button>
</div>
<div className="catalog-policy-note"><Icon name="shield" /><span><strong>Historial protegido.</strong> Los cambios crean una nueva revisión y no modifican los hallazgos ya registrados en actas anteriores.</span></div>
<FindingCatalogTypeApplicabilityPanel />
<FindingCatalogProposalsPanel catalog={catalog} />
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{catalog.categories.length === 0 ? <div className="empty-state"><strong>No hay categorías configuradas</strong><p>Creá la primera categoría para comenzar.</p><button className="button primary" onClick={() => openCategory('new')}>Nueva categoría</button></div> : <div className="asset-types-layout">
<aside className="panel role-list">
<div className="role-list-heading"><strong>CATEGORÍAS</strong><span>{categories.length}</span></div>
{categories.map((category) => <button key={category.id} type="button" className={`role-list-item ${selectedCategoryId === category.id ? 'active' : ''}`} onClick={() => { setSelectedCategoryId(category.id); setItemEditor(null); }}><span><strong>{category.name}</strong><small>{category.code}{!category.isActive ? ' · INACTIVA' : ''}</small></span><span className="role-count">{category.activeItemCount}/{category.itemCount}</span></button>)}
<label className="catalog-inactive-toggle"><input type="checkbox" checked={showInactive} onChange={(event) => setShowInactive(event.target.checked)} />Mostrar inactivos</label>
</aside>
<div className="asset-type-workspace">
{selectedCategory && <div className="panel catalog-heading-panel">
<div><span className="eyebrow">{selectedCategory.isActive ? 'CATEGORÍA ACTIVA' : 'CATEGORÍA INACTIVA'}</span><h2>{selectedCategory.name}</h2><small>{selectedCategory.code} · orden {selectedCategory.sortOrder}</small></div>
<div className="catalog-heading-actions"><button className="button secondary" onClick={() => openCategory(selectedCategory)}><Icon name="edit" />Editar categoría</button><button className="button primary" disabled={!selectedCategory.isActive} onClick={() => openItem('new')}><Icon name="plus" />Nuevo tipo de hallazgo</button></div>
</div>}
<div className="panel attributes-panel">
<div className="catalog-toolbar"><label className="search-field"><Icon name="search" /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar por título, código o fundamento…" /></label><span>{items.length} resultado{items.length === 1 ? '' : 's'}</span></div>
{items.length === 0 ? <div className="inline-empty">No hay tipos de hallazgo para los filtros seleccionados.</div> : <div className="attribute-list">{items.map((item) => <button type="button" className={`attribute-card catalog-item-card ${item.isActive ? '' : 'disabled'}`} key={item.id} onClick={() => openItem(item)}><span className="attribute-order">{item.sourceNumber}</span><span><strong>{item.title}</strong><small>{item.code} · revisión {item.revision} · usado en {item.usageCount} hallazgo{item.usageCount === 1 ? '' : 's'}</small></span><span className="attribute-flags">{!item.isActive && <span className="tag">Inactivo</span>}{item.suggestedSeverity && <span className="tag">Gravedad {item.suggestedSeverity}/10</span>}{item.legalBasis && <span className="tag">Con fundamento</span>}</span><Icon name="chevron" /></button>)}</div>}
</div>
</div>
</div>}
{categoryEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveCategory}><div className="drawer-heading"><div><span className="eyebrow">CATÁLOGO</span><h2>{categoryEditor === 'new' ? 'Nueva categoría' : 'Editar categoría'}</h2></div><button type="button" className="icon-button" onClick={() => setCategoryEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Código</span><input value={categoryCode} onChange={(event) => setCategoryCode(event.target.value.toUpperCase())} disabled={categoryEditor !== 'new'} required minLength={2} maxLength={80} pattern="[A-Z][A-Z0-9_]+" /></label><label className="field"><span>Nombre</span><input value={categoryName} onChange={(event) => setCategoryName(event.target.value)} required maxLength={200} /></label><label className="field"><span>Orden</span><input type="number" value={categoryOrder} onChange={(event) => setCategoryOrder(Number(event.target.value))} min={0} max={10000} required /></label>{categoryEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={categoryActive} onChange={(event) => setCategoryActive(event.target.checked)} /><span><strong>Categoría activa</strong><small>Al desactivarla deja de estar disponible en la APK.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setCategoryEditor(null)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar categoría'}</button></div></form></div>}
{itemEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveItem}><div className="drawer-heading"><div><span className="eyebrow">TIPO DE HALLAZGO</span><h2>{itemEditor === 'new' ? 'Nuevo tipo de hallazgo' : 'Editar tipo de hallazgo'}</h2>{itemEditor !== 'new' && <p>Revisión actual: {itemEditor.revision}</p>}</div><button type="button" className="icon-button" onClick={() => setItemEditor(null)}>×</button></div><div className="catalog-editor-fields"><div className="form-grid"><label className="field"><span>Categoría</span><SearchableSelect value={itemCategoryId} onChange={(event) => setItemCategoryId(event.target.value)} required>{catalog.categories.filter((category) => category.isActive || category.id === itemCategoryId).map((category) => <option key={category.id} value={category.id}>{category.name}</option>)}</SearchableSelect></label><label className="field"><span>Número</span><input type="number" value={itemNumber} onChange={(event) => setItemNumber(Number(event.target.value))} min={1} max={999999} required /></label></div><label className="field"><span>Código</span><input value={itemCode} onChange={(event) => setItemCode(event.target.value.toUpperCase())} disabled={itemEditor !== 'new'} required minLength={2} maxLength={120} pattern="[A-Z][A-Z0-9_]+" /></label><label className="field"><span>Título que verá el inspector</span><textarea value={itemTitle} onChange={(event) => setItemTitle(event.target.value)} rows={3} maxLength={500} required /></label><label className="field"><span>Gravedad sugerida <em>110 · opcional</em></span><input type="number" min={1} max={10} value={suggestedSeverity} onChange={(event) => setSuggestedSeverity(event.target.value ? Number(event.target.value) : '')} /></label><label className="field"><span>Fundamento legal <em>opcional</em></span><textarea value={legalBasis} onChange={(event) => setLegalBasis(event.target.value)} rows={5} maxLength={12000} /></label><label className="field"><span>Glosario o ayuda <em>opcional</em></span><textarea value={glossary} onChange={(event) => setGlossary(event.target.value)} rows={4} maxLength={12000} /></label><label className="field"><span>Nota de origen <em>opcional</em></span><textarea value={importNote} onChange={(event) => setImportNote(event.target.value)} rows={2} maxLength={4000} /></label>{itemEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={itemActive} onChange={(event) => setItemActive(event.target.checked)} /><span><strong>Tipo activo</strong><small>Desactivarlo conserva su historial y los hallazgos existentes.</small></span></label>}</div><div className="catalog-revision-warning"><strong>Al guardar se generará una nueva revisión.</strong><span>Las actas existentes conservarán el texto y fundamento que tenían al momento de la inspección.</span></div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setItemEditor(null)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar nueva revisión'}</button></div></form></div>}
</section>;
}
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { FindingOfficeWorkspace } from '../features/inspections/FindingOfficeWorkspace';
import { InspectionEvidencePanel } from '../features/inspections/InspectionEvidencePanel';
import { getInspectionFinding } from '../lib/api';
import type { InspectionFinding } from '../lib/api';
import { formatDate, formatDateOnly } from '../lib/format';
function todayInput(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
export function FindingDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const [finding, setFinding] = useState<InspectionFinding | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [evidenceRefreshKey, setEvidenceRefreshKey] = useState(0);
const canFollowUp = hasPermission('inspection_findings.follow_up');
const canClose = hasPermission('inspection_findings.close');
const canReadEvidence = hasPermission('inspection_evidence.read');
const canCreateEvidence = hasPermission('inspection_evidence.create');
const canReadCommunications = hasPermission('inspection_communications.read');
const canCreateCommunications = hasPermission('inspection_communications.create');
useEffect(() => {
if (!id) return;
setLoading(true);
setError('');
getInspectionFinding(id)
.then(setFinding)
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <LoadingBlock label="Cargando hallazgo…" />;
if (!finding) return <section><Alert>{error || 'Hallazgo no encontrado'}</Alert></section>;
const responseOverdue = finding.status === 'OPEN'
&& !finding.companyResponseReceivedOn
&& Boolean(finding.correctionDueOn && finding.correctionDueOn < todayInput());
const verificationOverdue = finding.status === 'OPEN'
&& Boolean(finding.companyResponseReceivedOn && finding.nextControlOn && finding.nextControlOn < todayInput());
const latestVerification = finding.latestVerification;
const verificationReadyToClose = latestVerification?.outcome === 'RESOLVED'
&& latestVerification.visitStatus === 'CLOSED';
const verificationOutcomeLabel = latestVerification?.outcome === 'RESOLVED'
? 'Solucionado'
: latestVerification?.outcome === 'NOT_RESOLVED'
? 'No solucionado'
: latestVerification?.outcome === 'REQUIRES_NEW_DATE'
? 'Requiere nueva fecha'
: 'Sin resultado';
return <section>
<div className="finding-detail-breadcrumb"><button type="button" className="text-link" onClick={() => navigate('/hallazgos')}>Hallazgos</button><span></span><strong>{finding.code}</strong></div>
<div className="page-heading finding-detail-heading">
<div><span className="eyebrow">{finding.code}</span><h1>{finding.title}</h1><p>{finding.asset.operatorCompany?.name ?? 'Empresa sin asignar'} · {finding.asset.operationalArea?.name ?? 'Área sin asignar'}</p></div>
<span className={`status-badge large ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : 'Cerrado'}</span>
</div>
{error && <Alert>{error}</Alert>}
<div className="finding-detail-summary-grid">
<article><small>Elemento del inventario</small><Link to={`/inventarios/${finding.assetId}`}>{finding.asset.name}</Link><span>{finding.asset.code} · {finding.asset.typeName}</span></article>
<article><small>Acta / inspección</small><Link to={`/inspecciones/actas/${finding.actId}`}>{finding.document.actCode}</Link><span><Link className="text-link" to={`/inspecciones/${finding.document.visitId}`}>{finding.document.visitCode}</Link></span></article>
<article className={responseOverdue ? 'overdue' : ''}><small>Vencimiento administrativo</small><strong>{formatDateOnly(finding.correctionDueOn)}</strong><span>{finding.companyResponseReceivedOn ? `Respondido ${formatDateOnly(finding.companyResponseReceivedOn)}` : 'Esperando respuesta de empresa'}</span></article>
<article className={verificationOverdue ? 'overdue' : ''}><small>Fecha de verificación</small><strong>{formatDateOnly(finding.nextControlOn)}</strong><span>{finding.nextControlOn ? 'Planificación de inspección o control' : 'Todavía sin definir'}</span></article>
<article><small>Gravedad</small><strong>{finding.severity ? `${finding.severity}/10` : 'Sin calificar'}</strong><span>{finding.suggestedSeverity ? `Sugerida por catálogo: ${finding.suggestedSeverity}/10` : finding.catalogItemId ? 'El catálogo no define una sugerencia' : 'Hallazgo OTROS'}</span></article>
</div>
{finding.verificationVisit && <article className="panel verification-linked-visit">
<div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN PLANIFICADA</span><h2>{finding.verificationVisit.code}</h2><p className="section-copy">Este hallazgo ya está incorporado a una visita de verificación.</p></div><Link className="button secondary" to={`/inspecciones/${finding.verificationVisit.id}`}>Abrir visita <Icon name="chevron" /></Link></div>
<div className="finding-two-deadlines"><div><span>FECHA OBJETIVO</span><strong>{formatDateOnly(finding.nextControlOn)}</strong><p>Fecha definida en el seguimiento del hallazgo.</p></div><div><span>VISITA</span><strong>{formatDate(finding.verificationVisit.plannedStartAt)}</strong><p>{finding.verificationVisit.code}</p></div></div>
</article>}
{latestVerification?.outcome && <article className={`panel verification-result-panel ${latestVerification.outcome === 'RESOLVED' ? 'resolved' : ''}`}>
<div className="panel-heading"><div><span className="eyebrow">ÚLTIMA VERIFICACIÓN DE CAMPO</span><h2>{verificationOutcomeLabel}</h2><p className="section-copy">Resultado registrado por el inspector durante {latestVerification.visitCode}.</p></div><Link className="button secondary" to={`/inspecciones/${latestVerification.visitId}`}>Abrir verificación <Icon name="chevron" /></Link></div>
<div className="finding-two-deadlines">
<div><span>RESULTADO</span><strong>{verificationOutcomeLabel}</strong><p>{latestVerification.resultNotes || 'Sin observaciones adicionales.'}</p></div>
<div><span>VERIFICADO</span><strong>{formatDate(latestVerification.verifiedAt)}</strong><p>{latestVerification.evidenceCount} foto{latestVerification.evidenceCount === 1 ? '' : 's'} de verificación.</p></div>
{latestVerification.rescheduledControlOn && <div><span>NUEVA FECHA</span><strong>{formatDateOnly(latestVerification.rescheduledControlOn)}</strong><p>El hallazgo vuelve a la planificación operativa.</p></div>}
</div>
{verificationReadyToClose && finding.status === 'OPEN' && <Alert type="success">La verificación de campo indicó que el hallazgo está solucionado y quedó listo para revisión de oficina.</Alert>}
{latestVerification.outcome === 'NOT_RESOLVED' && finding.status === 'OPEN' && <Alert>La verificación indicó que el hallazgo continúa abierto. Debe definirse una nueva fecha de control.</Alert>}
</article>}
<FindingOfficeWorkspace
finding={finding}
canFollowUp={canFollowUp}
canClose={canClose}
canCreateEvidence={canCreateEvidence}
canCreateCommunications={canCreateCommunications}
onFindingChanged={setFinding}
onEvidenceChanged={() => setEvidenceRefreshKey((value) => value + 1)}
/>
<div id="documentos-hallazgo">
<InspectionEvidencePanel
finding={finding}
canReadEvidence={canReadEvidence}
canCreateEvidence={canCreateEvidence}
canCreateFieldEvidence={false}
canReadCommunications={canReadCommunications}
canCreateCommunications={canCreateCommunications}
refreshKey={evidenceRefreshKey}
managedCompanyResponse
/>
</div>
{finding.status === 'CLOSED' && <article className="closed-seal finding-closed-seal"><Icon name="check" /><div><span className="eyebrow">HALLAZGO CERRADO</span><strong>{formatDate(finding.closedAt)}</strong><p>{finding.closureNotes}</p></div></article>}
</section>;
}
+190
View File
@@ -0,0 +1,190 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { PermissionGate } from '../auth/PermissionGate';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { useOperationalContext } from '../context/OperationalContext';
import { OperationalFilters } from '../features/inspections/OperationalFilters';
import { listInspectionFindingsGlobal } from '../lib/api';
import type {
InspectionFinding,
InspectionFindingWorkflow,
InspectionFindingWorkflowCounters,
PageMeta,
} from '../lib/api';
import { formatDateOnly } from '../lib/format';
const workflows: Array<{ value: InspectionFindingWorkflow; label: string }> = [
{ value: 'OPEN', label: 'Abiertos' },
{ value: 'WAITING_COMPANY', label: 'Esperando empresa' },
{ value: 'COMPANY_OVERDUE', label: 'Respuesta vencida' },
{ value: 'TO_SCHEDULE_VERIFICATION', label: 'Programar verificación' },
{ value: 'TO_VERIFY', label: 'Para verificar' },
{ value: 'VERIFICATION_OVERDUE', label: 'Verificación vencida' },
{ value: 'READY_TO_CLOSE', label: 'Listos para cerrar' },
{ value: 'CLOSED', label: 'Cerrados' },
{ value: 'ALL', label: 'Todos' },
];
function todayInput(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
function attentionLabel(finding: InspectionFinding): { label: string; className: string } {
const today = todayInput();
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') return { label: 'Listo para cerrar', className: 'active' };
if (finding.status === 'CLOSED') return { label: 'Cerrado', className: 'active' };
if (!finding.companyResponseReceivedOn) {
if (finding.correctionDueOn && finding.correctionDueOn < today) return { label: 'Respuesta vencida', className: 'danger' };
return { label: 'Esperando empresa', className: 'pending' };
}
if (!finding.nextControlOn) return { label: 'Programar verificación', className: 'observed' };
if (finding.nextControlOn < today) return { label: 'Verificación vencida', className: 'danger' };
return { label: 'Para verificar', className: 'blue' };
}
function deadlineText(finding: InspectionFinding): { title: string; value: string | null; overdue: boolean } {
const today = todayInput();
if (finding.latestVerification?.outcome === 'RESOLVED' && finding.latestVerification.visitStatus === 'CLOSED') return { title: 'Verificado conforme', value: finding.latestVerification.verifiedAt, overdue: false };
if (!finding.companyResponseReceivedOn) {
return {
title: 'Respuesta de empresa',
value: finding.correctionDueOn,
overdue: Boolean(finding.correctionDueOn && finding.correctionDueOn < today),
};
}
return {
title: 'Verificación DH',
value: finding.nextControlOn,
overdue: Boolean(finding.nextControlOn && finding.nextControlOn < today),
};
}
const emptyCounters: InspectionFindingWorkflowCounters = {
open: 0,
waitingCompany: 0,
companyOverdue: 0,
companyDueNext7Days: 0,
awaitingVerificationSchedule: 0,
toVerify: 0,
verificationOverdue: 0,
verificationNext30Days: 0,
readyToClose: 0,
closed: 0,
};
export function FindingsPage() {
const [params, setParams] = useSearchParams();
const operationalContext = useOperationalContext();
const [items, setItems] = useState<InspectionFinding[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [counters, setCounters] = useState(emptyCounters);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const workflowValue = params.get('workflow') ?? 'OPEN';
const workflow = workflows.some((item) => item.value === workflowValue)
? workflowValue as InspectionFindingWorkflow
: 'OPEN';
const search = params.get('search') ?? '';
const [draftSearch, setDraftSearch] = useState(search);
const companyId = operationalContext.companyId || params.get('companyId') || '';
const areaId = operationalContext.areaId || params.get('areaId') || '';
const inspectorId = params.get('inspectorId') ?? '';
const dateFrom = params.get('dateFrom') ?? '';
const dateTo = params.get('dateTo') ?? '';
const page = Math.max(1, Number(params.get('page') ?? 1) || 1);
useEffect(() => {
setLoading(true);
setError('');
listInspectionFindingsGlobal({ page, pageSize: 25, search, workflow, companyId, areaId, inspectorId, dateFrom, dateTo })
.then((response) => {
setItems(response.data);
setMeta(response.meta);
setCounters(response.counters);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [page, search, workflow, companyId, areaId, inspectorId, dateFrom, dateTo]);
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (key === 'areaId') {
operationalContext.setAreaId(value);
next.delete('areaId');
next.delete('companyId');
} else if (key === 'companyId') {
operationalContext.setCompanyId(value);
next.delete('companyId');
} else {
value ? next.set(key, value) : next.delete(key);
}
next.delete('page');
setParams(next);
};
const applySearch = (event: FormEvent) => {
event.preventDefault();
setFilter('search', draftSearch.trim());
};
const setPage = (value: number) => {
const next = new URLSearchParams(params);
value > 1 ? next.set('page', String(value)) : next.delete('page');
setParams(next);
};
return <section>
<div className="page-heading">
<div><span className="eyebrow">SEGUIMIENTO</span><h1>Hallazgos</h1><p>Bandeja de trabajo para respuestas de empresas, vencimientos, verificaciones y cierres.</p></div>
<PermissionGate permission="inspection_verifications.plan"><Link className="button primary" to="/hallazgos/planificacion"><Icon name="calendar" />Planificar verificaciones</Link></PermissionGate>
</div>
<div className="finding-workflow-explainer">
<div><span>1</span><strong>Vencimiento administrativo</strong><small>Fecha límite para recibir la respuesta de la empresa.</small></div>
<i></i>
<div><span>2</span><strong>Respuesta recibida</strong><small>Se carga la presentación y su documentación.</small></div>
<i></i>
<div><span>3</span><strong>Vencimiento de verificación</strong><small>Fecha para volver a inspeccionar o verificar la solución.</small></div>
<i></i>
<div><span>4</span><strong>Cierre</strong><small>El hallazgo se cierra cuando la solución queda verificada.</small></div>
</div>
<div className="finding-attention-grid">
<button type="button" className={workflow === 'WAITING_COMPANY' ? 'active' : ''} onClick={() => setFilter('workflow', 'WAITING_COMPANY')}><small>ESPERANDO EMPRESA</small><strong>{counters.waitingCompany}</strong><span>{counters.companyOverdue} vencidos</span></button>
<button type="button" className={workflow === 'COMPANY_OVERDUE' ? 'active danger' : 'danger'} onClick={() => setFilter('workflow', 'COMPANY_OVERDUE')}><small>RESPUESTA VENCIDA</small><strong>{counters.companyOverdue}</strong><span>{counters.companyDueNext7Days} vencen en 7 días</span></button>
<button type="button" className={workflow === 'TO_SCHEDULE_VERIFICATION' ? 'active' : ''} onClick={() => setFilter('workflow', 'TO_SCHEDULE_VERIFICATION')}><small>PROGRAMAR VERIFICACIÓN</small><strong>{counters.awaitingVerificationSchedule}</strong><span>Respuesta ya recibida</span></button>
<button type="button" className={workflow === 'TO_VERIFY' || workflow === 'VERIFICATION_OVERDUE' ? 'active' : ''} onClick={() => setFilter('workflow', 'TO_VERIFY')}><small>PARA VERIFICAR</small><strong>{counters.toVerify}</strong><span>{counters.verificationOverdue} vencidos</span></button>
<button type="button" className={workflow === 'READY_TO_CLOSE' ? 'active' : ''} onClick={() => setFilter('workflow', 'READY_TO_CLOSE')}><small>LISTOS PARA CERRAR</small><strong>{counters.readyToClose}</strong><span>Verificados conformes</span></button>
</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 hallazgo, empresa, área, acta o elemento" /><button>Buscar</button></label>
<label className="select-field"><span>Vista</span><SearchableSelect value={workflow} onChange={(event) => setFilter('workflow', event.target.value)}>{workflows.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={setFilter} />
</form>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando hallazgos…" /> : items.length === 0 ? <EmptyState title="Sin hallazgos en esta vista" text="No hay tareas pendientes para el filtro seleccionado." /> : <div className="table-panel findings-worklist">
<div className="table-summary"><strong>{meta.total} hallazgo{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Situación</th><th>Próximo vencimiento</th><th>Documentación</th><th /></tr></thead><tbody>{items.map((finding) => {
const attention = attentionLabel(finding);
const deadline = deadlineText(finding);
return <tr key={finding.id}>
<td><div className="finding-table-primary"><strong>{finding.title}</strong><small>{finding.code} · {finding.document.actCode}</small></div></td>
<td><div className="finding-table-primary"><strong>{finding.asset.operatorCompany?.name ?? 'Empresa sin asignar'}</strong><small>{finding.asset.operationalArea?.name ?? 'Área sin asignar'}</small></div></td>
<td><Link className="text-link" to={`/inventarios/${finding.assetId}`}>{finding.asset.name}<small className="block-muted">{finding.asset.code}</small></Link></td>
<td><span className={`status-badge ${attention.className}`}>{attention.label}</span></td>
<td><div className={`finding-deadline ${deadline.overdue ? 'overdue' : ''}`}><small>{deadline.title}</small><strong>{formatDateOnly(deadline.value)}</strong></div></td>
<td><small>{finding.companyResponseReceivedOn ? `Respuesta ${formatDateOnly(finding.companyResponseReceivedOn)}` : 'Sin respuesta'}</small></td>
<td className="action-cell"><Link className="icon-button" to={`/hallazgos/${finding.id}`} aria-label={`Abrir ${finding.code}`}><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>;
}
+130
View File
@@ -0,0 +1,130 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
import { assetStatusLabel, ASSET_STATUSES } from '../features/assets/assetPresentation';
import { AssetVersionDrawer } from '../features/assets/AssetVersionDrawer';
import {
ASSET_VERSION_CHANGES,
assetVersionChangeLabel,
assetVersionFieldLabel,
} from '../features/assets/assetVersionPresentation';
import {
getAssetVersion,
listAssetTypes,
listAssetVersions,
} from '../lib/api';
import type {
AssetInformationStatus,
AssetType,
AssetVersionChangeType,
AssetVersionDetail,
AssetVersionSummary,
PageMeta,
} from '../lib/api';
import { formatDate } from '../lib/format';
interface HistoryFilters {
search: string;
typeId: string;
status: AssetInformationStatus | '';
changeType: AssetVersionChangeType | '';
from: string;
to: string;
}
const emptyFilters: HistoryFilters = {
search: '',
typeId: '',
status: '',
changeType: '',
from: '',
to: '',
};
export function HistoryPage() {
const [versions, setVersions] = useState<AssetVersionSummary[]>([]);
const [types, setTypes] = useState<AssetType[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [draft, setDraft] = useState<HistoryFilters>(emptyFilters);
const [filters, setFilters] = useState<HistoryFilters>(emptyFilters);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<AssetVersionDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
listAssetTypes().then(setTypes).catch(() => undefined);
}, []);
useEffect(() => {
setLoading(true);
setError('');
listAssetVersions({
page,
pageSize: 25,
search: filters.search,
typeId: filters.typeId,
status: filters.status,
changeType: filters.changeType,
from: filters.from ? `${filters.from}T00:00:00.000Z` : undefined,
to: filters.to ? `${filters.to}T23:59:59.999Z` : undefined,
})
.then((response) => {
setVersions(response.data);
setMeta(response.meta);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [filters, page]);
const set = <K extends keyof HistoryFilters>(key: K, value: HistoryFilters[K]) => {
setDraft((current) => ({ ...current, [key]: value }));
};
const apply = (event: FormEvent) => {
event.preventDefault();
setPage(1);
setFilters({ ...draft, search: draft.search.trim() });
};
const clear = () => {
setDraft(emptyFilters);
setFilters(emptyFilters);
setPage(1);
};
const open = async (version: AssetVersionSummary) => {
setDetail(null);
setDetailLoading(true);
setError('');
try {
setDetail(await getAssetVersion(version.assetId, version.versionNumber));
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setDetailLoading(false);
}
};
return <section>
<div className="page-heading"><div><span className="eyebrow">INVENTARIOS</span><h1>Historial</h1><p>Historial completo de cambios realizados sobre los inventarios.</p></div><span className="count-pill large">{meta.total} versiones</span></div>
<AssetCenterTabs active="history" />
<form className="history-filters panel" onSubmit={apply}>
<label className="search-field"><Icon name="search" /><input value={draft.search} onChange={(event) => set('search', event.target.value)} placeholder="Código, registro o usuario" /></label>
<label className="field compact-field"><span>Tipo</span><SearchableSelect value={draft.typeId} onChange={(event) => set('typeId', event.target.value)}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado</span><SearchableSelect value={draft.status} onChange={(event) => set('status', event.target.value as AssetInformationStatus | '')}><option value="">Todos</option>{ASSET_STATUSES.map((status) => <option key={status.value} value={status.value}>{status.label}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Cambio</span><SearchableSelect value={draft.changeType} onChange={(event) => set('changeType', event.target.value as AssetVersionChangeType | '')}><option value="">Todos</option>{ASSET_VERSION_CHANGES.map((change) => <option key={change.value} value={change.value}>{change.label}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Desde</span><input type="date" value={draft.from} onChange={(event) => set('from', event.target.value)} /></label>
<label className="field compact-field"><span>Hasta</span><input type="date" value={draft.to} onChange={(event) => set('to', event.target.value)} /></label>
<div className="filter-actions"><button className="button text" type="button" onClick={clear}>Limpiar</button><button className="button primary">Aplicar filtros</button></div>
</form>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Consultando versiones…" /> : versions.length === 0 ? <EmptyState title="Sin versiones" text="No hay versiones históricas para los filtros seleccionados." /> : <div className="table-panel history-table"><div className="table-summary"><strong>{meta.total} versiones registradas</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Fecha y hora</th><th>Registro</th><th>Versión</th><th>Cambio</th><th>Campos</th><th>Usuario</th><th>Estado</th><th /></tr></thead><tbody>{versions.map((version) => <tr key={version.id}><td>{formatDate(version.occurredAt)}</td><td><Link className="history-asset-link" to={`/inventarios/${version.assetId}`}><strong>{version.assetName}</strong><small>{version.assetCode} · {version.typeName}</small></Link></td><td><span className={`version-badge ${version.isCurrent ? 'current' : ''}`}>v{version.versionNumber}{version.isCurrent ? ' · actual' : ''}</span></td><td><strong>{assetVersionChangeLabel(version.changeType)}</strong><small className="cell-subtext">{version.source}</small></td><td><div className="tag-list">{version.changedFields.slice(0, 3).map((field) => <span className="tag" key={field}>{assetVersionFieldLabel(field)}</span>)}</div></td><td>{version.actorUsername ?? 'Sistema'}</td><td>{assetStatusLabel(version.informationStatus)}</td><td><button type="button" className="icon-button" onClick={() => open(version)} aria-label={`Ver versión ${version.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button type="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 type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
</section>;
}
@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
inspectionActStatusClass,
inspectionActStatusLabel,
inspectionActVersionEventLabel,
} from '../features/inspections/inspectionActPresentation';
import { InspectionClosurePanel } from '../features/inspections/InspectionClosurePanel';
import { InspectionFindingsPanel } from '../features/inspections/InspectionFindingsPanel';
import { getInspectionAct, getInspectionVisit } from '../lib/api';
import type { InspectionAct, InspectionVisit } from '../lib/api';
import { formatDate } from '../lib/format';
export function InspectionActEditorPage() {
const { actId } = useParams();
const [visit, setVisit] = useState<InspectionVisit | null>(null);
const [act, setAct] = useState<InspectionAct | null>(null);
const [loading, setLoading] = useState(Boolean(actId));
const [error, setError] = useState('');
useEffect(() => {
if (!actId) return;
setLoading(true);
setError('');
getInspectionAct(actId)
.then(async (value) => {
setAct(value);
setVisit(await getInspectionVisit(value.visitId));
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [actId]);
if (!actId) {
return <section className="survey-editor inspection-act-editor">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Acta</span></div>
<div className="page-heading"><div><span className="eyebrow">OPERACIÓN EXCLUSIVA EN APK</span><h1>Crear acta de inspección</h1><p>Las actas sólo pueden generarse durante la visita física desde la aplicación móvil.</p></div></div>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Usá la APK con un usuario inspector.</strong> El dashboard recibirá el acta, sus hallazgos y firmas cuando la aplicación sincronice.</p></div>
<Link className="button secondary" to="/inspecciones">Volver a inspecciones</Link>
</section>;
}
if (loading) return <LoadingBlock label="Cargando acta…" />;
return <section className="survey-editor inspection-act-editor">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span>{visit && <><Link to={`/inspecciones/${visit.id}`}>{visit.code}</Link><span>/</span></>}<span>{act?.code ?? 'Acta'}</span></div>
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">ACTA DE INSPECCIÓN · SÓLO LECTURA</span><h1>{act?.title ?? 'Acta'}</h1><p>{act ? `${act.code} · versión ${act.currentVersion}` : 'Consulta del documento sincronizado desde la APK.'}</p></div>{act && <span className={`status-badge large ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span>}</div>
{error && <Alert>{error}</Alert>}
{visit && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>{visit.code}</strong> El contenido constatado, los hallazgos y el cierre se registran exclusivamente desde la APK. Este dashboard permite consultarlos y gestionar el seguimiento posterior.</p></div>}
{act?.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe relacionado: <Link to={`/informes/${act.report.id}`}>{act.report.code}</Link>.</strong> {act.report.pdfStatus === 'READY' ? 'El documento PDF está disponible.' : 'El contenido está congelado y el PDF continúa pendiente de composición.'}</p></div>}
{act?.status === 'CLOSED' && !act.report && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe pendiente de emisión.</strong> La solicitud se realiza desde la APK por un inspector asignado a la visita.</p></div>}
{act?.status === 'CANCELLED' && <Alert>Cancelada: {act.cancellationReason}</Alert>}
{act && <section className="panel inspection-act-form">
<div className="panel-heading"><div><span className="eyebrow">CONTENIDO SINCRONIZADO</span><h2>{act.code}</h2></div><small className="muted">Actualizado {formatDate(act.updatedAt)}</small></div>
<div className="responsible-summary">
<div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div>
<div><small>Título</small><strong>{act.title}</strong></div>
<div><small>Registros</small><strong>{act.assets.length}</strong></div>
<div><small>Hallazgos</small><strong>{act.findingCount}</strong></div>
</div>
<div className="closure-section"><span className="eyebrow">DESCRIPCIÓN DE LO ACTUADO</span><p>{act.summary}</p>{act.observations && <><span className="eyebrow">OBSERVACIONES</span><p>{act.observations}</p></>}</div>
<div className="inspection-act-assets"><div><span className="eyebrow">INVENTARIO DEL ACTA</span><h3>Referencias congeladas</h3></div><span className="count-pill">{act.assets.length}</span></div>
<div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div>
</section>}
{act && <InspectionFindingsPanel act={act} />}
{act && <InspectionClosurePanel act={act} />}
{act && <section className="panel survey-report-history">
<div className="panel-heading"><div><span className="eyebrow">VERSIONES INMUTABLES</span><h2>Historial exacto del acta</h2><p className="section-copy">Cada sincronización conserva el contenido y las versiones de los registros referenciados.</p></div><span className="count-pill">{act.versions.length}</span></div>
<div className="survey-version-list">{act.versions.map((version) => <details key={version.id}><summary><span className="count-pill">v{version.versionNumber}</span><strong>{inspectionActVersionEventLabel(version.event)}</strong><small>{formatDate(version.createdAt)} · {version.actorUsername ?? 'sistema'}</small></summary><pre>{JSON.stringify(version.snapshot, null, 2)}</pre></details>)}</div>
</section>}
</section>;
}
@@ -0,0 +1,410 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { useOperationalContext } from '../context/OperationalContext';
import {
inspectionStatusClass,
inspectionVisitStatusLabel,
} from '../features/inspections/inspectionPresentation';
import { InspectionActsPanel } from '../features/inspections/InspectionActsPanel';
import {
createInspectionVisit,
excludeInspectionVisitAsset,
generateInspectionVisitChecklist,
getInspectionVisit,
includeInspectionVisitAsset,
listAssets,
listInspectionAssignees,
listInspectionPlanningAreas,
listInspectionPlanningOperators,
replaceInspectionVisitAssets,
replaceInspectionVisitTeam,
updateInspectionVisit,
updateInspectionVisitStatus,
} from '../lib/api';
import type {
AssetListItem,
InspectionChecklistItemKind,
InspectionPerson,
InspectionPlanningContextAsset,
InspectionVisit,
InspectionVisitAssetPlanningSource,
InspectionVisitStatus,
} from '../lib/api';
import { formatDate, formatDateOnly } from '../lib/format';
interface VisitForm {
objective: string;
operationalAreaId: string;
operatorCompanyId: string;
plannedStartAt: string;
instructions: string;
}
const emptyVisit: VisitForm = {
objective: '',
operationalAreaId: '',
operatorCompanyId: '',
plannedStartAt: '',
instructions: '',
};
function localDateTime(value: string | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function isoOrNull(value: string): string | null {
return value ? new Date(value).toISOString() : null;
}
function personName(person: InspectionPerson): string {
return `${person.firstName} ${person.lastName}`;
}
function sourceLabel(source: InspectionVisitAssetPlanningSource): string {
if (source === 'AUTOMATIC') return 'Checklist automático';
if (source === 'PREVENTIVE') return 'Preventivo';
if (source === 'VERIFICATION') return 'Verificación';
return 'Plan previo';
}
function checklistLabel(kind: InspectionChecklistItemKind): string {
if (kind === 'COMPANY_OVERDUE') return 'Respuesta vencida';
if (kind === 'VERIFICATION_OVERDUE') return 'Control vencido';
if (kind === 'UPCOMING_CONTROL') return 'Próximo control';
return 'Antecedente';
}
export function InspectionVisitEditorPage() {
const { id } = useParams();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const operationalContext = useOperationalContext();
const isNew = !id;
const canManage = hasPermission('inspections.manage');
const canAssign = hasPermission('inspections.assign');
const [visit, setVisit] = useState<InspectionVisit | null>(null);
const [form, setForm] = useState<VisitForm>(emptyVisit);
const [areas, setAreas] = useState<InspectionPlanningContextAsset[]>([]);
const [operators, setOperators] = useState<InspectionPlanningContextAsset[]>([]);
const [assets, setAssets] = useState<AssetListItem[]>([]);
const [assignees, setAssignees] = useState<InspectionPerson[]>([]);
const [assetSearch, setAssetSearch] = useState('');
const [newAssetId, setNewAssetId] = useState('');
const [leadInspectorId, setLeadInspectorId] = useState('');
const [memberIds, setMemberIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(!isNew);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const applyVisit = (value: InspectionVisit, syncGeneral = true) => {
setVisit(value);
if (value.operationalArea?.id) operationalContext.setContext(value.operationalArea.id, value.operatorCompany?.id ?? '');
setLeadInspectorId(value.leadInspector?.id ?? '');
setMemberIds(new Set(value.team.map((member) => member.id)));
if (syncGeneral) {
setForm({
objective: value.objective ?? '',
operationalAreaId: value.operationalArea?.id ?? '',
operatorCompanyId: value.operatorCompany?.id ?? '',
plannedStartAt: localDateTime(value.plannedStartAt),
instructions: value.instructions ?? '',
});
}
};
useEffect(() => {
listInspectionPlanningAreas().then(setAreas).catch(() => undefined);
}, []);
useEffect(() => {
if (!isNew || form.operationalAreaId || !operationalContext.areaId) return;
setForm((current) => ({
...current,
operationalAreaId: operationalContext.areaId,
operatorCompanyId: operationalContext.companyId,
}));
}, [isNew, form.operationalAreaId, operationalContext.areaId, operationalContext.companyId]);
useEffect(() => {
if (!id) return;
setLoading(true);
getInspectionVisit(id)
.then((value) => applyVisit(value))
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (!form.operationalAreaId) {
setOperators([]);
return;
}
listInspectionPlanningOperators(form.operationalAreaId)
.then(setOperators)
.catch(() => setOperators([]));
}, [form.operationalAreaId]);
useEffect(() => {
if (!form.operationalAreaId || !form.operatorCompanyId) {
setAssets([]);
return;
}
const timer = window.setTimeout(() => {
listAssets({
pageSize: 100,
search: assetSearch.trim(),
operationalAreaId: form.operationalAreaId,
operatorCompanyId: form.operatorCompanyId,
})
.then((response) => setAssets(response.data))
.catch(() => undefined);
}, 220);
return () => window.clearTimeout(timer);
}, [assetSearch, form.operationalAreaId, form.operatorCompanyId]);
useEffect(() => {
if (!canAssign) return;
listInspectionAssignees().then(setAssignees).catch(() => undefined);
}, [canAssign]);
const planningEditable = !visit || visit.status === 'DRAFT' || visit.status === 'PLANNED';
const activeAssetIds = useMemo(
() => new Set(visit?.assets.map((asset) => asset.id) ?? []),
[visit],
);
const linkedAssetIds = useMemo(
() => new Set(visit?.planningAssets.map((asset) => asset.id) ?? []),
[visit],
);
const candidateAssets = assets.filter((asset) => !linkedAssetIds.has(asset.id));
const saveGeneral = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError('');
setSuccess('');
try {
if (isNew) {
const plannedStartAt = isoOrNull(form.plannedStartAt);
if (!form.operationalAreaId || !form.operatorCompanyId || !plannedStartAt || !leadInspectorId) {
setError('Seleccioná Área, Operadora, fecha de inicio e Inspector.');
return;
}
const created = await createInspectionVisit({
operationalAreaId: form.operationalAreaId,
operatorCompanyId: form.operatorCompanyId,
plannedStartAt,
leadInspectorUserId: leadInspectorId,
});
navigate(`/inspecciones/${created.id}`, { replace: true });
} else if (id) {
applyVisit(await updateInspectionVisit(id, {
objective: form.objective || null,
operationalAreaId: form.operationalAreaId || null,
operatorCompanyId: form.operatorCompanyId || null,
plannedStartAt: isoOrNull(form.plannedStartAt),
instructions: form.instructions || null,
}));
setSuccess('Planificación actualizada. Si cambió el contexto o la fecha, regenerá el checklist.');
}
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const generateChecklist = async () => {
if (!id) return;
setBusy(true);
setError('');
setSuccess('');
try {
applyVisit(await generateInspectionVisitChecklist(id), false);
setSuccess('Checklist recalculado con antecedentes, vencidos y próximos controles.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const addPreventiveAsset = async (event: FormEvent) => {
event.preventDefault();
if (!id || !visit || !newAssetId) return;
setBusy(true);
setError('');
setSuccess('');
try {
applyVisit(await replaceInspectionVisitAssets(id, [...activeAssetIds, newAssetId]), false);
setNewAssetId('');
setSuccess('Registro agregado como inspección preventiva.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const excludeAsset = async (assetId: string) => {
if (!id) return;
const reason = window.prompt('Motivo de exclusión (mínimo 10 caracteres). Quedará auditado:')?.trim() || '';
if (reason.length < 10) {
if (reason) setError('El motivo de exclusión debe tener al menos 10 caracteres.');
return;
}
setBusy(true);
setError('');
setSuccess('');
try {
applyVisit(await excludeInspectionVisitAsset(id, assetId, reason), false);
setSuccess('Registro excluido con motivo auditado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const reincludeAsset = async (assetId: string) => {
if (!id) return;
setBusy(true);
setError('');
setSuccess('');
try {
applyVisit(await includeInspectionVisitAsset(id, assetId), false);
setSuccess('Registro reincorporado; la exclusión anterior permanece en auditoría.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const chooseLead = (value: string) => {
setLeadInspectorId(value);
if (value) setMemberIds((current) => new Set([...current, value]));
};
const saveTeam = async (event: FormEvent) => {
event.preventDefault();
if (!id) return;
setBusy(true);
setError('');
setSuccess('');
try {
const nextMembers = new Set(memberIds);
if (leadInspectorId) nextMembers.add(leadInspectorId);
applyVisit(await replaceInspectionVisitTeam(
id,
leadInspectorId || null,
[...nextMembers],
), false);
setSuccess('Equipo de inspección actualizado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const changeStatus = async (status: InspectionVisitStatus) => {
if (!id) return;
let reason: string | null = null;
if (status === 'CANCELLED') {
reason = window.prompt('Indicá el motivo de cancelación (mínimo 10 caracteres):')?.trim() || null;
if (!reason) return;
}
setBusy(true);
setError('');
setSuccess('');
try {
applyVisit(await updateInspectionVisitStatus(id, status, reason));
setSuccess(`Visita ${inspectionVisitStatusLabel(status).toLocaleLowerCase('es-AR')}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
if (loading) return <LoadingBlock label="Cargando inspección…" />;
return <section className="survey-editor inspection-editor">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>{isNew ? 'Planificar inspección' : visit?.code ?? 'Detalle'}</span></div>
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">INSPECCIÓN</span><h1>{isNew ? 'Planificar inspección' : visit?.code ?? 'Inspección'}</h1><p>{isNew ? 'Cuatro datos y listo. El código y el checklist se generan automáticamente.' : `${visit?.operationalArea?.name ?? 'Sin Área'} · ${visit?.operatorCompany?.name ?? 'Sin Operadora'} · ${visit?.assetCount ?? 0} registros`}</p></div>{visit && <span className={`status-badge large ${inspectionStatusClass(visit.status)}`}>{inspectionVisitStatusLabel(visit.status)}</span>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<form className={`panel form-panel ${isNew ? 'inspection-quick-create' : ''}`} onSubmit={saveGeneral}>
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>{isNew ? '¿Dónde y cuándo se inspecciona?' : 'Contexto y fecha de inicio'}</h2><p className="section-copy">{isNew ? 'El código se asigna automáticamente. No hay título ni fecha de fin planificada.' : 'La inspección conserva una única fecha de inicio planificada; el cierre real se registra al finalizar en campo.'}</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
{!isNew && <div className="inspection-generated-code"><small>Código automático</small><strong>{visit?.code}</strong></div>}
<div className="form-grid">
<label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { operationalContext.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{visit?.operationalArea && !areas.some((area) => area.id === visit.operationalArea?.id) && <option value={visit.operationalArea.id}>{visit.operationalArea.name} · {visit.operationalArea.code}</option>}{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label>
<label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { operationalContext.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">{form.operationalAreaId ? 'Seleccionar Operadora…' : 'Primero seleccioná un Área'}</option>{visit?.operatorCompany && !operators.some((operator) => operator.id === visit.operatorCompany?.id) && <option value={visit.operatorCompany.id}>{visit.operatorCompany.name} · {visit.operatorCompany.code}</option>}{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect><small>Sólo operadoras vigentes para el Área elegida.</small></label>
<label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} required disabled={!canManage || !planningEditable} /></label>
{isNew && <label className="field"><span>Inspector</span><SearchableSelect searchPlaceholder="Buscar Inspector…" value={leadInspectorId} onChange={(event) => setLeadInspectorId(event.target.value)} required disabled={!canAssign}><option value="">Seleccionar Inspector</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>}
</div>
{!isNew && <><label className="field"><span>Objetivo <em>opcional</em></span><textarea rows={2} value={form.objective} onChange={(event) => setForm((current) => ({ ...current, objective: event.target.value }))} maxLength={4000} disabled={!canManage || !planningEditable} /></label><label className="field"><span>Instrucciones <em>opcional</em></span><textarea rows={2} value={form.instructions} onChange={(event) => setForm((current) => ({ ...current, instructions: event.target.value }))} maxLength={4000} disabled={!canManage || !planningEditable} /></label></>}
{canManage && planningEditable && <div className="form-actions"><Link className="button secondary" to="/inspecciones">Cancelar</Link><button className="button primary" disabled={busy || (isNew && (!canAssign || !form.operationalAreaId || !form.operatorCompanyId || !form.plannedStartAt || !leadInspectorId))}><Icon name="check" />{isNew ? (busy ? 'Creando…' : 'Crear inspección') : 'Guardar planificación'}</button></div>}
</form>
{visit && <article className="panel inspection-checklist-panel">
<div className="panel-heading"><div><span className="eyebrow">CHECKLIST AUTOMÁTICO</span><h2>Antecedentes y próximas acciones</h2><p className="section-copy">Se calcula para el Área, Operadora y fecha del plan. Las generaciones anteriores quedan preservadas.</p></div><div className="form-actions">{canManage && planningEditable && <button type="button" className="button secondary" onClick={() => void generateChecklist()} disabled={busy}><Icon name="check" />{visit.checklist.generation ? 'Regenerar checklist' : 'Generar checklist'}</button>}</div></div>
{visit.checklist.stale && <Alert>El contexto o la fecha cambió después de la última generación. Regenerá el checklist antes de confirmar.</Alert>}
{!visit.checklist.generatedAt && visit.checklist.generation === 0 && <Alert>El checklist todavía no fue generado. Al confirmar la planificación el servidor también exige una versión vigente.</Alert>}
<div className="inspection-checklist-metrics">
<div><strong>{visit.checklist.companyOverdue}</strong><span>respuestas vencidas</span></div>
<div><strong>{visit.checklist.verificationOverdue}</strong><span>controles vencidos</span></div>
<div><strong>{visit.checklist.upcomingControls}</strong><span>próximos 30 días</span></div>
<div><strong>{visit.checklist.antecedents}</strong><span>antecedentes</span></div>
<div><strong>{visit.checklist.actionableAssets}</strong><span>registros sugeridos</span></div>
</div>
{visit.checklist.generatedAt && <small className="muted">Generación {visit.checklist.generation} · {formatDate(visit.checklist.generatedAt)}</small>}
{visit.checklist.items.length === 0 ? <EmptyState title="Sin antecedentes" text="No hay hallazgos históricos para el contexto seleccionado. Podés agregar registros preventivos." /> : <div className="table-scroll"><table><thead><tr><th>Prioridad</th><th>Hallazgo</th><th>Registro</th><th>Fecha</th><th>Gravedad</th></tr></thead><tbody>{visit.checklist.items.map((item) => <tr key={item.id}><td><span className={`status-badge ${item.itemKind === 'ANTECEDENT' ? '' : 'warning'}`}>{checklistLabel(item.itemKind)}</span></td><td><Link className="history-asset-link" to={`/hallazgos/${item.findingId}`}><strong>{item.findingTitle}</strong><small>{item.findingCode} · {item.findingStatus}</small></Link></td><td><Link className="history-asset-link" to={`/inventarios/${item.asset.id}`}><strong>{item.asset.name}</strong><small>{item.asset.code} · {item.asset.typeName}</small></Link></td><td>{item.referenceOn ? formatDateOnly(item.referenceOn) : '—'}</td><td>{item.severity ?? '—'}</td></tr>)}</tbody></table></div>}
</article>}
{visit && visit.verificationFindings.length > 0 && <article className="panel verification-visit-findings">
<div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN DE HALLAZGOS</span><h2>Hallazgos a controlar</h2><p className="section-copy">Esta inspección nació desde una planificación de verificación y conserva ese vínculo histórico.</p></div><span className="count-pill">{visit.verificationFindings.length}</span></div>
<div className="dossier-link-list">{visit.verificationFindings.map((finding) => {
const outcome = finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Nueva fecha requerida' : visit.status === 'IN_PROGRESS' ? 'Pendiente de resultado' : 'Abrir seguimiento';
return <Link key={finding.id} to={`/hallazgos/${finding.id}`}><div><strong>{finding.title}</strong><small>{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}{finding.verificationEvidenceCount ? ` · ${finding.verificationEvidenceCount} foto${finding.verificationEvidenceCount === 1 ? '' : 's'}` : ''}</small>{finding.resultNotes && <small>{finding.resultNotes}</small>}{finding.rescheduledControlOn && <small>Nuevo control: {formatDateOnly(finding.rescheduledControlOn)}</small>}</div><span>{finding.status === 'CLOSED' ? 'Cerrado' : outcome}</span><Icon name="chevron" size={16} /></Link>;
})}</div>
</article>}
{visit && planningEditable && canManage && <form className="panel survey-add-target" onSubmit={addPreventiveAsset}>
<div className="panel-heading"><div><span className="eyebrow">PREVENTIVO</span><h2>Agregar registro sin pendiente previo</h2><p className="section-copy">Sólo se muestran registros pertenecientes al Área y Operadora seleccionadas.</p></div></div>
<label className="field"><span>Buscar registro</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label>
<div className="form-grid"><label className="field"><span>Registro preventivo</span><SearchableSelect value={newAssetId} onChange={(event) => setNewAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.type.name}</option>)}</SearchableSelect></label></div>
<div className="form-actions"><button className="button primary" disabled={busy || !newAssetId}><Icon name="plus" />Agregar preventivo</button></div>
</form>}
{visit && <div className="table-panel inspection-assets"><div className="table-summary"><strong>{visit.assets.length} registro{visit.assets.length === 1 ? '' : 's'} incluidos</strong><span>{visit.checklist.excludedAssets} excluidos con trazabilidad.</span></div>{visit.planningAssets.length === 0 ? <EmptyState title="Sin registros" text="Generá el checklist o agregá al menos un registro preventivo." /> : <div className="table-scroll"><table><thead><tr><th>Registro</th><th>Origen</th><th>Estado</th><th>Motivo</th><th /></tr></thead><tbody>{visit.planningAssets.map((asset) => <tr key={asset.id}><td><Link className="history-asset-link" to={`/inventarios/${asset.id}`}><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></Link></td><td>{sourceLabel(asset.planningSource)}</td><td><span className={`status-badge ${asset.included ? 'success' : 'muted'}`}>{asset.included ? 'Incluido' : 'Excluido'}</span></td><td>{asset.exclusionReason ? <><strong>{asset.exclusionReason}</strong><small>{asset.excludedBy ? `Por ${personName(asset.excludedBy)}` : ''}{asset.excludedAt ? ` · ${formatDate(asset.excludedAt)}` : ''}</small></> : '—'}</td><td className="action-cell">{canManage && planningEditable && (asset.included ? <button type="button" className="button danger-outline compact" onClick={() => void excludeAsset(asset.id)} disabled={busy}>Excluir</button> : <button type="button" className="button secondary compact" onClick={() => void reincludeAsset(asset.id)} disabled={busy}>Reincorporar</button>)}</td></tr>)}</tbody></table></div>}</div>}
{visit && canAssign && planningEditable && <form className="panel inspection-team-panel" onSubmit={saveTeam}>
<div className="panel-heading"><div><span className="eyebrow">EQUIPO</span><h2>Responsable e integrantes</h2></div><span className="count-pill">{memberIds.size}</span></div>
<label className="field"><span>Inspector responsable</span><SearchableSelect value={leadInspectorId} onChange={(event) => chooseLead(event.target.value)}><option value="">Seleccionar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>
<div className="inspection-team-grid">{assignees.map((person) => <label className={`inspection-member ${memberIds.has(person.id) ? 'selected' : ''}`} key={person.id}><input type="checkbox" checked={memberIds.has(person.id)} disabled={person.id === leadInspectorId} onChange={(event) => setMemberIds((current) => { const next = new Set(current); event.target.checked ? next.add(person.id) : next.delete(person.id); return next; })} /><span><strong>{personName(person)}</strong><small>{person.username}{person.id === leadInspectorId ? ' · Responsable' : ''}</small></span></label>)}</div>
<div className="form-actions"><button className="button primary" disabled={busy}><Icon name="check" />Guardar equipo</button></div>
</form>}
{visit && !canAssign && <article className="panel"><div className="panel-heading"><div><span className="eyebrow">EQUIPO</span><h2>{visit.leadInspector ? personName(visit.leadInspector) : 'Sin responsable'}</h2></div><span className="count-pill">{visit.team.length}</span></div><div className="inspection-team-grid">{visit.team.map((member) => <div className="inspection-member selected" key={member.id}><span><strong>{personName(member)}</strong><small>{member.username}{member.id === visit.leadInspector?.id ? ' · Responsable' : ''}</small></span></div>)}</div></article>}
{visit && <InspectionActsPanel visit={visit} />}
{visit && <div className="survey-status-actions panel"><div><strong>Flujo de la inspección</strong><p>Oficina planifica y asigna. El inspector responsable inicia la inspección desde la APK; toda la ejecución de campo sigue siendo exclusiva del dispositivo móvil.</p></div><div>{canManage && visit.status === 'DRAFT' && <button type="button" className="button secondary" onClick={() => void changeStatus('PLANNED')} disabled={busy}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" onClick={() => void changeStatus('DRAFT')} disabled={busy}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" onClick={() => void changeStatus('CANCELLED')} disabled={busy}>Cancelar planificación</button>}</div></div>}
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El inspector asignado debe iniciar la inspección desde la APK. El dashboard no dispone de acción de inicio.</p></div>}
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección iniciada {formatDate(visit.actualStartedAt)}.</strong> El acta, hallazgos, evidencias y cierre se registran desde la APK; aquí se consultan.</p></div>}
{visit?.status === 'CANCELLED' && <Alert>Cancelada: {visit.cancellationReason}</Alert>}
</section>;
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { PermissionGate } from '../auth/PermissionGate';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { useOperationalContext } from '../context/OperationalContext';
import { OperationalFilters } from '../features/inspections/OperationalFilters';
import {
INSPECTION_VISIT_STATUSES,
inspectionStatusClass,
inspectionVisitStatusLabel,
} from '../features/inspections/inspectionPresentation';
import { listInspectionVisits } from '../lib/api';
import type {
InspectionVisitListItem,
InspectionVisitStatus,
PageMeta,
} from '../lib/api';
import { formatDate } from '../lib/format';
const statusTabs: Array<{ value: InspectionVisitStatus | ''; label: string }> = [
{ value: '', label: 'Todas' },
...INSPECTION_VISIT_STATUSES,
];
export function InspectionVisitsPage() {
const [urlParams, setUrlParams] = useSearchParams();
const context = useOperationalContext();
const [visits, setVisits] = useState<InspectionVisitListItem[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
const search = urlParams.get('search') ?? '';
const rawStatus = urlParams.get('status') ?? '';
const status = INSPECTION_VISIT_STATUSES.some((item) => item.value === rawStatus)
? rawStatus as InspectionVisitStatus
: '';
const companyId = context.companyId || urlParams.get('companyId') || '';
const areaId = context.areaId || urlParams.get('areaId') || '';
const inspectorId = urlParams.get('inspectorId') ?? '';
const dateFrom = urlParams.get('dateFrom') ?? '';
const dateTo = urlParams.get('dateTo') ?? '';
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
useEffect(() => {
setLoading(true);
setError('');
listInspectionVisits({ page, pageSize: 25, search, status, companyId, areaId, inspectorId, dateFrom, dateTo })
.then((response) => {
setVisits(response.data);
setMeta(response.meta);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [page, search, status, companyId, areaId, inspectorId, dateFrom, dateTo]);
const updateFilter = (key: string, value: string) => {
const next = new URLSearchParams(urlParams);
if (key === 'areaId') {
context.setAreaId(value);
next.delete('areaId');
next.delete('companyId');
} else if (key === 'companyId') {
context.setCompanyId(value);
next.delete('companyId');
} else {
value ? next.set(key, value) : next.delete(key);
}
next.delete('page');
setUrlParams(next);
};
const applySearch = (event: FormEvent) => {
event.preventDefault();
updateFilter('search', draftSearch.trim());
};
const setPage = (value: number) => {
const next = new URLSearchParams(urlParams);
value > 1 ? next.set('page', String(value)) : next.delete('page');
setUrlParams(next);
};
const statusHref = (value: InspectionVisitStatus | '') => {
const next = new URLSearchParams(urlParams);
value ? next.set('status', value) : next.delete('status');
next.delete('page');
return `/inspecciones${next.size ? `?${next}` : ''}`;
};
return <section>
<div className="page-heading">
<div><span className="eyebrow">OPERACIÓN</span><h1>Inspecciones</h1><p>Planificá, seguí y consultá todo el ciclo de una inspección desde un único lugar.</p></div>
<PermissionGate permission="inspections.manage"><PermissionGate permission="inspections.assign"><Link className="button primary" to="/inspecciones/nueva"><Icon name="plus" />Planificar inspección</Link></PermissionGate></PermissionGate>
</div>
<nav className="inspection-status-tabs" aria-label="Estados de inspección">
{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 solo ciclo.</strong> La planificación, ejecución, Acta, Hallazgos e Informe pertenecen a la misma inspección. El inicio en campo sigue siendo exclusivo 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} />
</form>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando inspecciones…" /> : visits.length === 0 ? <EmptyState title="Sin inspecciones" text="No hay inspecciones que coincidan con el estado y contexto seleccionados." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} inspección{meta.total === 1 ? '' : 'es'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Inspección</th><th>Estado</th><th>Área / Operadora</th><th>Responsable</th><th>Fecha prevista</th><th>Inventario</th><th /></tr></thead><tbody>{visits.map((visit) => <tr key={visit.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{visit.code}</strong><small>{visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}</small></div></div></td><td><span className={`status-badge ${inspectionStatusClass(visit.status)}`}>{inspectionVisitStatusLabel(visit.status)}</span></td><td>{visit.operationalArea || visit.operatorCompany ? <span><strong className="table-primary">{visit.operationalArea?.name ?? 'Sin Área'}</strong><small className="cell-subtext">{visit.operatorCompany?.name ?? 'Sin Operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td><td>{visit.leadInspector ? `${visit.leadInspector.firstName} ${visit.leadInspector.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(visit.plannedStartAt)}<small>inicio planificado</small></span></td><td>{visit.scopeAsset ? <Link className="text-link" to={`/inventarios/${visit.scopeAsset.id}`}>{visit.scopeAsset.name}<small className="block-muted">{visit.scopeAsset.code}</small></Link> : <><strong>{visit.assetCount} registro{visit.assetCount === 1 ? '' : 's'}</strong><small className="block-muted">{visit.memberCount} integrante{visit.memberCount === 1 ? '' : 's'}</small></>}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/${visit.id}`} aria-label={`Abrir ${visit.code}`}><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>;
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, errorMessage } from '../components/Feedback';
import { APP_VERSION } from '../config/version';
export function LoginPage() {
const { loading, user, login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
if (!loading && user) {
return <Navigate to={user.mustChangePassword ? '/change-password' : '/'} replace />;
}
const submit = async (event: FormEvent) => {
event.preventDefault();
setError('');
setSubmitting(true);
try {
const authenticated = await login(identifier, password);
const requested = (location.state as { from?: string } | null)?.from;
navigate(authenticated.mustChangePassword ? '/change-password' : requested || '/', { replace: true });
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSubmitting(false);
}
};
return (
<main className="login-page">
<section className="login-intro">
<div className="login-brand"><span className="brand-mark large">DH</span><strong>DH Inspección</strong></div>
<div>
<span className="eyebrow light">PLATAFORMA GEOTEMPORAL</span>
<h1>Control operativo.<br />Información trazable.</h1>
<p>Administración segura de usuarios, roles, auditoría e información de inspecciones.</p>
</div>
<small>V2 · KoreX Labs · v{APP_VERSION}</small>
</section>
<section className="login-panel">
<form className="login-card" onSubmit={submit}>
<span className="eyebrow">ACCESO SEGURO</span>
<h2>Iniciar sesión</h2>
<p className="muted">Ingresá con tu usuario o correo electrónico.</p>
{error && <Alert>{error}</Alert>}
<label className="field"><span>Usuario o email</span><input autoFocus autoComplete="username" value={identifier} onChange={(event) => setIdentifier(event.target.value)} required minLength={3} /></label>
<label className="field"><span>Contraseña</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
<button className="button primary wide" disabled={submitting}>{submitting ? 'Ingresando…' : 'Ingresar al sistema'}</button>
<p className="security-note">Sesión protegida mediante cookies seguras. La contraseña nunca se almacena en el navegador.</p>
</form>
</section>
</main>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
import { DhMap } from '../features/map/DhMap';
import {
assetStatusClass,
assetStatusLabel,
ASSET_STATUSES,
} from '../features/assets/assetPresentation';
import { getMapAssets, listAssetTypes } from '../lib/api';
import type {
AssetGeometryType,
AssetInformationStatus,
AssetType,
MapAssetFeatureCollection,
} from '../lib/api';
import { formatDate } from '../lib/format';
const emptyCollection: MapAssetFeatureCollection = {
type: 'FeatureCollection', features: [], meta: { count: 0, truncated: false },
};
export function MapPage() {
const [types, setTypes] = useState<AssetType[]>([]);
const [data, setData] = useState<MapAssetFeatureCollection>(emptyCollection);
const [typeId, setTypeId] = useState('');
const [status, setStatus] = useState<AssetInformationStatus | ''>('');
const [geometryType, setGeometryType] = useState<AssetGeometryType | ''>('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
listAssetTypes().then(setTypes).catch(() => undefined);
}, []);
useEffect(() => {
setLoading(true); setError('');
getMapAssets({ typeId, status, geometryType })
.then((result) => {
setData(result);
setSelectedId((current) => result.features.some((item) => item.id === current) ? current : null);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [typeId, status, geometryType]);
const selected = useMemo(
() => data.features.find((feature) => feature.id === selectedId) ?? null,
[data, selectedId],
);
return <section>
<div className="page-heading"><div><span className="eyebrow">INVENTARIOS</span><h1>Mapa de inventarios</h1><p>Vista territorial de las ubicaciones registradas.</p></div><span className="map-count"><strong>{data.meta.count}</strong> geometría{data.meta.count === 1 ? '' : 's'}</span></div>
<AssetCenterTabs active="map" />
{error && <Alert>{error}</Alert>}
{data.meta.truncated && <Alert type="info">Se muestran los primeros 5000 registros. Aplicá filtros para reducir el resultado.</Alert>}
<div className="map-layout operational-map-layout">
<aside className="filters map-sidebar">
<div><span className="eyebrow">FILTROS</span><h2>Vista territorial</h2></div>
<label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Estado de información</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus | '')}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="field"><span>Geometría</span><SearchableSelect value={geometryType} onChange={(event) => setGeometryType(event.target.value as AssetGeometryType | '')}><option value="">Todas</option><option value="POINT">Puntos</option><option value="LINESTRING">Líneas</option><option value="POLYGON">Polígonos</option></SearchableSelect></label>
<button className="button secondary wide" onClick={() => { setTypeId(''); setStatus(''); setGeometryType(''); }}>Limpiar filtros</button>
{selected && <div className="map-selection"><span className="eyebrow">REGISTRO SELECCIONADO</span><h3>{selected.properties.name}</h3><code>{selected.properties.code}</code><div className="map-selection-meta"><span className="tag">{selected.properties.typeName}</span><span className={`status-badge ${assetStatusClass(selected.properties.informationStatus)}`}>{assetStatusLabel(selected.properties.informationStatus)}</span></div>{selected.properties.parentName && <p>Depende de <strong>{selected.properties.parentName}</strong></p>}<p>{selected.properties.geometryType === 'POINT' ? 'Punto' : selected.properties.geometryType === 'LINESTRING' ? 'Línea' : 'Polígono'} · actualizado {formatDate(selected.properties.updatedAt)}</p>{selected.properties.accuracyM != null && <p>Precisión informada: {selected.properties.accuracyM} m</p>}<Link className="button primary wide" to={`/inventarios/${selected.id}`}>Abrir registro <Icon name="chevron" /></Link></div>}
</aside>
<div className="map-stage">{loading && <div className="map-loading"><LoadingBlock label="Actualizando mapa…" /></div>}<DhMap data={data} selectedId={selectedId} onSelect={setSelectedId} />{!loading && data.features.length === 0 && <div className="map-empty"><Icon name="map" size={30} /><strong>No hay geometrías para mostrar</strong><span>Agregá una ubicación desde el detalle de un registro.</span></div>}</div>
</div>
</section>;
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { createUser, listRoles } from '../lib/api';
import type { AdministrativeRole } from '../lib/api';
export function NewUserPage() {
const navigate = useNavigate();
const [roles, setRoles] = useState<AdministrativeRole[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [roleIds, setRoleIds] = useState<string[]>([]);
useEffect(() => {
listRoles().then(setRoles).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []);
const toggleRole = (id: string) => setRoleIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setSubmitting(true);
const data = new FormData(event.currentTarget);
try {
const created = await createUser({
username: String(data.get('username')),
email: String(data.get('email') ?? '') || undefined,
firstName: String(data.get('firstName')),
lastName: String(data.get('lastName')),
password: String(data.get('password')),
mustChangePassword: data.get('mustChangePassword') === 'on',
roleIds,
});
navigate(`/admin/users/${created.id}`, { replace: true, state: { created: true } });
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSubmitting(false);
}
};
return <section className="narrow-section">
<div className="breadcrumb"><Link to="/admin/users">Usuarios</Link><span>/</span><strong>Nuevo usuario</strong></div>
<div className="page-heading"><div><span className="eyebrow">NUEVO ACCESO</span><h1>Crear usuario</h1><p>La contraseña inicial puede obligarse a cambiar en el primer ingreso.</p></div></div>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock /> : <form className="panel form-panel" onSubmit={submit}>
<div className="form-section"><h2>Datos personales</h2><div className="form-grid"><label className="field"><span>Nombre</span><input name="firstName" required maxLength={120} /></label><label className="field"><span>Apellido</span><input name="lastName" required maxLength={120} /></label><label className="field"><span>Usuario</span><input name="username" required minLength={3} maxLength={80} pattern="[a-zA-Z][a-zA-Z0-9._-]+" /></label><label className="field"><span>Email <em>opcional</em></span><input name="email" type="email" maxLength={320} /></label></div></div>
<div className="form-section"><h2>Seguridad</h2><label className="field"><span>Contraseña temporal</span><input name="password" type="password" minLength={12} maxLength={128} required autoComplete="new-password" /><small>Mínimo 12 caracteres.</small></label><label className="check-row"><input name="mustChangePassword" type="checkbox" defaultChecked /><span><strong>Exigir cambio de contraseña</strong><small>El usuario no podrá acceder a otros módulos hasta actualizarla.</small></span></label></div>
<div className="form-section"><h2>Roles</h2><div className="choice-grid">{roles.map((role) => <label className={`choice-card ${roleIds.includes(role.id) ? 'selected' : ''}`} key={role.id}><input type="checkbox" checked={roleIds.includes(role.id)} onChange={() => toggleRole(role.id)} /><span><strong>{role.name}</strong><small>{role.description}</small></span><Icon name="check" /></label>)}</div></div>
<div className="form-actions"><Link className="button secondary" to="/admin/users">Cancelar</Link><button className="button primary" disabled={submitting}>{submitting ? 'Creando…' : 'Crear usuario'}</button></div>
</form>}
</section>;
}
+10
View File
@@ -0,0 +1,10 @@
export function PlaceholderPage({ title }: { title: string }) {
return (
<section>
<span className="eyebrow">MÓDULO V2</span>
<h1>{title}</h1>
<p className="lead">Este módulo todavía no está habilitado en la versión actual del sistema.</p>
<div className="panel"><strong>Módulo preparado para su próxima implementación.</strong></div>
</section>
);
}
+208
View File
@@ -0,0 +1,208 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
approveInspectionReport,
getInspectionReport,
getInspectionReportReview,
inspectionReportRevisionUrl,
inspectionReportWordUrl,
signFinalInspectionReport,
uploadInspectionReportRevision,
} from '../lib/api';
import type { InspectionReport, InspectionReportReview } from '../lib/api';
import { formatDate } from '../lib/format';
function names(items: Array<{ name: string }>, empty: string): string {
return items.length ? items.map((item) => item.name).join(' · ') : empty;
}
function reviewLabel(value: InspectionReport['reviewStatus']): string {
if (value === 'SIGNED') return 'Firmado';
if (value === 'APPROVED') return 'Aprobado · falta firma';
return 'Pendiente de revisión';
}
function reviewClass(value: InspectionReport['reviewStatus']): string {
if (value === 'SIGNED') return 'active';
if (value === 'APPROVED') return 'observed';
return 'pending';
}
function revisionSource(value: 'AUTO' | 'DIRECTOR_UPLOAD'): string {
return value === 'AUTO' ? 'Automática' : 'Corrección cargada';
}
export function ReportDetailPage() {
const { id } = useParams();
const { hasPermission } = useAuth();
const [report, setReport] = useState<InspectionReport | null>(null);
const [review, setReview] = useState<InspectionReportReview | null>(null);
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [revisionFile, setRevisionFile] = useState<File | null>(null);
const [changeSummary, setChangeSummary] = useState('');
const [approvalNote, setApprovalNote] = useState('');
const [signatureConfirmed, setSignatureConfirmed] = useState(false);
const canRevise = hasPermission('inspection_reports.revise');
const canReview = hasPermission('inspection_reports.review');
const canSign = hasPermission('inspection_reports.sign_final');
const reload = async () => {
if (!id) return;
const [nextReport, nextReview] = await Promise.all([getInspectionReport(id), getInspectionReportReview(id)]);
setReport(nextReport);
setReview(nextReview);
};
useEffect(() => {
if (!id) return;
setLoading(true);
setError('');
reload()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [id]);
const uploadRevision = async (event: FormEvent) => {
event.preventDefault();
if (!id || !revisionFile || changeSummary.trim().length < 5) return;
setWorking(true);
setError('');
setSuccess('');
try {
const next = await uploadInspectionReportRevision(id, revisionFile, changeSummary.trim());
setReview(next);
setRevisionFile(null);
setChangeSummary('');
setSuccess(`Versión ${next.currentRevisionNumber} agregada al historial.`);
await reload();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setWorking(false);
}
};
const approve = async () => {
if (!id) return;
setWorking(true);
setError('');
setSuccess('');
try {
const next = await approveInspectionReport(id, approvalNote);
setReview(next);
setApprovalNote('');
setSuccess(`Versión ${next.currentRevisionNumber} aprobada por el Director.`);
await reload();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setWorking(false);
}
};
const signFinal = async () => {
if (!id || !signatureConfirmed) return;
setWorking(true);
setError('');
setSuccess('');
try {
const next = await signFinalInspectionReport(id);
setReview(next);
setSignatureConfirmed(false);
setSuccess('Informe final firmado electrónicamente. La versión firmada quedó bloqueada.');
await reload();
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setWorking(false);
}
};
if (loading) return <LoadingBlock label="Cargando informe…" />;
return <section>
<div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div>
<div className="page-heading survey-editor-heading">
<div><span className="eyebrow">INFORME DE INSPECCIÓN</span><h1>{report?.title ?? 'Informe'}</h1><p>{report ? `${report.code} · emitido ${formatDate(report.generatedAt)}` : 'Consulta del informe.'}</p></div>
{report && <div className="report-status-stack"><span className={`status-badge large ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span><span className={`status-badge large ${reviewClass(report.reviewStatus)}`}>{reviewLabel(report.reviewStatus)}</span></div>}
</div>
{error && <Alert>{error}</Alert>}
{success && <div className="success-banner">{success}</div>}
{report && <>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Informe Word automático.</strong> La versión inicial se genera desde la instantánea congelada del Acta. Las correcciones se agregan como nuevas versiones y nunca sobrescriben la anterior.</p></div>
<section className="panel report-summary-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
<div className="responsible-summary">
<div><small>Empresa</small><strong>{names(report.companies, 'Sin asignar')}</strong></div>
<div><small>Área</small><strong>{names(report.areas, 'Sin asignar')}</strong></div>
<div><small>Hallazgos</small><strong>{report.findingCount}</strong></div>
<div><small>Emitido por</small><strong>{report.generatedBy.firstName} {report.generatedBy.lastName}</strong></div>
</div>
{report.wordStatus === 'READY' && <div className="page-actions"><a className="button secondary" href={inspectionReportWordUrl(report.id)}>Descargar Word automático</a></div>}
<div className="report-linked-documents">
<Link to={`/inspecciones/${report.visitId}`}><span>Inspección</span><strong>{report.visit.code}</strong><Icon name="chevron" /></Link>
<Link to={`/inspecciones/actas/${report.actId}`}><span>Acta incorporada</span><strong>{report.act.code}</strong><small>{report.act.title}</small><Icon name="chevron" /></Link>
<Link to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}><span>Hallazgos</span><strong>{report.findingCount}</strong><small>Ver seguimiento relacionado</small><Icon name="chevron" /></Link>
</div>
</section>
<section className="panel report-review-panel">
<div className="panel-heading"><div><span className="eyebrow">REVISIÓN DIRECTIVA</span><h2>Versiones y firma final</h2><p className="section-copy">El Acta y los Hallazgos originales permanecen congelados. La revisión sólo versiona el documento Informe.</p></div>{review && <span className={`status-badge large ${reviewClass(review.reviewStatus)}`}>{reviewLabel(review.reviewStatus)}</span>}</div>
{!review ? <LoadingBlock label="Cargando revisión…" /> : <>
<div className="review-flow">
<div className="complete"><span>1</span><strong>Informe automático</strong><small>{review.revisions.length ? 'Versión inicial registrada' : 'Esperando Word'}</small></div>
<div className={review.currentRevisionNumber > 1 ? 'complete' : ''}><span>2</span><strong>Correcciones</strong><small>{review.currentRevisionNumber > 1 ? `${review.currentRevisionNumber - 1} versión/es agregada/s` : 'Sin correcciones'}</small></div>
<div className={review.reviewStatus !== 'PENDING_REVIEW' ? 'complete' : ''}><span>3</span><strong>Aprobación</strong><small>{review.approvedAt ? formatDate(review.approvedAt) : 'Pendiente del Director'}</small></div>
<div className={review.reviewStatus === 'SIGNED' ? 'complete' : ''}><span>4</span><strong>Firma final</strong><small>{review.signature ? formatDate(review.signature.signedAt) : 'Pendiente'}</small></div>
</div>
<div className="report-revision-list">
{review.revisions.map((revision) => <article key={revision.id} className={review.approvedRevisionId === revision.id ? 'approved' : ''}>
<div><span className="status-badge">Versión {revision.revisionNumber}</span><strong>{revision.originalName}</strong><small>{revisionSource(revision.source)} · {revision.createdBy.firstName} {revision.createdBy.lastName} · {formatDate(revision.createdAt)}</small>{revision.changeSummary && <p>{revision.changeSummary}</p>}</div>
<div className="revision-actions"><code title={revision.sha256}>{revision.sha256}</code><a className="button secondary" href={inspectionReportRevisionUrl(revision.id)}>Descargar</a></div>
</article>)}
</div>
{canRevise && review.reviewStatus === 'PENDING_REVIEW' && <form className="report-review-form" onSubmit={uploadRevision}>
<div><span className="eyebrow">NUEVA VERSIÓN</span><h3>Agregar Word corregido</h3><p className="section-copy">Descargá la última versión, realizá la corrección en Word y cargala nuevamente. La versión anterior queda intacta.</p></div>
<label><span>Archivo .docx</span><input type="file" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" onChange={(event) => setRevisionFile(event.target.files?.[0] ?? null)} /></label>
<label><span>Resumen de cambios</span><textarea value={changeSummary} onChange={(event) => setChangeSummary(event.target.value)} maxLength={1000} placeholder="Ej.: Se corrigió la conclusión técnica y la referencia del equipo inspeccionado." /></label>
<button className="button primary" disabled={working || !revisionFile || changeSummary.trim().length < 5}>Agregar versión</button>
</form>}
{canReview && review.reviewStatus === 'PENDING_REVIEW' && review.revisions.length > 0 && <div className="report-review-action">
<div><span className="eyebrow">APROBACIÓN</span><h3>Aprobar versión {review.currentRevisionNumber}</h3><p>La aprobación fija esta versión como candidata a firma final. Después de aprobar no se podrán cargar nuevas correcciones.</p></div>
<label><span>Nota de revisión opcional</span><textarea value={approvalNote} onChange={(event) => setApprovalNote(event.target.value)} maxLength={1000} placeholder="Observación interna de aprobación" /></label>
<button type="button" className="button primary" disabled={working} onClick={approve}>Aprobar versión actual</button>
</div>}
{canSign && review.reviewStatus === 'APPROVED' && <div className="report-review-action final-signature-action">
<div><span className="eyebrow">FIRMA FINAL</span><h3>Firma electrónica del Director</h3><p>La firma vincula de forma inmutable al Director, la versión aprobada, el hash del Informe y la fecha. Una vez firmada no admite nuevas versiones.</p></div>
<label className="check-label"><input type="checkbox" checked={signatureConfirmed} onChange={(event) => setSignatureConfirmed(event.target.checked)} /><span>Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.</span></label>
<button type="button" className="button primary" disabled={working || !signatureConfirmed} onClick={signFinal}>Firmar informe final</button>
</div>}
{review.signature && <div className="signed-report-box"><Icon name="check" /><div><strong>Informe final firmado</strong><p>{review.signature.signedBy.firstName} {review.signature.signedBy.lastName} · {formatDate(review.signature.signedAt)}</p><code title={review.signature.signatureSha256}>{review.signature.signatureSha256}</code></div></div>}
{review.reviewStatus === 'SIGNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Firma cerrada.</strong> La firma electrónica ya prueba quién aprobó la versión y qué hash fue firmado. La ubicación visual de la rúbrica dentro del documento se resolverá con la plantilla institucional definitiva.</p></div>}
</>}
</section>
<section className="panel report-integrity-panel">
<div className="panel-heading"><div><span className="eyebrow">INTEGRIDAD</span><h2>Contenido congelado</h2><p className="section-copy">El Informe conserva la versión exacta del Acta cerrada y su instantánea documental.</p></div></div>
<dl className="report-integrity-list">
<div><dt>Hash de cierre del Acta</dt><dd>{report.actClosureSha256}</dd></div>
<div><dt>Hash del Informe</dt><dd>{report.frozenSha256}</dd></div>
<div><dt>Estado base</dt><dd>{report.status === 'FROZEN' ? 'Congelado' : 'Cancelado'}</dd></div>
<div><dt>Revisión</dt><dd>{reviewLabel(report.reviewStatus)}</dd></div>
</dl>
</section>
</>}
</section>;
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { useOperationalContext } from '../context/OperationalContext';
import { DocumentCenterTabs } from '../features/documents/DocumentCenterTabs';
import { OperationalFilters } from '../features/inspections/OperationalFilters';
import { listInspectionReports, listPendingInspectionReports } from '../lib/api';
import type { InspectionReportListItem, PageMeta, PendingInspectionReportItem } from '../lib/api';
import { formatDate } from '../lib/format';
function contextLabel(items: Array<{ name: string }>, empty: string): string {
if (items.length === 0) return empty;
const first = items[0];
if (!first) return empty;
if (items.length === 1) return first.name;
return `${first.name} +${items.length - 1}`;
}
function reviewLabel(value: InspectionReportListItem['reviewStatus']): string {
if (value === 'SIGNED') return 'Firmado';
if (value === 'APPROVED') return 'Aprobado';
return 'Pendiente';
}
function reviewClass(value: InspectionReportListItem['reviewStatus']): string {
if (value === 'SIGNED') return 'active';
if (value === 'APPROVED') return 'observed';
return 'pending';
}
export function ReportsPage() {
const [params, setParams] = useSearchParams();
const operationalContext = useOperationalContext();
const view = params.get('view') === 'pending' ? 'pending' : 'issued';
const search = params.get('search') ?? '';
const year = params.get('year') ?? '';
const companyId = operationalContext.companyId || params.get('companyId') || '';
const areaId = operationalContext.areaId || params.get('areaId') || '';
const inspectorId = params.get('inspectorId') ?? '';
const dateFrom = params.get('dateFrom') ?? '';
const dateTo = params.get('dateTo') ?? '';
const page = Math.max(1, Number(params.get('page') ?? 1) || 1);
const [draftSearch, setDraftSearch] = useState(search);
const [issued, setIssued] = useState<InspectionReportListItem[]>([]);
const [pending, setPending] = useState<PendingInspectionReportItem[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const load = async () => {
try {
if (view === 'issued') {
const response = await listInspectionReports({ page, pageSize: 25, search, year: year ? Number(year) : '', companyId, areaId, inspectorId, dateFrom, dateTo });
if (cancelled) return;
setIssued(response.data);
setPending([]);
setMeta(response.meta);
} else {
const response = await listPendingInspectionReports({ page, pageSize: 25, search, year: year ? Number(year) : '', companyId, areaId, inspectorId, dateFrom, dateTo });
if (cancelled) return;
setPending(response.data);
setIssued([]);
setMeta(response.meta);
}
} catch (requestError) {
if (!cancelled) setError(errorMessage(requestError));
} finally {
if (!cancelled) setLoading(false);
}
};
void load();
return () => { cancelled = true; };
}, [page, search, view, year, companyId, areaId, inspectorId, dateFrom, dateTo]);
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (key === 'areaId') {
operationalContext.setAreaId(value);
next.delete('areaId');
next.delete('companyId');
} else if (key === 'companyId') {
operationalContext.setCompanyId(value);
next.delete('companyId');
} else {
value ? next.set(key, value) : next.delete(key);
}
next.delete('page');
setParams(next);
};
const applySearch = (event: FormEvent) => {
event.preventDefault();
setFilter('search', draftSearch.trim());
};
const setPage = (value: number) => {
const next = new URLSearchParams(params);
value > 1 ? next.set('page', String(value)) : next.delete('page');
setParams(next);
};
const empty = view === 'issued' ? issued.length === 0 : pending.length === 0;
return <section>
<div className="page-heading">
<div><span className="eyebrow">CENTRO DOCUMENTAL</span><h1>Informes</h1><p>Registro global de informes de inspección generados automáticamente al cerrar cada visita.</p></div>
</div>
<DocumentCenterTabs />
<div className="document-view-tabs">
<button type="button" className={view === 'issued' ? 'active' : ''} onClick={() => setFilter('view', 'issued')}>Informes emitidos</button>
<button type="button" className={view === 'pending' ? 'active' : ''} onClick={() => setFilter('view', 'pending')}>Pendientes de emisión</button>
</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 informe, acta, inspección, empresa o área" /><button>Buscar</button></label>
<label className="select-field"><span>Año</span><input inputMode="numeric" value={year} onChange={(event) => setFilter('year', event.target.value.replace(/\D/g, '').slice(0, 4))} placeholder="Todos" /></label>
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={setFilter} />
</form>
{view === 'pending' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Recuperación documental.</strong> Las actas históricas sin informe aparecen aquí. Desde D5.3.20 el informe se numera y congela automáticamente al cerrar la visita.</p></div>}
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label={view === 'issued' ? 'Cargando informes…' : 'Cargando pendientes…'} /> : empty ? <EmptyState title={view === 'issued' ? 'Sin informes emitidos' : 'Sin informes pendientes'} text={view === 'issued' ? 'Todavía no hay informes emitidos para los filtros seleccionados.' : 'Todas las actas cerradas tienen su informe emitido.'} /> : <div className="table-panel document-table">
<div className="table-summary"><strong>{meta.total} {view === 'issued' ? `informe${meta.total === 1 ? '' : 's'}` : `pendiente${meta.total === 1 ? '' : 's'}`}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead>{view === 'issued' ? <tr><th>Informe</th><th>Empresa / área</th><th>Acta</th><th>Hallazgos</th><th>Word</th><th>Revisión</th><th /></tr> : <tr><th>Acta cerrada</th><th>Empresa / área</th><th>Inspección</th><th>Hallazgos</th><th>Cierre</th><th /></tr>}</thead><tbody>{view === 'issued' ? issued.map((report) => <tr key={report.id}>
<td><div className="document-primary"><strong>{report.code}</strong><small>{report.title} · {formatDate(report.generatedAt)}</small></div></td>
<td><div className="document-primary"><strong>{contextLabel(report.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(report.areas, 'Área sin asignar')}</small></div></td>
<td><Link className="text-link" to={`/inspecciones/actas/${report.actId}`}>{report.act.code}<small className="block-muted">{report.act.title}</small></Link></td>
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}>{report.findingCount}</Link></td>
<td><span className={`status-badge ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span></td>
<td><span className={`status-badge ${reviewClass(report.reviewStatus)}`}>{reviewLabel(report.reviewStatus)}</span></td>
<td className="action-cell"><Link className="icon-button" to={`/informes/${report.id}`} aria-label={`Abrir ${report.code}`}><Icon name="chevron" /></Link></td>
</tr>) : pending.map((item) => <tr key={item.actId}>
<td><div className="document-primary"><strong>{item.actCode}</strong><small>{item.actTitle} · {formatDate(item.occurredAt)}</small></div></td>
<td><div className="document-primary"><strong>{contextLabel(item.companies, 'Empresa sin asignar')}</strong><small>{contextLabel(item.areas, 'Área sin asignar')}</small></div></td>
<td><Link className="text-link" to={`/inspecciones/${item.visitId}`}>{item.visitCode}</Link></td>
<td><Link className="text-link" to={`/hallazgos?search=${encodeURIComponent(item.actCode)}`}>{item.findingCount}</Link></td>
<td>{formatDate(item.closedAt)}</td>
<td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${item.actId}`} aria-label={`Abrir ${item.actCode}`}><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>;
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
createRole,
listPermissions,
listRoles,
replaceRolePermissions,
updateRole,
} from '../lib/api';
import type { AdministrativeRole, Permission } from '../lib/api';
const groupLabels: Record<string, string> = {
dashboard: 'Panel general',
users: 'Usuarios',
roles: 'Roles y permisos',
audit: 'Auditoría',
assets: 'Inventarios',
asset_types: 'Tipos y atributos de inventario',
asset_relations: 'Relaciones ÁreaOrganización',
asset_registry: 'Registro documental y legal',
asset_imports: 'Importaciones de inventarios',
inspections: 'Inspecciones',
inspection_acts: 'Actas de inspección',
inspection_findings: 'Hallazgos',
inspection_verifications: 'Planificación de verificaciones',
finding_catalog: 'Catálogo de hallazgos',
surveys: 'Relevamientos',
};
const permissionLabels: Record<string, string> = {
read: 'Consultar', create: 'Crear', update: 'Modificar', manage: 'Administrar', change_status: 'Cambiar estado',
change_operational_status: 'Cambiar estado operativo', read_history: 'Ver historial', read_temporal: 'Consultar estado histórico',
read_geometry: 'Ver ubicación', update_geometry: 'Modificar ubicación', read_media: 'Ver archivos', manage_media: 'Administrar archivos',
read_provenance: 'Ver procedencia', manage_provenance: 'Administrar procedencia', verify_provenance: 'Validar procedencia',
plan: 'Planificar',
};
function permissionLabel(permission: Permission) {
const action = permission.code.split('.').slice(1).join('_');
return permissionLabels[action] ?? permission.description ?? permission.code;
}
export function RolesPage() {
const { hasPermission } = useAuth();
const canManage = hasPermission('roles.manage');
const [roles, setRoles] = useState<AdministrativeRole[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
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 [permissionIds, setPermissionIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const selected = roles.find((role) => role.id === selectedId) ?? null;
const grouped = useMemo(() => permissions.reduce<Record<string, Permission[]>>((result, permission) => {
const group = permission.code.split('.')[0] ?? 'other';
(result[group] ??= []).push(permission);
return result;
}, {}), [permissions]);
const selectRole = (role: AdministrativeRole) => {
setCreating(false); setSelectedId(role.id); setCode(role.code); setName(role.name);
setDescription(role.description); setPermissionIds(role.permissions.map((permission) => permission.id));
setError(''); setSuccess('');
};
const startCreate = () => {
setCreating(true); setSelectedId(null); setCode(''); setName(''); setDescription('');
setPermissionIds([]); setError(''); setSuccess('');
};
const load = async (preferId?: string) => {
const [loadedRoles, loadedPermissions] = await Promise.all([listRoles(), listPermissions()]);
setRoles(loadedRoles); setPermissions(loadedPermissions);
const next = loadedRoles.find((role) => role.id === preferId) ?? loadedRoles[0];
if (next) selectRole(next);
};
useEffect(() => {
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []);
const togglePermission = (id: string) => setPermissionIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
const save = async (event: FormEvent) => {
event.preventDefault(); setError(''); setSuccess(''); setSaving(true);
try {
let saved: AdministrativeRole;
if (creating) {
saved = await createRole({ code, name, description, permissionIds });
} else if (selected) {
await updateRole(selected.id, { name, description });
saved = await replaceRolePermissions(selected.id, permissionIds);
} else return;
await load(saved.id);
setSuccess(creating ? 'Rol creado correctamente' : 'Rol y permisos actualizados');
setCreating(false);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <LoadingBlock label="Cargando roles…" />;
return <section>
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Roles y permisos</h1><p>Elegí qué puede consultar o modificar cada perfil. Los códigos técnicos quedan como referencia secundaria.</p></div>{canManage && <button className="button primary" onClick={startCreate}><Icon name="plus" />Nuevo rol</button>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="roles-layout">
<aside className="panel role-list"><div className="role-list-heading"><strong>Roles disponibles</strong><span>{roles.length}</span></div>{roles.map((role) => <button key={role.id} className={`role-list-item ${selectedId === role.id && !creating ? 'active' : ''}`} onClick={() => selectRole(role)}><span><strong>{role.name}</strong><small>{role.code}</small></span><span className="role-count">{role.userCount} usuario{Number(role.userCount) === 1 ? '' : 's'}</span></button>)}</aside>
<form className="panel form-panel role-editor" onSubmit={save}>
<div className="panel-heading"><div><span className="eyebrow">{creating ? 'NUEVO ROL' : selected?.isSystem ? 'ROL DEL SISTEMA' : 'ROL PERSONALIZADO'}</span><h2>{creating ? 'Crear rol' : name}</h2></div>{selected?.isSystem && !creating && <span className="tag">Sistema</span>}</div>
<div className="form-grid"><label className="field"><span>Código</span><input value={code} onChange={(event) => setCode(event.target.value.toLowerCase())} disabled={!creating || !canManage} required minLength={3} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Nombre</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canManage} required maxLength={120} /></label></div>
<label className="field"><span>Descripción</span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canManage} required maxLength={1000} rows={3} /></label>
<div className="permission-matrix"><div><h3>Permisos asignados</h3><p>Los cambios se aplican inmediatamente a los próximos requests del usuario.</p></div>{(Object.entries(grouped) as Array<[string, Permission[]]>).map(([group, items]) => <fieldset key={group}><legend>{groupLabels[group] ?? group}</legend><div className="permission-grid">{items.map((permission) => <label className={`permission-row ${permissionIds.includes(permission.id) ? 'checked' : ''}`} key={permission.id}><input type="checkbox" checked={permissionIds.includes(permission.id)} onChange={() => togglePermission(permission.id)} disabled={!canManage} /><span><strong>{permissionLabel(permission)}</strong><small>{permission.description}<br /><code>{permission.code}</code></small></span><Icon name="check" /></label>)}</div></fieldset>)}</div>
{canManage && <div className="form-actions">{creating && <button className="button secondary" type="button" onClick={() => roles[0] && selectRole(roles[0])}>Cancelar</button>}<button className="button primary" disabled={saving}>{saving ? 'Guardando…' : creating ? 'Crear rol' : 'Guardar cambios'}</button></div>}
</form>
</div>
</section>;
}
@@ -0,0 +1,309 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
SURVEY_TARGET_STATUSES,
surveyCampaignStatusLabel,
surveyStatusClass,
surveyTargetStatusLabel,
} from '../features/surveys/surveyPresentation';
import {
addSurveyTarget,
assignSurveyTarget,
createSurveyCampaign,
getSurveyCampaign,
listAssets,
listSurveyAssignees,
updateSurveyCampaign,
updateSurveyCampaignStatus,
updateSurveyTarget,
updateSurveyTargetStatus,
} from '../lib/api';
import type {
AssetListItem,
SurveyCampaign,
SurveyCampaignStatus,
SurveyPerson,
SurveyTarget,
SurveyTargetStatus,
} from '../lib/api';
import { formatDate } from '../lib/format';
interface CampaignForm {
code: string;
name: string;
description: string;
plannedStartAt: string;
plannedEndAt: string;
scopeAssetId: string;
coordinatorUserId: string;
}
interface TargetDraft { dueAt: string; instructions: string }
const emptyCampaign: CampaignForm = {
code: '', name: '', description: '', plannedStartAt: '', plannedEndAt: '',
scopeAssetId: '', coordinatorUserId: '',
};
function localDateTime(value: string | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function isoOrNull(value: string): string | null {
return value ? new Date(value).toISOString() : null;
}
function targetDrafts(targets: SurveyTarget[]): Record<string, TargetDraft> {
return Object.fromEntries(targets.map((target) => [target.id, {
dueAt: localDateTime(target.dueAt),
instructions: target.instructions ?? '',
}]));
}
function personName(person: SurveyPerson): string {
return `${person.firstName} ${person.lastName}`;
}
export function SurveyCampaignEditorPage() {
const { id } = useParams();
const navigate = useNavigate();
const { user, hasPermission } = useAuth();
const isNew = !id;
const canManage = hasPermission('surveys.manage');
const canAssign = hasPermission('surveys.assign');
const canExecute = hasPermission('surveys.execute');
const canReadReports = hasPermission('surveys.read_reports');
const [campaign, setCampaign] = useState<SurveyCampaign | null>(null);
const [form, setForm] = useState<CampaignForm>(emptyCampaign);
const [assets, setAssets] = useState<AssetListItem[]>([]);
const [assignees, setAssignees] = useState<SurveyPerson[]>([]);
const [assetSearch, setAssetSearch] = useState('');
const [targetAssetId, setTargetAssetId] = useState('');
const [targetAssigneeId, setTargetAssigneeId] = useState('');
const [targetDueAt, setTargetDueAt] = useState('');
const [targetInstructions, setTargetInstructions] = useState('');
const [drafts, setDrafts] = useState<Record<string, TargetDraft>>({});
const [loading, setLoading] = useState(!isNew);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const applyCampaign = (value: SurveyCampaign, syncForm = true) => {
setCampaign(value);
setDrafts(targetDrafts(value.targets));
if (syncForm) setForm({
code: value.code,
name: value.name,
description: value.description ?? '',
plannedStartAt: localDateTime(value.plannedStartAt),
plannedEndAt: localDateTime(value.plannedEndAt),
scopeAssetId: value.scopeAsset?.id ?? '',
coordinatorUserId: value.coordinator?.id ?? '',
});
};
useEffect(() => {
if (!id) return;
setLoading(true);
getSurveyCampaign(id)
.then((value) => applyCampaign(value))
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
const timer = window.setTimeout(() => {
listAssets({ pageSize: 100, search: assetSearch.trim() })
.then((response) => setAssets(response.data))
.catch(() => undefined);
}, 220);
return () => window.clearTimeout(timer);
}, [assetSearch]);
useEffect(() => {
if (!canAssign) return;
listSurveyAssignees().then(setAssignees).catch(() => undefined);
}, [canAssign]);
const closed = campaign?.status === 'COMPLETED' || campaign?.status === 'CANCELLED';
const targetIds = useMemo(() => new Set(campaign?.targets.map((target) => target.asset.id) ?? []), [campaign]);
const candidateAssets = assets.filter((asset) => !targetIds.has(asset.id));
const saveCampaign = async (event: FormEvent) => {
event.preventDefault();
setBusy(true); setError(''); setSuccess('');
const input = {
code: form.code,
name: form.name,
description: form.description || null,
plannedStartAt: isoOrNull(form.plannedStartAt),
plannedEndAt: isoOrNull(form.plannedEndAt),
scopeAssetId: form.scopeAssetId || null,
coordinatorUserId: form.coordinatorUserId || null,
};
try {
if (isNew) {
const created = await createSurveyCampaign(input);
navigate(`/relevamiento/${created.id}`, { replace: true });
} else if (id) {
applyCampaign(await updateSurveyCampaign(id, input));
setSuccess('Planificación actualizada.');
}
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const changeCampaignStatus = async (status: SurveyCampaignStatus) => {
if (!id) return;
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await updateSurveyCampaignStatus(id, status));
setSuccess(`Campaña ${surveyCampaignStatusLabel(status).toLocaleLowerCase('es-AR')}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const addTarget = async (event: FormEvent) => {
event.preventDefault();
if (!id || !targetAssetId) return;
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await addSurveyTarget(id, {
assetId: targetAssetId,
assignedUserId: targetAssigneeId || null,
dueAt: isoOrNull(targetDueAt),
instructions: targetInstructions || null,
}), false);
setTargetAssetId(''); setTargetAssigneeId(''); setTargetDueAt(''); setTargetInstructions('');
setSuccess('Registro agregado a la campaña.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const saveTargetPlan = async (target: SurveyTarget) => {
const draft = drafts[target.id];
if (!draft) return;
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await updateSurveyTarget(target.id, {
dueAt: isoOrNull(draft.dueAt),
instructions: draft.instructions || null,
}), false);
setSuccess(`Plan de ${target.asset.code} actualizado.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const assignTarget = async (target: SurveyTarget, assignedUserId: string) => {
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await assignSurveyTarget(target.id, assignedUserId || null), false);
setSuccess(`Responsable de ${target.asset.code} actualizado.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const changeTargetStatus = async (target: SurveyTarget, status: SurveyTargetStatus) => {
setBusy(true); setError(''); setSuccess('');
try {
applyCampaign(await updateSurveyTargetStatus(target.id, status), false);
setSuccess(`Avance de ${target.asset.code} actualizado.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const campaignActions = campaign?.status === 'DRAFT'
? [{ status: 'PLANNED' as const, label: 'Confirmar planificación' }, { status: 'CANCELLED' as const, label: 'Cancelar' }]
: campaign?.status === 'PLANNED'
? [{ status: 'IN_PROGRESS' as const, label: 'Iniciar campaña' }, { status: 'DRAFT' as const, label: 'Volver a borrador' }, { status: 'CANCELLED' as const, label: 'Cancelar' }]
: campaign?.status === 'IN_PROGRESS'
? [{ status: 'COMPLETED' as const, label: 'Completar campaña' }, { status: 'CANCELLED' as const, label: 'Cancelar' }]
: [];
if (loading) return <LoadingBlock label="Cargando campaña…" />;
return <section className="survey-editor">
<div className="breadcrumb"><Link to="/relevamiento">Relevamientos</Link><span>/</span><span>{isNew ? 'Nueva campaña' : campaign?.code ?? 'Detalle'}</span></div>
<div className="page-heading survey-editor-heading">
<div><span className="eyebrow">PLANIFICACIÓN DE CAMPO</span><h1>{isNew ? 'Nueva campaña' : campaign?.name ?? 'Campaña'}</h1><p>{isNew ? 'Definí alcance, fechas y coordinación inicial.' : `${campaign?.code} · ${campaign?.targetCount ?? 0} objetivos`}</p></div>
{campaign && <span className={`status-badge large ${surveyStatusClass(campaign.status)}`}>{surveyCampaignStatusLabel(campaign.status)}</span>}
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<form className="panel form-panel" onSubmit={saveCampaign}>
<div className="panel-heading"><div><span className="eyebrow">DATOS GENERALES</span><h2>Plan de relevamiento</h2></div>{campaign && <small className="muted">Actualizado {formatDate(campaign.updatedAt)}</small>}</div>
<div className="form-grid">
<label className="field"><span>Código</span><input value={form.code} onChange={(event) => setForm((current) => ({ ...current, code: event.target.value.toUpperCase() }))} required maxLength={80} disabled={!canManage || closed} /></label>
<label className="field"><span>Nombre</span><input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} required maxLength={200} disabled={!canManage || closed} /></label>
<label className="field"><span>Inicio previsto</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} disabled={!canManage || closed} /></label>
<label className="field"><span>Fin previsto</span><input type="datetime-local" value={form.plannedEndAt} onChange={(event) => setForm((current) => ({ ...current, plannedEndAt: event.target.value }))} disabled={!canManage || closed} /></label>
<label className="field"><span>Alcance jerárquico</span><SearchableSelect value={form.scopeAssetId} onChange={(event) => setForm((current) => ({ ...current, scopeAssetId: event.target.value }))} disabled={!canManage || closed}><option value="">Todos los inventarios</option>{campaign?.scopeAsset && !assets.some((asset) => asset.id === campaign.scopeAsset?.id) && <option value={campaign.scopeAsset.id}>{campaign.scopeAsset.code} · {campaign.scopeAsset.name}</option>}{assets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name}</option>)}</SearchableSelect><small>El alcance puede ser cualquier registro raíz o rama configurada.</small></label>
<label className="field"><span>Coordinación</span><SearchableSelect value={form.coordinatorUserId} onChange={(event) => setForm((current) => ({ ...current, coordinatorUserId: event.target.value }))} disabled={!canManage || closed}><option value="">Sin asignar</option>{campaign?.coordinator && !assignees.some((person) => person.id === campaign.coordinator?.id) && <option value={campaign.coordinator.id}>{personName(campaign.coordinator)}</option>}{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>
</div>
<label className="field"><span>Descripción</span><textarea value={form.description} onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))} maxLength={4000} disabled={!canManage || closed} /></label>
{canManage && !closed && <div className="form-actions"><Link className="button secondary" to="/relevamiento">Cancelar</Link><button className="button primary" disabled={busy}><Icon name="check" />{isNew ? 'Crear campaña' : 'Guardar planificación'}</button></div>}
</form>
{campaign && canManage && campaignActions.length > 0 && <div className="survey-status-actions panel"><div><strong>Flujo de campaña</strong><p>Los cambios de estado quedan registrados en auditoría.</p></div><div>{campaignActions.map((action) => <button key={action.status} type="button" className={`button ${action.status === 'CANCELLED' ? 'danger-outline' : 'secondary'}`} disabled={busy} onClick={() => changeCampaignStatus(action.status)}>{action.label}</button>)}</div></div>}
{campaign && canManage && !closed && <form className="panel survey-add-target" onSubmit={addTarget}>
<div className="panel-heading"><div><span className="eyebrow">OBJETIVOS</span><h2>Agregar registro existente</h2><p className="section-copy">El objetivo referencia al inventario; no copia sus datos.</p></div></div>
<label className="field"><span>Buscar registro</span><input value={assetSearch} onChange={(event) => setAssetSearch(event.target.value)} placeholder="Código o nombre" /></label>
<div className="form-grid">
<label className="field"><span>Registro</span><SearchableSelect value={targetAssetId} onChange={(event) => setTargetAssetId(event.target.value)} required><option value="">Seleccionar</option>{candidateAssets.map((asset) => <option key={asset.id} value={asset.id}>{asset.code} · {asset.name} · {asset.type.name}</option>)}</SearchableSelect></label>
{canAssign && <label className="field"><span>Responsable</span><SearchableSelect value={targetAssigneeId} onChange={(event) => setTargetAssigneeId(event.target.value)}><option value="">Sin asignar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)}</option>)}</SearchableSelect></label>}
<label className="field"><span>Vencimiento</span><input type="datetime-local" value={targetDueAt} onChange={(event) => setTargetDueAt(event.target.value)} /></label>
<label className="field"><span>Instrucciones</span><input value={targetInstructions} onChange={(event) => setTargetInstructions(event.target.value)} maxLength={4000} placeholder="Trabajo previsto" /></label>
</div>
<div className="form-actions"><button className="button primary" disabled={busy || !targetAssetId}><Icon name="plus" />Agregar objetivo</button></div>
</form>}
{campaign && <div className="table-panel survey-targets"><div className="table-summary"><strong>{campaign.targetCount} objetivo{campaign.targetCount === 1 ? '' : 's'}</strong><span>{campaign.completedCount} completados · {campaign.submittedCount} en revisión · {campaign.inProgressCount} en ejecución · {campaign.pendingCount} pendientes · {campaign.skippedCount} omitidos</span></div>{campaign.targets.length === 0 ? <EmptyState title="Sin objetivos" text="Agregá registros del inventario para completar la planificación." /> : <div className="table-scroll"><table><thead><tr><th>Registro</th><th>Estado</th><th>Responsable</th><th>Vencimiento</th><th>Instrucciones</th><th /></tr></thead><tbody>{campaign.targets.map((target) => {
const canAct = canExecute && (canManage || target.assignedUser?.id === user?.id);
const planLocked = target.status === 'SUBMITTED' || target.status === 'COMPLETED';
const availableStatuses = campaign.status === 'IN_PROGRESS'
? target.status === 'PENDING'
? ['IN_PROGRESS', 'SKIPPED'] as SurveyTargetStatus[]
: target.status === 'IN_PROGRESS'
? ['PENDING', 'SKIPPED'] as SurveyTargetStatus[]
: []
: canManage && (campaign.status === 'DRAFT' || campaign.status === 'PLANNED')
? target.status === 'PENDING'
? ['SKIPPED'] as SurveyTargetStatus[]
: target.status === 'SKIPPED'
? ['PENDING'] as SurveyTargetStatus[]
: []
: [];
const draft = drafts[target.id] ?? { dueAt: '', instructions: '' };
return <tr key={target.id}><td><Link className="history-asset-link" to={`/inventarios/${target.asset.id}`}><strong>{target.asset.name}</strong><small>{target.asset.code} · {target.asset.typeName}</small></Link></td><td><span className={`status-badge ${surveyStatusClass(target.status)}`}>{surveyTargetStatusLabel(target.status)}</span>{canAct && availableStatuses.length > 0 && <SearchableSelect className="survey-inline-select" value="" disabled={busy} onChange={(event) => event.target.value && changeTargetStatus(target, event.target.value as SurveyTargetStatus)}><option value="">Cambiar</option>{availableStatuses.map((status) => <option key={status} value={status}>{SURVEY_TARGET_STATUSES.find((item) => item.value === status)?.label}</option>)}</SearchableSelect>}</td><td>{canAssign && !closed && !planLocked ? <SearchableSelect className="survey-inline-select wide" value={target.assignedUser?.id ?? ''} disabled={busy} onChange={(event) => assignTarget(target, event.target.value)}><option value="">Sin asignar</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)}</option>)}</SearchableSelect> : target.assignedUser ? personName(target.assignedUser) : 'Sin asignar'}</td><td>{canManage && !closed && !planLocked ? <input className="survey-inline-input" type="datetime-local" value={draft.dueAt} onChange={(event) => setDrafts((current) => ({ ...current, [target.id]: { ...draft, dueAt: event.target.value } }))} /> : formatDate(target.dueAt)}</td><td>{canManage && !closed && !planLocked ? <input className="survey-inline-input instructions" value={draft.instructions} onChange={(event) => setDrafts((current) => ({ ...current, [target.id]: { ...draft, instructions: event.target.value } }))} maxLength={4000} placeholder="Sin instrucciones" /> : target.instructions ?? '—'}</td><td><div className="survey-row-actions">{canManage && !closed && !planLocked && <button type="button" className="icon-button" title="Guardar planificación del objetivo" disabled={busy} onClick={() => saveTargetPlan(target)}><Icon name="check" /></button>}{canReadReports && <Link className="icon-button" title="Abrir informe de campo" to={`/relevamiento/objetivos/${target.id}`}><Icon name="clipboard" /></Link>}</div></td></tr>;
})}</tbody></table></div>}</div>}
</section>;
}
+81
View File
@@ -0,0 +1,81 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { PermissionGate } from '../auth/PermissionGate';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
SURVEY_CAMPAIGN_STATUSES,
surveyCampaignStatusLabel,
surveyStatusClass,
} from '../features/surveys/surveyPresentation';
import { listSurveyCampaigns } from '../lib/api';
import type {
PageMeta,
SurveyCampaignListItem,
SurveyCampaignStatus,
} from '../lib/api';
import { formatDate } from '../lib/format';
export function SurveyCampaignsPage() {
const [urlParams, setUrlParams] = useSearchParams();
const [campaigns, setCampaigns] = useState<SurveyCampaignListItem[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
const search = urlParams.get('search') ?? '';
const rawStatus = urlParams.get('status') ?? '';
const status = SURVEY_CAMPAIGN_STATUSES.some((item) => item.value === rawStatus)
? rawStatus as SurveyCampaignStatus
: '';
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
useEffect(() => {
setLoading(true);
setError('');
listSurveyCampaigns({ page, pageSize: 25, search, status })
.then((response) => {
setCampaigns(response.data);
setMeta(response.meta);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [page, search, status]);
const updateFilter = (key: string, value: string) => {
const next = new URLSearchParams(urlParams);
value ? next.set(key, value) : next.delete(key);
next.delete('page');
setUrlParams(next);
};
const applySearch = (event: FormEvent) => {
event.preventDefault();
updateFilter('search', draftSearch.trim());
};
const setPage = (value: number) => {
const next = new URLSearchParams(urlParams);
value > 1 ? next.set('page', String(value)) : next.delete('page');
setUrlParams(next);
};
return <section>
<div className="page-heading">
<div><span className="eyebrow">OPERACIÓN DE CAMPO</span><h1>Relevamientos</h1><p>Campañas planificadas sobre registros existentes de los inventarios.</p></div>
<PermissionGate permission="surveys.manage"><Link className="button primary" to="/relevamiento/nuevo"><Icon name="plus" />Nueva campaña</Link></PermissionGate>
</div>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inventarios organizados por empresa.</strong> Las campañas organizan qué registros relevar, quién los atiende y su avance; no crean un inventario paralelo.</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 o nombre" /><button>Buscar</button></label>
<label className="select-field"><span>Estado</span><SearchableSelect value={status} onChange={(event) => updateFilter('status', event.target.value)}><option value="">Todos</option>{SURVEY_CAMPAIGN_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
</form>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando campañas…" /> : campaigns.length === 0 ? <EmptyState title="Sin campañas" text="Creá una campaña para planificar el próximo relevamiento de los inventarios." /> : <div className="table-panel"><div className="table-summary"><strong>{meta.total} campaña{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Campaña</th><th>Estado</th><th>Alcance</th><th>Coordinación</th><th>Fechas previstas</th><th>Avance</th><th /></tr></thead><tbody>{campaigns.map((campaign) => <tr key={campaign.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{campaign.name}</strong><small>{campaign.code}</small></div></div></td><td><span className={`status-badge ${surveyStatusClass(campaign.status)}`}>{surveyCampaignStatusLabel(campaign.status)}</span></td><td>{campaign.scopeAsset ? <Link className="text-link" to={`/inventarios/${campaign.scopeAsset.id}`}>{campaign.scopeAsset.name}</Link> : <span className="muted">Todos los inventarios</span>}</td><td>{campaign.coordinator ? `${campaign.coordinator.firstName} ${campaign.coordinator.lastName}` : 'Sin asignar'}</td><td><span className="survey-date-range">{formatDate(campaign.plannedStartAt)}<small>hasta {formatDate(campaign.plannedEndAt)}</small></span></td><td><div className="survey-progress"><strong>{campaign.completedCount + campaign.skippedCount}/{campaign.targetCount}</strong><span><i style={{ width: `${campaign.targetCount ? ((campaign.completedCount + campaign.skippedCount) / campaign.targetCount) * 100 : 0}%` }} /></span></div></td><td className="action-cell"><Link className="icon-button" to={`/relevamiento/${campaign.id}`} aria-label={`Abrir ${campaign.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>;
}
+303
View File
@@ -0,0 +1,303 @@
import { useEffect, useRef, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
surveyStatusClass,
surveyTargetStatusLabel,
} from '../features/surveys/surveyPresentation';
import {
getAssetMediaBlob,
getSurveyExecution,
reviewSurveyReport,
saveSurveyReport,
submitSurveyReport,
updateSurveyTargetStatus,
uploadAssetMedia,
} from '../lib/api';
import type {
SurveyExecution,
SurveyReportMedia,
SurveyReportOutcome,
} from '../lib/api';
import { formatDate } from '../lib/format';
interface ReportForm {
outcome: SurveyReportOutcome | '';
observedAt: string;
latitude: string;
longitude: string;
accuracyM: string;
notes: string;
}
const emptyForm: ReportForm = {
outcome: '', observedAt: '', latitude: '', longitude: '', accuracyM: '', notes: '',
};
const outcomes: Array<{ value: SurveyReportOutcome; label: string; text: string }> = [
{ value: 'CONFIRMED', label: 'Registro confirmado', text: 'Los datos actuales representan lo observado en campo.' },
{ value: 'CHANGES_RECORDED', label: 'Cambios registrados', text: 'Se actualizaron datos, geometría o archivos del registro.' },
{ value: 'NOT_LOCATED', label: 'No localizado', text: 'No fue posible localizar o verificar físicamente el registro.' },
];
function localDateTime(value: string | number | Date | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function personName(person: { firstName: string; lastName: string }): string {
return `${person.firstName} ${person.lastName}`;
}
function reportStatusLabel(status: string): string {
return ({
DRAFT: 'Borrador', SUBMITTED: 'En revisión', APPROVED: 'Aprobado', REJECTED: 'Rechazado',
} as Record<string, string>)[status] ?? status;
}
function reportEventLabel(event: string): string {
return ({ SUBMITTED: 'Enviado', APPROVED: 'Aprobado', REJECTED: 'Rechazado' } as Record<string, string>)[event] ?? event;
}
function EvidencePreview({ media }: { media: SurveyReportMedia }) {
const [url, setUrl] = useState('');
useEffect(() => {
let active = true;
let objectUrl = '';
getAssetMediaBlob(media.id).then((blob) => {
if (!active) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
}).catch(() => undefined);
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [media.id]);
return url
? <img src={url} alt={media.title || media.originalName} />
: <div className="media-preview-loading"><span className="spinner" /></div>;
}
export function SurveyExecutionPage() {
const { targetId } = useParams();
const { user, hasPermission } = useAuth();
const fileRef = useRef<HTMLInputElement | null>(null);
const [execution, setExecution] = useState<SurveyExecution | null>(null);
const [form, setForm] = useState<ReportForm>(emptyForm);
const [selectedMedia, setSelectedMedia] = useState<Set<string>>(new Set());
const [file, setFile] = useState<File | null>(null);
const [reviewNotes, setReviewNotes] = useState('');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const sync = (value: SurveyExecution) => {
setExecution(value);
setSelectedMedia(new Set(value.report?.selectedMediaIds ?? []));
setForm({
outcome: value.report?.outcome ?? '',
observedAt: localDateTime(value.report?.observedAt ?? null),
latitude: value.report?.latitude == null ? '' : String(value.report.latitude),
longitude: value.report?.longitude == null ? '' : String(value.report.longitude),
accuracyM: value.report?.accuracyM == null ? '' : String(value.report.accuracyM),
notes: value.report?.notes ?? '',
});
setReviewNotes(value.report?.reviewNotes ?? '');
};
const load = async () => {
if (!targetId) return;
sync(await getSurveyExecution(targetId));
};
useEffect(() => {
setLoading(true);
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, [targetId]);
const assignedToCurrent = execution?.target.assignedUser?.id === user?.id;
const canCapture = hasPermission('surveys.capture') && (
hasPermission('surveys.manage') || assignedToCurrent
);
const canExecute = hasPermission('surveys.execute') && (
hasPermission('surveys.manage') || assignedToCurrent
);
const canReview = hasPermission('surveys.review');
const canUpload = hasPermission('assets.manage_media');
const editable = Boolean(
execution && canCapture && execution.campaign.status === 'IN_PROGRESS' &&
execution.target.status === 'IN_PROGRESS' &&
(!execution.report || execution.report.status === 'DRAFT' || execution.report.status === 'REJECTED'),
);
const input = (mediaIds = [...selectedMedia]) => ({
outcome: form.outcome || null,
observedAt: form.observedAt ? new Date(form.observedAt).toISOString() : null,
latitude: form.latitude ? Number(form.latitude) : null,
longitude: form.longitude ? Number(form.longitude) : null,
accuracyM: form.accuracyM ? Number(form.accuracyM) : null,
notes: form.notes.trim() || null,
mediaIds,
});
const useDeviceLocation = () => {
if (!navigator.geolocation) {
setError('Este navegador no permite obtener la ubicación del dispositivo.');
return;
}
setLocating(true); setError('');
navigator.geolocation.getCurrentPosition(
(position) => {
setForm((current) => ({
...current,
latitude: position.coords.latitude.toFixed(6),
longitude: position.coords.longitude.toFixed(6),
accuracyM: position.coords.accuracy.toFixed(3),
observedAt: current.observedAt || localDateTime(position.timestamp),
}));
setLocating(false);
},
() => {
setError('No fue posible obtener la ubicación del dispositivo.');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 15_000, maximumAge: 0 },
);
};
const startTarget = async () => {
if (!targetId) return;
setBusy(true); setError(''); setSuccess('');
try {
await updateSurveyTargetStatus(targetId, 'IN_PROGRESS');
await load();
setSuccess('Objetivo iniciado. Ya podés registrar el trabajo de campo.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const save = async (event?: FormEvent) => {
event?.preventDefault();
if (!targetId) return;
setBusy(true); setError(''); setSuccess('');
try {
sync(await saveSurveyReport(targetId, input()));
setSuccess('Borrador de campo guardado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const uploadEvidence = async (event: FormEvent) => {
event.preventDefault();
if (!targetId || !execution || !file) return;
setBusy(true); setError(''); setSuccess('');
try {
const media = await uploadAssetMedia(execution.asset.id, {
file,
kind: 'PHOTO',
title: `Evidencia ${execution.campaign.code}`,
description: form.notes.trim() || undefined,
capturedAt: form.observedAt ? new Date(form.observedAt).toISOString() : undefined,
latitude: form.latitude ? Number(form.latitude) : undefined,
longitude: form.longitude ? Number(form.longitude) : undefined,
accuracyM: form.accuracyM ? Number(form.accuracyM) : undefined,
});
const nextIds = [...selectedMedia, media.id];
sync(await saveSurveyReport(targetId, input(nextIds)));
setFile(null);
if (fileRef.current) fileRef.current.value = '';
setSuccess('Fotografía protegida y vinculada como evidencia.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const submit = async () => {
if (!targetId || !window.confirm('¿Enviar este relevamiento a revisión? El contenido quedará congelado.')) return;
setBusy(true); setError(''); setSuccess('');
try {
await saveSurveyReport(targetId, input());
sync(await submitSurveyReport(targetId));
setSuccess('Relevamiento enviado y versión de campo congelada.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
const review = async (decision: 'APPROVE' | 'REJECT') => {
if (!targetId) return;
const verb = decision === 'APPROVE' ? 'aprobar' : 'rechazar';
if (!window.confirm(`¿Confirmás que querés ${verb} este relevamiento?`)) return;
setBusy(true); setError(''); setSuccess('');
try {
sync(await reviewSurveyReport(targetId, decision, reviewNotes.trim() || null));
setSuccess(decision === 'APPROVE'
? 'Relevamiento aprobado y registro validado en el inventario.'
: 'Relevamiento rechazado y devuelto a ejecución.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setBusy(false);
}
};
if (loading) return <LoadingBlock label="Cargando informe de campo…" />;
if (!execution) return <Alert>{error || 'No se pudo cargar el objetivo.'}</Alert>;
return <section className="survey-execution-page">
<div className="breadcrumb"><Link to="/relevamiento">Relevamientos</Link><span>/</span><Link to={`/relevamiento/${execution.campaign.id}`}>{execution.campaign.code}</Link><span>/</span><span>{execution.asset.code}</span></div>
<div className="page-heading survey-editor-heading"><div><span className="eyebrow">EJECUCIÓN DE CAMPO</span><h1>{execution.asset.name}</h1><p>{execution.asset.code} · {execution.asset.typeName} · versión actual {execution.asset.currentVersion}</p></div><span className={`status-badge large ${surveyStatusClass(execution.target.status)}`}>{surveyTargetStatusLabel(execution.target.status)}</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="survey-execution-summary">
<article className="panel"><span className="eyebrow">CAMPAÑA</span><strong>{execution.campaign.name}</strong><small>{execution.campaign.code}</small></article>
<article className="panel"><span className="eyebrow">RESPONSABLE</span><strong>{execution.target.assignedUser ? personName(execution.target.assignedUser) : 'Sin asignar'}</strong><small>Vence {formatDate(execution.target.dueAt)}</small></article>
<article className="panel"><span className="eyebrow">INFORME</span><strong>{execution.report ? reportStatusLabel(execution.report.status) : 'Sin iniciar'}</strong><small>{execution.report ? `Actualizado ${formatDate(execution.report.updatedAt)}` : 'Todavía no hay captura'}</small></article>
</div>
{execution.target.instructions && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Instrucciones:</strong> {execution.target.instructions}</p></div>}
{execution.target.status === 'PENDING' && canExecute && execution.campaign.status === 'IN_PROGRESS' && <div className="panel survey-start-panel"><div><strong>Objetivo pendiente</strong><p>Iniciá la ejecución antes de capturar datos de campo.</p></div><button type="button" className="button primary" onClick={startTarget} disabled={busy}><Icon name="check" />Iniciar objetivo</button></div>}
<form className="panel survey-report-form" onSubmit={save}>
<div className="panel-heading"><div><span className="eyebrow">VERIFICACIÓN DEL MAESTRO</span><h2>Informe de campo</h2></div>{execution.report && <span className={`status-badge ${execution.report.status === 'APPROVED' ? 'active' : execution.report.status === 'REJECTED' ? 'inactive' : execution.report.status === 'SUBMITTED' ? 'observed' : 'pending'}`}>{reportStatusLabel(execution.report.status)}</span>}</div>
<div className="survey-outcome-grid">{outcomes.map((item) => <label className={`survey-outcome ${form.outcome === item.value ? 'selected' : ''}`} key={item.value}><input type="radio" name="outcome" value={item.value} checked={form.outcome === item.value} onChange={() => setForm((current) => ({ ...current, outcome: item.value }))} disabled={!editable} /><strong>{item.label}</strong><small>{item.text}</small></label>)}</div>
<div className="form-grid"><label className="field"><span>Fecha y hora observada</span><input type="datetime-local" value={form.observedAt} onChange={(event) => setForm((current) => ({ ...current, observedAt: event.target.value }))} disabled={!editable} /></label><label className="field"><span>Ubicación del trabajo</span><button type="button" className="button secondary" onClick={useDeviceLocation} disabled={!editable || locating}><Icon name="map" />{locating ? 'Obteniendo GPS…' : 'Capturar GPS actual'}</button></label></div>
<div className="form-grid survey-gps-grid"><label className="field"><span>Latitud</span><input type="number" min="-90" max="90" step="0.000001" value={form.latitude} onChange={(event) => setForm((current) => ({ ...current, latitude: event.target.value }))} disabled={!editable} /></label><label className="field"><span>Longitud</span><input type="number" min="-180" max="180" step="0.000001" value={form.longitude} onChange={(event) => setForm((current) => ({ ...current, longitude: event.target.value }))} disabled={!editable} /></label><label className="field"><span>Precisión GPS (m)</span><input type="number" min="0" max="100000" step="0.001" value={form.accuracyM} onChange={(event) => setForm((current) => ({ ...current, accuracyM: event.target.value }))} disabled={!editable} /></label></div>
<label className="field"><span>Observaciones de campo</span><textarea rows={5} value={form.notes} onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))} maxLength={8000} disabled={!editable} placeholder="Describí verificaciones, cambios realizados o motivo por el que no se localizó el registro." /></label>
<div className="survey-master-link"><div><strong>¿Encontraste datos desactualizados?</strong><p>Editá el registro original, su geometría o sus archivos. El informe congelará la versión exacta vigente al enviarlo.</p></div><Link className="button secondary" to={`/inventarios/${execution.asset.id}`}><Icon name="edit" />Abrir registro del inventario</Link></div>
{editable && <div className="form-actions"><button className="button secondary" disabled={busy}><Icon name="check" />Guardar borrador</button><button type="button" className="button primary" disabled={busy} onClick={submit}>Enviar a revisión</button></div>}
</form>
<article className="panel survey-evidence-panel">
<div className="panel-heading"><div><span className="eyebrow">EVIDENCIA PROTEGIDA</span><h2>Fotografías del relevamiento</h2></div><span className="count-pill">{selectedMedia.size} seleccionada{selectedMedia.size === 1 ? '' : 's'}</span></div>
<p className="section-copy">La evidencia queda vinculada al registro original y su hash se congela en cada versión del informe.</p>
{editable && canUpload && <form className="survey-evidence-upload" onSubmit={uploadEvidence}><label className="field"><span>Nueva fotografía</span><input ref={fileRef} type="file" accept="image/jpeg,image/png,image/webp" capture="environment" onChange={(event) => setFile(event.target.files?.[0] ?? null)} required /></label><button className="button primary" disabled={!file || busy}><Icon name="plus" />Tomar o subir evidencia</button></form>}
{execution.availableMedia.length === 0 ? <div className="inline-empty">Todavía no hay fotografías disponibles para este registro.</div> : <div className="survey-evidence-grid">{execution.availableMedia.map((media) => <label className={`survey-evidence-card ${selectedMedia.has(media.id) ? 'selected' : ''}`} key={media.id}><div className="media-photo-preview"><EvidencePreview media={media} /></div><span className="survey-evidence-check"><input type="checkbox" checked={selectedMedia.has(media.id)} disabled={!editable} onChange={(event) => setSelectedMedia((current) => { const next = new Set(current); event.target.checked ? next.add(media.id) : next.delete(media.id); return next; })} />Usar como evidencia</span><strong>{media.title || media.originalName}</strong><small>{formatDate(media.capturedAt || media.createdAt)} · {Math.max(1, Math.round(media.sizeBytes / 1024))} KB</small></label>)}</div>}
</article>
{execution.report?.status === 'SUBMITTED' && canReview && <article className="panel survey-review-panel"><div className="panel-heading"><div><span className="eyebrow">CONTROL DE CALIDAD</span><h2>Revisión del relevamiento</h2></div><span className="version-badge">Registro v{execution.report.assetVersionAtSubmission}</span></div><label className="field"><span>Observaciones de revisión</span><textarea rows={4} value={reviewNotes} onChange={(event) => setReviewNotes(event.target.value)} maxLength={4000} placeholder="Obligatorio para rechazar" /></label><div className="form-actions"><button type="button" className="button danger-outline" onClick={() => review('REJECT')} disabled={busy || reviewNotes.trim().length < 10}>Rechazar y devolver</button><button type="button" className="button success-outline" onClick={() => review('APPROVE')} disabled={busy}><Icon name="check" />Aprobar y validar registro</button></div></article>}
{execution.report && execution.report.status !== 'DRAFT' && <article className="panel survey-review-result"><div><span className="eyebrow">TRAZABILIDAD</span><h2>{reportStatusLabel(execution.report.status)}</h2></div><dl className="detail-list compact"><div><dt>Enviado</dt><dd>{formatDate(execution.report.submittedAt)}{execution.report.submittedBy ? ` por ${personName(execution.report.submittedBy)}` : ''}</dd></div><div><dt>Versión del registro enviada</dt><dd>{execution.report.assetVersionAtSubmission ? `v${execution.report.assetVersionAtSubmission}` : '—'}</dd></div><div><dt>Revisado</dt><dd>{formatDate(execution.report.reviewedAt)}{execution.report.reviewedBy ? ` por ${personName(execution.report.reviewedBy)}` : ''}</dd></div><div><dt>Observaciones</dt><dd>{execution.report.reviewNotes || '—'}</dd></div></dl></article>}
{execution.versions.length > 0 && <article className="panel survey-report-history"><div className="panel-heading"><div><span className="eyebrow">VERSIONES INMUTABLES</span><h2>Historial del informe</h2></div><span className="count-pill">{execution.versions.length}</span></div><div className="survey-version-list">{execution.versions.map((version) => <details key={version.id}><summary><span className={`version-badge ${version.event === 'APPROVED' ? 'current' : ''}`}>v{version.versionNumber}</span><strong>{reportEventLabel(version.event)}</strong><small>{formatDate(version.createdAt)} · {version.actorUsername || 'sistema'}</small></summary><pre>{JSON.stringify(version.snapshot, null, 2)}</pre></details>)}</div></article>}
</section>;
}
+9
View File
@@ -0,0 +1,9 @@
import { Link } from 'react-router';
export function AccessDeniedPage() {
return <section className="system-page"><span className="status-code">403</span><h1>Acceso restringido</h1><p>Tu rol no tiene permisos para consultar este módulo.</p><Link className="button primary" to="/">Volver al inicio</Link></section>;
}
export function NotFoundPage() {
return <section className="system-page"><span className="status-code">404</span><h1>Página no encontrada</h1><p>La dirección solicitada no existe en DH Inspección V2.</p><Link className="button primary" to="/">Volver al inicio</Link></section>;
}
+95
View File
@@ -0,0 +1,95 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetCenterTabs } from '../features/assets/AssetCenterTabs';
import { assetStatusLabel, ASSET_STATUSES } from '../features/assets/assetPresentation';
import { AssetVersionDrawer } from '../features/assets/AssetVersionDrawer';
import { assetVersionChangeLabel } from '../features/assets/assetVersionPresentation';
import { getTemporalAsset, listAssetTypes, listTemporalAssets } from '../lib/api';
import type {
AssetInformationStatus,
AssetType,
PageMeta,
TemporalAssetDetail,
TemporalAssetSummary,
} from '../lib/api';
import { formatDate } from '../lib/format';
function localDateTime(date: Date): string {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
interface TemporalFilters {
search: string;
typeId: string;
status: AssetInformationStatus | '';
}
const emptyFilters: TemporalFilters = { search: '', typeId: '', status: '' };
export function TemporalAssetsPage() {
const initialDate = new Date();
const [draftAt, setDraftAt] = useState(localDateTime(initialDate));
const [at, setAt] = useState(initialDate.toISOString());
const [draft, setDraft] = useState<TemporalFilters>(emptyFilters);
const [filters, setFilters] = useState<TemporalFilters>(emptyFilters);
const [assets, setAssets] = useState<TemporalAssetSummary[]>([]);
const [types, setTypes] = useState<AssetType[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<TemporalAssetDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => { listAssetTypes().then(setTypes).catch(() => undefined); }, []);
useEffect(() => {
setLoading(true); setError('');
listTemporalAssets({ at, page, pageSize: 25, ...filters })
.then((response) => { setAssets(response.data); setMeta(response.meta); })
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [at, filters, page]);
const apply = (event: FormEvent) => {
event.preventDefault();
const selected = new Date(draftAt);
if (Number.isNaN(selected.getTime())) { setError('Seleccioná una fecha y hora válida.'); return; }
setPage(1);
setAt(selected.toISOString());
setFilters({ ...draft, search: draft.search.trim() });
};
const reset = () => {
const now = new Date();
setDraftAt(localDateTime(now)); setAt(now.toISOString());
setDraft(emptyFilters); setFilters(emptyFilters); setPage(1);
};
const open = async (asset: TemporalAssetSummary) => {
setDetail(null); setDetailLoading(true); setError('');
try { setDetail(await getTemporalAsset(asset.assetId, at)); }
catch (requestError) { setError(errorMessage(requestError)); }
finally { setDetailLoading(false); }
};
return <section>
<div className="page-heading"><div><span className="eyebrow">INVENTARIOS</span><h1>Registros</h1><p>Reconstruí cómo se encontraban los inventarios en una fecha y hora.</p></div><span className="count-pill large">{meta.total} registros</span></div>
<AssetCenterTabs active="temporal" />
<div className="temporal-notice"><Icon name="history" /><p><strong>Vista histórica exacta.</strong> Los resultados provienen de snapshots inmutables; no reemplazan ni modifican los datos actuales.</p></div>
<form className="history-filters panel temporal-filters" onSubmit={apply}>
<label className="field temporal-date"><span>Fecha y hora de consulta</span><input type="datetime-local" value={draftAt} onChange={(event) => setDraftAt(event.target.value)} required /></label>
<label className="search-field"><Icon name="search" /><input value={draft.search} onChange={(event) => setDraft((current) => ({ ...current, search: event.target.value }))} placeholder="Código o nombre histórico" /></label>
<label className="field compact-field"><span>Tipo</span><SearchableSelect value={draft.typeId} onChange={(event) => setDraft((current) => ({ ...current, typeId: event.target.value }))}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado</span><SearchableSelect value={draft.status} onChange={(event) => setDraft((current) => ({ ...current, status: event.target.value as AssetInformationStatus | '' }))}><option value="">Todos</option>{ASSET_STATUSES.map((status) => <option key={status.value} value={status.value}>{status.label}</option>)}</SearchableSelect></label>
<div className="filter-actions"><button className="button text" type="button" onClick={reset}>Volver al presente</button><button className="button primary"><Icon name="history" />Reconstruir</button></div>
</form>
<div className="temporal-result-heading"><strong>Inventarios reconstruidos al {formatDate(at)}</strong><span>Las vigencias terminan cuando se registra la versión siguiente.</span></div>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Reconstruyendo inventarios…" /> : assets.length === 0 ? <EmptyState title="Sin registros en esa fecha" text="No existen versiones históricas que coincidan con la fecha y los filtros seleccionados." /> : <div className="table-panel temporal-table"><div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'} reconstruido{meta.total === 1 ? '' : 's'}</strong><span>Página {page} de {Math.max(meta.totalPages, 1)}</span></div><div className="table-scroll"><table><thead><tr><th>Registro histórico</th><th>Tipo</th><th>Estado</th><th>Versión aplicable</th><th>Vigente desde</th><th>Vigente hasta</th><th>Cambio</th><th /></tr></thead><tbody>{assets.map((asset) => <tr key={asset.assetId}><td><Link className="history-asset-link" to={`/inventarios/${asset.assetId}`}><strong>{asset.assetName}</strong><small>{asset.assetCode}</small></Link></td><td><span className="tag">{asset.typeName}</span></td><td>{assetStatusLabel(asset.informationStatus)}</td><td><span className={`version-badge ${asset.isCurrent ? 'current' : ''}`}>v{asset.versionNumber}{asset.isCurrent ? ' · actual' : ''}</span></td><td>{formatDate(asset.occurredAt)}</td><td>{asset.effectiveUntil ? formatDate(asset.effectiveUntil) : 'Continúa vigente'}</td><td>{assetVersionChangeLabel(asset.changeType)}</td><td><button type="button" className="icon-button" onClick={() => open(asset)} aria-label={`Ver versión ${asset.versionNumber}`}><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button type="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 type="button" className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
{(detailLoading || detail) && <AssetVersionDrawer detail={detail} loading={detailLoading} onClose={() => setDetail(null)} />}
</section>;
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useLocation, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
getUser,
listRoles,
replaceUserRoles,
resetUserPassword,
updateUser,
updateUserStatus,
} from '../lib/api';
import type { AdministrativeRole, AdministrativeUser } from '../lib/api';
import { formatDate, initials } from '../lib/format';
export function UserDetailPage() {
const { id = '' } = useParams();
const location = useLocation();
const { user: currentUser, hasPermission } = useAuth();
const [user, setUser] = useState<AdministrativeUser | null>(null);
const [roles, setRoles] = useState<AdministrativeRole[]>([]);
const [roleIds, setRoleIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState((location.state as { created?: boolean } | null)?.created ? 'Usuario creado correctamente' : '');
const [resetPasswordValue, setResetPasswordValue] = useState('');
const [resetPasswordConfirm, setResetPasswordConfirm] = useState('');
const [forcePasswordChange, setForcePasswordChange] = useState(true);
const load = async () => {
setLoading(true);
try {
const [loadedUser, loadedRoles] = await Promise.all([getUser(id), listRoles()]);
setUser(loadedUser);
setRoles(loadedRoles);
setRoleIds(loadedUser.roles.map((role) => role.id));
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoading(false);
}
};
useEffect(() => { void load(); }, [id]);
const saveProfile = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(''); setSuccess(''); setSaving('profile');
const data = new FormData(event.currentTarget);
try {
const updated = await updateUser(id, {
firstName: String(data.get('firstName')),
lastName: String(data.get('lastName')),
username: String(data.get('username')),
email: String(data.get('email') ?? '') || null,
});
setUser(updated); setSuccess('Datos del usuario actualizados');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
const saveRoles = async () => {
setError(''); setSuccess(''); setSaving('roles');
try {
const updated = await replaceUserRoles(id, roleIds);
setUser(updated); setSuccess('Roles actualizados correctamente');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
const changeStatus = async () => {
if (!user) return;
const next = user.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
const verb = next === 'INACTIVE' ? 'desactivar' : 'activar';
if (!window.confirm(`¿Confirmás que querés ${verb} a ${user.username}?`)) return;
setError(''); setSuccess(''); setSaving('status');
try {
const updated = await updateUserStatus(id, next);
setUser(updated); setSuccess(`Usuario ${next === 'ACTIVE' ? 'activado' : 'desactivado'}`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
const resetPassword = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (resetPasswordValue.length < 12) { setError('La contraseña temporal debe tener al menos 12 caracteres.'); return; }
if (resetPasswordValue !== resetPasswordConfirm) { setError('Las contraseñas no coinciden.'); return; }
if (!window.confirm(`¿Restablecer la contraseña de ${user?.username ?? 'este usuario'}? Se cerrarán todas sus sesiones activas.`)) return;
setError(''); setSuccess(''); setSaving('password');
try {
const updated = await resetUserPassword(id, resetPasswordValue, forcePasswordChange);
setUser(updated);
setResetPasswordValue(''); setResetPasswordConfirm('');
setSuccess('Contraseña restablecida. Las sesiones anteriores fueron revocadas.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
const toggleRole = (roleId: string) => setRoleIds((current) => current.includes(roleId) ? current.filter((value) => value !== roleId) : [...current, roleId]);
if (loading) return <LoadingBlock label="Cargando usuario…" />;
if (!user) return <Alert>{error || 'Usuario no encontrado'}</Alert>;
const canUpdate = hasPermission('users.update');
const canAssign = hasPermission('users.assign_roles');
const canChangeStatus = hasPermission('users.change_status');
return <section>
<div className="breadcrumb"><Link to="/admin/users">Usuarios</Link><span>/</span><strong>{user.username}</strong></div>
<div className="page-heading user-heading"><div className="profile-title"><span className="profile-avatar">{initials(user.firstName, user.lastName)}</span><div><span className="eyebrow">DETALLE DE USUARIO</span><h1>{user.firstName} {user.lastName}</h1><p>@{user.username} · Creado {formatDate(user.createdAt)}</p></div></div>{canChangeStatus && <button className={`button ${user.status === 'ACTIVE' ? 'danger-outline' : 'success-outline'}`} disabled={saving === 'status' || currentUser?.id === user.id} onClick={changeStatus}>{user.status === 'ACTIVE' ? 'Desactivar acceso' : 'Activar acceso'}</button>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="detail-grid">
<form className="panel form-panel" onSubmit={saveProfile}>
<div className="panel-heading"><div><span className="eyebrow">CUENTA</span><h2>Datos personales</h2></div><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span></div>
<div className="form-grid"><label className="field"><span>Nombre</span><input name="firstName" defaultValue={user.firstName} required disabled={!canUpdate} /></label><label className="field"><span>Apellido</span><input name="lastName" defaultValue={user.lastName} required disabled={!canUpdate} /></label><label className="field"><span>Usuario</span><input name="username" defaultValue={user.username} required disabled={!canUpdate} /></label><label className="field"><span>Email</span><input name="email" type="email" defaultValue={user.email ?? ''} disabled={!canUpdate} /></label></div>
<div className="metadata-grid"><div><small>Último acceso</small><strong>{formatDate(user.lastLoginAt)}</strong></div><div><small>Último cambio de clave</small><strong>{formatDate(user.passwordChangedAt)}</strong></div><div><small>Intentos fallidos</small><strong>{user.failedLoginAttempts}</strong></div><div><small>Bloqueado hasta</small><strong>{formatDate(user.lockedUntil)}</strong></div></div>
{user.mustChangePassword && <Alert type="info">Este usuario debe cambiar su contraseña temporal en el próximo ingreso.</Alert>}
{canUpdate && <div className="form-actions"><button className="button primary" disabled={saving === 'profile'}><Icon name="edit" />{saving === 'profile' ? 'Guardando…' : 'Guardar datos'}</button></div>}
</form>
{canUpdate && <form className="panel form-panel" onSubmit={resetPassword}>
<div className="panel-heading"><div><span className="eyebrow">SEGURIDAD</span><h2>Restablecer contraseña</h2></div><Icon name="key" /></div>
<Alert type="info">El restablecimiento revoca todas las sesiones activas del usuario. La contraseña nunca se guarda en auditoría.</Alert>
<label className="field"><span>Nueva contraseña temporal</span><input type="password" value={resetPasswordValue} onChange={(event) => setResetPasswordValue(event.target.value)} minLength={12} maxLength={128} autoComplete="new-password" required /><small>Mínimo 12 caracteres.</small></label>
<label className="field"><span>Repetir contraseña</span><input type="password" value={resetPasswordConfirm} onChange={(event) => setResetPasswordConfirm(event.target.value)} minLength={12} maxLength={128} autoComplete="new-password" required /></label>
<label className="check-row"><input type="checkbox" checked={forcePasswordChange} onChange={(event) => setForcePasswordChange(event.target.checked)} /><span><strong>Exigir cambio en el próximo ingreso</strong><small>Recomendado para claves temporales entregadas por administración.</small></span></label>
<div className="form-actions"><button className="button primary" disabled={saving === 'password'}>{saving === 'password' ? 'Restableciendo…' : 'Restablecer contraseña'}</button></div>
</form>}
<article className="panel form-panel">
<div className="panel-heading"><div><span className="eyebrow">AUTORIZACIÓN</span><h2>Roles asignados</h2></div><span className="count-pill">{roleIds.length}</span></div>
<div className="choice-list">{roles.map((role) => <label className={`choice-card compact ${roleIds.includes(role.id) ? 'selected' : ''}`} key={role.id}><input type="checkbox" checked={roleIds.includes(role.id)} onChange={() => toggleRole(role.id)} disabled={!canAssign} /><span><strong>{role.name}</strong><small>{role.description}</small></span><Icon name="check" /></label>)}</div>
{canAssign && <div className="form-actions"><button className="button primary" type="button" onClick={saveRoles} disabled={saving === 'roles'}>{saving === 'roles' ? 'Guardando…' : 'Guardar roles'}</button></div>}
</article>
</div>
</section>;
}
+73
View File
@@ -0,0 +1,73 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { PermissionGate } from '../auth/PermissionGate';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { listUsers } from '../lib/api';
import type { AdministrativeUser, PageMeta } from '../lib/api';
import { formatDate, initials } from '../lib/format';
export function UsersPage() {
const [urlParams, setUrlParams] = useSearchParams();
const [users, setUsers] = useState<AdministrativeUser[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
const search = urlParams.get('search') ?? '';
const rawStatus = urlParams.get('status');
const status = rawStatus === 'ACTIVE' || rawStatus === 'INACTIVE' ? rawStatus : '';
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
useEffect(() => {
setLoading(true);
setError('');
listUsers({ page, pageSize: 20, search, status })
.then((response) => { setUsers(response.data); setMeta(response.meta); })
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [page, search, status]);
const applyFilters = (event: FormEvent) => {
event.preventDefault();
const next = new URLSearchParams();
if (draftSearch.trim()) next.set('search', draftSearch.trim());
if (status) next.set('status', status);
setUrlParams(next);
};
const setStatus = (value: string) => {
const next = new URLSearchParams(urlParams);
value ? next.set('status', value) : next.delete('status');
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);
};
return (
<section>
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Usuarios</h1><p>Gestioná accesos, estados y roles del sistema.</p></div><PermissionGate permission="users.create"><Link className="button primary" to="/admin/users/new"><Icon name="plus" />Nuevo usuario</Link></PermissionGate></div>
<form className="toolbar" onSubmit={applyFilters}>
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por nombre, usuario o email" /><button type="submit">Buscar</button></label>
<label className="select-field"><span>Estado</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value)}><option value="">Todos</option><option value="ACTIVE">Activos</option><option value="INACTIVE">Inactivos</option></SearchableSelect></label>
</form>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando usuarios…" /> : users.length === 0 ? <EmptyState title="Sin resultados" text="No encontramos usuarios con los filtros seleccionados." /> : <div className="table-panel">
<div className="table-summary"><strong>{meta.total} usuario{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>Usuario</th><th>Roles</th><th>Estado</th><th>Último acceso</th><th aria-label="Acciones" /></tr></thead><tbody>
{users.map((user) => <tr key={user.id}><td><div className="person-cell"><span className="mini-avatar">{initials(user.firstName, user.lastName)}</span><div><strong>{user.firstName} {user.lastName}</strong><small>@{user.username}{user.email ? ` · ${user.email}` : ''}</small></div></div></td><td><div className="tag-list">{user.roles.length ? user.roles.map((role) => <span className="tag" key={role.id}>{role.name}</span>) : <span className="muted">Sin rol</span>}</div></td><td><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span>{user.mustChangePassword && <span className="inline-note">Clave temporal</span>}</td><td>{formatDate(user.lastLoginAt)}</td><td className="action-cell"><Link className="icon-button" to={`/admin/users/${user.id}`} aria-label={`Abrir ${user.username}`}><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>
);
}
@@ -0,0 +1,238 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { inspectionVisitStatusLabel } from '../features/inspections/inspectionPresentation';
import { listVerificationPlanning, planVerificationVisit } from '../lib/api';
import type {
PageMeta,
VerificationPlanningCounters,
VerificationPlanningItem,
VerificationPlanningStatus,
} from '../lib/api';
import { formatDate, formatDateOnly } from '../lib/format';
const emptyCounters: VerificationPlanningCounters = {
eligible: 0,
unplanned: 0,
overdueUnplanned: 0,
dueNext30Days: 0,
planned: 0,
};
function todayInput(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
function defaultStart(items: VerificationPlanningItem[]): string {
const dates = items.map((item) => item.nextControlOn).filter(Boolean).sort();
const today = todayInput();
const target = dates[0] ?? today;
if (target > today) return `${target}T09:00`;
const now = new Date();
now.setMinutes(0, 0, 0);
now.setHours(now.getHours() + 1);
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function dateOffset(days: number): string {
const date = new Date();
date.setDate(date.getDate() + days);
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}
function toIso(value: string): string {
return new Date(value).toISOString();
}
function groupKey(item: VerificationPlanningItem): string {
return `${item.company?.id ?? 'none'}:${item.area?.id ?? 'none'}`;
}
export function VerificationPlanningPage() {
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const [items, setItems] = useState<VerificationPlanningItem[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 50, total: 0, totalPages: 0 });
const [counters, setCounters] = useState(emptyCounters);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [planningOpen, setPlanningOpen] = useState(false);
const [plannedStartAt, setPlannedStartAt] = useState('');
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
const search = params.get('search') ?? '';
const [draftSearch, setDraftSearch] = useState(search);
const rawPlanningStatus = params.get('planningStatus') ?? 'UNPLANNED';
const planningStatus: VerificationPlanningStatus = ['ALL', 'UNPLANNED', 'PLANNED'].includes(rawPlanningStatus)
? rawPlanningStatus as VerificationPlanningStatus
: 'UNPLANNED';
const dueFrom = params.get('dueFrom') ?? '';
const dueTo = params.get('dueTo') ?? '';
const page = Math.max(1, Number(params.get('page') ?? 1) || 1);
const load = () => {
setLoading(true);
setError('');
return listVerificationPlanning({ page, pageSize: 50, search, planningStatus, dueFrom: dueFrom || undefined, dueTo: dueTo || undefined })
.then((response) => {
setItems(response.data);
setMeta(response.meta);
setCounters(response.counters);
setSelectedIds((current) => current.filter((id) => response.data.some((item) => item.id === id && !item.verificationVisit)));
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [page, search, planningStatus, dueFrom, dueTo]);
const selectedItems = useMemo(
() => items.filter((item) => selectedIds.includes(item.id)),
[items, selectedIds],
);
const selectedGroup = selectedItems[0] ? groupKey(selectedItems[0]) : null;
const selectedCompany = selectedItems[0]?.company ?? null;
const selectedArea = selectedItems[0]?.area ?? null;
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(params);
value ? next.set(key, value) : next.delete(key);
next.delete('page');
setSelectedIds([]);
setParams(next);
};
const applySearch = (event: FormEvent) => {
event.preventDefault();
setFilter('search', draftSearch.trim());
};
const setPlanningView = (status: VerificationPlanningStatus, from = '', to = '') => {
const next = new URLSearchParams(params);
next.set('planningStatus', status);
from ? next.set('dueFrom', from) : next.delete('dueFrom');
to ? next.set('dueTo', to) : next.delete('dueTo');
next.delete('page');
setSelectedIds([]);
setParams(next);
};
const setPage = (value: number) => {
const next = new URLSearchParams(params);
value > 1 ? next.set('page', String(value)) : next.delete('page');
setSelectedIds([]);
setParams(next);
};
const toggle = (item: VerificationPlanningItem) => {
if (item.verificationVisit || !item.company || !item.area) return;
const key = groupKey(item);
if (selectedGroup && selectedGroup !== key) return;
setSelectedIds((current) => current.includes(item.id)
? current.filter((id) => id !== item.id)
: [...current, item.id]);
};
const openPlanning = () => {
if (selectedItems.length === 0) return;
setPlannedStartAt(defaultStart(selectedItems));
setNotes('');
setError('');
setSuccess('');
setPlanningOpen(true);
};
const submitPlanning = async (event: FormEvent) => {
event.preventDefault();
if (!plannedStartAt || selectedIds.length === 0) return;
setSaving(true);
setError('');
setSuccess('');
try {
const result = await planVerificationVisit({
findingIds: selectedIds,
plannedStartAt: toIso(plannedStartAt),
notes: notes.trim() || null,
});
setPlanningOpen(false);
setSelectedIds([]);
setSuccess(`${result.visit.code} creada con ${result.findingCount} hallazgo${result.findingCount === 1 ? '' : 's'}. Completá equipo e inspector para dejarla planificada.`);
navigate(`/inspecciones/${result.visit.id}`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
return <section>
<div className="page-heading">
<div><span className="eyebrow">PLANIFICACIÓN OPERATIVA</span><h1>Verificaciones de hallazgos</h1><p>Convertí la fecha de verificación en una inspección de campo, agrupando hallazgos de una misma empresa y área.</p></div>
<Link className="button secondary" to="/hallazgos"><Icon name="chevron" />Volver a hallazgos</Link>
</div>
<div className="temporal-notice"><Icon name="calendar" /><p><strong>La fecha de verificación no es el vencimiento administrativo.</strong> Acá sólo aparecen hallazgos que ya recibieron respuesta de la empresa y tienen una fecha de control definida.</p></div>
<div className="finding-attention-grid verification-metrics">
<button type="button" className={planningStatus === 'UNPLANNED' && !dueFrom && !dueTo ? 'active' : ''} onClick={() => setPlanningView('UNPLANNED')}><small>SIN VISITA</small><strong>{counters.unplanned}</strong><span>{counters.overdueUnplanned} vencidos</span></button>
<button type="button" className={`danger ${planningStatus === 'UNPLANNED' && dueTo === dateOffset(-1) ? 'active' : ''}`} onClick={() => setPlanningView('UNPLANNED', '', dateOffset(-1))}><small>VENCIDOS SIN PLANIFICAR</small><strong>{counters.overdueUnplanned}</strong><span>Requieren prioridad</span></button>
<button type="button" className={planningStatus === 'UNPLANNED' && dueFrom === todayInput() && dueTo === dateOffset(30) ? 'active' : ''} onClick={() => setPlanningView('UNPLANNED', todayInput(), dateOffset(30))}><small>PRÓXIMOS 30 DÍAS</small><strong>{counters.dueNext30Days}</strong><span>{counters.eligible} verificables</span></button>
<button type="button" className={planningStatus === 'PLANNED' && !dueFrom && !dueTo ? 'active' : ''} onClick={() => setPlanningView('PLANNED')}><small>CON VISITA CREADA</small><strong>{counters.planned}</strong><span>En preparación o campo</span></button>
</div>
<form className="toolbar survey-toolbar verification-toolbar" onSubmit={applySearch}>
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar hallazgo, empresa, área, acta o elemento" /><button>Buscar</button></label>
<label className="select-field"><span>Estado</span><SearchableSelect value={planningStatus} onChange={(event) => setPlanningView(event.target.value as VerificationPlanningStatus, dueFrom, dueTo)}><option value="UNPLANNED">Sin inspección</option><option value="PLANNED">Con inspección</option><option value="ALL">Todos</option></SearchableSelect></label>
<label className="field compact-date"><span>Desde</span><input type="date" value={dueFrom} onChange={(event) => setFilter('dueFrom', event.target.value)} /></label>
<label className="field compact-date"><span>Hasta</span><input type="date" value={dueTo} onChange={(event) => setFilter('dueTo', event.target.value)} /></label>
{(dueFrom || dueTo) && <button type="button" className="button secondary" onClick={() => setPlanningView(planningStatus)}>Limpiar fechas</button>}
</form>
{selectedItems.length > 0 && <div className="verification-selection-bar">
<div><strong>{selectedItems.length} seleccionado{selectedItems.length === 1 ? '' : 's'}</strong><span>{selectedCompany?.name} · {selectedArea?.name}</span></div>
<div><button type="button" className="button secondary" onClick={() => setSelectedIds([])}>Limpiar</button><button type="button" className="button primary" onClick={openPlanning}><Icon name="calendar" />Crear inspección de verificación</button></div>
</div>}
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{loading ? <LoadingBlock label="Cargando verificaciones…" /> : items.length === 0 ? <EmptyState title="Sin verificaciones en esta vista" text="No hay hallazgos que coincidan con el filtro seleccionado." /> : <div className="table-panel verification-planning-table">
<div className="table-summary"><strong>{meta.total} hallazgo{meta.total === 1 ? '' : 's'}</strong><span>Seleccioná únicamente registros de una misma empresa y área</span></div>
<div className="table-scroll"><table><thead><tr><th /><th>Hallazgo</th><th>Empresa / área</th><th>Elemento</th><th>Fecha objetivo</th><th>Planificación</th><th /></tr></thead><tbody>{items.map((item) => {
const key = groupKey(item);
const blockedByGroup = Boolean(selectedGroup && selectedGroup !== key);
const selectable = !item.verificationVisit && Boolean(item.company && item.area) && !blockedByGroup;
const overdue = item.nextControlOn < todayInput();
return <tr key={item.id} className={selectedIds.includes(item.id) ? 'selected-row' : ''}>
<td><input type="checkbox" aria-label={`Seleccionar ${item.code}`} checked={selectedIds.includes(item.id)} disabled={!selectable && !selectedIds.includes(item.id)} onChange={() => toggle(item)} /></td>
<td><div className="finding-table-primary"><strong>{item.title}</strong><small>{item.code} · {item.act.code}</small></div></td>
<td><div className="finding-table-primary"><strong>{item.company?.name ?? 'Empresa sin asignar'}</strong><small>{item.area?.name ?? 'Área sin asignar'}</small></div></td>
<td><Link className="text-link" to={`/inventarios/${item.asset.id}`}>{item.asset.name}<small className="block-muted">{item.asset.typeName} · {item.asset.code}</small></Link></td>
<td><div className={`finding-deadline ${overdue ? 'overdue' : ''}`}><small>{overdue ? 'Vencida' : 'Verificar'}</small><strong>{formatDateOnly(item.nextControlOn)}</strong></div></td>
<td>{item.verificationVisit ? <Link className="text-link" to={`/inspecciones/${item.verificationVisit.id}`}><strong>{item.verificationVisit.code}</strong><small className="block-muted">{formatDate(item.verificationVisit.plannedStartAt)} · {inspectionVisitStatusLabel(item.verificationVisit.status)}</small></Link> : item.company && item.area ? <span className="status-badge pending">Sin inspección</span> : <span className="status-badge danger">Falta contexto</span>}</td>
<td className="action-cell"><Link className="icon-button" to={`/hallazgos/${item.id}`} aria-label={`Abrir ${item.code}`}><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>}
{planningOpen && <div className="verification-planning-panel panel">
<div className="panel-heading"><div><span className="eyebrow">NUEVA VISITA</span><h2>Preparar verificación</h2><p className="section-copy">Se creará una inspección borrador con los elementos seleccionados. Sólo se define la fecha de inicio; el cierre real se registra en campo.</p></div><button type="button" className="icon-button" onClick={() => setPlanningOpen(false)} aria-label="Cerrar">×</button></div>
<div className="verification-context-summary"><div><small>Empresa</small><strong>{selectedCompany?.name}</strong></div><div><small>Área</small><strong>{selectedArea?.name}</strong></div><div><small>Hallazgos</small><strong>{selectedItems.length}</strong></div><div><small>Elementos</small><strong>{new Set(selectedItems.map((item) => item.asset.id)).size}</strong></div></div>
<form onSubmit={submitPlanning}>
<div className="form-grid"><label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={plannedStartAt} onChange={(event) => setPlannedStartAt(event.target.value)} required /></label></div>
<label className="field"><span>Indicaciones para la inspección</span><textarea rows={3} maxLength={4000} value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Información útil para el inspector, acceso, contacto o prioridad." /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setPlanningOpen(false)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Creando…' : 'Crear inspección'}</button></div>
</form>
</div>}
</section>;
}
File diff suppressed because it is too large Load Diff