feat(audit): implement audit page state management and API integration

- Add utility functions for managing audit page state including parsing, serialization, and updates.
- Introduce audit period resolution logic to handle various time frames.
- Create API functions for finding and searching audit entries with appropriate DTOs and mappers.
- Implement hooks for fetching audit entries and lists, integrating with the data source.
- Add tests for utility functions, API calls, and mappers to ensure correctness.
- Establish shared entities and DTOs for consistent data handling across the module.
- Set up TypeScript configuration for the audit module.
This commit is contained in:
David Arranz 2026-08-25 16:23:46 +02:00
parent c5cae1fbfd
commit 7033d02a69
51 changed files with 2879 additions and 1 deletions

View File

@ -30,6 +30,7 @@
},
"dependencies": {
"@erp/auth": "workspace:*",
"@erp/audit": "workspace:*",
"@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*",
"@erp/companies": "workspace:*",
@ -62,4 +63,4 @@
"tw-animate-css": "^1.4.0",
"vite-plugin-html": "^3.2.2"
}
}
}

View File

@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import {
BanknoteIcon,
BarChart3Icon,
HistoryIcon,
BoxesIcon,
ClipboardListIcon,
CreditCardIcon,
@ -107,6 +108,11 @@ export const appSidebarNavSections: AppSidebarNavSection[] = [
{ title: "Informes financieros", href: "/accounting/reports", icon: BarChart3Icon },
],
},
{
title: "Sistema",
accent: "violet",
items: [{ title: "Auditoría", href: "/audit", icon: HistoryIcon }],
},
];
export interface AppSidebarSettingsItem {

View File

@ -1,3 +1,4 @@
import { AuditModuleManifest } from "@erp/audit/client";
import { CatalogsModuleManifest } from "@erp/catalogs/client";
import { CompaniesModuleManifest } from "@erp/companies/client";
import CoreModuleManifest, { type IModuleClient } from "@erp/core/client";
@ -9,6 +10,7 @@ import { SuppliersModuleManifest } from "@erp/suppliers/client";
export const modules: IModuleClient[] = [
IdentityModuleManifest,
CoreModuleManifest,
AuditModuleManifest,
CompaniesModuleManifest,
CatalogsModuleManifest,
CustomersModuleManifest,

View File

@ -1,3 +1,13 @@
> **Documento histórico / deprecado**
>
> Este documento refleja un diseño previo del sistema transversal de auditoría y no constituye la especificación vigente.
>
> La referencia canónica y consolidada actual es:
>
> `docs/architecture/audit-system-design.md`
>
> Este fichero se conserva únicamente como referencia histórica.
**DOCUMENTO DE DISEÑO**
Sistema transversal de auditoría

View File

@ -0,0 +1,232 @@
# DOCUMENTO DE DISENO - SISTEMA TRANSVERSAL DE AUDITORIA
**Estado:** consolidado actual.
**Fecha:** 25 de agosto de 2026.
**Alcance:** Writer portable, composición server-side, lectura HTTP y frontend de `/audit` actualmente implementados.
## Resumen ejecutivo
Audit registra **quién** realizó una operación, **qué** ocurrió, **cuándo**, sobre qué **target**, bajo qué **scope** y, para una mutación, los cambios históricos exactos. Es un Audit Trail con Change History append-only.
No es Event Sourcing, logging técnico general, un sistema genérico de versionado, `BusinessVersion` ni `DocumentVersion`. El estado actual implementa Audit Trail y Change History. BusinessVersion y DocumentVersion son evoluciones distintas y no están implementadas.
## Separación conceptual
| Concepto | Responsabilidad | Estado |
| --- | --- | --- |
| Audit Trail | Quién, qué, cuándo, target, contexto y operación realizada | Implementado |
| Change History | Diff histórico de una mutation | Implementado |
| BusinessVersion | Versionado funcional de entidades/documentos de negocio | Futuro |
| DocumentVersion | Versionado de binarios o documentos | Futuro |
## Principios y límites del paquete portable
`@repo/rdx-audit` proporciona el Writer portable. No conoce Sequelize, MariaDB, Express, Company, tenant, Criteria, Identity, roles, permisos, transacciones concretas ni el `AsyncLocalStorage` del host. El dominio funcional tampoco conoce Audit: los application services reciben `IAudit` por composición.
Principios normativos:
- Store append-only y actions históricas estables.
- La mutación de negocio y su AuditEntry comparten transacción.
- La autorización de lectura está separada de los filtros solicitados.
- El Reader está separado del Writer y no pertenece al paquete portable.
- Cada módulo proyecta snapshots históricos controlados; Audit no inspecciona aggregates ni ORM.
- Audit no depende de módulos funcionales concretos.
## Contratos Writer
`IAudit.record()` es la operación canónica. Los atajos sólo fijan el `kind` y delegan en ella:
```ts
interface IAudit {
record(input: RecordAuditInput): Promise<Result<void, AuditError>>;
recordEvent(input: RecordAuditEventShortcutInput): Promise<Result<void, AuditError>>;
recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result<void, AuditError>>;
}
```
Un `event` representa una acción ocurrida y siempre persiste `changes: []`; su target puede no tener ID. Una `mutation` exige target identificado, recibe `before` y `after`, calcula el diff y sólo se persiste si hay al menos un cambio. Una mutation no-op devuelve éxito y no crea AuditEntry.
La estructura base contiene `id`, `occurredAt` ISO, `source`, `operation`, `actor`, `scope`, `action`, `kind`, `target` y `changes`. El ID se genera con `randomUUID()` salvo generador inyectado; el timestamp procede de `now().toISOString()`.
### AuditState y snapshots
`AuditState` admite objetos planos readonly cuyos valores son `string`, número finito, `boolean`, `null`, objeto plano readonly o array readonly. Se rechazan `undefined`, `Date`, `Buffer`, `bigint`, funciones, `Map`, `Set`, instancias de clase, Value Objects, `Maybe`, `Collection`, símbolos y ciclos. Las claves no pueden contener `.`.
Los snapshots son funcionales, no serializaciones ORM. No se debe aplicar `aggregate.toPrimitives()` automáticamente. Normalmente excluyen timestamps técnicos y `companyId` cuando el scope ya expresa compañía. PII puede incluirse si la proyección histórica lo decide; referencias de catálogo se guardan como IDs históricos y no se resuelven con labels actuales. Los arrays son sensibles al orden; si el orden no tiene semántica, el módulo debe normalizarlo antes de auditar.
### Diff
`diffAuditState()` compara objetos recursivamente, ordena paths de forma determinista y es independiente del orden de inserción de keys. Los arrays son atómicos; `atomicPaths` permite tratar un objeto como atómico. La ausencia de clave no equivale a `null`:
```ts
interface AuditChange {
readonly path: string;
readonly kind: "set" | "unset" | "change";
readonly before: AuditValue | null;
readonly after: AuditValue | null;
}
```
- `set`: clave ausente antes y presente después.
- `unset`: presente antes y ausente después.
- `change`: presente en ambos, con valor distinto o cambio atómico/tipo incompatible.
## Contexto de operación y transacción
`OperationContext` contiene `operationId`, `correlationId`, `origin`, `actor` y `scope`. `AsyncLocalStorageOperationContextStore` lo aísla por operación asíncrona y restaura correctamente scopes anidados. Operation/correlation/origin son inmutables; sólo se enriquecen actor y scope. Un actor concreto no puede sustituirse por otro, y un scope concreto no puede sustituirse por otro; el actor anónimo y el scope nulo pueden enriquecerse una vez.
Pipeline HTTP actual:
```text
request -> OperationContext middleware -> authenticateUser -> requireCompanyContext -> controller
```
El middleware genera `operationId`, adopta `X-Correlation-Id` si es válido o genera uno, responde `X-Request-Id` y `X-Correlation-Id`, y parte de `origin: "http"`, actor anónimo y scope nulo. Autenticación enriquece actor como `user`; el contexto de compañía autorizado enriquece scope como `company`.
`OperationAuditContextProvider` adapta `OperationContext` al puerto `IAuditContextProvider`, sin invertir la dependencia del paquete portable. `scope: null` sólo significa que la operación histórica no tuvo scope; nunca significa visibilidad global.
`SequelizeTransactionContext` usa otro `AsyncLocalStorage` exclusivamente para la transacción Sequelize activa. No reemplaza el paso explícito de `Transaction` entre casos de uso y repositorios. `SequelizeTransactionManager.complete()` prohíbe transacciones anidadas, abre T1, publica T1, ejecuta el callback y hace rollback si lanza o devuelve `Result.fail`; en éxito hace commit.
Para una mutation la propiedad requerida es:
```text
BEGIN T1
mutación y persistencia funcional
Audit.recordMutation
audit_events + audit_changes
COMMIT
```
Si Audit falla, el resultado debe propagarse y T1 revierte negocio y auditoría. Los tests MariaDB de `sequelize-audit-store.integration.test.ts` comprueban commit, rollback, cambios JSON y fallo de mutation sin T.
## Store y persistencia MariaDB
`SequelizeAuditStore` implementa `IAuditStore.append(entry)`. No hay upsert: un ID duplicado falla. Un event puede insertarse sin transacción; una mutation sin transacción devuelve `AuditStoreError` controlado. Con T activa crea el event y hace `bulkCreate` de los changes en la misma T.
`audit_events` tiene PK `id` y columnas `kind`, `occurred_at`, source, operation/correlation/origin, actor, scope, action y target. Índices: `(occurred_at,id)`, scope+fecha+id, target+fecha+id, actor+fecha+id, `operation_id` y `correlation_id`.
`audit_changes` tiene PK compuesta `(audit_event_id, position)`, FK a `audit_events(id)` con `ON DELETE CASCADE`, además de `path`, `kind`, `before_value` y `after_value` JSON. MariaDB puede devolver JSON como texto; los getters del modelo lo parsean para mantener el contrato al leer. `position` preserva el orden persistido.
La composición productiva crea una única instancia: `AsyncLocalStorageOperationContextStore -> OperationAuditContextProvider`, `SequelizeTransactionContext -> SequelizeAuditStore`, y `Audit({ source: { type: "application", id: "uecko-erp" }, ... })`. Esa instancia se entrega como `ModuleParams.audit`; los módulos reciben el puerto, no el store concreto ni una instancia por request.
## Patrón de instrumentación funcional
Para actualizar: cargar agregado, proyectar `before`, mutar, terminar sin persistencia/audit si es no-op, persistir con T, proyectar `after`, `recordMutation`, y propagar cualquier fallo para rollback. Para create: crear, persistir, proyectar estado y auditar dentro de T. Para delete: cargar, proyectar before, borrar y auditar dentro de T.
Normalmente no se auditan validation failure, not found, rechazo de dominio, cross-company, duplicados ni no-ops: Audit describe operaciones realizadas, no intentos fallidos.
## Módulos instrumentados y actions
| Target | Action | Kind | Estado |
| --- | --- | --- | --- |
| payment_method | `payment_method.created` | mutation | Implementada |
| payment_method | `payment_method.updated` | mutation | Implementada |
| payment_method | `payment_method.enabled` | mutation | Implementada |
| payment_method | `payment_method.disabled` | mutation | Implementada |
| payment_method | `payment_method.deleted` | mutation | Implementada |
| payment_method | `payment_method.default_set` | mutation | Implementada |
| payment_method | `payment_method.default_unset` | mutation | Implementada |
| payment_term | `payment_term.created` | mutation | Implementada |
| payment_term | `payment_term.updated` | mutation | Implementada |
| payment_term | `payment_term.enabled` | mutation | Implementada |
| payment_term | `payment_term.disabled` | mutation | Implementada |
| payment_term | `payment_term.deleted` | mutation | Implementada |
| customer | `customer.created` | mutation | Implementada |
| customer | `customer.updated` | mutation | Implementada |
| company | `company.created` | mutation | Implementada |
| company | `company.updated` | mutation | Implementada |
| company | `company.enabled` | mutation | Implementada |
| company | `company.disabled` | mutation | Implementada |
| supplier | `supplier.created` | mutation | Implementada |
| supplier | `supplier.updated` | mutation | Implementada |
| supplier | `supplier.activated` | mutation | Implementada |
| supplier | `supplier.deactivated` | mutation | Implementada |
Payment Methods proyecta `name`, `description`, flags `isActive/isSystem/isDefault`; cambiar el default puede producir dos AuditEntries (`default_unset` y `default_set`) bajo la misma operación y T. Payment Terms añade `dueRules` como array atómico ordenado por el dominio, con porcentajes serializados como strings.
Customers y Suppliers proyectan estado, referencia, identidad comercial/fiscal, dirección, contactos, idioma y moneda. Customers añade método/plazo de pago, régimen fiscal y flags de recargo/retención; incluye PII y excluye deliberadamente `legalRecord`. No hay actions lifecycle de Customer. Suppliers separa status del PATCH mediante lifecycle explícito; los inactivos siguen siendo consultables. Companies proyecta identidad, contacto, slug, plazo por defecto y estado; create es unscoped y update/status son scoped en las rutas actuales.
Una `action` es un string jerárquico validado en minúsculas, por ejemplo `customer.updated`. Tras registrarse se convierte en contrato histórico: no debe renombrarse arbitrariamente ni centralizarse en un enum global inexistente.
## Reader, autorización y HTTP
`IAuditReader` y `SequelizeAuditReader` viven en `apps/server` porque dependen de Sequelize. `AuditReadAccess` puede ser `unrestricted` o `scoped { scopes, includeUnscoped }`. La ruta HTTP V1 sólo crea acceso scoped con la compañía activa y `includeUnscoped: false`; no expone unrestricted ni RBAC funcional. Por tanto, cualquier usuario autenticado con membership activa de la compañía puede leer todo su Audit, incluidos snapshots con TIN, email, teléfonos y direcciones. Es una limitación y riesgo de PII explícito.
Regla normativa de lectura: `AUTHORIZED_WHERE AND REQUESTED_FILTERS`. El test prueba que autorizado en A y filtro `scopeId=B` produce `[]`. Detail fuera de scope devuelve 404, no 403, para no revelar existencia.
Endpoints actuales:
- `GET /audit`
- `GET /audit/:audit_entry_id`
Ambos pasan por `requireIdentityTenant()`. Zod usa objetos estrictos; una query/params inválida devuelve 422 mediante `ValidationApiError`, no 400. Query soporta `pageNumber`, `pageSize`, `q`, `from`, `to`, `kind`, `action`, `targetType`, `targetId`, `actorType`, `actorId`, `scopeType`, `scopeId`, `operationId`, `correlationId`, `origin`, `orderBy` y `order`.
El Reader aplica una whitelist propia incluso cuando se invoca directamente. Rechaza `unknownField`, `beforeValue`, `afterValue` y `some_sql_column` antes de SQL. Quick search busca `action`, labels de target/actor/scope y coincidencias exactas de target/operation/correlation ID; escapa `%`, `_` y `\\`, y no busca dentro del JSON histórico.
Los periodos son normativos `[from,to)`: `occurred_at >= from` y `< to`. El backend usa paginación zero-based, `pageSize` máximo 100, y responde `page`, `per_page`, `total_pages`, `total_items`. Sorting acepta `occurredAt`, `action`, `targetType` y `actorLabel`; el default y tie-breaker son `occurred_at DESC, id DESC`.
Cada AuditEntry es una fila de list. La list no carga changes: resuelve `changes_count` con `COUNT + SELECT` y subquery correlacionada, por lo que es O(1) queries respecto a N entries. Detail primero autoriza y carga el event, después carga changes en consulta adicional por `position ASC`; un event devuelve `changes: []`.
## Frontend `/audit`
El módulo es `modules/audit`; la ruta protegida y con compañía requerida es `/audit`. La URL es la fuente de verdad para `q`, `period`, `from`, `to`, `kind`, `targetType`, `action`, `pageNumber`, `pageSize`, `orderBy`, `order` y `entry`. `pageNumber` URL es one-based; controller y backend son zero-based. El default es `last7Days`.
Filtros visibles V1: búsqueda, periodo, kind, recurso y action. No hay filtro Actor ni Company. Actor no se expone porque no existe endpoint productivo de company users ni selector reutilizable; `/users` es legacy/desconectado, `actor.label` es histórico y hay actores no humanos. El backend sí soporta actorId/actorType.
Presets: `today` usa inicio local del día a inicio del siguiente; `last24Hours`, `last7Days` y `last30Days` terminan en now; `custom` conserva sus ISO. Registries de targets/actions/paths son metadata de presentación frontend, no contratos backend, y usan fallback raw para actions, paths o targets desconocidos.
`entry=<id>` abre un Sheet lateral y permite deep-link, reload y Back/Forward. `GET /audit/:id` se mapea de DTO snake_case a modelo camelCase. El Sheet muestra action/target/actor, fecha local, changes (`set`: nuevo valor; `unset`: valor eliminado; `change`: antes/después), evento sin cambios, y metadata técnica: Audit Entry ID, action raw, kind, target/actor/scope ID, operation/correlation ID, origin, source type/id y timestamp ISO. El renderer genérico cubre null, boolean, scalar, array y objeto; no hay renderers de dominio especializados ni resolución actual de catálogos.
Estados UX: skeleton inicial, datos conservados durante refetch, empty y filtered empty, PageError inicial, error inline de refresh, skeleton/error/retry en detail y 404 neutro.
### Refresh y Live
Actualizar llama al `refetch()` de la query actual. Live es una preferencia local no persistida. React Query hace polling de 5 segundos sólo cuando Live es efectivo: primera página y periodo que incluye now. Para custom se usa `from <= now < to`; fuera de primera página o periodo incompatible Live permanece solicitado pero pausado y explica el motivo. El detalle no hace polling.
Los rangos relativos se recalculan dentro del request de polling sin modificar la URL. La query key mantiene el estado semántico ya renderizado, evitando crear una entrada de caché por cada `now`. Las keys de list y detail incluyen `companyId`; al cambiar compañía se limpia `entry` y Live solicitado continúa sobre la key de la nueva compañía.
## Validación, regresiones y evidencia
La cobertura existente incluye Writer/diff/state, OperationContext y transaction context, store y rollback MariaDB, integrations de Payment Methods/Terms, Customer, Company y Supplier, Reader scoped/unrestricted/whitelist/rango/búsqueda/paginación/detail, HTTP tenant A/B/404/422, y frontend mapper/URL/periodos/filtros/detail/renderer/live. La ejecución actual de `pnpm --filter @erp/audit test` valida 32 tests frontend.
Lecciones verificadas en código y pruebas:
- Los operadores de Criteria `GREATER_THAN_OR_EQUAL` y `LOWER_THAN` se traducen expresamente a `Op.gte` y `Op.lt`; fue un bug de implementación del Reader, no una regla opcional.
- `from` y `to` deben combinarse sobre la misma columna `occurred_at`; sobrescribir el where habría roto `[from,to)`. La integración Reader cubre el rango.
- El parser frontend convierte URL one-based a estado/backend zero-based y serializa a la inversa; fue una regresión de integración UI, cubierta por test.
- El contrato HTTP de login es `access_token`; la divergencia `accessToken` corresponde al mapeo interno/frontend o a harnesses y no a un campo HTTP alternativo productivo.
## Limitaciones V1 y evolución prudente
Limitaciones actuales: sin RBAC granular, filtro Actor, filtro Company, facets, export, saved searches, búsqueda en JSON before/after, SSE/WebSocket, cursor pagination, contador de nuevos eventos, navegación al target ni resolución histórica de labels de catálogo. Los events unscoped no son visibles por HTTP normal.
Posibles evoluciones, no implementadas ni obligatorias: RBAC y endpoint unrestricted de administrador, endpoint company-users y actor filter, facets/export/cursor pagination, SSE/WebSocket si polling no basta, renderers especializados, navegación a recursos, BusinessVersion y DocumentVersion.
## Guía para instrumentar un módulo nuevo
1. Identificar operaciones efectivamente realizadas.
2. Definir actions estables y target.
3. Diseñar AuditState histórico y decidir PII.
4. Decidir arrays, normalización y `atomicPaths`.
5. Detectar no-ops.
6. Instrumentar el application service, no controller, repository ni domain.
7. Compartir la misma T y propagar el fallo de Audit.
8. Añadir tests unitarios, integración funcional y rollback MariaDB.
9. Actualizar la tabla de actions y registries/traducciones frontend si procede.
Anti-patrones: auditar desde controllers/repositorios/domain, registrar no-ops o mutaciones fuera de T, usar `toPrimitives()` automático, reescribir historia con labels actuales, usar scope solicitado como autorización, tratar `scope=null` como global, renombrar actions históricas, usar Audit como Event Sourcing o como directorio de usuarios.
## Decisiones normativas e implementativas
Son normativas: actions estables, snapshots explícitos, mutation atómica con negocio, no auditar no-ops, separación entre scope y autorización, y una AuditEntry por hecho auditado. Una operación de negocio puede producir más de un hecho, como el cambio de método de pago predeterminado.
Son implementativas actuales y reemplazables: MariaDB, Sequelize, modelos `audit_events/audit_changes`, subquery correlacionada de `changes_count`, paginación offset, React Query y polling Live de cinco segundos. Cambiar esa infraestructura no debe reinterpretar las reglas históricas.
## Referencias de implementación
- Writer y diff: `packages/rdx-audit/src/audit.ts`, `audit.types.ts`, `internal/audit-diff.ts`, `internal/audit-state-validator.ts`.
- Contexto y T: `modules/core/src/api/infrastructure/context/`, `.../persistence/sequelize/sequelize-transaction-*.ts`.
- Store/modelos/Reader/rutas: `apps/server/src/infrastructure/audit/`.
- Evidencia server: `apps/server/src/testing/http/*audit*.test.ts` y `sequelize-audit-store.integration.test.ts`.
- Instrumentaciones: los directorios `audit/` de Catalogs, Customers, Companies y Suppliers.
- Frontend: `modules/audit/src/web/` y sus tests de utils/shared.

View File

@ -0,0 +1,43 @@
{
"name": "@erp/audit",
"description": "Audit frontend module",
"version": "0.10.5",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"test": "node --import tsx --test",
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint ."
},
"exports": {
"./client": "./src/web/manifest.ts"
},
"peerDependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
},
"dependencies": {
"@erp/core": "workspace:*",
"@erp/identity": "workspace:*",
"@repo/i18next": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.98.0",
"@tanstack/react-table": "^8.21.3",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0",
"zod": "^4.3.6"
}
}

View File

@ -0,0 +1,150 @@
{
"audit": {
"title": "Audit log",
"description": "Review changes and operations made in the ERP.",
"columns": {
"date": "Date",
"action": "Action",
"target": "Resource",
"actor": "User",
"changes": "Changes"
},
"empty": "There are no audit records.",
"error": {
"load": "The audit log could not be loaded.",
"refresh": "The audit log could not be refreshed. The latest available data is shown."
},
"retry": "Retry",
"refresh": "Refresh",
"refreshing": "Refreshing",
"live": {
"label": "Live",
"active": "Live",
"paused": "Live · paused",
"periodUnavailable": "Live mode requires a period that includes the current time.",
"firstPageOnly": "Live mode will resume on the first page."
},
"reset": "Reset",
"search": { "placeholder": "Search audit log..." },
"empty_filtered": "There are no records matching the current filters.",
"filters": {
"period": "Period",
"from": "From",
"to": "To",
"kind": "Type",
"target": "Resource",
"action": "Action",
"all": "All",
"active": "Active filters"
},
"period": {
"today": "Today",
"last24Hours": "Last 24 hours",
"last7Days": "Last 7 days",
"last30Days": "Last 30 days",
"custom": "Custom"
},
"kind": { "mutation": "Changes", "event": "Events" },
"targets": {
"customer": "Customers",
"supplier": "Suppliers",
"company": "Companies",
"payment_method": "Payment methods",
"payment_term": "Payment terms"
},
"actions": {
"customer.created": "Customer created",
"customer.updated": "Customer updated",
"supplier.created": "Supplier created",
"supplier.updated": "Supplier updated",
"supplier.activated": "Supplier activated",
"supplier.deactivated": "Supplier deactivated",
"company.created": "Company created",
"company.updated": "Company updated",
"company.enabled": "Company enabled",
"company.disabled": "Company disabled",
"payment_method.created": "Payment method created",
"payment_method.updated": "Payment method updated",
"payment_method.enabled": "Payment method enabled",
"payment_method.disabled": "Payment method disabled",
"payment_method.deleted": "Payment method deleted",
"payment_method.default_set": "Default payment method set",
"payment_method.default_unset": "Default payment method unset",
"payment_term.created": "Payment term created",
"payment_term.updated": "Payment term updated",
"payment_term.enabled": "Payment term enabled",
"payment_term.disabled": "Payment term disabled",
"payment_term.deleted": "Payment term deleted"
},
"changes_count": "{{count}} changes",
"detail": {
"title": "Audit detail",
"description": "Information about the selected record.",
"company": "Company: {{value}}",
"noChanges": "This event does not contain state changes.",
"error": "The audit detail could not be loaded.",
"unavailable": "The audit record is not available."
},
"changes": {
"title": "Changes",
"before": "Before",
"after": "After",
"newValue": "New value",
"removedValue": "Removed value"
},
"changeKinds": { "set": "Value set", "unset": "Value removed", "change": "Value changed" },
"values": { "null": "No value", "yes": "Yes", "no": "No" },
"technical": {
"title": "Technical information",
"entryId": "Audit entry ID",
"action": "Action",
"kind": "Kind",
"targetId": "Target ID",
"actorId": "Actor ID",
"scopeId": "Scope ID",
"operationId": "Operation ID",
"correlationId": "Correlation ID",
"origin": "Origin",
"sourceType": "Source type",
"sourceId": "Source ID",
"timestamp": "ISO timestamp"
},
"actors": { "user": "User", "system": "System" },
"paths": {
"status": "Status",
"name": "Name",
"description": "Description",
"reference": "Reference",
"isCompany": "Is company",
"tradeName": "Trade name",
"tin": "Tax ID",
"emailPrimary": "Primary email",
"emailSecondary": "Secondary email",
"phonePrimary": "Primary phone",
"phoneSecondary": "Secondary phone",
"mobilePrimary": "Mobile phone",
"fax": "Fax",
"website": "Website",
"languageCode": "Language",
"currencyCode": "Currency",
"paymentMethodId": "Payment method ID",
"paymentTermId": "Payment term ID",
"taxRegimeCode": "Tax regime",
"usesEquivalenceSurcharge": "Uses equivalence surcharge",
"usesRetention": "Uses retention",
"legalName": "Legal name",
"slug": "Slug",
"defaultPaymentTermId": "Default payment term ID",
"isActive": "Active",
"isSystem": "Is system",
"isDefault": "Is default",
"addressStreet": "Street",
"addressStreet2": "Street line 2",
"addressCity": "City",
"addressProvince": "Province",
"addressPostalCode": "Postal code",
"addressCountry": "Country",
"dueRules": "Due rules"
}
}
}

View File

@ -0,0 +1,154 @@
{
"audit": {
"title": "Auditoría",
"description": "Consulta los cambios y operaciones realizadas en el ERP.",
"columns": {
"date": "Fecha",
"action": "Acción",
"target": "Recurso",
"actor": "Usuario",
"changes": "Cambios"
},
"empty": "No hay registros de auditoría.",
"error": {
"load": "No se pudo cargar la auditoría.",
"refresh": "No se pudo actualizar la auditoría. Se muestran los últimos datos disponibles."
},
"retry": "Reintentar",
"refresh": "Actualizar",
"refreshing": "Actualizando",
"live": {
"label": "En vivo",
"active": "En vivo",
"paused": "En vivo · pausado",
"periodUnavailable": "El modo En vivo requiere un periodo que incluya el momento actual.",
"firstPageOnly": "El modo En vivo se reanudará en la primera página."
},
"reset": "Restablecer",
"search": { "placeholder": "Buscar en auditoría..." },
"empty_filtered": "No hay registros que coincidan con los filtros actuales.",
"filters": {
"period": "Periodo",
"from": "Desde",
"to": "Hasta",
"kind": "Tipo",
"target": "Recurso",
"action": "Acción",
"all": "Todos",
"active": "Filtros activos"
},
"period": {
"today": "Hoy",
"last24Hours": "Últimas 24 horas",
"last7Days": "Últimos 7 días",
"last30Days": "Últimos 30 días",
"custom": "Personalizado"
},
"kind": { "mutation": "Cambios", "event": "Eventos" },
"targets": {
"customer": "Clientes",
"supplier": "Proveedores",
"company": "Empresas",
"payment_method": "Métodos de pago",
"payment_term": "Plazos de pago"
},
"actions": {
"customer.created": "Cliente creado",
"customer.updated": "Cliente actualizado",
"supplier.created": "Proveedor creado",
"supplier.updated": "Proveedor actualizado",
"supplier.activated": "Proveedor activado",
"supplier.deactivated": "Proveedor desactivado",
"company.created": "Empresa creada",
"company.updated": "Empresa actualizada",
"company.enabled": "Empresa activada",
"company.disabled": "Empresa desactivada",
"payment_method.created": "Método de pago creado",
"payment_method.updated": "Método de pago actualizado",
"payment_method.enabled": "Método de pago activado",
"payment_method.disabled": "Método de pago desactivado",
"payment_method.deleted": "Método de pago eliminado",
"payment_method.default_set": "Método de pago predeterminado",
"payment_method.default_unset": "Método de pago predeterminado retirado",
"payment_term.created": "Plazo de pago creado",
"payment_term.updated": "Plazo de pago actualizado",
"payment_term.enabled": "Plazo de pago activado",
"payment_term.disabled": "Plazo de pago desactivado",
"payment_term.deleted": "Plazo de pago eliminado"
},
"changes_count": "{{count}} cambios",
"detail": {
"title": "Detalle de auditoría",
"description": "Información del registro seleccionado.",
"company": "Compañía: {{value}}",
"noChanges": "Este evento no contiene cambios de estado.",
"error": "No se pudo cargar el detalle de auditoría.",
"unavailable": "El registro de auditoría no está disponible."
},
"changes": {
"title": "Cambios",
"before": "Antes",
"after": "Después",
"newValue": "Nuevo valor",
"removedValue": "Valor eliminado"
},
"changeKinds": {
"set": "Valor establecido",
"unset": "Valor eliminado",
"change": "Valor modificado"
},
"values": { "null": "Sin valor", "yes": "Sí", "no": "No" },
"technical": {
"title": "Información técnica",
"entryId": "ID de entrada de auditoría",
"action": "Acción",
"kind": "Tipo",
"targetId": "ID del recurso",
"actorId": "ID del actor",
"scopeId": "ID del ámbito",
"operationId": "ID de operación",
"correlationId": "ID de correlación",
"origin": "Origen",
"sourceType": "Tipo de fuente",
"sourceId": "ID de fuente",
"timestamp": "Marca temporal ISO"
},
"actors": { "user": "Usuario", "system": "Sistema" },
"paths": {
"status": "Estado",
"name": "Nombre",
"description": "Descripción",
"reference": "Referencia",
"isCompany": "Es empresa",
"tradeName": "Nombre comercial",
"tin": "NIF",
"emailPrimary": "Email principal",
"emailSecondary": "Email secundario",
"phonePrimary": "Teléfono principal",
"phoneSecondary": "Teléfono secundario",
"mobilePrimary": "Móvil",
"fax": "Fax",
"website": "Web",
"languageCode": "Idioma",
"currencyCode": "Moneda",
"paymentMethodId": "ID de método de pago",
"paymentTermId": "ID de plazo de pago",
"taxRegimeCode": "Régimen fiscal",
"usesEquivalenceSurcharge": "Usa recargo de equivalencia",
"usesRetention": "Usa retención",
"legalName": "Razón social",
"slug": "Slug",
"defaultPaymentTermId": "ID de plazo predeterminado",
"isActive": "Activo",
"isSystem": "Es de sistema",
"isDefault": "Es predeterminado",
"addressStreet": "Dirección",
"addressStreet2": "Dirección adicional",
"addressCity": "Ciudad",
"addressProvince": "Provincia",
"addressPostalCode": "Código postal",
"addressCountry": "País",
"dueRules": "Reglas de vencimiento"
}
}
}

View File

@ -0,0 +1,13 @@
import type { ModuleClientParams } from "@erp/core/client";
import { lazy } from "react";
import type { RouteObject } from "react-router-dom";
const AuditPage = lazy(() => import("./list").then((module) => ({ default: module.AuditPage })));
export const AuditRoutes = (_params: ModuleClientParams): RouteObject[] => [
{
path: "audit",
handle: { layout: "app-sidebar", protected: true, requireCompany: true },
element: <AuditPage />,
},
];

View File

@ -0,0 +1,21 @@
import { registerTranslations } from "@repo/i18next";
import { useEffect } from "react";
import { useTranslation as useI18NextTranslation } from "react-i18next";
import enResources from "../common/locales/en.json";
import esResources from "../common/locales/es.json";
import { MODULE_NAME } from "./manifest";
const ensureModuleTranslations = () => {
registerTranslations(MODULE_NAME, "en", enResources);
registerTranslations(MODULE_NAME, "es", esResources);
};
export const useTranslation = () => {
useEffect(() => {
ensureModuleTranslations();
}, []);
return useI18NextTranslation(MODULE_NAME);
};

View File

@ -0,0 +1 @@
export * from "./use-audit-page-controller";

View File

@ -0,0 +1,117 @@
import { useDebounce } from "@erp/core/hooks";
import { useIdentityAuthSession } from "@erp/identity/client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { type AuditList, useAuditList } from "../../shared";
import { resolveAuditLiveState, toggleAuditLive } from "../utils/audit-live";
import {
parseAuditPageState,
resetAuditFilters,
serializeAuditPageState,
setAuditEntry,
setAuditOrder,
setAuditPageNumber,
setAuditPageSize,
toAuditSearchParams,
updateAuditFilters,
} from "../utils/audit-page-search-params";
const EMPTY_AUDIT_LIST: AuditList = {
items: [],
page: 0,
perPage: 50,
totalPages: 0,
totalItems: 0,
};
export const useAuditPageController = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { activeCompany } = useIdentityAuthSession();
const state = useMemo(() => parseAuditPageState(searchParams), [searchParams]);
const previousCompanyId = useRef(activeCompany?.id);
const [searchInput, setSearchInput] = useState(state.filters.q ?? "");
const [isLiveRequested, setIsLiveRequested] = useState(false);
const debouncedSearch = useDebounce(searchInput, 300);
useEffect(() => {
setSearchInput(state.filters.q ?? "");
}, [state.filters.q]);
useEffect(() => {
if (debouncedSearch.trim() !== (state.filters.q ?? ""))
setSearchParams(
serializeAuditPageState(updateAuditFilters(state, { q: debouncedSearch.trim() || null }))
);
}, [debouncedSearch, setSearchParams, state]);
const params = useMemo(() => toAuditSearchParams(state, new Date()), [state]);
const liveState = resolveAuditLiveState({
isRequested: isLiveRequested,
pageNumber: state.pageNumber,
filters: state.filters,
now: new Date(),
});
const getCurrentSearchParams = useCallback(() => toAuditSearchParams(state, new Date()), [state]);
const query = useAuditList(params, {
refetchInterval: liveState.refetchInterval,
getSearchParams: liveState.isEffective ? getCurrentSearchParams : undefined,
});
useEffect(() => {
if (
previousCompanyId.current &&
previousCompanyId.current !== activeCompany?.id &&
state.entry
) {
setSearchParams(serializeAuditPageState(setAuditEntry(state, null)));
}
previousCompanyId.current = activeCompany?.id;
}, [activeCompany?.id, setSearchParams, state]);
const setPageNumber = useCallback(
(nextPageNumber: number) => {
setSearchParams(serializeAuditPageState(setAuditPageNumber(state, nextPageNumber)));
},
[setSearchParams, state]
);
const setPageSize = useCallback(
(nextPageSize: number) => {
setSearchParams(serializeAuditPageState(setAuditPageSize(state, nextPageSize)));
},
[setSearchParams, state]
);
const setOrder = useCallback(
(nextOrder: "asc" | "desc") => {
setSearchParams(serializeAuditPageState(setAuditOrder(state, nextOrder)));
},
[setSearchParams, state]
);
return {
data: query.data ?? EMPTY_AUDIT_LIST,
hasUsableData: query.data !== undefined,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refetch: query.refetch,
handleRefresh: query.refetch,
isLive: isLiveRequested,
isLiveEffective: liveState.isEffective,
isLiveSuspended: liveState.isSuspended,
canUseLive: liveState.canUseLive,
liveSuspensionReason: liveState.suspensionReason,
toggleLive: () => setIsLiveRequested(toggleAuditLive),
...state,
searchInput,
setSearchInput,
setFilters: (updates: Parameters<typeof updateAuditFilters>[1]) =>
setSearchParams(serializeAuditPageState(updateAuditFilters(state, updates))),
resetFilters: () => setSearchParams(serializeAuditPageState(resetAuditFilters(state))),
setPageNumber,
setPageSize,
setOrder,
selectEntry: (id: string) => setSearchParams(serializeAuditPageState(setAuditEntry(state, id))),
closeEntry: () => setSearchParams(serializeAuditPageState(setAuditEntry(state, null))),
};
};

View File

@ -0,0 +1 @@
export * from "./ui/audit-page";

View File

@ -0,0 +1,165 @@
import { isApiClientError } from "@erp/core/client";
import {
Button,
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
Skeleton,
} from "@repo/shadcn-ui/components";
import { useTranslation } from "../../i18n";
import { type AuditValue, useAuditEntry } from "../../shared";
import {
getAuditChangeSections,
isAuditEventWithoutChanges,
presentAuditPath,
presentAuditValue,
} from "../utils/audit-detail-presenters";
import { formatAuditOccurredAt, presentAuditAction } from "../utils/audit-presenters";
const AuditValueView = ({ value }: { value: AuditValue | null }) => {
const { t } = useTranslation();
const content = presentAuditValue(value, t);
return typeof value === "object" && value !== null ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
{content}
</pre>
) : (
<p className="whitespace-pre-wrap break-words text-sm">{content}</p>
);
};
export const AuditEntrySheet = ({
entryId,
onClose,
}: {
entryId: string | null;
onClose: () => void;
}) => {
const { i18n, t } = useTranslation();
const query = useAuditEntry(entryId);
const detail = query.data;
const unavailable = isApiClientError(query.error) && query.error.kind === "notFound";
return (
<Sheet onOpenChange={(open) => !open && onClose()} open={entryId !== null}>
<SheetContent className="w-full gap-0 p-0 sm:max-w-xl" side="right">
<SheetHeader className="border-b pr-12">
<SheetTitle>
{detail ? presentAuditAction(detail.action, t) : t("audit.detail.title")}
</SheetTitle>
<SheetDescription>
{detail
? `${detail.target.label ?? detail.target.id ?? "-"} · ${formatAuditOccurredAt(detail.occurredAt, i18n.language)}`
: t("audit.detail.description")}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{query.isLoading ? (
<div className="space-y-4">
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-28 w-full" />
</div>
) : null}
{query.isError ? (
<div className="space-y-3">
<p className="text-sm" role="alert">
{unavailable ? t("audit.detail.unavailable") : t("audit.detail.error")}
</p>
<Button onClick={() => query.refetch()} type="button" variant="outline">
{t("audit.retry")}
</Button>
</div>
) : null}
{detail ? <AuditEntryContent detail={detail} /> : null}
</div>
</SheetContent>
</Sheet>
);
};
const AuditEntryContent = ({
detail,
}: {
detail: NonNullable<ReturnType<typeof useAuditEntry>["data"]>;
}) => {
const { t } = useTranslation();
const actorTypeKey = `audit.actors.${detail.actor.type}`;
const translatedActorType = t(actorTypeKey);
const actor =
detail.actor.label ??
(translatedActorType === actorTypeKey ? detail.actor.type : translatedActorType) ??
"-";
return (
<div className="space-y-6">
<div>
<p className="text-sm text-muted-foreground">{actor}</p>
{detail.scope ? (
<p className="text-sm text-muted-foreground">
{t("audit.detail.company", { value: detail.scope.label ?? detail.scope.id })}
</p>
) : null}
</div>
{isAuditEventWithoutChanges(detail.kind, detail.changes.length) ? (
<p className="text-sm text-muted-foreground">{t("audit.detail.noChanges")}</p>
) : null}
{detail.kind === "mutation" ? (
<section aria-labelledby="audit-changes">
<h2 className="text-base font-semibold" id="audit-changes">
{t("audit.changes.title")}
</h2>
<div className="mt-3 space-y-4">
{detail.changes.map((change) => (
<article className="rounded-lg border p-3" key={`${change.position}-${change.path}`}>
<h3 className="font-medium">{presentAuditPath(change.path, t)}</h3>
<p className="mt-1 text-xs text-muted-foreground">
{t(`audit.changeKinds.${change.kind}`)}
</p>
<div className="mt-3 space-y-3">
{getAuditChangeSections(change, t).map((section) => (
<div key={section.label}>
<h4 className="text-sm font-medium">{section.label}</h4>
<AuditValueView value={section.value} />
</div>
))}
</div>
</article>
))}
</div>
</section>
) : null}
<details>
<summary className="cursor-pointer text-sm font-medium">
{t("audit.technical.title")}
</summary>
<dl className="mt-3 grid gap-3 text-sm">
<Technical label={t("audit.technical.entryId")} value={detail.id} />
<Technical label={t("audit.technical.action")} value={detail.action} />
<Technical label={t("audit.technical.kind")} value={detail.kind} />
<Technical label={t("audit.technical.targetId")} value={detail.target.id} />
<Technical label={t("audit.technical.actorId")} value={detail.actor.id} />
<Technical label={t("audit.technical.scopeId")} value={detail.scope?.id ?? null} />
<Technical label={t("audit.technical.operationId")} value={detail.operation.id} />
<Technical
label={t("audit.technical.correlationId")}
value={detail.operation.correlationId}
/>
<Technical label={t("audit.technical.origin")} value={detail.operation.origin} />
<Technical label={t("audit.technical.sourceType")} value={detail.source.type} />
<Technical label={t("audit.technical.sourceId")} value={detail.source.id} />
<Technical label={t("audit.technical.timestamp")} value={detail.occurredAt} />
</dl>
</details>
</div>
);
};
const Technical = ({ label, value }: { label: string; value: string | null }) => (
<div>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="break-all font-mono text-xs">{value ?? "-"}</dd>
</div>
);

View File

@ -0,0 +1,151 @@
import { Button, InputGroup, InputGroupInput } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../i18n";
import type { AuditFilters } from "../../shared";
import { AUDIT_TARGETS, getActionsForTarget } from "../utils/audit-filter-registry";
import { isoToLocalDateTime, localDateTimeToIso } from "../utils/audit-period";
export const AuditFiltersPanel = ({
filters,
onChange,
}: {
filters: AuditFilters;
onChange: (value: Partial<AuditFilters>) => void;
}) => {
const { t } = useTranslation();
const actions = getActionsForTarget(filters.targetType);
return (
<aside className="w-full rounded-lg border bg-muted/20 p-4 lg:w-72">
<div className="space-y-4">
<label className="grid gap-1 text-sm font-medium">
{t("audit.filters.period")}
<select
aria-label={t("audit.filters.period")}
className="h-9 rounded-md border bg-background px-2"
onChange={(e) => onChange({ period: e.target.value as AuditFilters["period"] })}
value={filters.period}
>
<option value="today">{t("audit.period.today")}</option>
<option value="last24Hours">{t("audit.period.last24Hours")}</option>
<option value="last7Days">{t("audit.period.last7Days")}</option>
<option value="last30Days">{t("audit.period.last30Days")}</option>
<option value="custom">{t("audit.period.custom")}</option>
</select>
</label>
{filters.period === "custom" ? (
<div className="grid gap-2">
<div className="grid gap-1 text-sm">
{t("audit.filters.from")}
<InputGroup>
<InputGroupInput
onChange={(e) => onChange({ from: localDateTimeToIso(e.target.value) })}
type="datetime-local"
value={isoToLocalDateTime(filters.from)}
/>
</InputGroup>
</div>
<div className="grid gap-1 text-sm">
{t("audit.filters.to")}
<InputGroup>
<InputGroupInput
onChange={(e) => onChange({ to: localDateTimeToIso(e.target.value) })}
type="datetime-local"
value={isoToLocalDateTime(filters.to)}
/>
</InputGroup>
</div>
</div>
) : null}
<label className="grid gap-1 text-sm font-medium">
{t("audit.filters.kind")}
<select
aria-label={t("audit.filters.kind")}
className="h-9 rounded-md border bg-background px-2"
onChange={(e) => onChange({ kind: (e.target.value || null) as AuditFilters["kind"] })}
value={filters.kind ?? ""}
>
<option value="">{t("audit.filters.all")}</option>
<option value="mutation">{t("audit.kind.mutation")}</option>
<option value="event">{t("audit.kind.event")}</option>
</select>
</label>
<label className="grid gap-1 text-sm font-medium">
{t("audit.filters.target")}
<select
aria-label={t("audit.filters.target")}
className="h-9 rounded-md border bg-background px-2"
onChange={(e) => onChange({ targetType: e.target.value || null })}
value={filters.targetType ?? ""}
>
<option value="">{t("audit.filters.all")}</option>
{AUDIT_TARGETS.map((x) => (
<option key={x.targetType} value={x.targetType}>
{t(x.labelKey)}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-sm font-medium">
{t("audit.filters.action")}
<select
aria-label={t("audit.filters.action")}
className="h-9 rounded-md border bg-background px-2"
onChange={(e) => onChange({ action: e.target.value || null })}
value={filters.action ?? ""}
>
<option value="">{t("audit.filters.all")}</option>
{actions.map((x) => (
<option key={x.action} value={x.action}>
{t(x.labelKey, x.action)}
</option>
))}
</select>
</label>
</div>
</aside>
);
};
export const AuditFilterChips = ({
filters,
onChange,
onReset,
}: {
filters: AuditFilters;
onChange: (value: Partial<AuditFilters>) => void;
onReset: () => void;
}) => {
const { t } = useTranslation();
const chips: [string, string, Partial<AuditFilters>][] = [];
if (filters.period !== "last7Days")
chips.push([
"period",
t(`audit.period.${filters.period}`),
{ period: "last7Days", from: null, to: null },
]);
if (filters.kind) chips.push(["kind", t(`audit.kind.${filters.kind}`), { kind: null }]);
if (filters.targetType)
chips.push([
"target",
t(`audit.targets.${filters.targetType}`, filters.targetType),
{ targetType: null },
]);
if (filters.action)
chips.push(["action", t(`audit.actions.${filters.action}`, filters.action), { action: null }]);
if (!(chips.length || filters.q)) return null;
return (
<div className="flex flex-wrap gap-2">
{chips.map(([id, label, value]) => (
<Button key={id} onClick={() => onChange(value)} size="sm" variant="outline">
{label}
<span aria-hidden className="ml-1">
×
</span>
</Button>
))}
<Button onClick={onReset} size="sm" variant="ghost">
{t("audit.reset")}
</Button>
</div>
);
};

View File

@ -0,0 +1,62 @@
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import type { AuditList, AuditListItem } from "../../shared";
interface AuditGridProps {
data: AuditList;
columns: ColumnDef<AuditListItem, unknown>[];
loading: boolean;
fetching: boolean;
pageNumber: number;
pageSize: number;
onPageChange: (pageNumber: number) => void;
onPageSizeChange: (pageSize: number) => void;
order: "asc" | "desc";
onOrderChange: (order: "asc" | "desc") => void;
onRowClick: (id: string) => void;
}
export const AuditGrid = ({
data,
columns,
loading,
fetching,
pageNumber,
pageSize,
onPageChange,
onPageSizeChange,
order,
onOrderChange,
onRowClick,
}: AuditGridProps) => {
if (loading) {
return (
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex: pageNumber, pageSize, totalItems: data.totalItems }}
rows={Math.max(6, pageSize)}
showFooter
/>
);
}
return (
<DataTable
columns={columns}
data={data.items}
enablePagination
isFetching={fetching}
manualPagination
manualSorting
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={(row) => onRowClick(row.id)}
onSortChange={(sort) => onOrderChange(sort.direction)}
pageIndex={pageNumber}
pageSize={pageSize}
sort={{ field: "occurredAt", direction: order }}
totalItems={data.totalItems}
/>
);
};

View File

@ -0,0 +1,159 @@
import { ErrorAlert, PageErrorState, SimpleSearchInput } from "@erp/core/components";
import { RdxPageContainer, RdxPageHeader, RdxPageStack } from "@repo/rdx-ui/components";
import {
Button,
Spinner,
Toggle,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { useCallback } from "react";
import { useTranslation } from "../../i18n";
import { useAuditPageController } from "../controllers";
import { AuditEntrySheet } from "./audit-entry-sheet";
import { AuditFilterChips, AuditFiltersPanel } from "./audit-filters";
import { AuditGrid } from "./audit-grid";
import { useAuditGridColumns } from "./use-audit-grid-columns";
export const AuditPage = () => {
const { t } = useTranslation();
const controller = useAuditPageController();
const columns = useAuditGridColumns();
const retry = useCallback(async () => {
await controller.refetch();
}, [controller.refetch]);
if (controller.isError && !controller.hasUsableData) {
return (
<RdxPageContainer width="full">
<PageErrorState
description={t("audit.error.load")}
primaryAction={{
label: t("audit.retry"),
onClick: retry,
isLoading: controller.isFetching,
}}
title={t("audit.error.load")}
variant="unknown"
/>
</RdxPageContainer>
);
}
return (
<RdxPageContainer width="full">
<RdxPageStack spacing="default">
<RdxPageHeader description={t("audit.description")} title={t("audit.title")} />
{controller.isError && controller.hasUsableData ? (
<ErrorAlert message={t("audit.error.refresh")} title={t("audit.error.load")} />
) : null}
<div className="flex flex-col gap-4 lg:flex-row">
<AuditFiltersPanel filters={controller.filters} onChange={controller.setFilters} />
<div className="min-w-0 flex-1 space-y-4">
<AuditToolbar controller={controller} />
<AuditFilterChips
filters={controller.filters}
onChange={controller.setFilters}
onReset={controller.resetFilters}
/>
{!controller.isLoading && controller.data.items.length === 0 ? (
<p className="text-sm text-muted-foreground" role="status">
{controller.filters.q ||
controller.filters.kind ||
controller.filters.targetType ||
controller.filters.action
? t("audit.empty_filtered")
: t("audit.empty")}
</p>
) : (
<AuditGrid
columns={columns}
data={controller.data}
fetching={controller.isFetching}
loading={controller.isLoading}
onOrderChange={controller.setOrder}
onPageChange={controller.setPageNumber}
onPageSizeChange={controller.setPageSize}
onRowClick={controller.selectEntry}
order={controller.order}
pageNumber={controller.pageNumber}
pageSize={controller.pageSize}
/>
)}
</div>
</div>
<AuditEntrySheet entryId={controller.entry} onClose={controller.closeEntry} />
</RdxPageStack>
</RdxPageContainer>
);
};
const AuditToolbar = ({
controller,
}: {
controller: ReturnType<typeof useAuditPageController>;
}) => {
const { t } = useTranslation();
const liveLabel = controller.isLiveEffective
? t("audit.live.active")
: controller.isLiveSuspended
? `${t("audit.live.label")} · ${t(`audit.live.${controller.liveSuspensionReason}`)}`
: t("audit.live.label");
const suspensionMessage = t(
`audit.live.${controller.liveSuspensionReason ?? "periodUnavailable"}`
);
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<SimpleSearchInput
loading={controller.isFetching}
onSearchChange={controller.setSearchInput}
placeholder={t("audit.search.placeholder")}
value={controller.searchInput}
/>
</div>
<div className="flex items-center gap-2">
<Button
aria-label={t("audit.refresh")}
disabled={controller.isFetching}
onClick={() => controller.handleRefresh()}
type="button"
variant="outline"
>
{controller.isFetching ? <Spinner className="mr-2" /> : null}
{controller.isFetching ? t("audit.refreshing") : t("audit.refresh")}
</Button>
<Tooltip>
<TooltipTrigger
render={<span tabIndex={controller.canUseLive || controller.isLive ? -1 : 0} />}
>
<Toggle
aria-label={t("audit.live.label")}
disabled={!(controller.canUseLive || controller.isLive)}
onPressedChange={controller.toggleLive}
pressed={controller.isLive}
variant="outline"
>
<span
aria-hidden={controller.isLiveEffective ? undefined : true}
className={
controller.isLiveEffective ? "size-2 rounded-full bg-emerald-500" : undefined
}
>
{controller.isLiveEffective ? "●" : null}
</span>
{liveLabel}
</Toggle>
</TooltipTrigger>
{!controller.canUseLive || controller.isLiveSuspended ? (
<TooltipContent>{suspensionMessage}</TooltipContent>
) : null}
</Tooltip>
</div>
</div>
);
};

View File

@ -0,0 +1,72 @@
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import { useMemo } from "react";
import { useTranslation } from "../../i18n";
import type { AuditListItem } from "../../shared";
import {
formatAuditOccurredAt,
presentAuditAction,
presentAuditActor,
presentAuditChanges,
presentAuditTarget,
} from "../utils/audit-presenters";
export const useAuditGridColumns = (): ColumnDef<AuditListItem, unknown>[] => {
const { i18n, t } = useTranslation();
return useMemo<ColumnDef<AuditListItem, unknown>[]>(
() => [
{
id: "occurredAt",
accessorKey: "occurredAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("audit.columns.date")} />
),
cell: ({ row }) => formatAuditOccurredAt(row.original.occurredAt, i18n.language),
},
{
id: "action",
accessorKey: "action",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("audit.columns.action")} />
),
cell: ({ row }) => presentAuditAction(row.original.action, t),
enableSorting: false,
},
{
id: "target",
accessorFn: presentAuditTarget,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("audit.columns.target")} />
),
cell: ({ row }) => (
<div className="flex flex-col">
<span>{presentAuditTarget(row.original)}</span>
<span className="text-xs text-muted-foreground">{row.original.target.type}</span>
</div>
),
enableSorting: false,
},
{
id: "actor",
accessorFn: presentAuditActor,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("audit.columns.actor")} />
),
cell: ({ row }) => presentAuditActor(row.original),
enableSorting: false,
},
{
id: "changes",
accessorFn: (row) => row.changesCount,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("audit.columns.changes")} />
),
cell: ({ row }) => presentAuditChanges(row.original, t),
enableSorting: false,
},
],
[i18n.language, t]
);
};

View File

@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getAuditChangeSections,
isAuditEventWithoutChanges,
presentAuditPath,
presentAuditValue,
} from "./audit-detail-presenters";
const translate = (key: string) => key;
test("presenta valores null, booleanos, escalares, arrays y objetos", () => {
assert.equal(presentAuditValue(null, translate), "audit.values.null");
assert.equal(presentAuditValue(true, translate), "audit.values.yes");
assert.equal(presentAuditValue(false, translate), "audit.values.no");
assert.equal(presentAuditValue("texto", translate), "texto");
assert.equal(presentAuditValue(42, translate), "42");
assert.equal(presentAuditValue(["a", "b"], translate), '[\n "a",\n "b"\n]');
assert.equal(presentAuditValue({ active: true }, translate), '{\n "active": true\n}');
});
test("presenta rutas conocidas y conserva rutas desconocidas", () => {
assert.equal(presentAuditPath("address.city", translate), "audit.paths.addressCity");
assert.equal(presentAuditPath("unknown.path", translate), "unknown.path");
});
test("expone las secciones semánticas de cada tipo de cambio", () => {
assert.deepEqual(
getAuditChangeSections(
{ position: 0, path: "name", kind: "set", before: null, after: "Nuevo" },
translate
),
[{ label: "audit.changes.newValue", value: "Nuevo" }]
);
assert.deepEqual(
getAuditChangeSections(
{ position: 0, path: "name", kind: "unset", before: "Anterior", after: null },
translate
),
[{ label: "audit.changes.removedValue", value: "Anterior" }]
);
assert.deepEqual(
getAuditChangeSections(
{ position: 0, path: "name", kind: "change", before: "Anterior", after: "Nuevo" },
translate
),
[
{ label: "audit.changes.before", value: "Anterior" },
{ label: "audit.changes.after", value: "Nuevo" },
]
);
});
test("identifica eventos sin cambios para mostrar el mensaje vacío", () => {
assert.equal(isAuditEventWithoutChanges("event", 0), true);
assert.equal(isAuditEventWithoutChanges("event", 1), false);
assert.equal(isAuditEventWithoutChanges("mutation", 0), false);
});

View File

@ -0,0 +1,68 @@
import type { AuditChange, AuditValue } from "../../shared";
type Translate = (key: string, options?: Record<string, unknown>) => string;
const PATH_KEYS: Record<string, string> = {
status: "audit.paths.status",
name: "audit.paths.name",
description: "audit.paths.description",
reference: "audit.paths.reference",
isCompany: "audit.paths.isCompany",
tradeName: "audit.paths.tradeName",
tin: "audit.paths.tin",
emailPrimary: "audit.paths.emailPrimary",
emailSecondary: "audit.paths.emailSecondary",
phonePrimary: "audit.paths.phonePrimary",
phoneSecondary: "audit.paths.phoneSecondary",
mobilePrimary: "audit.paths.mobilePrimary",
fax: "audit.paths.fax",
website: "audit.paths.website",
languageCode: "audit.paths.languageCode",
currencyCode: "audit.paths.currencyCode",
paymentMethodId: "audit.paths.paymentMethodId",
paymentTermId: "audit.paths.paymentTermId",
taxRegimeCode: "audit.paths.taxRegimeCode",
usesEquivalenceSurcharge: "audit.paths.usesEquivalenceSurcharge",
usesRetention: "audit.paths.usesRetention",
legalName: "audit.paths.legalName",
slug: "audit.paths.slug",
defaultPaymentTermId: "audit.paths.defaultPaymentTermId",
isActive: "audit.paths.isActive",
isSystem: "audit.paths.isSystem",
isDefault: "audit.paths.isDefault",
"address.street": "audit.paths.addressStreet",
"address.street2": "audit.paths.addressStreet2",
"address.city": "audit.paths.addressCity",
"address.province": "audit.paths.addressProvince",
"address.postalCode": "audit.paths.addressPostalCode",
"address.country": "audit.paths.addressCountry",
dueRules: "audit.paths.dueRules",
};
export const presentAuditPath = (path: string, translate: Translate) => {
const key = PATH_KEYS[path];
return key ? translate(key) : path;
};
export const presentAuditValue = (value: AuditValue, translate: Translate): string => {
if (value === null) return translate("audit.values.null");
if (value === true) return translate("audit.values.yes");
if (value === false) return translate("audit.values.no");
if (typeof value === "string" || typeof value === "number") return String(value);
return JSON.stringify(value, null, 2);
};
export const getAuditChangeSections = (change: AuditChange, translate: Translate) => {
if (change.kind === "set")
return [{ label: translate("audit.changes.newValue"), value: change.after }];
if (change.kind === "unset") {
return [{ label: translate("audit.changes.removedValue"), value: change.before }];
}
return [
{ label: translate("audit.changes.before"), value: change.before },
{ label: translate("audit.changes.after"), value: change.after },
];
};
export const isAuditEventWithoutChanges = (kind: "event" | "mutation", changesCount: number) =>
kind === "event" && changesCount === 0;

View File

@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createDefaultAuditPageState,
resetAuditFilters,
serializeAuditPageState,
updateAuditFilters,
} from "./audit-page-search-params";
test("la búsqueda confirmada tras debounce actualiza q, reinicia página y se elimina al limpiar", () => {
const initial = { ...createDefaultAuditPageState(), pageNumber: 4 };
const searched = updateAuditFilters(initial, { q: "proveedor" });
assert.equal(searched.filters.q, "proveedor");
assert.equal(searched.pageNumber, 0);
assert.equal(serializeAuditPageState(searched).get("q"), "proveedor");
const cleared = updateAuditFilters(searched, { q: null });
assert.equal(cleared.filters.q, null);
assert.equal(cleared.pageNumber, 0);
assert.equal(serializeAuditPageState(cleared).get("q"), null);
});
test("mantiene una acción compatible y limpia la incompatible al cambiar recurso", () => {
const selected = updateAuditFilters(createDefaultAuditPageState(), {
targetType: "supplier",
action: "supplier.updated",
});
assert.equal(selected.filters.action, "supplier.updated");
const changedTarget = updateAuditFilters(
{ ...selected, pageNumber: 3 },
{ targetType: "customer" }
);
assert.equal(changedTarget.filters.targetType, "customer");
assert.equal(changedTarget.filters.action, null);
assert.equal(changedTarget.pageNumber, 0);
});
test("los callbacks de chips limpian sólo su filtro y conservan los demás", () => {
const state = updateAuditFilters(
{ ...createDefaultAuditPageState(), pageNumber: 2 },
{
kind: "mutation",
targetType: "supplier",
action: "supplier.updated",
}
);
const removedKindChip = updateAuditFilters(state, { kind: null });
assert.equal(removedKindChip.filters.kind, null);
assert.equal(removedKindChip.filters.targetType, "supplier");
assert.equal(removedKindChip.filters.action, "supplier.updated");
assert.equal(removedKindChip.pageNumber, 0);
});
test("reset conserva paginación configurada y orden mientras restablece filtros", () => {
const active = {
...updateAuditFilters(createDefaultAuditPageState(), {
q: "proveedor",
period: "last30Days",
kind: "event",
targetType: "supplier",
action: "supplier.updated",
}),
pageNumber: 5,
pageSize: 100,
order: "asc" as const,
};
const reset = resetAuditFilters(active);
assert.deepEqual(reset.filters, createDefaultAuditPageState().filters);
assert.equal(reset.pageNumber, 0);
assert.equal(reset.pageSize, 100);
assert.equal(reset.orderBy, "occurredAt");
assert.equal(reset.order, "asc");
});
test("identifica el empty state filtrado cuando hay filtros de UI-2 activos", () => {
const isFiltered = (filters: ReturnType<typeof createDefaultAuditPageState>["filters"]) =>
Boolean(filters.q || filters.kind || filters.targetType || filters.action);
assert.equal(isFiltered(createDefaultAuditPageState().filters), false);
assert.equal(isFiltered({ ...createDefaultAuditPageState().filters, kind: "event" }), true);
});

View File

@ -0,0 +1,45 @@
export const AUDIT_TARGETS = [
{ targetType: "customer", labelKey: "audit.targets.customer" },
{ targetType: "supplier", labelKey: "audit.targets.supplier" },
{ targetType: "company", labelKey: "audit.targets.company" },
{ targetType: "payment_method", labelKey: "audit.targets.payment_method" },
{ targetType: "payment_term", labelKey: "audit.targets.payment_term" },
] as const;
const entries = (targetType: string, actions: string[]) =>
actions.map((action) => ({ action, targetType, labelKey: `audit.actions.${action}` }));
export const AUDIT_ACTIONS = [
...entries("payment_method", [
"payment_method.created",
"payment_method.updated",
"payment_method.enabled",
"payment_method.disabled",
"payment_method.deleted",
"payment_method.default_set",
"payment_method.default_unset",
]),
...entries("payment_term", [
"payment_term.created",
"payment_term.updated",
"payment_term.enabled",
"payment_term.disabled",
"payment_term.deleted",
]),
...entries("customer", ["customer.created", "customer.updated"]),
...entries("company", [
"company.created",
"company.updated",
"company.enabled",
"company.disabled",
]),
...entries("supplier", [
"supplier.created",
"supplier.updated",
"supplier.activated",
"supplier.deactivated",
]),
] as const;
export const isActionCompatibleWithTarget = (action: string, targetType: string | null) =>
!(targetType && AUDIT_ACTIONS.find((item) => item.action === action)) ||
AUDIT_ACTIONS.find((item) => item.action === action)?.targetType === targetType;
export const getActionsForTarget = (targetType: string | null) =>
targetType ? AUDIT_ACTIONS.filter((item) => item.targetType === targetType) : AUDIT_ACTIONS;

View File

@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AuditFilters } from "../../shared";
import {
AUDIT_LIVE_REFETCH_INTERVAL_MS,
resolveAuditLiveState,
toggleAuditLive,
} from "./audit-live";
const NOW = new Date("2026-08-25T14:30:00.000Z");
const filters: AuditFilters = {
q: null,
period: "last7Days",
from: null,
to: null,
kind: null,
targetType: null,
action: null,
};
test("Live apagado no configura polling", () => {
assert.equal(
resolveAuditLiveState({ isRequested: false, pageNumber: 0, filters, now: NOW }).refetchInterval,
false
);
});
test("el toggle Live alterna la preferencia local sin modificar la URL", () => {
assert.equal(toggleAuditLive(false), true);
assert.equal(toggleAuditLive(true), false);
});
test("Live efectivo en primera página y periodo compatible usa cinco segundos", () => {
const state = resolveAuditLiveState({ isRequested: true, pageNumber: 0, filters, now: NOW });
assert.equal(state.isEffective, true);
assert.equal(state.refetchInterval, AUDIT_LIVE_REFETCH_INTERVAL_MS);
});
test("Live se suspende fuera de primera página o con periodo incompatible", () => {
assert.equal(
resolveAuditLiveState({ isRequested: true, pageNumber: 1, filters, now: NOW }).refetchInterval,
false
);
assert.equal(
resolveAuditLiveState({
isRequested: true,
pageNumber: 0,
filters: {
...filters,
period: "custom",
from: "2026-08-20T00:00:00.000Z",
to: "2026-08-21T00:00:00.000Z",
},
now: NOW,
}).refetchInterval,
false
);
});

View File

@ -0,0 +1,30 @@
import type { AuditFilters } from "../../shared";
import { isAuditPeriodLiveCompatible } from "./audit-period";
export const AUDIT_LIVE_REFETCH_INTERVAL_MS = 5_000;
export const toggleAuditLive = (isRequested: boolean) => !isRequested;
export const resolveAuditLiveState = ({
isRequested,
pageNumber,
filters,
now,
}: {
isRequested: boolean;
pageNumber: number;
filters: AuditFilters;
now: Date;
}) => {
const canUseLive = isAuditPeriodLiveCompatible(filters.period, now, filters);
const isFirstPage = pageNumber === 0;
const isEffective = isRequested && isFirstPage && canUseLive;
return {
canUseLive,
isEffective,
isSuspended: isRequested && !isEffective,
suspensionReason: isFirstPage ? (canUseLive ? null : "periodUnavailable") : "firstPageOnly",
refetchInterval: isEffective ? AUDIT_LIVE_REFETCH_INTERVAL_MS : false,
} as const;
};

View File

@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createDefaultAuditPageState,
parseAuditPageState,
serializeAuditPageState,
setAuditEntry,
setAuditPageNumber,
setAuditPageSize,
toAuditSearchParams,
} from "./audit-page-search-params";
test("la paginación convierte el índice interno zero-based a parámetros URL one-based", () => {
assert.deepEqual(parseAuditPageState(new URLSearchParams()), {
filters: createDefaultAuditPageState().filters,
entry: null,
pageNumber: 0,
pageSize: 50,
orderBy: "occurredAt",
order: "desc",
});
assert.equal(setAuditPageNumber(createDefaultAuditPageState(), 2).pageNumber, 2);
});
test("al cambiar el tamaño de página se vuelve a la primera página", () => {
const next = setAuditPageSize(
{ ...createDefaultAuditPageState(), pageNumber: 3, pageSize: 25 },
100
);
assert.equal(next.pageNumber, 0);
assert.equal(next.pageSize, 100);
});
test("parse y serialize conservan filtros, paginación y orden", () => {
const source = new URLSearchParams({
q: " proveedor actualizado ",
period: "custom",
from: "2026-08-01T00:00:00.000Z",
to: "2026-08-02T00:00:00.000Z",
kind: "mutation",
targetType: "supplier",
action: "supplier.updated",
pageNumber: "3",
pageSize: "100",
orderBy: "occurredAt",
order: "asc",
});
const state = parseAuditPageState(source);
assert.equal(state.filters.q, "proveedor actualizado");
assert.equal(state.filters.period, "custom");
assert.equal(state.filters.from, "2026-08-01T00:00:00.000Z");
assert.equal(state.filters.to, "2026-08-02T00:00:00.000Z");
assert.equal(state.filters.kind, "mutation");
assert.equal(state.filters.targetType, "supplier");
assert.equal(state.filters.action, "supplier.updated");
assert.equal(state.pageNumber, 2);
assert.equal(state.pageSize, 100);
assert.equal(state.orderBy, "occurredAt");
assert.equal(state.order, "asc");
assert.deepEqual(parseAuditPageState(serializeAuditPageState(state)), state);
});
test("serializa defaults y normaliza cadenas vacías a null", () => {
const parsed = parseAuditPageState(
new URLSearchParams({ q: " ", kind: "", targetType: " ", action: "" })
);
assert.deepEqual(parsed.filters, createDefaultAuditPageState().filters);
const serialized = serializeAuditPageState(parsed);
assert.equal(serialized.get("q"), null);
assert.equal(serialized.get("period"), null);
assert.equal(serialized.get("kind"), null);
assert.equal(serialized.get("targetType"), null);
assert.equal(serialized.get("action"), null);
assert.deepEqual(parseAuditPageState(serialized), parsed);
});
test("mantiene estables los parametros de consulta con la misma URL y reloj", () => {
const state = parseAuditPageState(new URLSearchParams());
const now = new Date("2026-08-25T14:33:05.913Z");
assert.deepEqual(toAuditSearchParams(state, now), {
pageNumber: 0,
pageSize: 50,
orderBy: "occurredAt",
order: "desc",
from: "2026-08-18T14:33:05.913Z",
to: "2026-08-25T14:33:05.913Z",
});
});
test("conserva la selección entry en URL y permite cerrarla sin perder el listado", () => {
const source = new URLSearchParams({
q: "proveedor",
pageNumber: "3",
pageSize: "100",
order: "asc",
entry: " audit-entry-1 ",
});
const selected = parseAuditPageState(source);
assert.equal(selected.entry, "audit-entry-1");
assert.deepEqual(parseAuditPageState(serializeAuditPageState(selected)), selected);
const closed = setAuditEntry(selected, null);
assert.equal(closed.entry, null);
assert.equal(closed.filters.q, "proveedor");
assert.equal(closed.pageNumber, 2);
assert.equal(closed.pageSize, 100);
assert.equal(closed.order, "asc");
assert.equal(serializeAuditPageState(closed).get("entry"), null);
});

View File

@ -0,0 +1,138 @@
import { NumberHelper } from "@repo/rdx-utils";
import type { AuditFilters, AuditSearchParams } from "../../shared";
import { isActionCompatibleWithTarget } from "./audit-filter-registry";
import { resolveAuditPeriodRange } from "./audit-period";
export interface AuditPageState {
filters: AuditFilters;
entry: string | null;
pageNumber: number;
pageSize: number;
orderBy: "occurredAt";
order: "asc" | "desc";
}
const text = (value: string | null) => value?.trim().replace(/\s+/g, " ") || null;
export const createDefaultAuditPageState = (): AuditPageState => ({
filters: {
q: null,
period: "last7Days",
from: null,
to: null,
kind: null,
targetType: null,
action: null,
},
entry: null,
pageNumber: 0,
pageSize: 50,
orderBy: "occurredAt",
order: "desc",
});
export const parseAuditPageState = (params: URLSearchParams): AuditPageState => {
const defaults = createDefaultAuditPageState();
const targetType = text(params.get("targetType"));
const rawAction = text(params.get("action"));
const period = params.get("period");
const kind = params.get("kind");
return {
filters: {
q: text(params.get("q")),
period:
period === "today" ||
period === "last24Hours" ||
period === "last7Days" ||
period === "last30Days" ||
period === "custom"
? period
: "last7Days",
from: text(params.get("from")),
to: text(params.get("to")),
kind: kind === "event" || kind === "mutation" ? kind : null,
targetType,
action: rawAction && isActionCompatibleWithTarget(rawAction, targetType) ? rawAction : null,
},
entry: text(params.get("entry")),
pageNumber: Math.max(0, NumberHelper.parsePositiveInteger(params.get("pageNumber"), 1) - 1),
pageSize: NumberHelper.parsePositiveInteger(params.get("pageSize"), defaults.pageSize),
orderBy: "occurredAt",
order: params.get("order") === "asc" ? "asc" : "desc",
};
};
export const serializeAuditPageState = (state: AuditPageState) => {
const p = new URLSearchParams();
const f = state.filters;
if (f.q) p.set("q", f.q);
if (f.period !== "last7Days") p.set("period", f.period);
if (f.period === "custom") {
if (f.from) p.set("from", f.from);
if (f.to) p.set("to", f.to);
}
if (f.kind) p.set("kind", f.kind);
if (f.targetType) p.set("targetType", f.targetType);
if (f.action) p.set("action", f.action);
if (state.entry) p.set("entry", state.entry);
p.set("pageNumber", String(state.pageNumber + 1));
p.set("pageSize", String(state.pageSize));
p.set("orderBy", state.orderBy);
p.set("order", state.order);
return p;
};
export const toAuditSearchParams = (state: AuditPageState, now: Date): AuditSearchParams | null => {
const range = resolveAuditPeriodRange(state.filters.period, now, state.filters);
if (!range) return null;
const f = state.filters;
return {
pageNumber: state.pageNumber,
pageSize: state.pageSize,
orderBy: state.orderBy,
order: state.order,
from: range.from,
to: range.to,
...(f.q ? { q: f.q } : {}),
...(f.kind ? { kind: f.kind } : {}),
...(f.targetType ? { targetType: f.targetType } : {}),
...(f.action ? { action: f.action } : {}),
};
};
export const updateAuditFilters = (
state: AuditPageState,
updates: Partial<AuditFilters>
): AuditPageState => {
const filters = { ...state.filters, ...updates };
return {
...state,
filters: {
...filters,
action:
filters.action && isActionCompatibleWithTarget(filters.action, filters.targetType)
? filters.action
: null,
},
pageNumber: 0,
};
};
export const resetAuditFilters = (state: AuditPageState): AuditPageState => ({
...state,
filters: createDefaultAuditPageState().filters,
pageNumber: 0,
});
export const setAuditPageNumber = (state: AuditPageState, pageNumber: number): AuditPageState => ({
...state,
pageNumber,
});
export const setAuditPageSize = (state: AuditPageState, pageSize: number): AuditPageState => ({
...state,
pageSize,
pageNumber: 0,
});
export const setAuditOrder = (state: AuditPageState, order: "asc" | "desc"): AuditPageState => ({
...state,
order,
pageNumber: 0,
});
export const setAuditEntry = (state: AuditPageState, entry: string | null): AuditPageState => ({
...state,
entry: text(entry),
});

View File

@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isAuditPeriodLiveCompatible,
localDateTimeToIso,
resolveAuditPeriodRange,
} from "./audit-period";
const NOW = new Date("2026-08-25T14:30:00.000Z");
test("resuelve today como [inicio local de hoy, inicio local de mañana) en ISO UTC", () => {
const expectedFrom = new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate());
const expectedTo = new Date(expectedFrom);
expectedTo.setDate(expectedTo.getDate() + 1);
assert.deepEqual(resolveAuditPeriodRange("today", NOW), {
from: expectedFrom.toISOString(),
to: expectedTo.toISOString(),
});
});
test("resuelve periodos relativos respecto a now fijo", () => {
assert.deepEqual(resolveAuditPeriodRange("last24Hours", NOW), {
from: "2026-08-24T14:30:00.000Z",
to: "2026-08-25T14:30:00.000Z",
});
assert.deepEqual(resolveAuditPeriodRange("last7Days", NOW), {
from: "2026-08-18T14:30:00.000Z",
to: "2026-08-25T14:30:00.000Z",
});
assert.deepEqual(resolveAuditPeriodRange("last30Days", NOW), {
from: "2026-07-26T14:30:00.000Z",
to: "2026-08-25T14:30:00.000Z",
});
});
test("valida rangos custom y convierte datetime-local a ISO UTC", () => {
const from = "2026-08-25T09:00:00.000Z";
const to = "2026-08-25T10:00:00.000Z";
assert.deepEqual(resolveAuditPeriodRange("custom", NOW, { from, to }), { from, to });
assert.equal(resolveAuditPeriodRange("custom", NOW, { from, to: from }), null);
assert.equal(resolveAuditPeriodRange("custom", NOW, { from: to, to: from }), null);
assert.equal(localDateTimeToIso("2026-08-25T09:00"), new Date("2026-08-25T09:00").toISOString());
});
test("determina compatibilidad Live respetando el límite superior exclusivo", () => {
assert.equal(isAuditPeriodLiveCompatible("today", NOW), true);
assert.equal(isAuditPeriodLiveCompatible("last24Hours", NOW), true);
assert.equal(isAuditPeriodLiveCompatible("last7Days", NOW), true);
assert.equal(isAuditPeriodLiveCompatible("last30Days", NOW), true);
assert.equal(
isAuditPeriodLiveCompatible("custom", NOW, {
from: "2026-08-25T14:00:00.000Z",
to: "2026-08-25T15:00:00.000Z",
}),
true
);
assert.equal(
isAuditPeriodLiveCompatible("custom", NOW, {
from: NOW.toISOString(),
to: "2026-08-25T15:00:00.000Z",
}),
true
);
assert.equal(
isAuditPeriodLiveCompatible("custom", NOW, {
from: "2026-08-25T14:00:00.000Z",
to: NOW.toISOString(),
}),
false
);
});
test("recalcula rangos relativos sin mutar el periodo semántico", () => {
const later = new Date("2026-08-25T15:30:00.000Z");
assert.deepEqual(resolveAuditPeriodRange("last24Hours", later), {
from: "2026-08-24T15:30:00.000Z",
to: "2026-08-25T15:30:00.000Z",
});
});

View File

@ -0,0 +1,42 @@
import type { AuditPeriodPreset } from "../../shared";
export const isAuditPeriodLiveCompatible = (
period: AuditPeriodPreset,
now: Date,
custom?: { from: string | null; to: string | null }
) => {
if (period !== "custom") return true;
if (!(custom?.from && custom.to)) return false;
const from = new Date(custom.from).getTime();
const to = new Date(custom.to).getTime();
const current = now.getTime();
return Number.isFinite(from) && Number.isFinite(to) && from <= current && current < to;
};
export const resolveAuditPeriodRange = (
period: AuditPeriodPreset,
now: Date,
custom?: { from: string | null; to: string | null }
) => {
if (period === "custom")
return custom?.from && custom.to && custom.from < custom.to
? { from: custom.from, to: custom.to }
: null;
if (period === "today") {
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const to = new Date(from);
to.setDate(to.getDate() + 1);
return { from: from.toISOString(), to: to.toISOString() };
}
const hours = period === "last24Hours" ? 24 : period === "last7Days" ? 168 : 720;
return { from: new Date(now.getTime() - hours * 3_600_000).toISOString(), to: now.toISOString() };
};
export const localDateTimeToIso = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
};
export const isoToLocalDateTime = (value: string | null) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
};

View File

@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AuditListItem } from "../../shared";
import {
formatAuditOccurredAt,
presentAuditAction,
presentAuditActor,
presentAuditChanges,
presentAuditTarget,
} from "./audit-presenters";
const event: AuditListItem = {
id: "event-1",
kind: "event",
occurredAt: "2026-08-25T10:30:00.000Z",
source: { type: "application", id: "erp" },
action: "unknown.action",
actor: { type: "system", id: null, label: null },
scope: null,
target: { type: "customer", id: null, label: null },
operation: { id: "operation", correlationId: "correlation", origin: "http" },
changesCount: 0,
};
test("los presenters cubren los fallbacks de UI-1 y la distinción evento/mutación", () => {
const translate = (key: string, options?: Record<string, unknown>) =>
key === "audit.changes_count" ? `${String(options?.count)} cambios` : key;
assert.equal(presentAuditAction(event.action, translate), "unknown.action");
assert.equal(presentAuditTarget(event), "-");
assert.equal(presentAuditActor(event), "system");
assert.equal(presentAuditChanges(event, translate), "—");
assert.equal(
presentAuditChanges({ ...event, kind: "mutation", changesCount: 2 }, translate),
"2 cambios"
);
});
test("formatea la fecha de auditoría sin mezclar dateStyle con campos explícitos", () => {
assert.doesNotThrow(() => formatAuditOccurredAt(event.occurredAt, "es-ES"));
assert.notEqual(formatAuditOccurredAt(event.occurredAt, "es-ES"), "");
});

View File

@ -0,0 +1,29 @@
import { DateHelper } from "@repo/rdx-utils";
import type { AuditListItem } from "../../shared";
type Translate = (key: string, options?: Record<string, unknown>) => string;
export const presentAuditAction = (action: string, translate: Translate) => {
const key = `audit.actions.${action}`;
const translated = translate(key);
return translated === key ? action : translated;
};
export const presentAuditTarget = (item: AuditListItem) =>
item.target.label ?? item.target.id ?? "-";
export const presentAuditActor = (item: AuditListItem) =>
item.actor.label ?? item.actor.type ?? "-";
export const presentAuditChanges = (item: AuditListItem, translate: Translate) =>
item.kind === "mutation" ? translate("audit.changes_count", { count: item.changesCount }) : "—";
export const formatAuditOccurredAt = (occurredAt: string, locale: string) =>
DateHelper.format(occurredAt, locale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});

View File

@ -0,0 +1,16 @@
import type { IModuleClient, ModuleClientParams } from "@erp/core/client";
import { AuditRoutes } from "./audit-routes";
export const MODULE_NAME = "Audit";
export const AuditModuleManifest: IModuleClient = {
name: MODULE_NAME,
version: "1.0.0",
dependencies: ["auth", "Core", "Identity"],
protected: true,
layout: "app-sidebar",
routes: (params: ModuleClientParams) => AuditRoutes(params),
};
export default AuditModuleManifest;

View File

@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { IDataSource } from "@erp/core/client";
import { findAuditEntryById } from "./find-audit-entry-by-id.api";
test("findAuditEntryById solicita GET /audit/:id y mapea la respuesta", async () => {
let received: { resource: string; id: string } | undefined;
const dataSource = {
getOne: async (resource: string, id: string) => {
received = { resource, id };
return {
id,
kind: "event",
occurred_at: "2026-08-25T14:32:00.000Z",
source: { type: "http", id: "request-1" },
operation: { id: "operation-1", correlation_id: "correlation-1", origin: "api" },
actor: { type: "system", id: null, label: null },
scope: null,
action: "unknown.action",
target: { type: "unknown_target", id: null, label: null },
changes: [],
};
},
} as IDataSource;
const detail = await findAuditEntryById(dataSource, { id: "entry-1" });
assert.deepEqual(received, { resource: "audit", id: "entry-1" });
assert.equal(detail.occurredAt, "2026-08-25T14:32:00.000Z");
assert.equal(detail.operation.correlationId, "correlation-1");
});

View File

@ -0,0 +1,20 @@
import type { IDataSource } from "@erp/core/client";
import { AuditEntryDetailDTOSchema } from "../dto/audit-entry-detail.dto";
import type { AuditEntryDetail } from "../entities/audit-entry-detail.entity";
import { mapAuditEntryDetail } from "../mappers/audit-entry-detail.mapper";
export interface FindAuditEntryByIdParams {
id: string;
signal?: AbortSignal;
}
export const findAuditEntryById = async (
dataSource: IDataSource,
{ id, signal }: FindAuditEntryByIdParams
): Promise<AuditEntryDetail> => {
if (!id) throw new Error("auditEntryId is required");
const response = await dataSource.getOne<unknown>("audit", id, { signal });
return mapAuditEntryDetail(AuditEntryDetailDTOSchema.parse(response));
};

View File

@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { IDataSource } from "@erp/core/client";
import { searchAudit } from "./search-audit.api";
test("searchAudit envía solo los parámetros definidos del contrato UI-1", async () => {
let receivedParams: Record<string, unknown> | undefined;
const dataSource = {
getList: async (_resource: string, params?: Record<string, unknown>) => {
receivedParams = params;
return { items: [], page: 0, per_page: 50, total_pages: 0, total_items: 0 };
},
} as IDataSource;
await searchAudit(dataSource, {
search: { pageNumber: 0, pageSize: 50, orderBy: "occurredAt", order: "desc" },
});
assert.deepEqual(receivedParams, {
pageNumber: 0,
pageSize: 50,
orderBy: "occurredAt",
order: "desc",
signal: undefined,
});
});

View File

@ -0,0 +1,24 @@
import type { IDataSource } from "@erp/core/client";
import { AuditListResponseDTOSchema, type AuditListResponseDto } from "../dto/audit-list.dto";
import type { AuditSearchParams } from "../entities/audit-list.entity";
export interface SearchAuditParams {
search: AuditSearchParams;
signal?: AbortSignal;
}
const definedSearchParams = (params: AuditSearchParams) =>
Object.fromEntries(Object.entries(params).filter(([, value]) => value !== undefined));
export const searchAudit = async (
dataSource: IDataSource,
{ search, signal }: SearchAuditParams
): Promise<AuditListResponseDto> => {
const response = await dataSource.getList<unknown>("audit", {
...definedSearchParams(search),
signal,
});
return AuditListResponseDTOSchema.parse(response);
};

View File

@ -0,0 +1,52 @@
import { z } from "zod/v4";
export const AuditValueDTOSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(z.unknown()),
z.record(z.string(), z.unknown()),
]);
const AuditActorDTOSchema = z.object({
type: z.string(),
id: z.string().nullable(),
label: z.string().nullable(),
});
const AuditScopeDTOSchema = z.object({
type: z.string(),
id: z.string(),
label: z.string().nullable(),
});
const AuditTargetDTOSchema = z.object({
type: z.string(),
id: z.string().nullable(),
label: z.string().nullable(),
});
export const AuditEntryDetailDTOSchema = z.object({
id: z.string(),
kind: z.enum(["event", "mutation"]),
occurred_at: z.string(),
source: z.object({ type: z.string(), id: z.string() }),
operation: z.object({ id: z.string(), correlation_id: z.string(), origin: z.string() }),
actor: AuditActorDTOSchema,
scope: AuditScopeDTOSchema.nullable(),
action: z.string(),
target: AuditTargetDTOSchema,
changes: z.array(
z.object({
position: z.number().int().nonnegative(),
path: z.string(),
kind: z.enum(["set", "unset", "change"]),
before: AuditValueDTOSchema.nullable(),
after: AuditValueDTOSchema.nullable(),
})
),
});
export type AuditEntryDetailDto = z.infer<typeof AuditEntryDetailDTOSchema>;
export type AuditValueDto = z.infer<typeof AuditValueDTOSchema>;

View File

@ -0,0 +1,43 @@
import { z } from "zod/v4";
const AuditActorDTOSchema = z.object({
type: z.string(),
id: z.string().nullable(),
label: z.string().nullable(),
});
const AuditScopeDTOSchema = z.object({
type: z.string(),
id: z.string(),
label: z.string().nullable(),
});
const AuditTargetDTOSchema = z.object({
type: z.string(),
id: z.string().nullable(),
label: z.string().nullable(),
});
export const AuditListItemDTOSchema = z.object({
id: z.string(),
kind: z.enum(["event", "mutation"]),
occurred_at: z.string(),
source: z.object({ type: z.string(), id: z.string() }),
action: z.string(),
actor: AuditActorDTOSchema,
scope: AuditScopeDTOSchema.nullable(),
target: AuditTargetDTOSchema,
operation: z.object({ id: z.string(), correlation_id: z.string(), origin: z.string() }),
changes_count: z.number().int().nonnegative(),
});
export const AuditListResponseDTOSchema = z.object({
items: z.array(AuditListItemDTOSchema),
page: z.number().int().nonnegative(),
per_page: z.number().int().positive(),
total_pages: z.number().int().nonnegative(),
total_items: z.number().int().nonnegative(),
});
export type AuditListItemDto = z.infer<typeof AuditListItemDTOSchema>;
export type AuditListResponseDto = z.infer<typeof AuditListResponseDTOSchema>;

View File

@ -0,0 +1,36 @@
import type {
AuditActor,
AuditOperation,
AuditScope,
AuditSource,
AuditTarget,
} from "./audit-list.entity";
export type AuditValue =
| string
| number
| boolean
| null
| AuditValue[]
| { [key: string]: AuditValue };
export interface AuditChange {
position: number;
path: string;
kind: "set" | "unset" | "change";
before: AuditValue | null;
after: AuditValue | null;
}
export interface AuditEntryDetail {
id: string;
kind: "event" | "mutation";
occurredAt: string;
source: AuditSource;
operation: AuditOperation;
actor: AuditActor;
scope: AuditScope | null;
action: string;
target: AuditTarget;
changes: AuditChange[];
}

View File

@ -0,0 +1,73 @@
export interface AuditSource {
type: string;
id: string;
}
export interface AuditActor {
type: string;
id: string | null;
label: string | null;
}
export interface AuditScope {
type: string;
id: string;
label: string | null;
}
export interface AuditTarget {
type: string;
id: string | null;
label: string | null;
}
export interface AuditOperation {
id: string;
correlationId: string;
origin: string;
}
export interface AuditListItem {
id: string;
kind: "event" | "mutation";
occurredAt: string;
source: AuditSource;
action: string;
actor: AuditActor;
scope: AuditScope | null;
target: AuditTarget;
operation: AuditOperation;
changesCount: number;
}
export interface AuditList {
items: AuditListItem[];
page: number;
perPage: number;
totalPages: number;
totalItems: number;
}
export interface AuditSearchParams {
pageNumber: number;
pageSize: number;
orderBy?: "occurredAt";
order?: "asc" | "desc";
q?: string;
from?: string;
to?: string;
kind?: "event" | "mutation";
targetType?: string;
action?: string;
}
export type AuditPeriodPreset = "today" | "last24Hours" | "last7Days" | "last30Days" | "custom";
export interface AuditFilters {
q: string | null;
period: AuditPeriodPreset;
from: string | null;
to: string | null;
kind: "event" | "mutation" | null;
targetType: string | null;
action: string | null;
}

View File

@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { auditQueryKeys, normalizeAuditSearchParams } from "./audit-query-keys";
test("normaliza los valores opcionales antes de construir la query key", () => {
assert.deepEqual(normalizeAuditSearchParams({ pageNumber: 0, pageSize: 50 }), {
pageNumber: 0,
pageSize: 50,
orderBy: "occurredAt",
order: "desc",
q: undefined,
from: undefined,
to: undefined,
kind: undefined,
targetType: undefined,
action: undefined,
});
assert.deepEqual(auditQueryKeys.list("company-1", { pageNumber: 0, pageSize: 50 }), [
"audit",
"list",
"company-1",
{
pageNumber: 0,
pageSize: 50,
orderBy: "occurredAt",
order: "desc",
q: undefined,
from: undefined,
to: undefined,
kind: undefined,
targetType: undefined,
action: undefined,
},
]);
});
test("aísla el detalle de la misma entrada entre compañías", () => {
assert.notDeepEqual(
auditQueryKeys.detail("company-a", "entry-1"),
auditQueryKeys.detail("company-b", "entry-1")
);
});

View File

@ -0,0 +1,23 @@
import type { AuditSearchParams } from "../entities/audit-list.entity";
export const normalizeAuditSearchParams = (params: AuditSearchParams) => ({
pageNumber: params.pageNumber,
pageSize: params.pageSize,
orderBy: params.orderBy ?? "occurredAt",
order: params.order ?? "desc",
q: params.q?.trim() || undefined,
from: params.from,
to: params.to,
kind: params.kind,
targetType: params.targetType?.trim() || undefined,
action: params.action?.trim() || undefined,
});
export const auditQueryKeys = {
all: ["audit"] as const,
lists: () => [...auditQueryKeys.all, "list"] as const,
details: () => [...auditQueryKeys.all, "detail"] as const,
list: (companyId: string, params: AuditSearchParams) =>
[...auditQueryKeys.lists(), companyId, normalizeAuditSearchParams(params)] as const,
detail: (companyId: string, id: string) => [...auditQueryKeys.details(), companyId, id] as const,
};

View File

@ -0,0 +1,23 @@
import { useDataSource } from "@erp/core/hooks";
import { useIdentityAuthSession } from "@erp/identity/client";
import { type DefaultError, useQuery } from "@tanstack/react-query";
import { findAuditEntryById } from "../api/find-audit-entry-by-id.api";
import type { AuditEntryDetail } from "../entities/audit-entry-detail.entity";
import { auditQueryKeys } from "./audit-query-keys";
export const useAuditEntry = (id: string | null) => {
const dataSource = useDataSource();
const { activeCompany } = useIdentityAuthSession();
const companyId = activeCompany?.id ?? "none";
return useQuery<AuditEntryDetail, DefaultError>({
queryKey: auditQueryKeys.detail(companyId, id ?? "none"),
queryFn: ({ signal }) => {
if (!id) throw new Error("auditEntryId is required");
return findAuditEntryById(dataSource, { id, signal });
},
enabled: activeCompany !== null && id !== null,
});
};

View File

@ -0,0 +1,34 @@
import { useDataSource } from "@erp/core/hooks";
import { useIdentityAuthSession } from "@erp/identity/client";
import { type DefaultError, useQuery } from "@tanstack/react-query";
import { searchAudit } from "../api/search-audit.api";
import type { AuditList, AuditSearchParams } from "../entities/audit-list.entity";
import { mapAuditList } from "../mappers/audit-list.mapper";
import { auditQueryKeys } from "./audit-query-keys";
interface UseAuditListOptions {
refetchInterval?: number | false;
getSearchParams?: () => AuditSearchParams | null;
}
export const useAuditList = (
params: AuditSearchParams | null,
options: UseAuditListOptions = {}
) => {
const dataSource = useDataSource();
const { activeCompany } = useIdentityAuthSession();
const companyId = activeCompany?.id ?? "none";
return useQuery<AuditList, DefaultError>({
queryKey: auditQueryKeys.list(companyId, params ?? { pageNumber: 0, pageSize: 50 }),
queryFn: async ({ signal }) => {
const search = options.getSearchParams?.() ?? params ?? { pageNumber: 0, pageSize: 50 };
return mapAuditList(await searchAudit(dataSource, { search, signal }));
},
enabled: activeCompany !== null && params !== null,
placeholderData: (previousData) => previousData,
refetchInterval: options.refetchInterval,
});
};

View File

@ -0,0 +1,6 @@
export * from "./entities/audit-entry-detail.entity";
export * from "./entities/audit-list.entity";
export * from "./hooks/audit-query-keys";
export * from "./hooks/use-audit-entry";
export * from "./hooks/use-audit-list";
export * from "./mappers/audit-list.mapper";

View File

@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AuditEntryDetailDto } from "../dto/audit-entry-detail.dto";
import { mapAuditEntryDetail } from "./audit-entry-detail.mapper";
test("mapAuditEntryDetail convierte snake_case, scope null y valores históricos", () => {
const dto: AuditEntryDetailDto = {
id: "entry-1",
kind: "mutation",
occurred_at: "2026-08-25T14:32:00.000Z",
source: { type: "http", id: "request-1" },
operation: { id: "operation-1", correlation_id: "correlation-1", origin: "api" },
actor: { type: "user", id: "user-1", label: "David" },
scope: null,
action: "supplier.updated",
target: { type: "supplier", id: "supplier-1", label: "Proveedor" },
changes: [
{ position: 0, path: "name", kind: "change", before: "Antes", after: "Después" },
{ position: 1, path: "dueRules", kind: "set", before: null, after: [{ days: 30 }] },
{ position: 2, path: "description", kind: "unset", before: { text: "nota" }, after: null },
],
};
assert.deepEqual(mapAuditEntryDetail(dto), {
id: "entry-1",
kind: "mutation",
occurredAt: dto.occurred_at,
source: dto.source,
operation: { id: "operation-1", correlationId: "correlation-1", origin: "api" },
actor: dto.actor,
scope: null,
action: "supplier.updated",
target: dto.target,
changes: dto.changes,
});
});

View File

@ -0,0 +1,58 @@
import type { AuditEntryDetailDto, AuditValueDto } from "../dto/audit-entry-detail.dto";
import type { AuditEntryDetail, AuditValue } from "../entities/audit-entry-detail.entity";
const mapAuditValue = (value: AuditValueDto): AuditValue => {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (Array.isArray(value)) return value.map(mapUnknownAuditValue);
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, mapUnknownAuditValue(item)])
);
};
const mapUnknownAuditValue = (value: unknown): AuditValue => {
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (Array.isArray(value)) return value.map(mapUnknownAuditValue);
if (typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, mapUnknownAuditValue(item)])
);
}
throw new Error("Invalid audit value");
};
export const mapAuditEntryDetail = (dto: AuditEntryDetailDto): AuditEntryDetail => ({
id: dto.id,
kind: dto.kind,
occurredAt: dto.occurred_at,
source: dto.source,
operation: {
id: dto.operation.id,
correlationId: dto.operation.correlation_id,
origin: dto.operation.origin,
},
actor: dto.actor,
scope: dto.scope,
action: dto.action,
target: dto.target,
changes: dto.changes.map((change) => ({
position: change.position,
path: change.path,
kind: change.kind,
before: change.before === null ? null : mapAuditValue(change.before),
after: change.after === null ? null : mapAuditValue(change.after),
})),
});

View File

@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AuditListResponseDto } from "../dto/audit-list.dto";
import { mapAuditList } from "./audit-list.mapper";
test("mapAuditList convierte el contrato snake_case al modelo de interfaz", () => {
const result = mapAuditList({
items: [
{
id: "audit-1",
kind: "mutation",
occurred_at: "2026-08-25T10:30:00.000Z",
source: { type: "application", id: "erp" },
action: "customer.updated",
actor: { type: "user", id: "user-1", label: "Ada" },
scope: { type: "company", id: "company-1", label: "Acme" },
target: { type: "customer", id: "customer-1", label: "Cliente" },
operation: { id: "operation-1", correlation_id: "correlation-1", origin: "http" },
changes_count: 2,
},
],
page: 0,
per_page: 50,
total_pages: 1,
total_items: 1,
} satisfies AuditListResponseDto);
assert.deepEqual(result, {
items: [
{
id: "audit-1",
kind: "mutation",
occurredAt: "2026-08-25T10:30:00.000Z",
source: { type: "application", id: "erp" },
action: "customer.updated",
actor: { type: "user", id: "user-1", label: "Ada" },
scope: { type: "company", id: "company-1", label: "Acme" },
target: { type: "customer", id: "customer-1", label: "Cliente" },
operation: { id: "operation-1", correlationId: "correlation-1", origin: "http" },
changesCount: 2,
},
],
page: 0,
perPage: 50,
totalPages: 1,
totalItems: 1,
});
});

View File

@ -0,0 +1,27 @@
import type { AuditListItemDto, AuditListResponseDto } from "../dto/audit-list.dto";
import type { AuditList, AuditListItem } from "../entities/audit-list.entity";
export const mapAuditListItem = (dto: AuditListItemDto): AuditListItem => ({
id: dto.id,
kind: dto.kind,
occurredAt: dto.occurred_at,
source: dto.source,
action: dto.action,
actor: dto.actor,
scope: dto.scope,
target: dto.target,
operation: {
id: dto.operation.id,
correlationId: dto.operation.correlation_id,
origin: dto.operation.origin,
},
changesCount: dto.changes_count,
});
export const mapAuditList = (dto: AuditListResponseDto): AuditList => ({
items: dto.items.map(mapAuditListItem),
page: dto.page,
perPage: dto.per_page,
totalPages: dto.total_pages,
totalItems: dto.total_items,
});

View File

@ -0,0 +1,29 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/audit/*": ["./src/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"types": ["node"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -8,6 +8,9 @@
"@erp/core/*": [
"modules/core/src/*"
],
"@erp/audit/*": [
"modules/audit/src/*"
],
"@erp/auth/*": [
"modules/auth/src/*"
],

View File

@ -243,6 +243,9 @@ importers:
apps/web:
dependencies:
'@erp/audit':
specifier: workspace:*
version: link:../../modules/audit
'@erp/auth':
specifier: workspace:*
version: link:../../modules/auth
@ -371,6 +374,64 @@ importers:
specifier: ^8.0.8
version: 8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0)
modules/audit:
dependencies:
'@erp/core':
specifier: workspace:*
version: link:../core
'@erp/identity':
specifier: workspace:*
version: link:../identity
'@repo/i18next':
specifier: workspace:*
version: link:../../packages/i18n
'@repo/rdx-ui':
specifier: workspace:*
version: link:../../packages/rdx-ui
'@repo/rdx-utils':
specifier: workspace:*
version: link:../../packages/rdx-utils
'@repo/shadcn-ui':
specifier: workspace:*
version: link:../../packages/shadcn-ui
'@tanstack/react-query':
specifier: ^5.98.0
version: 5.100.14(react@19.2.6)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-i18next:
specifier: ^17.0.2
version: 17.0.8(i18next@26.4.0(typescript@6.0.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.2)
react-router-dom:
specifier: ^7.14.0
version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
zod:
specifier: ^4.3.6
version: 4.4.3
devDependencies:
'@types/node':
specifier: ^25.6.0
version: 25.9.1
'@types/react':
specifier: ^19.2.14
version: 19.2.15
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.15)
react:
specifier: ^19.2.5
version: 19.2.6
react-dom:
specifier: ^19.2.5
version: 19.2.6(react@19.2.6)
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^6.0.2
version: 6.0.2
modules/auth:
dependencies:
'@erp/core':