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(' '), + }, ); + }); + return cloneElement(row, undefined, headers); + }); + return cloneElement(child, undefined, rows); + }), [children, sort]); + + return {renderedChildren}
; +} 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 ? :
{dossier.findings.map((finding) => )}
HallazgoEstadoActaVencimiento empresaVerificación
{finding.title}{finding.code}{findingStatusLabel(finding.status)}{finding.actCode}{formatDateOnly(finding.correctionDueOn)}{formatDateOnly(finding.nextControlOn)}
} + {dossier.findings.length === 0 ? :
HallazgoEstadoActaVencimiento empresaVerificació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({
} - {relations.length === 0 ? :
{relations.map((relation) => { + {relations.length === 0 ? :
{relations.map((relation) => { const counterpart = role === 'AREA' ? relation.company : relation.area; return @@ -133,6 +134,6 @@ export function AssetOperationalRelationsPanel({ ?
setEndReason(event.target.value)} placeholder="Motivo de finalización" minLength={3} maxLength={1000} />
: )}; - })}
{role === 'AREA' ? 'Organización' : 'Área'}RolVigenciaMotivoRegistros asignadosEstado
{role === 'AREA' ? 'Organización' : 'Área'}RolVigenciaMotivoRegistros asignadosEstado
{counterpart.name}{counterpart.code} · {counterpart.typeName}
} + })}
} ; } 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)} -
+
{act.findings.map((finding) => @@ -85,7 +86,7 @@ export function FieldBriefingPanel({ visit }: { visit: InspectionVisit }) { )} -
HallazgoInventarioGravedadControl
{finding.title}{finding.code} · {finding.status}{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ónNumeración oficial global por año
{acts.map((act) => )}
ActaEstadoFechaRegistrosHallazgosVersión
{act.title}{act.code}
{inspectionActStatusLabel(act.status)}{formatDate(act.occurredAt)}{act.assetCount}{act.findingCount}v{act.currentVersion}
} + :
Actas de la inspecciónNumeración oficial global por año
ActaEstadoFechaRegistrosHallazgosVersión{acts.map((act) =>
{act.title}{act.code}
{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=>)}
- {error&&{error}}{loading?:items.length===0?:
{meta.total} acta{meta.total===1?'':'s'}
{items.map(a=>)}
ActaÁrea / empresaHallazgosPlazo empresaRespuestaEstado
{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 / empresaHallazgosPlazo empresaRespuestaEstado{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)}
-
{items.map((act) => +
{items.map((act) => @@ -119,7 +120,7 @@ export function ActsPage() { - )}
ActaEmpresa / territorioInspecciónHallazgosEstadoInforme
ActaEmpresa / territorioInspecciónHallazgosEstadoInforme
{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}{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 : }
+ )}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} ; 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}

{visibleKinds.map((kind) => { const counts = record(planByKind[kind])!; return ; })}
EntidadCrear nuevosUsar existentesRevisarIgnorarTotal
{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')}
; + return

{group.label}

EntidadCrear nuevosUsar existentesRevisarIgnorarTotal{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')}; })}
; })} - {(planDetailKind || planDetailAction) &&
{planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'}{planDetailMeta.total.toLocaleString('es-AR')} elementos únicos
{planDetailLoading ? : <>
{planDetailItems.map((item) => { const objectId = matchedObjectId(item); return ; })}
DecisiónEntidad / relaciónQué hará el sistemaFilas fuente
{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 nuevoNo 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 &&
Página {planDetailMeta.page} de {planDetailMeta.totalPages}
}}
} + {(planDetailKind || planDetailAction) &&
{planDetailKind ? ENTITY_LABELS[planDetailKind] : planDetailAction ? PLAN_ACTION_LABELS[planDetailAction] : 'Detalle del plan'}{planDetailMeta.total.toLocaleString('es-AR')} elementos únicos
{planDetailLoading ? : <>
DecisiónEntidad / relaciónQué hará el sistemaFilas 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 nuevoNo 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 &&
Página {planDetailMeta.page} de {planDetailMeta.totalPages}
}}
} } {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' ? <>
{rows.map((row) => { const state = rowStatus(row.status); const rec = record(row.normalizedData.reconciliation); return @@ -766,7 +767,7 @@ Escribí IMPORTAR para continuar:`); ; })} } -
FilaResultadoÁreaYacimientoDepartamentoDerechoOperadoraConciliaciónObservaciones
{row.issues.length ? row.issues.slice(0, 3).map((issue) => {ISSUE_LABELS[issue] ?? issue}) : Sin observaciones}{row.issues.length > 3 && +{row.issues.length - 3}}
+
Página {rowMeta.page} de {Math.max(rowMeta.totalPages, 1)}
} 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)}
-
+
{assets.map((asset) => @@ -194,7 +195,7 @@ export function AssetsPage() { )} -
RegistroSecciónUbicación / OperadoraEstadoActualizado
RegistroSecciónUbicación / OperadoraEstadoActualizado
{asset.name}{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}
{asset.type.name}{formatDate(asset.updatedAt)}
+
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} ; 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() {
{error && {error}} - {loading ? : events.length === 0 ? :
{events.map((event) => )}
Fecha y horaUsuarioAcciónEntidadOrigenRequest ID
{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)}…` : '—'}
Página {page} de {Math.max(meta.totalPages, 1)}
} + {loading ? : events.length === 0 ? :
Fecha y horaUsuarioAcciónEntidadOrigenRequest 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)}…` : '—'})}
Página {page} de {Math.max(meta.totalPages, 1)}
} {(detailLoading || detail) &&
{ if (event.target === event.currentTarget && !detailLoading) setDetail(null); }}>
} ; 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
{documentsError && {documentsError}} - {documentsLoading ? : filteredActs.length === 0 ? :
{filteredActs.map((act) => { + {documentsLoading ? : filteredActs.length === 0 ? :
{filteredActs.map((act) => { const report = reportByAct.get(act.id); return @@ -416,7 +417,7 @@ export function CompanyInventoryPage({ initialAsset }: { initialAsset: AssetDeta ; - })}
FechaÁreaActaInspecciónInformeOficialización
FechaÁreaActaInspecciónInformeOficialización
{formatDate(act.occurredAt)}{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.
:
{items.map((item) => )}
DocumentoDestinoEstadoIntentos
{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' && }
} + {items.length === 0 ?
Todavía no existen entregas documentales.
:
DocumentoDestinoEstadoIntentos{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' && })}
}
; } 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() {
- + {act.findings.map((finding) => { const action = findingAction(finding, act, briefing.plannedOn); @@ -264,7 +265,7 @@ export function FieldBriefingsPage() { ; })} -
Qué hacerHallazgoInventarioControl
{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)}
-
+
{items.map((item) => @@ -141,14 +142,14 @@ export function FieldDiscoveriesPage() { } )} -
ElementoInspecciónInspectorEstadoAcciones
ElementoInspecciónInspectorEstadoAcciones
{item.asset.name}{item.asset.code} · {item.asset.typeName}{item.asset.commonName ? ` · ${item.asset.commonName}` : ''} {item.visit.code}{formatDate(item.observedAt)}
+
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} {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.

setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /> - {candidates.length > 0 &&
{candidates.map((candidate) => )}
Registro existenteTipo
{candidate.name}{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}{candidate.type.name}
} + {candidates.length > 0 &&
Registro existenteTipo{candidates.map((candidate) => {candidate.name}{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}{candidate.type.name})}
}
} ; } 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)}
-
{items.map((finding) => { +
{items.map((finding) => { const attention = attentionLabel(finding); const deadline = deadlineText(finding); return @@ -172,7 +173,7 @@ export function FindingsPage() { ; - })}
HallazgoEmpresa / áreaElementoSituaciónFecha de controlDescripción
HallazgoEmpresa / áreaElementoSituaciónFecha de controlDescripción
{finding.description}
+ })}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} ; 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 registradasPágina {page} de {Math.max(meta.totalPages, 1)}
{versions.map((version) => )}
Fecha y horaRegistroVersiónCambioCamposUsuarioEstado
{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)}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} + {loading ? : versions.length === 0 ? :
{meta.total} versiones registradasPágina {page} de {Math.max(meta.totalPages, 1)}
Fecha y horaRegistroVersiónCambioCamposUsuarioEstado{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)})}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} {(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 && }
{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 ? :
{visit.checklist.items.map((item) => )}
TipoHallazgoInventarioFechaGravedad
{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 ? :
TipoHallazgoInventarioFechaGravedad{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 &&
PREVENTIVO

Agregar Inventario sin pendiente previo

Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento fijado para esta Inspección.

} - {visit &&
{visit.assets.length} incluidos{visit.checklist.excludedAssets} excluidos con trazabilidad
{visit.planningAssets.length === 0 ? :
{visit.planningAssets.map((asset) => )}
InventarioOrigenEstadoMotivo
{asset.name}{asset.code} · {asset.typeName}{sourceLabel(asset.planningSource)}{asset.included ? 'Incluido' : 'Excluido'}{asset.exclusionReason ?? '—'}{canManage && planningEditable && (asset.included ? : )}
}
} + {visit &&
{visit.assets.length} incluidos{visit.checklist.excludedAssets} excluidos con trazabilidad
{visit.planningAssets.length === 0 ? :
InventarioOrigenEstadoMotivo{visit.planningAssets.map((asset) => {asset.name}{asset.code} · {asset.typeName}{sourceLabel(asset.planningSource)}{asset.included ? 'Incluido' : 'Excluido'}{asset.exclusionReason ?? '—'}{canManage && planningEditable && (asset.included ? : )})}
}
} {visit && canAssign && planningEditable &&
EQUIPO

Inspectores asignados

{assignees.map((person) => )}
} 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)}
{visits.map((visit) => )}
InspecciónEstadoÁrea / OperadoraResponsableFecha previstaInventario
{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'}}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} + {loading ? : visits.length === 0 ? :
{meta.total} inspección{meta.total === 1 ? '' : 'es'}Página {page} de {Math.max(meta.totalPages, 1)}
InspecciónEstadoÁrea / OperadoraResponsableFecha previstaInventario{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'}})}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} ; } 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 &&
} } -
{report.findings.map((finding) => )}
HallazgoElementoEstadoVencimiento
{finding.code}{finding.title}{finding.assetName}{finding.assetCode}{finding.status === 'OPEN' ? 'Abierto' : finding.status}{formatDateOnly(finding.responseDueOn)}
+
HallazgoElementoEstadoVencimiento{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' ? : }{view === 'issued' ? issued.map((report) => +
{view === 'issued' ? : }{view === 'issued' ? issued.map((report) => @@ -162,7 +163,7 @@ export function ReportsPage() { - )}
InformeEmpresa / áreaActaHallazgosWordEstado
Acta selladaEmpresa / áreaInspecciónHallazgosSellado
InformeEmpresa / áreaActaHallazgosWordEstado
Acta selladaEmpresa / áreaInspecciónHallazgosSellado
{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}{item.findingCount} {formatDate(item.sealedAt)}
+ )}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
}
; 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() {
} - {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 ? :
{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 ? :
{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' @@ -304,6 +305,6 @@ export function SurveyCampaignEditorPage() { : []; const draft = drafts[target.id] ?? { dueAt: '', instructions: '' }; return ; - })}
RegistroEstadoResponsableVencimientoInstrucciones
RegistroEstadoResponsableVencimientoInstrucciones
{target.asset.name}{target.asset.code} · {target.asset.typeName}{surveyTargetStatusLabel(target.status)}{canAct && availableStatuses.length > 0 && event.target.value && changeTargetStatus(target, event.target.value as SurveyTargetStatus)}>{availableStatuses.map((status) => )}}{canAssign && !closed && !planLocked ? assignTarget(target, event.target.value)}>{assignees.map((person) => )} : target.assignedUser ? personName(target.assignedUser) : 'Sin asignar'}{canManage && !closed && !planLocked ? setDrafts((current) => ({ ...current, [target.id]: { ...draft, dueAt: event.target.value } }))} /> : formatDate(target.dueAt)}{canManage && !closed && !planLocked ? setDrafts((current) => ({ ...current, [target.id]: { ...draft, instructions: event.target.value } }))} maxLength={4000} placeholder="Sin instrucciones" /> : target.instructions ?? '—'}
{canManage && !closed && !planLocked && }{canReadReports && }
}
} + })}}} ; } 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)}
{campaigns.map((campaign) => )}
CampañaEstadoAlcanceCoordinaciónFechas previstasAvance
{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}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} + {loading ? : campaigns.length === 0 ? :
{meta.total} campaña{meta.total === 1 ? '' : 's'}Página {page} de {Math.max(meta.totalPages, 1)}
CampañaEstadoAlcanceCoordinaciónFechas previstasAvance{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}
)}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} ; } 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)}
{assets.map((asset) => )}
Registro históricoTipoEstadoVersión aplicableVigente desdeVigente hastaCambio
{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)}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} + {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óricoTipoEstadoVersión aplicableVigente desdeVigente hastaCambio{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)})}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} {(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)}
-
+
{users.map((user) => )} -
UsuarioRolesEstadoÚltimo acceso
UsuarioRolesEstadoÚltimo acceso
{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)}
+
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} 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
-
{items.map((item) => { +
{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() { ; - })}
HallazgoEmpresa / áreaElementoFecha objetivoPlanificación
HallazgoEmpresa / áreaElementoFecha objetivoPlanificación
{item.verificationVisit ? {item.verificationVisit.code}{formatDate(item.verificationVisit.plannedStartAt)} · {inspectionVisitStatusLabel(item.verificationVisit.status)} : item.company && item.area ? Sin inspección : Falta contexto}
+ })}
{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}
} 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; }