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
+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>;
}