diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json
index bf41247..51dffeb 100644
--- a/api-v3/package-lock.json
+++ b/api-v3/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "dhv2-api",
- "version": "0.29.0-16",
+ "version": "0.29.0-17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
- "version": "0.29.0-16",
+ "version": "0.29.0-17",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
diff --git a/api-v3/package.json b/api-v3/package.json
index a01bf24..555413a 100644
--- a/api-v3/package.json
+++ b/api-v3/package.json
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
- "version": "0.29.0-16",
+ "version": "0.29.0-17",
"private": true,
"license": "UNLICENSED",
"scripts": {
diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts
index 604a661..4579b0f 100644
--- a/api-v3/src/version.ts
+++ b/api-v3/src/version.ts
@@ -1,2 +1,2 @@
-export const API_VERSION = '0.29.0-16';
-export const API_PHASE = 'F6.16';
+export const API_VERSION = '0.29.0-17';
+export const API_PHASE = 'F6.17';
diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts
index 0d112ff..2609510 100644
--- a/api-v3/test/unit/f4-health-metadata.test.ts
+++ b/api-v3/test/unit/f4-health-metadata.test.ts
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version';
-test('health metadata reports the current F6.16 release', () => {
- assert.equal(API_PHASE, 'F6.16');
+test('health metadata reports the current F6.17 release', () => {
+ assert.equal(API_PHASE, 'F6.17');
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
assert.equal(API_VERSION, pkg.version);
- assert.equal(API_VERSION, '0.29.0-16');
+ assert.equal(API_VERSION, '0.29.0-17');
});
diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts
index 8be86c2..5d28c3a 100644
--- a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts
+++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version);
- assert.match(version, /APP_PHASE\s*=\s*'F6\.16/);
+ assert.match(version, /APP_PHASE\s*=\s*'F6\.17/);
});
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
diff --git a/api-v3/test/unit/f6-17-sortable-tables.test.ts b/api-v3/test/unit/f6-17-sortable-tables.test.ts
new file mode 100644
index 0000000..7a8c416
--- /dev/null
+++ b/api-v3/test/unit/f6-17-sortable-tables.test.ts
@@ -0,0 +1,34 @@
+import assert from 'node:assert/strict';
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { resolve } from 'node:path';
+import test from 'node:test';
+
+function collectTsx(directory: string): string[] {
+ return readdirSync(directory).flatMap((name) => {
+ const path = resolve(directory, name);
+ return statSync(path).isDirectory() ? collectTsx(path) : name.endsWith('.tsx') ? [path] : [];
+ });
+}
+
+test('F6.17 uses one sortable table component for every active WEB table', () => {
+ const webRoot = resolve(process.cwd(), '..', 'web-v2', 'src');
+ const files = collectTsx(webRoot);
+ const rawTables = files.filter((path) => !path.endsWith('components/SortableTable.tsx') && readFileSync(path, 'utf8').includes('
readFileSync(path, 'utf8').includes(' count + (readFileSync(path, 'utf8').match(/ {
+ const source = readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', 'components', 'SortableTable.tsx'), 'utf8');
+ assert.match(source, /aria-sort/);
+ assert.match(source, /ascending/);
+ assert.match(source, /descending/);
+ assert.match(source, /dateValue/);
+ assert.match(source, /numericPattern/);
+ assert.match(source, /localeCompare\(right, 'es-AR'/);
+ assert.match(source, /data-sortable/);
+});
diff --git a/docs/PHASE_F6_17_SORTABLE_TABLES.md b/docs/PHASE_F6_17_SORTABLE_TABLES.md
new file mode 100644
index 0000000..a3dc26f
--- /dev/null
+++ b/docs/PHASE_F6_17_SORTABLE_TABLES.md
@@ -0,0 +1,9 @@
+# F6.17 · Tablas ordenables
+
+La WEB utiliza un único componente `SortableTable` para las 28 tablas activas.
+
+- Las cabeceras con texto alternan orden ascendente y descendente.
+- Las columnas sin título (acciones) no son ordenables.
+- El comparador reconoce texto, números y fechas visibles.
+- El estado del orden se conserva mientras la tabla permanezca montada.
+- El patrón aplica también a tablas de detalle, historial, auditoría, documentación e importaciones.
diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json
index ebfed0e..f8b0a51 100644
--- a/web-v2/package-lock.json
+++ b/web-v2/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "dhv2-web",
- "version": "0.23.0-12",
+ "version": "0.23.0-13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-web",
- "version": "0.23.0-12",
+ "version": "0.23.0-13",
"dependencies": {
"maplibre-gl": "6.4.1",
"react": "^19.0.0",
diff --git a/web-v2/package.json b/web-v2/package.json
index e4c3a14..f313649 100644
--- a/web-v2/package.json
+++ b/web-v2/package.json
@@ -1,6 +1,6 @@
{
"name": "dhv2-web",
- "version": "0.23.0-12",
+ "version": "0.23.0-13",
"private": true,
"type": "module",
"engines": {
diff --git a/web-v2/src/components/SortableTable.tsx b/web-v2/src/components/SortableTable.tsx
new file mode 100644
index 0000000..d55d84a
--- /dev/null
+++ b/web-v2/src/components/SortableTable.tsx
@@ -0,0 +1,119 @@
+import {
+ Children,
+ cloneElement,
+ isValidElement,
+ useMemo,
+ useState,
+ type ReactElement,
+ type ReactNode,
+ type TableHTMLAttributes,
+} from 'react';
+
+type SortDirection = 'ascending' | 'descending';
+type SortState = { column: number; direction: SortDirection } | null;
+type ElementProps = Record & { children?: ReactNode; colSpan?: number };
+
+function textValue(node: ReactNode): string {
+ if (node == null || typeof node === 'boolean') return '';
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
+ if (Array.isArray(node)) return node.map(textValue).join(' ');
+ if (!isValidElement(node)) return '';
+ const props = node.props as ElementProps;
+ const explicit = props['data-sort-value'];
+ if (explicit != null) return String(explicit);
+ return textValue(props.children);
+}
+
+function dateValue(value: string): number | null {
+ const normalized = value.replace(/\s+/g, ' ').trim();
+ const local = normalized.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})(?:,?\s+(\d{1,2}):(\d{2}))?/);
+ if (local) {
+ const dayText = local[1]!;
+ const monthText = local[2]!;
+ const yearText = local[3]!;
+ const hourText = local[4] ?? '0';
+ const minuteText = local[5] ?? '0';
+ const year = Number(yearText.length === 2 ? `20${yearText}` : yearText);
+ return Date.UTC(year, Number(monthText) - 1, Number(dayText), Number(hourText), Number(minuteText));
+ }
+ if (/^\d{4}-\d{2}-\d{2}/.test(normalized)) {
+ const parsed = Date.parse(normalized);
+ return Number.isNaN(parsed) ? null : parsed;
+ }
+ return null;
+}
+
+function compareValues(leftRaw: string, rightRaw: string): number {
+ const left = leftRaw.replace(/\s+/g, ' ').trim();
+ const right = rightRaw.replace(/\s+/g, ' ').trim();
+ if (!left && !right) return 0;
+ if (!left) return 1;
+ if (!right) return -1;
+
+ const leftDate = dateValue(left);
+ const rightDate = dateValue(right);
+ if (leftDate != null && rightDate != null) return leftDate - rightDate;
+
+ const numericPattern = /^[-+]?\d+(?:[.,]\d+)?$/;
+ if (numericPattern.test(left) && numericPattern.test(right)) {
+ return Number(left.replace(',', '.')) - Number(right.replace(',', '.'));
+ }
+
+ return left.localeCompare(right, 'es-AR', { numeric: true, sensitivity: 'base' });
+}
+
+function rowValue(row: ReactNode, column: number): string {
+ if (!isValidElement(row)) return '';
+ const cells = Children.toArray((row.props as ElementProps).children);
+ return textValue(cells[column]);
+}
+
+function sortBody(body: ReactElement, sort: SortState): ReactElement {
+ if (!sort) return body;
+ const props = body.props as ElementProps;
+ const rows = Children.toArray(props.children).map((row, index) => ({ row, index }));
+ rows.sort((left, right) => {
+ const compared = compareValues(rowValue(left.row, sort.column), rowValue(right.row, sort.column));
+ if (compared === 0) return left.index - right.index;
+ return sort.direction === 'ascending' ? compared : -compared;
+ });
+ return cloneElement(body, undefined, rows.map((entry) => entry.row));
+}
+
+export function SortableTable({ children, ...props }: TableHTMLAttributes) {
+ const [sort, setSort] = useState(null);
+
+ const renderedChildren = useMemo(() => Children.toArray(children).map((child) => {
+ if (!isValidElement(child)) return child;
+ if (child.type === 'tbody') return sortBody(child, sort);
+ if (child.type !== 'thead') return child;
+
+ const headProps = child.props as ElementProps;
+ const rows = Children.toArray(headProps.children).map((row) => {
+ if (!isValidElement(row) || row.type !== 'tr') return row;
+ const rowProps = row.props as ElementProps;
+ const headers = Children.toArray(rowProps.children).map((header, column) => {
+ if (!isValidElement(header) || header.type !== 'th') return header;
+ const headerProps = header.props as ElementProps;
+ const label = textValue(headerProps.children).trim();
+ const sortable = Boolean(label) && headerProps['data-sortable'] !== false && (headerProps.colSpan ?? 1) === 1;
+ if (!sortable) return header;
+ const active = sort?.column === column;
+ const direction = active ? sort.direction : undefined;
+ const nextDirection: SortDirection = active && sort.direction === 'ascending' ? 'descending' : 'ascending';
+ const headerElement = header as ReactElement>;
+ return cloneElement(headerElement, {
+ 'aria-sort': direction ?? 'none',
+ className: [String(headerProps.className ?? ''), 'sortable-th', active ? 'is-sorted' : ''].filter(Boolean).join(' '),
+ }, setSort({ column, direction: nextDirection })}>
+ {headerProps.children}
+ {active ? (sort.direction === 'ascending' ? '▲' : '▼') : '↕'}
+ );
+ });
+ return cloneElement(row, undefined, headers);
+ });
+ return cloneElement(child, undefined, rows);
+ }), [children, sort]);
+
+ return ;
+}
diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts
index 0e4a5d9..5e90114 100644
--- a/web-v2/src/config/version.ts
+++ b/web-v2/src/config/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '0.23.0-12';
-export const APP_PHASE = 'F6.16 · Mapa GPS puntual';
+export const APP_VERSION = '0.23.0-13';
+export const APP_PHASE = 'F6.17 · Tablas ordenables';
diff --git a/web-v2/src/features/assets/AssetDossierPanel.tsx b/web-v2/src/features/assets/AssetDossierPanel.tsx
index e907562..d3c50b1 100644
--- a/web-v2/src/features/assets/AssetDossierPanel.tsx
+++ b/web-v2/src/features/assets/AssetDossierPanel.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../../components/SortableTable';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
@@ -225,7 +226,7 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
HALLAZGOS
Seguimiento del elemento Ver bandeja general
- {dossier.findings.length === 0 ? : Hallazgo Estado Acta Vencimiento empresa Verificación {dossier.findings.map((finding) => {finding.title} {finding.code} {findingStatusLabel(finding.status)} {finding.actCode}{formatDateOnly(finding.correctionDueOn)} {formatDateOnly(finding.nextControlOn)} )}
}
+ {dossier.findings.length === 0 ? : Hallazgo Estado Acta Vencimiento empresa Verificación {dossier.findings.map((finding) => {finding.title} {finding.code} {findingStatusLabel(finding.status)} {finding.actCode}{formatDateOnly(finding.correctionDueOn)} {formatDateOnly(finding.nextControlOn)} )}
}
}
diff --git a/web-v2/src/features/assets/AssetOperationalRelationsPanel.tsx b/web-v2/src/features/assets/AssetOperationalRelationsPanel.tsx
index f9343a6..73ac512 100644
--- a/web-v2/src/features/assets/AssetOperationalRelationsPanel.tsx
+++ b/web-v2/src/features/assets/AssetOperationalRelationsPanel.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../../components/SortableTable';
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
@@ -121,7 +122,7 @@ export function AssetOperationalRelationsPanel({
{busy ? 'Guardando…' : 'Vincular'}
}
- {relations.length === 0 ? : {role === 'AREA' ? 'Organización' : 'Área'} Rol Vigencia Motivo Registros asignados Estado {relations.map((relation) => {
+ {relations.length === 0 ? :
}
+ })} }
;
}
diff --git a/web-v2/src/features/inspections/FieldBriefingPanel.tsx b/web-v2/src/features/inspections/FieldBriefingPanel.tsx
index 5839bbb..10d2fe8 100644
--- a/web-v2/src/features/inspections/FieldBriefingPanel.tsx
+++ b/web-v2/src/features/inspections/FieldBriefingPanel.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../../components/SortableTable';
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
@@ -76,7 +77,7 @@ export function FieldBriefingPanel({ visit }: { visit: InspectionVisit }) {
{act.actCode} {formatDate(act.occurredAt)} · {act.findings.length} hallazgo{act.findings.length === 1 ? '' : 's'}
{stateLabel(act.adminState)}
-
+
Hallazgo Inventario Gravedad Control
{act.findings.map((finding) =>
{finding.title} {finding.code} · {finding.status}
@@ -85,7 +86,7 @@ export function FieldBriefingPanel({ visit }: { visit: InspectionVisit }) {
{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'A definir'}
Ver Acta
)}
-
+
Plazo empresa: {act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'sin definir'}
Respuesta: {act.responseReceivedOn ? formatDateOnly(act.responseReceivedOn) : 'pendiente'}
diff --git a/web-v2/src/features/inspections/InspectionActsPanel.tsx b/web-v2/src/features/inspections/InspectionActsPanel.tsx
index 1d24c95..9756c3f 100644
--- a/web-v2/src/features/inspections/InspectionActsPanel.tsx
+++ b/web-v2/src/features/inspections/InspectionActsPanel.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../../components/SortableTable';
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
@@ -37,7 +38,7 @@ export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
{error &&
{error} }
{loading ?
: acts.length === 0
?
- :
Actas de la inspección Numeración oficial global por año
Acta Estado Fecha Registros Hallazgos Versión {acts.map((act) => {inspectionActStatusLabel(act.status)} {formatDate(act.occurredAt)} {act.assetCount} {act.findingCount} v{act.currentVersion} )}
}
+ :
Actas de la inspección Numeración oficial global por año
Acta Estado Fecha Registros Hallazgos Versión {acts.map((act) => {inspectionActStatusLabel(act.status)} {formatDate(act.occurredAt)} {act.assetCount} {act.findingCount} v{act.currentVersion} )} }
}
>;
}
diff --git a/web-v2/src/pages/ActAdministrationPage.tsx b/web-v2/src/pages/ActAdministrationPage.tsx
index 6a860b5..cb0a0c0 100644
--- a/web-v2/src/pages/ActAdministrationPage.tsx
+++ b/web-v2/src/pages/ActAdministrationPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
@@ -18,5 +19,5 @@ export function ActAdministrationPage(){
useEffect(()=>{setLoading(true);setError('');listActAdministrationQueue({state,areaId:ctx.areaId||undefined,companyId:ctx.companyId||undefined,page:1,pageSize:50}).then(r=>{setItems(r.data);setMeta(r.meta);setCounters(r.counters)}).catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[state,ctx.areaId,ctx.companyId]);
return
SEGUIMIENTO ADMINISTRATIVO Seguimiento de Actas El plazo y la respuesta de la empresa se gestionan sobre el Acta completa. GEDO no activa el vencimiento por sí solo; el plazo se define cuando corresponda. Los hallazgos quedan dentro de su expediente.
{states.map(s=>setState(s.value)}>{s.label}{s.value!=='ALL'&&{counters[s.value]??0} } )}
- {error&&{error} }{loading? :items.length===0? :{meta.total} acta{meta.total===1?'':'s'}
Acta Área / empresa Hallazgos Plazo empresa Respuesta Estado {items.map(a=>{a.actCode} {formatDate(a.occurredAt)} {a.areaName??'—'} {a.companyName??'—'} {a.openFindingCount} abiertos / {a.findingCount} {a.responseDueOn?formatDate(a.responseDueOn):'Sin definir'} {a.responseReceivedOn?formatDate(a.responseReceivedOn):'Pendiente'} {label(a.adminState)} Gestionar )}
};
+ {error&&
{error} }{loading?
:items.length===0?
:
{meta.total} acta{meta.total===1?'':'s'}
Acta Área / empresa Hallazgos Plazo empresa Respuesta Estado {items.map(a=>{a.actCode} {formatDate(a.occurredAt)} {a.areaName??'—'} {a.companyName??'—'} {a.openFindingCount} abiertos / {a.findingCount} {a.responseDueOn?formatDate(a.responseDueOn):'Sin definir'} {a.responseReceivedOn?formatDate(a.responseReceivedOn):'Pendiente'} {label(a.adminState)} Gestionar )}
};
}
diff --git a/web-v2/src/pages/ActsPage.tsx b/web-v2/src/pages/ActsPage.tsx
index 813b6fc..7ad29cf 100644
--- a/web-v2/src/pages/ActsPage.tsx
+++ b/web-v2/src/pages/ActsPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
@@ -111,7 +112,7 @@ export function ActsPage() {
{error &&
{error} }
{loading ?
: items.length === 0 ?
:
{meta.total} acta{meta.total === 1 ? '' : 's'} Página {page} de {Math.max(meta.totalPages, 1)}
-
Acta Empresa / territorio Inspección Hallazgos Estado Informe {items.map((act) =>
+ Acta Empresa / territorio Inspección Hallazgos Estado Informe {items.map((act) =>
{act.code} {formatDate(act.occurredAt)}
{act.context.company?.name ?? contextLabel(act.companies, 'Empresa sin asignar')} {[act.context.department?.name, act.context.area?.name, act.context.yacimiento?.name].filter(Boolean).join(' · ') || contextLabel(act.areas, 'Área sin asignar')}
{act.visit.code}
@@ -119,7 +120,7 @@ export function ActsPage() {
{inspectionActStatusLabel(act.status)}
{act.report ? {act.report.code}{act.report.status === 'OFFICIALIZED' ? `Oficializado en GEDO${act.report.gedoIfIdentifier ? ` · ${act.report.gedoIfIdentifier}` : ''}` : act.report.wordStatus === 'READY' ? 'INF listo' : 'En preparación'} : ['SEALED', 'CLOSED'].includes(act.status) ? Pendiente de emisión : — }
- )}
+ )}
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
;
diff --git a/web-v2/src/pages/AssetImportsPage.tsx b/web-v2/src/pages/AssetImportsPage.tsx
index 97a8f4d..4e39662 100644
--- a/web-v2/src/pages/AssetImportsPage.tsx
+++ b/web-v2/src/pages/AssetImportsPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
@@ -654,9 +655,9 @@ Escribí IMPORTAR para continuar:`);
{PLAN_GROUPS.map((group) => {
const visibleKinds = group.kinds.filter((kind) => Number(record(planByKind[kind])?.total ?? 0) > 0);
if (!visibleKinds.length) return null;
- return {group.label} Entidad Crear nuevos Usar existentes Revisar Ignorar Total {visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return {ENTITY_LABELS[kind]} {Number(counts.create ?? 0).toLocaleString('es-AR')} {Number(counts.match ?? 0).toLocaleString('es-AR')} {Number(counts.review ?? 0).toLocaleString('es-AR')} {Number(counts.ignore ?? 0).toLocaleString('es-AR')} {Number(counts.total ?? 0).toLocaleString('es-AR')} void loadPlanDetail(kind, 1, null)}>Ver detalle ; })}
;
+ return {group.label} Entidad Crear nuevos Usar existentes Revisar Ignorar Total {visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return {ENTITY_LABELS[kind]} {Number(counts.create ?? 0).toLocaleString('es-AR')} {Number(counts.match ?? 0).toLocaleString('es-AR')} {Number(counts.review ?? 0).toLocaleString('es-AR')} {Number(counts.ignore ?? 0).toLocaleString('es-AR')} {Number(counts.total ?? 0).toLocaleString('es-AR')} void loadPlanDetail(kind, 1, null)}>Ver detalle ; })}
;
})}
- {(planDetailKind || planDetailAction) && {planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'} {planDetailMeta.total.toLocaleString('es-AR')} elementos únicos
{planDetailLoading ?
: <>
Decisión Entidad / relación Qué hará el sistema Filas fuente {planDetailItems.map((item) => { const objectId = matchedObjectId(item); return {PLAN_ACTION_LABELS[item.action]} {item.displayName} {ENTITY_LABELS[item.entityKind]} {item.entityKind === 'DEPARTMENT' && Código {String(item.payload.departmentCode ?? '—')} }{item.entityKind === 'LEGAL_RIGHT' && {String(item.payload.rightType ?? '—')} · estado inicial PENDING }{item.action === 'MATCH' ? Se reutiliza un registro del inventario {MATCH_CRITERIA[item.entityKind]} {objectId && ID {objectId.slice(0, 8)}…}
: item.action === 'CREATE' ? Se creará un registro nuevo No se encontró una coincidencia exacta y segura con las reglas de este tipo.
: item.action === 'REVIEW' ? {item.reviewCodes.slice(0, 3).map((code) => {reviewGuidance(code).title} )}
: No se incorporará al inventario. } {sourceRowsLabel(item.sourceRowNumbers)} ; })}
{planDetailMeta.totalPages > 1 &&
void loadPlanDetail(planDetailKind, planDetailMeta.page - 1, planDetailAction)}>Anterior Página {planDetailMeta.page} de {planDetailMeta.totalPages} = planDetailMeta.totalPages} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page + 1, planDetailAction)}>Siguiente
}>}
}
+ {(planDetailKind || planDetailAction) && {planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'} {planDetailMeta.total.toLocaleString('es-AR')} elementos únicos
{planDetailLoading ?
: <>
Decisión Entidad / relación Qué hará el sistema Filas fuente {planDetailItems.map((item) => { const objectId = matchedObjectId(item); return {PLAN_ACTION_LABELS[item.action]} {item.displayName} {ENTITY_LABELS[item.entityKind]} {item.entityKind === 'DEPARTMENT' && Código {String(item.payload.departmentCode ?? '—')} }{item.entityKind === 'LEGAL_RIGHT' && {String(item.payload.rightType ?? '—')} · estado inicial PENDING }{item.action === 'MATCH' ? Se reutiliza un registro del inventario {MATCH_CRITERIA[item.entityKind]} {objectId && ID {objectId.slice(0, 8)}…}
: item.action === 'CREATE' ? Se creará un registro nuevo No se encontró una coincidencia exacta y segura con las reglas de este tipo.
: item.action === 'REVIEW' ? {item.reviewCodes.slice(0, 3).map((code) => {reviewGuidance(code).title} )}
: No se incorporará al inventario. } {sourceRowsLabel(item.sourceRowNumbers)} ; })} {planDetailMeta.totalPages > 1 &&
void loadPlanDetail(planDetailKind, planDetailMeta.page - 1, planDetailAction)}>Anterior Página {planDetailMeta.page} de {planDetailMeta.totalPages} = planDetailMeta.totalPages} onClick={() => void loadPlanDetail(planDetailKind, planDetailMeta.page + 1, planDetailAction)}>Siguiente
}>}
}
}
{plan.status === 'REVIEW_REQUIRED' &&
@@ -743,7 +744,7 @@ Escribí IMPORTAR para continuar:`);
Vista previa normalizada {rowMeta.total.toLocaleString('es-AR')} filas
-
+
{detail.profileCode === 'MENDOZA_YACIMIENTOS_V1' ? <>
Fila Resultado Área Yacimiento Departamento Derecho Operadora Conciliación Observaciones
{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return
@@ -766,7 +767,7 @@ Escribí IMPORTAR para continuar:`);
{row.issues.length ? row.issues.slice(0, 3).map((issue) => {ISSUE_LABELS[issue] ?? issue} ) : Sin observaciones }{row.issues.length > 3 && +{row.issues.length - 3} }
; })}
>}
-
+
void loadDetail(detail.id, rowMeta.page - 1)}>Anterior Página {rowMeta.page} de {Math.max(rowMeta.totalPages, 1)} = rowMeta.totalPages || detailLoading} onClick={() => void loadDetail(detail.id, rowMeta.page + 1)}>Siguiente
>}
diff --git a/web-v2/src/pages/AssetsPage.tsx b/web-v2/src/pages/AssetsPage.tsx
index 344a32f..547dd53 100644
--- a/web-v2/src/pages/AssetsPage.tsx
+++ b/web-v2/src/pages/AssetsPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
@@ -185,7 +186,7 @@ export function AssetsPage() {
?
:
{meta.total} registro{meta.total === 1 ? '' : 's'} Página {meta.page} de {Math.max(meta.totalPages, 1)}
-
Registro Sección Ubicación / Operadora Estado Actualizado
+ Registro Sección Ubicación / Operadora Estado Actualizado
{assets.map((asset) =>
{asset.name}{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}
{asset.type.name}
@@ -194,7 +195,7 @@ export function AssetsPage() {
{formatDate(asset.updatedAt)}
)}
-
+
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
;
diff --git a/web-v2/src/pages/AuditPage.tsx b/web-v2/src/pages/AuditPage.tsx
index 35f7505..a2e88fa 100644
--- a/web-v2/src/pages/AuditPage.tsx
+++ b/web-v2/src/pages/AuditPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
@@ -62,7 +63,7 @@ export function AuditPage() {
Limpiar Aplicar filtros
{error && {error} }
- {loading ? : events.length === 0 ? : Fecha y hora Usuario Acción Entidad Origen Request ID {events.map((event) => {formatDate(event.occurredAt)} {event.actorUsername ?? 'Sistema'} {event.ip ?? 'IP no registrada'} {actionLabel(event.action)} {event.action} {event.entityType ?? '—'}{event.entityId ?? ''} {event.source} {event.requestId ? `${event.requestId.slice(0, 12)}…` : '—'} openDetail(event.id)} aria-label="Ver detalle"> )}
setPage(page - 1)}>Anterior Página {page} de {Math.max(meta.totalPages, 1)} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
+ {loading ? : events.length === 0 ? : Fecha y hora Usuario Acción Entidad Origen Request ID {events.map((event) => {formatDate(event.occurredAt)} {event.actorUsername ?? 'Sistema'} {event.ip ?? 'IP no registrada'} {actionLabel(event.action)} {event.action} {event.entityType ?? '—'}{event.entityId ?? ''} {event.source} {event.requestId ? `${event.requestId.slice(0, 12)}…` : '—'} openDetail(event.id)} aria-label="Ver detalle"> )}
setPage(page - 1)}>Anterior Página {page} de {Math.max(meta.totalPages, 1)} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
{(detailLoading || detail) && { if (event.target === event.currentTarget && !detailLoading) setDetail(null); }}>
{detailLoading ? : detail && <>EVENTO DE AUDITORÍA
{actionLabel(detail.action)} setDetail(null)} aria-label="Cerrar">×
Fecha {formatDate(detail.occurredAt)}
Usuario {detail.actorUsername ?? 'Sistema'}
Origen / IP {detail.source} · {detail.ip ?? '—'}
Entidad {detail.entityType ?? '—'} {detail.entityId ? `· ${detail.entityId}` : ''}
Request ID {detail.requestId ?? '—'}
User agent {detail.userAgent ?? '—'} >} }
;
diff --git a/web-v2/src/pages/CompanyInventoryPage.tsx b/web-v2/src/pages/CompanyInventoryPage.tsx
index 175e77f..a2a9a92 100644
--- a/web-v2/src/pages/CompanyInventoryPage.tsx
+++ b/web-v2/src/pages/CompanyInventoryPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
@@ -405,7 +406,7 @@ export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDeta
DOCUMENTACIÓN EMITIDA Actas e informes Historial documental de esta empresa. Sólo se muestran Actas ya emitidas y sus Informes asociados.
{acts.length} actas {reports.length} informes
setDocumentSearch(event.target.value)} placeholder="Buscar por acta, informe, inspección, área o GEDO" />
{documentsError && {documentsError} }
- {documentsLoading ? : filteredActs.length === 0 ? : Fecha Área Acta Inspección Informe Oficialización {filteredActs.map((act) => {
+ {documentsLoading ? : filteredActs.length === 0 ? : Fecha Área Acta Inspección Informe Oficialización {filteredActs.map((act) => {
const report = reportByAct.get(act.id);
return
{formatDate(act.occurredAt)}
@@ -416,7 +417,7 @@ export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDeta
{report ? {reportStatusLabel(report)} : — }
;
- })}
}
+ })}}
}
diff --git a/web-v2/src/pages/DocumentDeliveryPage.tsx b/web-v2/src/pages/DocumentDeliveryPage.tsx
index 275a41c..bdaabe5 100644
--- a/web-v2/src/pages/DocumentDeliveryPage.tsx
+++ b/web-v2/src/pages/DocumentDeliveryPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { useAuth } from '../auth/AuthContext';
@@ -189,7 +190,7 @@ export function DocumentDeliveryPage() {
TRAZABILIDAD Últimas entregas Actas PDF a Empresa/Oficina/Inspector e INF Word editable al Inspector responsable.
{items.length}
- {items.length === 0 ? Todavía no existen entregas documentales.
: Documento Destino Estado Intentos {items.map((item) => {item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode} {item.documentKind === 'ACT_PDF' ? 'Acta PDF inmutable' : 'INF Word editable'} {recipientLabel(item)}{item.recipientEmail ?? 'Sin email configurado'} {statusLabel[item.status]} {item.lastError && {item.lastError} }{item.attempts} {canManage && item.status !== 'SENT' && { setSaving(true); try { await retryDocumentDeliveryF4(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar } )}
}
+ {items.length === 0 ? Todavía no existen entregas documentales.
: Documento Destino Estado Intentos {items.map((item) => {item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode} {item.documentKind === 'ACT_PDF' ? 'Acta PDF inmutable' : 'INF Word editable'} {recipientLabel(item)}{item.recipientEmail ?? 'Sin email configurado'} {statusLabel[item.status]} {item.lastError && {item.lastError} }{item.attempts} {canManage && item.status !== 'SENT' && { setSaving(true); try { await retryDocumentDeliveryF4(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar } )}
}
;
}
diff --git a/web-v2/src/pages/FieldBriefingsPage.tsx b/web-v2/src/pages/FieldBriefingsPage.tsx
index d502614..07bf04e 100644
--- a/web-v2/src/pages/FieldBriefingsPage.tsx
+++ b/web-v2/src/pages/FieldBriefingsPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { SearchableSelect } from '../components/SearchableSelect';
@@ -244,7 +245,7 @@ export function FieldBriefingsPage() {
-
+
Qué hacer Hallazgo Inventario Control
{act.findings.map((finding) => {
const action = findingAction(finding, act, briefing.plannedOn);
@@ -264,7 +265,7 @@ export function FieldBriefingsPage() {
{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'Sin fecha'}
;
})}
-
+
)}
>}
diff --git a/web-v2/src/pages/FieldDiscoveriesPage.tsx b/web-v2/src/pages/FieldDiscoveriesPage.tsx
index 877549b..dee371d 100644
--- a/web-v2/src/pages/FieldDiscoveriesPage.tsx
+++ b/web-v2/src/pages/FieldDiscoveriesPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link } from 'react-router';
@@ -126,7 +127,7 @@ export function FieldDiscoveriesPage() {
{loading ? : items.length === 0 ? :
{meta.total} alta{meta.total === 1 ? '' : 's'} Página {meta.page} de {Math.max(meta.totalPages, 1)}
-
Elemento Inspección Inspector Estado Acciones
+ Elemento Inspección Inspector Estado Acciones
{items.map((item) =>
{item.asset.name}{item.asset.code} · {item.asset.typeName}{item.asset.commonName ? ` · ${item.asset.commonName}` : ''}
{item.visit.code}{formatDate(item.observedAt)}
@@ -141,14 +142,14 @@ export function FieldDiscoveriesPage() {
>}
)}
-
+
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
{matchFor &&
CONCILIAR Buscar registro existente Buscá dentro de la misma Área física. El alta de campo se archivará, pero su historial no se elimina.
setMatchFor(null)}>Cerrar
- {candidates.length > 0 &&
Registro existente Tipo {candidates.map((candidate) => {candidate.name} {candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''} {candidate.type.name} chooseMatch(candidate)}>Usar esta coincidencia )}
}
+ {candidates.length > 0 &&
Registro existente Tipo {candidates.map((candidate) => {candidate.name} {candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''} {candidate.type.name} chooseMatch(candidate)}>Usar esta coincidencia )}
}
}
;
}
diff --git a/web-v2/src/pages/FindingsPage.tsx b/web-v2/src/pages/FindingsPage.tsx
index b987118..571fcd4 100644
--- a/web-v2/src/pages/FindingsPage.tsx
+++ b/web-v2/src/pages/FindingsPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
@@ -160,7 +161,7 @@ export function FindingsPage() {
{error && {error} }
{loading ? : items.length === 0 ? :
{meta.total} hallazgo{meta.total === 1 ? '' : 's'} Página {page} de {Math.max(meta.totalPages, 1)}
-
Hallazgo Empresa / área Elemento Situación Fecha de control Descripción {items.map((finding) => {
+ Hallazgo Empresa / área Elemento Situación Fecha de control Descripción {items.map((finding) => {
const attention = attentionLabel(finding);
const deadline = deadlineText(finding);
return
@@ -172,7 +173,7 @@ export function FindingsPage() {
{finding.description}
;
- })}
+ })}
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
;
diff --git a/web-v2/src/pages/HistoryPage.tsx b/web-v2/src/pages/HistoryPage.tsx
index c6c4406..6703fe7 100644
--- a/web-v2/src/pages/HistoryPage.tsx
+++ b/web-v2/src/pages/HistoryPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
@@ -123,7 +124,7 @@ export function HistoryPage() {
{error && {error} }
- {loading ? : versions.length === 0 ? : {meta.total} versiones registradas Página {page} de {Math.max(meta.totalPages, 1)}
Fecha y hora Registro Versión Cambio Campos Usuario Estado {versions.map((version) => {formatDate(version.occurredAt)} {version.assetName} {version.assetCode} · {version.typeName} v{version.versionNumber}{version.isCurrent ? ' · actual' : ''} {assetVersionChangeLabel(version.changeType)} {version.source} {version.changedFields.slice(0, 3).map((field) => {assetVersionFieldLabel(field)} )}
{version.actorUsername ?? 'Sistema'} {assetStatusLabel(version.informationStatus)} open(version)} aria-label={`Ver versión ${version.versionNumber}`}> )}
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
+ {loading ? : versions.length === 0 ? : {meta.total} versiones registradas Página {page} de {Math.max(meta.totalPages, 1)}
Fecha y hora Registro Versión Cambio Campos Usuario Estado {versions.map((version) => {formatDate(version.occurredAt)} {version.assetName} {version.assetCode} · {version.typeName} v{version.versionNumber}{version.isCurrent ? ' · actual' : ''} {assetVersionChangeLabel(version.changeType)} {version.source} {version.changedFields.slice(0, 3).map((field) => {assetVersionFieldLabel(field)} )}
{version.actorUsername ?? 'Sistema'} {assetStatusLabel(version.informationStatus)} open(version)} aria-label={`Ver versión ${version.versionNumber}`}> )} setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
{(detailLoading || detail) && setDetail(null)} />}
;
diff --git a/web-v2/src/pages/InspectionVisitEditorF4Page.tsx b/web-v2/src/pages/InspectionVisitEditorF4Page.tsx
index 78ba97e..ddceb36 100644
--- a/web-v2/src/pages/InspectionVisitEditorF4Page.tsx
+++ b/web-v2/src/pages/InspectionVisitEditorF4Page.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
@@ -357,14 +358,14 @@ export function InspectionVisitEditorF4Page() {
CHECKLIST TÉCNICO Antecedentes y controles Se construye para el Área, Yacimiento y Operadora fijados en la Inspección, tomando la fecha planificada. Las respuestas administrativas de empresa no modifican este checklist.
{canManage && planningEditable &&
void regenerateChecklist()} disabled={busy}>Regenerar checklist }
{visit.checklist.stale && La fecha cambió. Regenerá el checklist antes de planificar. }
{visit.checklist.verificationOverdue} controles vencidos
{visit.checklist.upcomingControls} próximos 30 días
{visit.checklist.antecedents} antecedentes
{visit.checklist.actionableAssets} registros sugeridos
- {visit.checklist.items.length === 0 ? : Tipo Hallazgo Inventario Fecha Gravedad {visit.checklist.items.map((item) => {checklistLabel(item.itemKind)} {item.findingTitle} {item.findingCode} {item.asset.name} {item.asset.code} · {item.asset.typeName} {formatDateOnly(item.referenceOn)} {item.severity ?? '—'} )}
}
+ {visit.checklist.items.length === 0 ? : Tipo Hallazgo Inventario Fecha Gravedad {visit.checklist.items.map((item) => {checklistLabel(item.itemKind)} {item.findingTitle} {item.findingCode} {item.asset.name} {item.asset.code} · {item.asset.typeName} {formatDateOnly(item.referenceOn)} {item.severity ?? '—'} )}
}
}
{visit && visit.verificationFindings.length > 0 && VERIFICACIÓN
Hallazgos a controlar {visit.verificationFindings.length} {visit.verificationFindings.map((finding) =>
{finding.title} {finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)} {finding.resultNotes && {finding.resultNotes} }
{finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Reprogramar' : 'Pendiente'} )}
}
{visit && planningEditable && canManage && }
- {visit && {visit.assets.length} incluidos {visit.checklist.excludedAssets} excluidos con trazabilidad
{visit.planningAssets.length === 0 ? : Inventario Origen Estado Motivo {visit.planningAssets.map((asset) => {asset.name} {asset.code} · {asset.typeName} {sourceLabel(asset.planningSource)} {asset.included ? 'Incluido' : 'Excluido'} {asset.exclusionReason ?? '—'} {canManage && planningEditable && (asset.included ? void excludeAsset(asset.id)}>Excluir : void reincludeAsset(asset.id)}>Reincorporar )} )}
} }
+ {visit && {visit.assets.length} incluidos {visit.checklist.excludedAssets} excluidos con trazabilidad
{visit.planningAssets.length === 0 ? : Inventario Origen Estado Motivo {visit.planningAssets.map((asset) => {asset.name} {asset.code} · {asset.typeName} {sourceLabel(asset.planningSource)} {asset.included ? 'Incluido' : 'Excluido'} {asset.exclusionReason ?? '—'} {canManage && planningEditable && (asset.included ? void excludeAsset(asset.id)}>Excluir : void reincludeAsset(asset.id)}>Reincorporar )} )}
} }
{visit && canAssign && planningEditable && }
diff --git a/web-v2/src/pages/InspectionVisitsPage.tsx b/web-v2/src/pages/InspectionVisitsPage.tsx
index 8b37b84..5d252d5 100644
--- a/web-v2/src/pages/InspectionVisitsPage.tsx
+++ b/web-v2/src/pages/InspectionVisitsPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
@@ -106,6 +107,6 @@ export function InspectionVisitsPage() {
{error && {error} }
- {loading ? : visits.length === 0 ? : {meta.total} inspección{meta.total === 1 ? '' : 'es'} Página {page} de {Math.max(meta.totalPages, 1)}
Inspección Estado Área / Operadora Responsable Fecha prevista Inventario {visits.map((visit) => {visit.code} {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
{inspectionVisitStatusLabel(visit.status)} {visit.operationalArea || visit.operatorCompany ? {visit.operationalArea?.name ?? 'Sin Área'} {visit.operatorCompany?.name ?? 'Sin Operadora'} : Sin contexto } {visit.leadInspector ? `${visit.leadInspector.firstName} ${visit.leadInspector.lastName}` : 'Sin asignar'} {formatDate(visit.plannedStartAt)}inicio planificado {visit.scopeAsset ? {visit.scopeAsset.name}{visit.scopeAsset.code} : <>{visit.assetCount} registro{visit.assetCount === 1 ? '' : 's'} {visit.memberCount} integrante{visit.memberCount === 1 ? '' : 's'} >} )}
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
+ {loading ? : visits.length === 0 ? : {meta.total} inspección{meta.total === 1 ? '' : 'es'} Página {page} de {Math.max(meta.totalPages, 1)}
Inspección Estado Área / Operadora Responsable Fecha prevista Inventario {visits.map((visit) => {visit.code} {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
{inspectionVisitStatusLabel(visit.status)} {visit.operationalArea || visit.operatorCompany ? {visit.operationalArea?.name ?? 'Sin Área'} {visit.operatorCompany?.name ?? 'Sin Operadora'} : Sin contexto } {visit.leadInspector ? `${visit.leadInspector.firstName} ${visit.leadInspector.lastName}` : 'Sin asignar'} {formatDate(visit.plannedStartAt)}inicio planificado {visit.scopeAsset ? {visit.scopeAsset.name}{visit.scopeAsset.code} : <>{visit.assetCount} registro{visit.assetCount === 1 ? '' : 's'} {visit.memberCount} integrante{visit.memberCount === 1 ? '' : 's'} >} )} setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
;
}
diff --git a/web-v2/src/pages/ReportDetailPage.tsx b/web-v2/src/pages/ReportDetailPage.tsx
index b9cbdcd..53d0b1c 100644
--- a/web-v2/src/pages/ReportDetailPage.tsx
+++ b/web-v2/src/pages/ReportDetailPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router';
@@ -243,7 +244,7 @@ export function ReportDetailPage() {
Acta relacionada {report.act.code}
Vencimiento vigente {formatDateOnly(report.responseDueOn)}
Hallazgos alcanzados {report.findings.length}
Motivo / referencia {report.deadlineReason ?? 'Sin definir'}
{canManage && }
>}
- Hallazgo Elemento Estado Vencimiento {report.findings.map((finding) => {finding.code}{finding.title} {finding.assetName} {finding.assetCode} {finding.status === 'OPEN' ? 'Abierto' : finding.status} {formatDateOnly(finding.responseDueOn)} )}
+ Hallazgo Elemento Estado Vencimiento {report.findings.map((finding) => {finding.code}{finding.title} {finding.assetName} {finding.assetCode} {finding.status === 'OPEN' ? 'Abierto' : finding.status} {formatDateOnly(finding.responseDueOn)} )}
diff --git a/web-v2/src/pages/ReportsPage.tsx b/web-v2/src/pages/ReportsPage.tsx
index def6ed2..e04fb2c 100644
--- a/web-v2/src/pages/ReportsPage.tsx
+++ b/web-v2/src/pages/ReportsPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
@@ -147,7 +148,7 @@ export function ReportsPage() {
{error && {error} }
{loading ? : empty ? :
{meta.total} {view === 'issued' ? `informe${meta.total === 1 ? '' : 's'}` : `acta${meta.total === 1 ? '' : 's'} pendiente${meta.total === 1 ? '' : 's'}`} Página {page} de {Math.max(meta.totalPages, 1)}
-
{view === 'issued' ? Informe Empresa / área Acta Hallazgos Word Estado : Acta sellada Empresa / área Inspección Hallazgos Sellado } {view === 'issued' ? issued.map((report) =>
+ {view === 'issued' ? Informe Empresa / área Acta Hallazgos Word Estado : Acta sellada Empresa / área Inspección Hallazgos Sellado } {view === 'issued' ? issued.map((report) =>
{report.code} {report.title} · {formatDate(report.generatedAt)}
{contextLabel(report.companies, 'Empresa sin asignar')} {contextLabel(report.areas, 'Área sin asignar')}
{report.act.code}{report.act.title}
@@ -162,7 +163,7 @@ export function ReportsPage() {
{item.findingCount}
{formatDate(item.sealedAt)}
- )}
+ )}
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
;
diff --git a/web-v2/src/pages/SurveyCampaignEditorPage.tsx b/web-v2/src/pages/SurveyCampaignEditorPage.tsx
index 3233188..8d0ac69 100644
--- a/web-v2/src/pages/SurveyCampaignEditorPage.tsx
+++ b/web-v2/src/pages/SurveyCampaignEditorPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
@@ -286,7 +287,7 @@ export function SurveyCampaignEditorPage() {
Agregar objetivo
}
- {campaign && {campaign.targetCount} objetivo{campaign.targetCount === 1 ? '' : 's'} {campaign.completedCount} completados · {campaign.submittedCount} en revisión · {campaign.inProgressCount} en ejecución · {campaign.pendingCount} pendientes · {campaign.skippedCount} omitidos
{campaign.targets.length === 0 ?
:
Registro Estado Responsable Vencimiento Instrucciones {campaign.targets.map((target) => {
+ {campaign && {campaign.targetCount} objetivo{campaign.targetCount === 1 ? '' : 's'} {campaign.completedCount} completados · {campaign.submittedCount} en revisión · {campaign.inProgressCount} en ejecución · {campaign.pendingCount} pendientes · {campaign.skippedCount} omitidos
{campaign.targets.length === 0 ?
:
}
}
+ })}}}
;
}
diff --git a/web-v2/src/pages/SurveyCampaignsPage.tsx b/web-v2/src/pages/SurveyCampaignsPage.tsx
index 5d41f0e..ec2cde9 100644
--- a/web-v2/src/pages/SurveyCampaignsPage.tsx
+++ b/web-v2/src/pages/SurveyCampaignsPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
@@ -76,6 +77,6 @@ export function SurveyCampaignsPage() {
{error && {error} }
- {loading ? : campaigns.length === 0 ? : {meta.total} campaña{meta.total === 1 ? '' : 's'} Página {page} de {Math.max(meta.totalPages, 1)}
Campaña Estado Alcance Coordinación Fechas previstas Avance {campaigns.map((campaign) => {campaign.name} {campaign.code}
{surveyCampaignStatusLabel(campaign.status)} {campaign.scopeAsset ? {campaign.scopeAsset.name} : Todos los inventarios } {campaign.coordinator ? `${campaign.coordinator.firstName} ${campaign.coordinator.lastName}` : 'Sin asignar'} {formatDate(campaign.plannedStartAt)}hasta {formatDate(campaign.plannedEndAt)} {campaign.completedCount + campaign.skippedCount}/{campaign.targetCount}
)}
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
+ {loading ? : campaigns.length === 0 ? : {meta.total} campaña{meta.total === 1 ? '' : 's'} Página {page} de {Math.max(meta.totalPages, 1)}
Campaña Estado Alcance Coordinación Fechas previstas Avance {campaigns.map((campaign) => {campaign.name} {campaign.code}
{surveyCampaignStatusLabel(campaign.status)} {campaign.scopeAsset ? {campaign.scopeAsset.name} : Todos los inventarios } {campaign.coordinator ? `${campaign.coordinator.firstName} ${campaign.coordinator.lastName}` : 'Sin asignar'} {formatDate(campaign.plannedStartAt)}hasta {formatDate(campaign.plannedEndAt)} {campaign.completedCount + campaign.skippedCount}/{campaign.targetCount}
)} setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
;
}
diff --git a/web-v2/src/pages/TemporalAssetsPage.tsx b/web-v2/src/pages/TemporalAssetsPage.tsx
index 5b8c569..519c41c 100644
--- a/web-v2/src/pages/TemporalAssetsPage.tsx
+++ b/web-v2/src/pages/TemporalAssetsPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
@@ -89,7 +90,7 @@ export function TemporalAssetsPage() {
Inventarios reconstruidos al {formatDate(at)} Las vigencias terminan cuando se registra la versión siguiente.
{error && {error} }
- {loading ? : assets.length === 0 ? : {meta.total} registro{meta.total === 1 ? '' : 's'} reconstruido{meta.total === 1 ? '' : 's'} Página {page} de {Math.max(meta.totalPages, 1)}
Registro histórico Tipo Estado Versión aplicable Vigente desde Vigente hasta Cambio {assets.map((asset) => {asset.assetName} {asset.assetCode} {asset.typeName} {assetStatusLabel(asset.informationStatus)} v{asset.versionNumber}{asset.isCurrent ? ' · actual' : ''} {formatDate(asset.occurredAt)} {asset.effectiveUntil ? formatDate(asset.effectiveUntil) : 'Continúa vigente'} {assetVersionChangeLabel(asset.changeType)} open(asset)} aria-label={`Ver versión ${asset.versionNumber}`}> )}
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
+ {loading ? : assets.length === 0 ? : {meta.total} registro{meta.total === 1 ? '' : 's'} reconstruido{meta.total === 1 ? '' : 's'} Página {page} de {Math.max(meta.totalPages, 1)}
Registro histórico Tipo Estado Versión aplicable Vigente desde Vigente hasta Cambio {assets.map((asset) => {asset.assetName} {asset.assetCode} {asset.typeName} {assetStatusLabel(asset.informationStatus)} v{asset.versionNumber}{asset.isCurrent ? ' · actual' : ''} {formatDate(asset.occurredAt)} {asset.effectiveUntil ? formatDate(asset.effectiveUntil) : 'Continúa vigente'} {assetVersionChangeLabel(asset.changeType)} open(asset)} aria-label={`Ver versión ${asset.versionNumber}`}> )}
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
{(detailLoading || detail) && setDetail(null)} />}
;
}
diff --git a/web-v2/src/pages/UsersPage.tsx b/web-v2/src/pages/UsersPage.tsx
index 5593412..84ab52c 100644
--- a/web-v2/src/pages/UsersPage.tsx
+++ b/web-v2/src/pages/UsersPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
@@ -63,9 +64,9 @@ export function UsersPage() {
{error && {error} }
{loading ? : users.length === 0 ? :
{meta.total} usuario{meta.total === 1 ? '' : 's'} Página {meta.page} de {Math.max(meta.totalPages, 1)}
-
Usuario Roles Estado Último acceso
+ Usuario Roles Estado Último acceso
{users.map((user) => {initials(user.firstName, user.lastName)} {user.firstName} {user.lastName} @{user.username}{user.email ? ` · ${user.email}` : ''}
{user.roles.length ? user.roles.map((role) => {role.name} ) : Sin rol }
{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'} {user.mustChangePassword && Clave temporal }{formatDate(user.lastLoginAt)} )}
-
+
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
diff --git a/web-v2/src/pages/VerificationPlanningPage.tsx b/web-v2/src/pages/VerificationPlanningPage.tsx
index eb975f6..0df0189 100644
--- a/web-v2/src/pages/VerificationPlanningPage.tsx
+++ b/web-v2/src/pages/VerificationPlanningPage.tsx
@@ -1,3 +1,4 @@
+import { SortableTable } from '../components/SortableTable';
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
@@ -207,7 +208,7 @@ export function VerificationPlanningPage() {
{success && {success} }
{loading ? : items.length === 0 ? :
{meta.total} hallazgo{meta.total === 1 ? '' : 's'} Seleccioná únicamente registros de una misma empresa y área
-
Hallazgo Empresa / área Elemento Fecha objetivo Planificación {items.map((item) => {
+ Hallazgo Empresa / área Elemento Fecha objetivo Planificación {items.map((item) => {
const key = groupKey(item);
const blockedByGroup = Boolean(selectedGroup && selectedGroup !== key);
const selectable = !item.verificationVisit && Boolean(item.company && item.area) && !blockedByGroup;
@@ -221,7 +222,7 @@ export function VerificationPlanningPage() {
{item.verificationVisit ? {item.verificationVisit.code} {formatDate(item.verificationVisit.plannedStartAt)} · {inspectionVisitStatusLabel(item.verificationVisit.status)} : item.company && item.area ? Sin inspección : Falta contexto }
;
- })}
+ })}
setPage(page - 1)}>Anterior {meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'} = meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente
}
diff --git a/web-v2/src/styles.css b/web-v2/src/styles.css
index cf5f597..bed3fea 100644
--- a/web-v2/src/styles.css
+++ b/web-v2/src/styles.css
@@ -1459,3 +1459,15 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
.operational-map-stage .map-canvas { height: 650px; }
.map-selection-overlay { position: absolute; left: 16px; bottom: 16px; z-index: 4; width: min(330px, calc(100% - 32px)); max-height: 52%; overflow-y: auto; margin: 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: rgba(255,255,255,.96); box-shadow: var(--shadow); backdrop-filter: blur(5px); }
@media (max-width: 780px) { .operational-map-stage .map-canvas { height: 520px; } .map-selection-overlay { max-height: 62%; } }
+
+/* F6.17 · Ordenamiento consistente de tablas */
+.sortable-th { padding: 0 !important; }
+.sortable-th-button {
+ width: 100%; min-height: 38px; display: flex; align-items: center; justify-content: space-between;
+ gap: 7px; padding: 10px 12px; border: 0; color: inherit; background: transparent;
+ font: inherit; font-weight: inherit; letter-spacing: inherit; text-transform: inherit; text-align: left; cursor: pointer;
+}
+.sortable-th-button:hover, .sortable-th-button:focus-visible { color: var(--blue); background: #f4f7ff; outline: none; }
+.sort-indicator { flex: 0 0 auto; color: #9aa6bb; font-size: 9px; line-height: 1; opacity: .55; }
+.sortable-th.is-sorted .sort-indicator { color: var(--blue); opacity: 1; }
+.sortable-th[aria-sort='ascending'], .sortable-th[aria-sort='descending'] { color: var(--blue); background: #f7f9ff; }