feat: add optional registeredOn field to asset model and update related components
- Introduced a new optional field `registeredOn` to the asset input schema. - Updated asset values and persistence layers to handle the new field. - Enhanced tests to validate the behavior of the `registeredOn` field, including various date formats and null values. - Modified UI components to display the registration date in asset details and forms. - Added localization support for the new field in English and Spanish. - Implemented helper functions for formatting the registration date. - Created integration tests for reading and writing assets with the new field in the database.
This commit is contained in:
parent
f6c6b8c95c
commit
37110d87ed
@ -97,6 +97,7 @@ Reutilizar y extender la biblioteca visual existente antes de duplicar estructur
|
|||||||
Construir progresivamente una biblioteca visual básica con contratos claros, accesibilidad, textos traducibles y composición sencilla. Mantener los componentes genéricos independientes de entidades, DTO, consultas y endpoints de Metis Desk; el módulo consumidor aporta datos, textos y acciones. Documentar patrones y componentes en docs/componentes-de-interfaz.md y en el estándar correspondiente.
|
Construir progresivamente una biblioteca visual básica con contratos claros, accesibilidad, textos traducibles y composición sencilla. Mantener los componentes genéricos independientes de entidades, DTO, consultas y endpoints de Metis Desk; el módulo consumidor aporta datos, textos y acciones. Documentar patrones y componentes en docs/componentes-de-interfaz.md y en el estándar correspondiente.
|
||||||
Preparar esa independencia para poder agrupar o extraer posteriormente componentes en un paquete reutilizable en otras aplicaciones. Aprovechar @repo/shadcn-ui y crear abstracciones conforme aparezcan consumidores reales.
|
Preparar esa independencia para poder agrupar o extraer posteriormente componentes en un paquete reutilizable en otras aplicaciones. Aprovechar @repo/shadcn-ui y crear abstracciones conforme aparezcan consumidores reales.
|
||||||
Las pantallas de listado de entidades siguen [el patrón de listados](docs/patron-de-listados-de-entidades.md), reutilizando los componentes comunes de @repo/rdx-ui y las primitivas de @repo/shadcn-ui.
|
Las pantallas de listado de entidades siguen [el patrón de listados](docs/patron-de-listados-de-entidades.md), reutilizando los componentes comunes de @repo/rdx-ui y las primitivas de @repo/shadcn-ui.
|
||||||
|
En cualquier listado, incluidas tablas, sublistados y PickerList, las celdas sin valor muestran exclusivamente un guion largo (`—`). No usar textos como «Sin indicar», «Sin valor», «Sin especificar», «Sin asignación» o equivalentes traducidos. Conservar valores reales como cero y los estados de negocio; esta regla no sustituye los mensajes de carga, error o listado vacío.
|
||||||
Los listados no muestran formularios de alta ni edición junto a los datos. El alta se inicia con un botón en la cabecera, a la derecha; formularios en modal shadcn/ui como norma general, con anchura y desplazamiento adaptados a su contenido. La edición se abre mediante una acción explícita, siguiendo el mismo criterio. Mantener foco, cierre por teclado, cancelación y errores dentro del formulario. Incluir ayuda contextual con finalidad, ámbito y ejemplos, sin rellenar los campos automáticamente. En formularios, colocar la ayuda en la parte inferior, inmediatamente antes del pie del modal con Guardar y Cancelar.
|
Los listados no muestran formularios de alta ni edición junto a los datos. El alta se inicia con un botón en la cabecera, a la derecha; formularios en modal shadcn/ui como norma general, con anchura y desplazamiento adaptados a su contenido. La edición se abre mediante una acción explícita, siguiendo el mismo criterio. Mantener foco, cierre por teclado, cancelación y errores dentro del formulario. Incluir ayuda contextual con finalidad, ámbito y ejemplos, sin rellenar los campos automáticamente. En formularios, colocar la ayuda en la parte inferior, inmediatamente antes del pie del modal con Guardar y Cancelar.
|
||||||
|
|
||||||
## Validación y entrega
|
## Validación y entrega
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
|
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test src/identity.test.ts",
|
"test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test src/asset-inventory.integration.test.ts src/identity.test.ts",
|
||||||
"build": "tsup --config tsup.config.ts",
|
"build": "tsup --config tsup.config.ts",
|
||||||
"start": "node --env-file=../../.env dist/index.js"
|
"start": "node --env-file=../../.env dist/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -42,7 +42,7 @@ test("HTTP real: estado, contrato, errores y trazas sin datos sensibles", async
|
|||||||
assert.equal(entries.length, 3);
|
assert.equal(entries.length, 3);
|
||||||
assert.equal(new Set(entries.map((entry) => entry.requestId)).size, 3);
|
assert.equal(new Set(entries.map((entry) => entry.requestId)).size, 3);
|
||||||
assert.equal(JSON.stringify(entries).includes("secret-test-value"), false);
|
assert.equal(JSON.stringify(entries).includes("secret-test-value"), false);
|
||||||
assert.ok(entries.every((entry) => entry.durationMs >= 0));
|
assert.ok(entries.every((entry) => entry.event === "http_request" && entry.durationMs >= 0));
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
server.close((error) => (error ? reject(error) : resolve()));
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
@ -57,7 +57,18 @@ test("HTTP real: entrada JSON inválida y errores internos conservan un contrato
|
|||||||
middleware: (_request, _response, next) => next(),
|
middleware: (_request, _response, next) => next(),
|
||||||
register: (app) => {
|
register: (app) => {
|
||||||
app.get("/api/failure", () => {
|
app.get("/api/failure", () => {
|
||||||
throw new Error("secret-test-value: internal SQL and credentials");
|
const databaseError = Object.assign(
|
||||||
|
new Error("secret-test-value: internal SQL and credentials"),
|
||||||
|
{
|
||||||
|
code: "ER_BAD_FIELD_ERROR",
|
||||||
|
errno: 1054,
|
||||||
|
sqlState: "42S22",
|
||||||
|
sql: "SELECT secret-test-value FROM customers",
|
||||||
|
parameters: { password: "secret-test-value" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error("secret-test-value: application failure", { cause: databaseError });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}).listen(0, "127.0.0.1");
|
}).listen(0, "127.0.0.1");
|
||||||
@ -93,7 +104,21 @@ test("HTTP real: entrada JSON inválida y errores internos conservan un contrato
|
|||||||
|
|
||||||
assert.equal(error.code, "INTERNAL_ERROR");
|
assert.equal(error.code, "INTERNAL_ERROR");
|
||||||
assert.equal(error.requestId, response.headers.get("x-request-id"));
|
assert.equal(error.requestId, response.headers.get("x-request-id"));
|
||||||
assert.equal(entries.length, 4);
|
assert.equal(entries.filter((entry) => entry.event === "http_request").length, 4);
|
||||||
|
const failures = entries.filter((entry) => entry.event === "http_error");
|
||||||
|
|
||||||
|
assert.equal(failures.length, 1);
|
||||||
|
const logged = failures[0];
|
||||||
|
|
||||||
|
assert.ok(logged);
|
||||||
|
assert.equal(logged.requestId, error.requestId);
|
||||||
|
assert.equal(logged.method, "GET");
|
||||||
|
assert.equal(logged.statusCode, 500);
|
||||||
|
assert.equal(logged.errors.length, 2);
|
||||||
|
assert.equal(logged.errors[1]?.code, "ER_BAD_FIELD_ERROR");
|
||||||
|
assert.equal(logged.errors[1]?.errno, 1054);
|
||||||
|
assert.equal(logged.errors[1]?.sqlState, "42S22");
|
||||||
|
assert.ok(logged.errors[0]?.frames.some((frame) => frame.includes("app.test.ts:")));
|
||||||
assert.equal(JSON.stringify(entries).includes("secret-test-value"), false);
|
assert.equal(JSON.stringify(entries).includes("secret-test-value"), false);
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { Application, ErrorRequestHandler, RequestHandler } from "express";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
|
|
||||||
|
import { httpErrorDiagnostics } from "./infrastructure/http/http-error-diagnostic";
|
||||||
import type { HttpLogger } from "./infrastructure/http/http-logger";
|
import type { HttpLogger } from "./infrastructure/http/http-logger";
|
||||||
import { logHttpRequest } from "./infrastructure/http/http-logger";
|
import { logHttpRequest } from "./infrastructure/http/http-logger";
|
||||||
|
|
||||||
@ -51,13 +52,23 @@ export function createApp(
|
|||||||
response.status(404).json(error);
|
response.status(404).json(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
const errorHandler: ErrorRequestHandler = (failure: unknown, _request, response, next) => {
|
const errorHandler: ErrorRequestHandler = (failure: unknown, request, response, next) => {
|
||||||
if (response.headersSent) {
|
if (response.headersSent) {
|
||||||
next(failure);
|
next(failure);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const status = bodyFailureStatus(failure);
|
const status = bodyFailureStatus(failure);
|
||||||
|
|
||||||
|
if (status === 500) {
|
||||||
|
logger({
|
||||||
|
event: "http_error",
|
||||||
|
requestId: response.getHeader("x-request-id")?.toString() ?? "",
|
||||||
|
method: request.method,
|
||||||
|
statusCode: 500,
|
||||||
|
errors: httpErrorDiagnostics(failure),
|
||||||
|
});
|
||||||
|
}
|
||||||
const error: HttpErrorResponseDTO = {
|
const error: HttpErrorResponseDTO = {
|
||||||
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
|
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
|
||||||
requestId: response.getHeader("x-request-id")?.toString() ?? "",
|
requestId: response.getHeader("x-request-id")?.toString() ?? "",
|
||||||
|
|||||||
129
apps/server/src/asset-inventory.integration.test.ts
Normal file
129
apps/server/src/asset-inventory.integration.test.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { Asset } from "../../../modules/assets/src/api/domain/asset.aggregate";
|
||||||
|
import {
|
||||||
|
AssetSpecification,
|
||||||
|
AssetValues,
|
||||||
|
} from "../../../modules/assets/src/api/domain/value-objects/asset-values.vo";
|
||||||
|
import {
|
||||||
|
readInventory,
|
||||||
|
writeInventory,
|
||||||
|
} from "../../../modules/assets/src/api/infrastructure/persistence/asset-inventory-relational";
|
||||||
|
import { AssetPersistenceMapper } from "../../../modules/assets/src/api/infrastructure/persistence/asset-persistence.mapper";
|
||||||
|
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
|
||||||
|
|
||||||
|
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||||
|
|
||||||
|
// Tablas temporales de la conexión: no se modifica el esquema ni el inventario de la base de pruebas.
|
||||||
|
const TABLES = {
|
||||||
|
assets:
|
||||||
|
"id CHAR(36) PRIMARY KEY, companyId CHAR(36), templateId CHAR(36), siteId CHAR(36), brandId CHAR(36), name VARCHAR(100), registeredOn DATE, status VARCHAR(15), notes TEXT, brandDefined BOOLEAN, templateName VARCHAR(100), sectionsDefined BOOLEAN",
|
||||||
|
asset_revisions:
|
||||||
|
"id CHAR(36) PRIMARY KEY, assetId CHAR(36), actorId CHAR(36), recordedAt VARCHAR(30), position INTEGER, templateId CHAR(36), siteId CHAR(36), brandId CHAR(36), name VARCHAR(100), registeredOn DATE, status VARCHAR(15), notes TEXT, brandDefined BOOLEAN",
|
||||||
|
asset_specification_fields:
|
||||||
|
"assetId CHAR(36), fieldId CHAR(36), name VARCHAR(100), fieldType VARCHAR(12), unit VARCHAR(30), required BOOLEAN, sectionId CHAR(36), position INTEGER, PRIMARY KEY (assetId,fieldId)",
|
||||||
|
asset_specification_sections:
|
||||||
|
"assetId CHAR(36), id CHAR(36), name VARCHAR(100), description TEXT, position INTEGER, PRIMARY KEY (assetId,id)",
|
||||||
|
asset_specification_field_choices:
|
||||||
|
"assetId CHAR(36), fieldId CHAR(36), position INTEGER, value VARCHAR(100), PRIMARY KEY (assetId,fieldId,position)",
|
||||||
|
asset_characteristics:
|
||||||
|
"assetId CHAR(36), fieldId CHAR(36), valueType VARCHAR(12), textValue TEXT, numberValue DOUBLE, booleanValue BOOLEAN, dateValue DATE, PRIMARY KEY (assetId,fieldId)",
|
||||||
|
asset_revision_characteristics:
|
||||||
|
"revisionId CHAR(36), assetId CHAR(36), fieldId CHAR(36), valueType VARCHAR(12), textValue TEXT, numberValue DOUBLE, booleanValue BOOLEAN, dateValue DATE, PRIMARY KEY (revisionId,fieldId)",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("MariaDB: lectura de activos e historial con fecha de alta ISO y nula sin columnas duplicadas", async () => {
|
||||||
|
const environment = readDatabaseEnvironment(process.env);
|
||||||
|
|
||||||
|
assert.equal(environment.NODE_ENV, "test");
|
||||||
|
assert.equal(environment.DB_NAME, "metis_desk_test");
|
||||||
|
const database = createDatabase(environment);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
try {
|
||||||
|
for (const [table, columns] of Object.entries(TABLES)) {
|
||||||
|
await database.query(`CREATE TEMPORARY TABLE ${table} (${columns})`, { transaction });
|
||||||
|
}
|
||||||
|
const companyId = UniqueID.generateNewID();
|
||||||
|
const actorId = UniqueID.generateNewID();
|
||||||
|
const templateId = UniqueID.generateNewID().toPrimitive();
|
||||||
|
const fieldId = UniqueID.generateNewID().toPrimitive();
|
||||||
|
const specification = AssetSpecification.create({
|
||||||
|
templateId,
|
||||||
|
templateName: "Equipo",
|
||||||
|
fields: [
|
||||||
|
{ fieldId, name: "Compra", type: "date", unit: "", choices: [], required: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(specification.isSuccess);
|
||||||
|
const expected: Asset[] = [];
|
||||||
|
const mapper = new AssetPersistenceMapper();
|
||||||
|
|
||||||
|
for (const registeredOn of ["2024-02-29", null]) {
|
||||||
|
const values = AssetValues.create(
|
||||||
|
{
|
||||||
|
name: registeredOn ? "Con fecha" : "Sin fecha",
|
||||||
|
templateId,
|
||||||
|
siteId: null,
|
||||||
|
registeredOn,
|
||||||
|
status: "operational",
|
||||||
|
notes: "",
|
||||||
|
characteristics: { [fieldId]: "2024-01-15" },
|
||||||
|
},
|
||||||
|
specification.data
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(values.isSuccess);
|
||||||
|
const created = Asset.create(
|
||||||
|
companyId,
|
||||||
|
specification.data,
|
||||||
|
values.data,
|
||||||
|
actorId,
|
||||||
|
"2026-10-15T12:00:00.000Z"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(created.isSuccess);
|
||||||
|
expected.push(created.data);
|
||||||
|
await writeInventory(
|
||||||
|
database,
|
||||||
|
mapper.toPersistence(created.data),
|
||||||
|
new Set(),
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inventory = await readInventory(database, companyId.toPrimitive(), transaction);
|
||||||
|
|
||||||
|
assert.equal(inventory.rows.length, 2);
|
||||||
|
assert.equal(inventory.revisions.length, 2);
|
||||||
|
for (const asset of expected) {
|
||||||
|
const row = inventory.rows.find((candidate) => candidate.id === asset.id.toPrimitive());
|
||||||
|
|
||||||
|
assert.ok(row);
|
||||||
|
const restored = mapper.toDomain(
|
||||||
|
row,
|
||||||
|
inventory.revisions.filter((revision) => revision.assetId === row.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(restored.values.toPrimitive(), asset.values.toPrimitive());
|
||||||
|
assert.deepEqual(
|
||||||
|
restored.history[0]?.values.toPrimitive(),
|
||||||
|
asset.history[0]?.values.toPrimitive()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
await readInventory(database, UniqueID.generateNewID().toPrimitive(), transaction),
|
||||||
|
{ rows: [], revisions: [] }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
for (const table of Object.keys(TABLES).reverse()) {
|
||||||
|
await database.query(`DROP TEMPORARY TABLE IF EXISTS ${table}`, { transaction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await database.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { httpErrorDiagnostics } from "./http-error-diagnostic";
|
||||||
|
|
||||||
|
test("diagnóstico: causas de Sequelize, ciclos y valores lanzados que no son Error", () => {
|
||||||
|
const parent = Object.assign(new Error("secret-parent"), { code: "ER_BAD_FIELD_ERROR" });
|
||||||
|
const failure = Object.assign(new Error("secret-query"), { parent });
|
||||||
|
|
||||||
|
parent.cause = failure;
|
||||||
|
const diagnostics = httpErrorDiagnostics(failure);
|
||||||
|
|
||||||
|
assert.equal(diagnostics.length, 2);
|
||||||
|
assert.equal(diagnostics[1]?.code, "ER_BAD_FIELD_ERROR");
|
||||||
|
assert.equal(JSON.stringify(diagnostics).includes("secret"), false);
|
||||||
|
assert.deepEqual(httpErrorDiagnostics({ password: "secret" }), [
|
||||||
|
{ name: "NonErrorThrown", frames: [] },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(httpErrorDiagnostics(null), [{ name: "NonErrorThrown", frames: [] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diagnóstico: mensajes multilínea, propiedades y cadenas largas no se vuelcan", () => {
|
||||||
|
const failure = Object.assign(new Error("private\n at secret (/private.ts:1:2)"), {
|
||||||
|
code: "private-code",
|
||||||
|
sqlState: "private-state",
|
||||||
|
errno: Number.NaN,
|
||||||
|
sql: "private-query",
|
||||||
|
});
|
||||||
|
const diagnostics = httpErrorDiagnostics(failure);
|
||||||
|
|
||||||
|
assert.equal(JSON.stringify(diagnostics).includes("private"), false);
|
||||||
|
assert.equal(JSON.stringify(diagnostics).includes("secret"), false);
|
||||||
|
assert.ok(
|
||||||
|
diagnostics[0]?.frames.some((frame) => frame.includes("http-error-diagnostic.test.ts:"))
|
||||||
|
);
|
||||||
|
let chain = new Error("private");
|
||||||
|
|
||||||
|
for (let index = 0; index < 10; index++) {
|
||||||
|
chain = new Error("private", { cause: chain });
|
||||||
|
}
|
||||||
|
assert.equal(httpErrorDiagnostics(chain).length, 5);
|
||||||
|
});
|
||||||
48
apps/server/src/infrastructure/http/http-error-diagnostic.ts
Normal file
48
apps/server/src/infrastructure/http/http-error-diagnostic.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
export type HttpErrorDiagnostic = Readonly<{
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
errno?: number;
|
||||||
|
sqlState?: string;
|
||||||
|
frames: readonly string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** Extrae datos técnicos sin serializar mensajes, SQL ni propiedades arbitrarias del error. */
|
||||||
|
export function httpErrorDiagnostics(failure: unknown): readonly HttpErrorDiagnostic[] {
|
||||||
|
const diagnostics: HttpErrorDiagnostic[] = [];
|
||||||
|
const visited = new Set<Error>();
|
||||||
|
let current = failure;
|
||||||
|
|
||||||
|
while (current instanceof Error && !visited.has(current) && diagnostics.length < 5) {
|
||||||
|
visited.add(current);
|
||||||
|
diagnostics.push(describeError(current));
|
||||||
|
current =
|
||||||
|
current.cause instanceof Error
|
||||||
|
? current.cause
|
||||||
|
: "parent" in current
|
||||||
|
? current.parent
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics.length ? diagnostics : [{ name: "NonErrorThrown", frames: [] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(failure: Error): HttpErrorDiagnostic {
|
||||||
|
const code = "code" in failure ? failure.code : undefined;
|
||||||
|
const errno = "errno" in failure ? failure.errno : undefined;
|
||||||
|
const sqlState = "sqlState" in failure ? failure.sqlState : undefined;
|
||||||
|
// El prefijo puede contener un mensaje multilínea: se elimina completo antes de leer los frames.
|
||||||
|
const prefix = failure.message ? `${failure.name}: ${failure.message}` : failure.name;
|
||||||
|
const stack = failure.stack?.startsWith(prefix) ? failure.stack.slice(prefix.length) : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: /^[A-Za-z][A-Za-z0-9]*Error$|^Error$/.test(failure.name) ? failure.name : "Error",
|
||||||
|
...(typeof code === "string" && /^(?:ER_[A-Z0-9_]+|E[A-Z0-9_]+)$/.test(code) ? { code } : {}),
|
||||||
|
...(typeof errno === "number" && Number.isSafeInteger(errno) ? { errno } : {}),
|
||||||
|
...(typeof sqlState === "string" && /^[0-9A-Z]{5}$/.test(sqlState) ? { sqlState } : {}),
|
||||||
|
frames: stack
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => /^\s+at .+:\d+:\d+\)?$/.test(line))
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((line) => line.trim()),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
export type HttpLogEntry = Readonly<{
|
import type { HttpErrorDiagnostic } from "./http-error-diagnostic";
|
||||||
|
|
||||||
|
export type HttpRequestLogEntry = Readonly<{
|
||||||
event: "http_request";
|
event: "http_request";
|
||||||
requestId: string;
|
requestId: string;
|
||||||
method: string;
|
method: string;
|
||||||
@ -6,9 +8,23 @@ export type HttpLogEntry = Readonly<{
|
|||||||
durationMs: number;
|
durationMs: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type HttpLogEntry =
|
||||||
|
| HttpRequestLogEntry
|
||||||
|
| Readonly<{
|
||||||
|
event: "http_error";
|
||||||
|
requestId: string;
|
||||||
|
method: string;
|
||||||
|
statusCode: 500;
|
||||||
|
errors: readonly HttpErrorDiagnostic[];
|
||||||
|
}>;
|
||||||
|
|
||||||
export type HttpLogger = (entry: HttpLogEntry) => void;
|
export type HttpLogger = (entry: HttpLogEntry) => void;
|
||||||
|
|
||||||
// No se registran URL, cabeceras ni cuerpos: pueden contener datos de clientes o credenciales.
|
// No se registran URL, cabeceras ni cuerpos: pueden contener datos de clientes o credenciales.
|
||||||
export const logHttpRequest: HttpLogger = (entry) => {
|
export const logHttpRequest: HttpLogger = (entry) => {
|
||||||
console.info(JSON.stringify(entry));
|
if (entry.event === "http_error") {
|
||||||
|
console.error(JSON.stringify(entry));
|
||||||
|
} else {
|
||||||
|
console.info(JSON.stringify(entry));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -87,7 +87,39 @@ export async function verifyInventory(
|
|||||||
assert.equal(created.history[0]?.id.charAt(14), "7");
|
assert.equal(created.history[0]?.id.charAt(14), "7");
|
||||||
assert.ok(created.history[0]?.actorName);
|
assert.ok(created.history[0]?.actorName);
|
||||||
assert.ok(created.history[0]?.actorId);
|
assert.ok(created.history[0]?.actorId);
|
||||||
const disposable = await save(path, { ...input, name: "MON-ELIMINAR" });
|
for (const registeredOn of ["2026-02-29", "", "2026-10-15T00:00:00Z"]) {
|
||||||
|
await error(path, { ...input, registeredOn }, "INVALID_REQUEST", 400);
|
||||||
|
}
|
||||||
|
assert.equal(created.values.registeredOn, null);
|
||||||
|
const disposable = await save(path, {
|
||||||
|
...input,
|
||||||
|
name: "MON-ELIMINAR",
|
||||||
|
registeredOn: "2024-02-29",
|
||||||
|
});
|
||||||
|
const dated = (await read()).assets.find((asset) => asset.id === disposable.id);
|
||||||
|
|
||||||
|
assert.deepEqual(dated, disposable);
|
||||||
|
const editedDate = await save(`${path}/${disposable.id}/update`, {
|
||||||
|
...disposable.values,
|
||||||
|
registeredOn: "2026-10-15",
|
||||||
|
});
|
||||||
|
const repeatedDate = await save(`${path}/${disposable.id}/update`, editedDate.values);
|
||||||
|
|
||||||
|
assert.deepEqual(repeatedDate.history, editedDate.history);
|
||||||
|
const clearedDate = await save(`${path}/${disposable.id}/update`, {
|
||||||
|
...editedDate.values,
|
||||||
|
registeredOn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(clearedDate.values.registeredOn, null);
|
||||||
|
assert.deepEqual(
|
||||||
|
clearedDate.history.map((revision) => revision.values.registeredOn),
|
||||||
|
["2024-02-29", "2026-10-15", null]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await read()).assets.find((asset) => asset.id === disposable.id),
|
||||||
|
clearedDate
|
||||||
|
);
|
||||||
const deletion = await request(`${path}/${disposable.id}/delete`, {});
|
const deletion = await request(`${path}/${disposable.id}/delete`, {});
|
||||||
|
|
||||||
assert.equal(deletion.status, 200, await deletion.clone().text());
|
assert.equal(deletion.status, 200, await deletion.clone().text());
|
||||||
|
|||||||
@ -28,5 +28,9 @@ Cada petición obtiene un UUID generado por el servidor, devuelto como x-request
|
|||||||
Los errores HTTP tienen code y requestId. No devuelven detalles internos.
|
Los errores HTTP tienen code y requestId. No devuelven detalles internos.
|
||||||
El middleware registra método, estado, duración e identificador; no registra URL, query, cabeceras ni cuerpo.
|
El middleware registra método, estado, duración e identificador; no registra URL, query, cabeceras ni cuerpo.
|
||||||
|
|
||||||
|
Ante una excepción inesperada que se traduce en HTTP 500, emite además un evento JSON `http_error` en stderr (`console.error`), con el mismo `requestId` de la respuesta y del evento `http_request`. Incluye método, estado y diagnósticos técnicos: nombre del error, código de Node/MariaDB, número de error y SQLState cuando existen, y frames con archivos y líneas de origen. Recorre `cause` o `parent` de Sequelize hasta cinco errores, sin repetir ciclos; cada diagnóstico conserva hasta veinte frames.
|
||||||
|
|
||||||
|
No se serializa el objeto Error completo ni sus mensajes arbitrarios, que pueden incluir SQL, parámetros o credenciales. Los frames excluyen el mensaje completo, incluso si contiene varias líneas. Los valores lanzados que no son Error se identifican como `NonErrorThrown`. Los errores esperados del parser (400, 413 y 415) conservan únicamente el registro de petición. La respuesta HTTP continúa exponiendo solo `code` y `requestId`.
|
||||||
|
|
||||||
El servidor escucha en 127.0.0.1. La interfaz de desarrollo usa el proxy de Vite para /api.
|
El servidor escucha en 127.0.0.1. La interfaz de desarrollo usa el proxy de Vite para /api.
|
||||||
Las rutas de datos de clientes requieren sesión autenticada y los guards correspondientes. Las escrituras validan origen y CSRF antes de ejecutar la operación.
|
Las rutas de datos de clientes requieren sesión autenticada y los guards correspondientes. Las escrituras validan origen y CSRF antes de ejecutar la operación.
|
||||||
|
|||||||
@ -161,6 +161,8 @@ La paginación es local sobre los conjuntos que las API actuales ya devuelven. N
|
|||||||
|
|
||||||
Los inventarios de activos y recursos componen las primitivas Tabs de shadcn/ui con EntityTable para filtrar por plantilla. La barra admite desplazamiento horizontal y navegación por teclado; incluye «Todo» y las plantillas del catálogo de la compañía. Un único panel conserva la tabla, su ordenación y sus columnas al cambiar de pestaña. `filterKey` reinicia la página; `filtersApplied` y `onClearFilters` integran el filtro con los estados vacíos y la limpieza común del listado.
|
Los inventarios de activos y recursos componen las primitivas Tabs de shadcn/ui con EntityTable para filtrar por plantilla. La barra admite desplazamiento horizontal y navegación por teclado; incluye «Todo» y las plantillas del catálogo de la compañía. Un único panel conserva la tabla, su ordenación y sus columnas al cambiar de pestaña. `filterKey` reinicia la página; `filtersApplied` y `onClearFilters` integran el filtro con los estados vacíos y la limpieza común del listado.
|
||||||
|
|
||||||
|
EntityTable muestra `—` en las celdas cuyo renderizador devuelve ausencia, texto vacío, espacios, arrays vacíos o fragmentos sin contenido. Los consumidores devuelven ausencia directamente, sin textos como «Sin indicar»; véase [celdas sin valor](patron-de-listados-de-entidades.md#celdas-sin-valor).
|
||||||
|
|
||||||
EntityTable separa internamente la configuración de columnas/ordenación, el contenido semántico y la paginación; useEntityTable concentra el estado de representación. entityLabel permite contar «plantillas», «campos» o «recursos» aunque el título del espacio sea más amplio. Los tamaños disponibles son 10, 25, 50 y 100; una opción vacía nunca cambia el tamaño a cero.
|
EntityTable separa internamente la configuración de columnas/ordenación, el contenido semántico y la paginación; useEntityTable concentra el estado de representación. entityLabel permite contar «plantillas», «campos» o «recursos» aunque el título del espacio sea más amplio. Los tamaños disponibles son 10, 25, 50 y 100; una opción vacía nunca cambia el tamaño a cero.
|
||||||
|
|
||||||
Se declara @tanstack/react-table en el paquete compartido porque es quien consume ahora la librería; se reutiliza la versión 8.21.3 que ya utilizaba Compañías. No se introduce un segundo sistema de tablas.
|
Se declara @tanstack/react-table en el paquete compartido porque es quien consume ahora la librería; se reutiliza la versión 8.21.3 que ya utilizaba Compañías. No se introduce un segundo sistema de tablas.
|
||||||
|
|||||||
@ -6,12 +6,20 @@ La ficha de compañía incorpora la sección Activos. El listado permite buscar
|
|||||||
|
|
||||||
El listado ofrece una pestaña «Todo» y una por cada plantilla global o propia disponible en el catálogo de la compañía, incluidas las inactivas y las que todavía no tienen activos. Las pestañas filtran por identidad de plantilla y se combinan con la búsqueda; cambiar de pestaña vuelve a la primera página sin perder la ordenación ni las columnas configuradas. La URL conserva el filtro en `template` y la búsqueda en `q`. Limpiar filtros recupera «Todo» y vacía la búsqueda. Si una plantilla deja de estar disponible, el listado vuelve a «Todo».
|
El listado ofrece una pestaña «Todo» y una por cada plantilla global o propia disponible en el catálogo de la compañía, incluidas las inactivas y las que todavía no tienen activos. Las pestañas filtran por identidad de plantilla y se combinan con la búsqueda; cambiar de pestaña vuelve a la primera página sin perder la ordenación ni las columnas configuradas. La URL conserva el filtro en `template` y la búsqueda en `q`. Limpiar filtros recupera «Todo» y vacía la búsqueda. Si una plantilla deja de estar disponible, el listado vuelve a «Todo».
|
||||||
|
|
||||||
Un activo representa un elemento concreto, identificado mediante UUID v7. Su nombre sirve para reconocerlo (por ejemplo PORT-ANA-01), pero no sustituye al UUID y puede repetirse. La compañía y la plantilla son obligatorias. Sede, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla. La persona se relaciona mediante un puesto de trabajo o un perfil de usuario.
|
Un activo representa un elemento concreto, identificado mediante UUID v7. Su nombre sirve para reconocerlo (por ejemplo PORT-ANA-01), pero no sustituye al UUID y puede repetirse. La compañía y la plantilla son obligatorias. Fecha de alta, sede, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla. La persona se relaciona mediante un puesto de trabajo o un perfil de usuario.
|
||||||
|
|
||||||
Estados técnicos manuales: Operativo, Averiado, En mantenimiento y Retirado. Un activo operativo se muestra Disponible si ningún puesto activo lo utiliza, o En uso si figura en un puesto activo. La asignación se calcula desde la composición, independientemente del responsable. Los puestos inactivos y las revisiones históricas no cuentan como uso vigente. Para retirarlo se exige desvincularlo primero de todos los puestos activos, conservando sede e historial. La eliminación definitiva existente sigue protegida por referencias; no hay transferencia entre compañías.
|
Estados técnicos manuales: Operativo, Averiado, En mantenimiento y Retirado. Un activo operativo se muestra Disponible si ningún puesto activo lo utiliza, o En uso si figura en un puesto activo. La asignación se calcula desde la composición, independientemente del responsable. Los puestos inactivos y las revisiones históricas no cuentan como uso vigente. Para retirarlo se exige desvincularlo primero de todos los puestos activos, conservando sede e historial. La eliminación definitiva existente sigue protegida por referencias; no hay transferencia entre compañías.
|
||||||
|
|
||||||
La sede y la persona pertenecen a la compañía. Solo se permiten nuevas asignaciones a personas activas. Una persona que se desactiva conserva su asignación anterior, para facilitar la revisión y retirada; puede desasignarse. Las personas no reciben acceso a Metis Desk.
|
La sede y la persona pertenecen a la compañía. Solo se permiten nuevas asignaciones a personas activas. Una persona que se desactiva conserva su asignación anterior, para facilitar la revisión y retirada; puede desasignarse. Las personas no reciben acceso a Metis Desk.
|
||||||
|
|
||||||
|
## Fecha de alta
|
||||||
|
|
||||||
|
`values.registeredOn` es la fecha opcional de incorporación del activo a la compañía, sin hora y en formato `YYYY-MM-DD`. Es un dato propio del activo, independiente de la fecha de compra configurable como característica dinámica. No se rellena automáticamente ni se deduce del historial técnico.
|
||||||
|
|
||||||
|
El formulario compartido de alta y edición permite indicarla o borrarla. El inventario y los picker lists comparten su columna ordenable; la ficha, su vista compacta y las revisiones muestran la fecha o la ausencia de valor. Se conserva al editar otros campos y en cada revisión. Omitirla y enviarla como `null` representan la misma ausencia para la igualdad semántica.
|
||||||
|
|
||||||
|
La migración idempotente añade una columna `DATE` nullable `registeredOn` a `assets` y `asset_revisions`. Los registros previos permanecen sin fecha. Las lecturas SQL devuelven fechas ISO explícitamente para evitar cambios de día por zona horaria.
|
||||||
|
|
||||||
## Características y plantillas
|
## Características y plantillas
|
||||||
|
|
||||||
El alta utiliza una plantilla activa global o propia de la compañía. Se captura una copia de su nombre, campos, tipos, unidades, valores de lista y obligatoriedad. La plantilla del activo queda fija. Cambiar o desactivar la definición del catálogo no cambia las fichas ni las revisiones existentes. Un activo existente sigue siendo editable aunque su plantilla se desactive.
|
El alta utiliza una plantilla activa global o propia de la compañía. Se captura una copia de su nombre, campos, tipos, unidades, valores de lista y obligatoriedad. La plantilla del activo queda fija. Cambiar o desactivar la definición del catálogo no cambia las fichas ni las revisiones existentes. Un activo existente sigue siendo editable aunque su plantilla se desactive.
|
||||||
|
|||||||
@ -37,6 +37,14 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c
|
|||||||
- Cancelar un modal conserva búsqueda, filtros, página y selección de la lista. Las actualizaciones continúan invalidando las consultas actuales.
|
- Cancelar un modal conserva búsqueda, filtros, página y selección de la lista. Las actualizaciones continúan invalidando las consultas actuales.
|
||||||
- Las consultas que alimentan un listado usan `useListQuery`: se vuelven a consultar cada 60 segundos mientras la pestaña está visible y también al recuperar foco o conexión. No se consulta en segundo plano ni se aplica este intervalo a formularios, sesión u otras consultas que no presenten un listado.
|
- Las consultas que alimentan un listado usan `useListQuery`: se vuelven a consultar cada 60 segundos mientras la pestaña está visible y también al recuperar foco o conexión. No se consulta en segundo plano ni se aplica este intervalo a formularios, sesión u otras consultas que no presenten un listado.
|
||||||
|
|
||||||
|
## Celdas sin valor
|
||||||
|
|
||||||
|
En cualquier listado, tabla, sublistado o PickerList, una celda sin valor muestra exclusivamente un guion largo (`—`, U+2014), cualquiera que sea la causa de la ausencia. No mostrar «Sin indicar», «Sin valor», «Sin especificar», «Sin asignación», «Sin fecha», «Sin logotipo» ni equivalentes en otros idiomas. El signo es común a es/en y no requiere traducción.
|
||||||
|
|
||||||
|
`EntityTable` aplica este criterio cuando `render` devuelve `null`, `undefined`, un booleano sin representación visual, una cadena vacía o formada solo por espacios, un array vacío o un fragmento sin contenido. Los consumidores deben devolver ausencia directamente en lugar de envolverla en componentes vacíos o convertirla en textos sustitutorios. Los renderizadores de contenido compuesto son responsables de aplicar el mismo criterio a sus valores opcionales.
|
||||||
|
|
||||||
|
El cero y los valores reales permanecen visibles; los booleanos de negocio se presentan con sus etiquetas o estados correspondientes. Se mantienen los estados de negocio explícitos, por ejemplo «Inactivo» o «Abierto». Esta pauta se refiere a celdas de datos y no sustituye los mensajes de carga, error, ausencia de registros o búsqueda sin resultados, ni los placeholders de formularios.
|
||||||
|
|
||||||
## Común y específico
|
## Común y específico
|
||||||
|
|
||||||
Común: distribución, cabecera, cuatro bandas, márgenes, altura base de filas, búsqueda, limpieza, contador, columnas, ordenación, paginación, accesibilidad y estados.
|
Común: distribución, cabecera, cuatro bandas, márgenes, altura base de filas, búsqueda, limpieza, contador, columnas, ordenación, paginación, accesibilidad y estados.
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export const AssetInputSchema = z.strictObject({
|
|||||||
templateId: z.uuid(),
|
templateId: z.uuid(),
|
||||||
siteId: z.uuid().nullable(),
|
siteId: z.uuid().nullable(),
|
||||||
brandId: z.uuid().nullable().optional(),
|
brandId: z.uuid().nullable().optional(),
|
||||||
|
registeredOn: z.iso.date().nullable().optional(),
|
||||||
status: AssetStatusSchema,
|
status: AssetStatusSchema,
|
||||||
notes: z.string().max(10000),
|
notes: z.string().max(10000),
|
||||||
characteristics: z.record(z.uuid(), AssetValueSchema),
|
characteristics: z.record(z.uuid(), AssetValueSchema),
|
||||||
|
|||||||
@ -166,3 +166,54 @@ test("estado técnico y uso: asignaciones calculadas, elegibilidad exclusiva/com
|
|||||||
assert.equal(previous.data.toPrimitive().status, "in_use");
|
assert.equal(previous.data.toPrimitive().status, "in_use");
|
||||||
assert.ok(previous.data.equals(current.data));
|
assert.ok(previous.data.equals(current.data));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Fecha de alta opcional: calendario real, independencia de compra, revisiones e idempotencia", () => {
|
||||||
|
const spec = specification();
|
||||||
|
const purchaseField = ids[2];
|
||||||
|
|
||||||
|
assert.ok(purchaseField);
|
||||||
|
const original = input({ [purchaseField]: "2024-01-15" });
|
||||||
|
|
||||||
|
for (const registeredOn of [undefined, null, "2024-02-29", "2026-10-15"]) {
|
||||||
|
const candidate = { ...original, ...(registeredOn === undefined ? {} : { registeredOn }) };
|
||||||
|
|
||||||
|
assert.ok(AssetInputSchema.safeParse(candidate).success);
|
||||||
|
assert.ok(AssetValues.create(candidate, spec).isSuccess);
|
||||||
|
}
|
||||||
|
for (const registeredOn of [
|
||||||
|
"",
|
||||||
|
"2026-02-29",
|
||||||
|
"2026-04-31",
|
||||||
|
"15/10/2026",
|
||||||
|
"2026-10-15T00:00:00Z",
|
||||||
|
]) {
|
||||||
|
assert.equal(AssetInputSchema.safeParse({ ...original, registeredOn }).success, false);
|
||||||
|
assert.equal(AssetValues.create({ ...original, registeredOn }, spec).isSuccess, false);
|
||||||
|
}
|
||||||
|
const first = AssetValues.create(original, spec);
|
||||||
|
const absent = AssetValues.create({ ...original, registeredOn: null }, spec);
|
||||||
|
const dated = AssetValues.create({ ...original, registeredOn: "2024-02-29" }, spec);
|
||||||
|
|
||||||
|
assert.ok(first.isSuccess && absent.isSuccess && dated.isSuccess);
|
||||||
|
assert.ok(first.data.equals(absent.data));
|
||||||
|
const created = Asset.create(company, spec, first.data, actor, "2026-10-15T10:00:00.000Z");
|
||||||
|
|
||||||
|
assert.ok(created.isSuccess);
|
||||||
|
const changed = created.data.update(dated.data, actor, "2026-10-15T11:00:00.000Z");
|
||||||
|
|
||||||
|
assert.ok(changed.isSuccess);
|
||||||
|
assert.equal(changed.data.history.length, 2);
|
||||||
|
assert.equal(changed.data.history[0]?.values.toPrimitive().registeredOn, null);
|
||||||
|
assert.equal(changed.data.values.toPrimitive().registeredOn, "2024-02-29");
|
||||||
|
assert.equal(changed.data.values.toPrimitive().characteristics[purchaseField], "2024-01-15");
|
||||||
|
const repeated = changed.data.update(dated.data, actor, "2026-10-15T12:00:00.000Z");
|
||||||
|
|
||||||
|
assert.ok(repeated.isSuccess);
|
||||||
|
assert.equal(repeated.data, changed.data);
|
||||||
|
const cleared = changed.data.update(absent.data, actor, "2026-10-15T13:00:00.000Z");
|
||||||
|
|
||||||
|
assert.ok(cleared.isSuccess);
|
||||||
|
assert.equal(cleared.data.values.toPrimitive().registeredOn, null);
|
||||||
|
assert.equal(cleared.data.history.length, 3);
|
||||||
|
assert.equal(cleared.data.history[1]?.values.toPrimitive().registeredOn, "2024-02-29");
|
||||||
|
});
|
||||||
|
|||||||
@ -165,6 +165,7 @@ export class AssetValues {
|
|||||||
new AssetValues(
|
new AssetValues(
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
...data,
|
...data,
|
||||||
|
registeredOn: data.registeredOn ?? null,
|
||||||
name: name.data.toPrimitive(),
|
name: name.data.toPrimitive(),
|
||||||
templateId: data.templateId.toLowerCase(),
|
templateId: data.templateId.toLowerCase(),
|
||||||
siteId: data.siteId?.toLowerCase() ?? null,
|
siteId: data.siteId?.toLowerCase() ?? null,
|
||||||
@ -190,6 +191,7 @@ export class AssetValues {
|
|||||||
first.name === second.name &&
|
first.name === second.name &&
|
||||||
first.templateId === second.templateId &&
|
first.templateId === second.templateId &&
|
||||||
first.siteId === second.siteId &&
|
first.siteId === second.siteId &&
|
||||||
|
(first.registeredOn ?? null) === (second.registeredOn ?? null) &&
|
||||||
(first.brandId ?? null) === (second.brandId ?? null) &&
|
(first.brandId ?? null) === (second.brandId ?? null) &&
|
||||||
this.technicalStatus === other.technicalStatus &&
|
this.technicalStatus === other.technicalStatus &&
|
||||||
first.notes === second.notes &&
|
first.notes === second.notes &&
|
||||||
|
|||||||
@ -33,6 +33,7 @@ type AssetRow = Omit<InventoryRow, "values" | "specification"> & {
|
|||||||
status: string;
|
status: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
brandDefined: boolean;
|
brandDefined: boolean;
|
||||||
|
registeredOn: string | null;
|
||||||
templateName: string;
|
templateName: string;
|
||||||
sectionsDefined: boolean;
|
sectionsDefined: boolean;
|
||||||
};
|
};
|
||||||
@ -42,6 +43,7 @@ type RevisionColumns = Omit<RevisionRow, "values"> & {
|
|||||||
status: string;
|
status: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
brandDefined: boolean;
|
brandDefined: boolean;
|
||||||
|
registeredOn: string | null;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -141,12 +143,9 @@ export async function readInventory(
|
|||||||
companyId: string,
|
companyId: string,
|
||||||
transaction: Transaction
|
transaction: Transaction
|
||||||
) {
|
) {
|
||||||
const assets = await readAssetRows<AssetRow>(
|
const assets = await database.query<AssetRow>(
|
||||||
database,
|
"SELECT id,companyId,templateId,siteId,brandId,name,status,notes,brandDefined,templateName,sectionsDefined,DATE_FORMAT(registeredOn,'%Y-%m-%d') AS registeredOn FROM assets WHERE companyId=:companyId",
|
||||||
"assets",
|
{ replacements: { companyId }, type: QueryTypes.SELECT, transaction }
|
||||||
"companyId",
|
|
||||||
[companyId],
|
|
||||||
transaction
|
|
||||||
);
|
);
|
||||||
const ids = assets.map((row) => row.id);
|
const ids = assets.map((row) => row.id);
|
||||||
const fields = await readAssetRows<SpecificationField>(
|
const fields = await readAssetRows<SpecificationField>(
|
||||||
@ -171,13 +170,12 @@ export async function readInventory(
|
|||||||
transaction
|
transaction
|
||||||
);
|
);
|
||||||
const current = await readCharacteristics(database, "asset_characteristics", ids, transaction);
|
const current = await readCharacteristics(database, "asset_characteristics", ids, transaction);
|
||||||
const histories = await readAssetRows<RevisionColumns>(
|
const histories = ids.length
|
||||||
database,
|
? await database.query<RevisionColumns>(
|
||||||
"asset_revisions",
|
"SELECT id,assetId,actorId,recordedAt,`position`,templateId,siteId,brandId,name,status,notes,brandDefined,DATE_FORMAT(registeredOn,'%Y-%m-%d') AS registeredOn FROM asset_revisions WHERE assetId IN (:ids)",
|
||||||
"assetId",
|
{ replacements: { ids }, type: QueryTypes.SELECT, transaction }
|
||||||
ids,
|
)
|
||||||
transaction
|
: [];
|
||||||
);
|
|
||||||
const historical = await readCharacteristics(
|
const historical = await readCharacteristics(
|
||||||
database,
|
database,
|
||||||
"asset_revision_characteristics",
|
"asset_revision_characteristics",
|
||||||
@ -188,6 +186,7 @@ export async function readInventory(
|
|||||||
const values = (row: AssetRow | RevisionColumns, entries: CharacteristicRow[]) =>
|
const values = (row: AssetRow | RevisionColumns, entries: CharacteristicRow[]) =>
|
||||||
AssetRecordedInputSchema.parse({
|
AssetRecordedInputSchema.parse({
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
registeredOn: row.registeredOn,
|
||||||
templateId: row.templateId,
|
templateId: row.templateId,
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
...(row.brandDefined ? { brandId: row.brandId } : {}),
|
...(row.brandDefined ? { brandId: row.brandId } : {}),
|
||||||
@ -243,6 +242,19 @@ export async function readInventory(
|
|||||||
return { rows, revisions };
|
return { rows, revisions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inventoryValueColumns(values: AssetRecordedInput) {
|
||||||
|
return {
|
||||||
|
templateId: values.templateId,
|
||||||
|
siteId: values.siteId,
|
||||||
|
brandId: values.brandId ?? null,
|
||||||
|
name: values.name,
|
||||||
|
registeredOn: values.registeredOn ?? null,
|
||||||
|
status: values.status,
|
||||||
|
notes: values.notes,
|
||||||
|
brandDefined: values.brandId !== undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function writeInventory(
|
export async function writeInventory(
|
||||||
database: Sequelize,
|
database: Sequelize,
|
||||||
rows: ReturnType<AssetPersistenceMapper["toPersistence"]>,
|
rows: ReturnType<AssetPersistenceMapper["toPersistence"]>,
|
||||||
@ -259,13 +271,7 @@ export async function writeInventory(
|
|||||||
{
|
{
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
companyId: asset.companyId,
|
companyId: asset.companyId,
|
||||||
templateId: v.templateId,
|
...inventoryValueColumns(v),
|
||||||
siteId: v.siteId,
|
|
||||||
brandId: v.brandId ?? null,
|
|
||||||
name: v.name,
|
|
||||||
status: v.status,
|
|
||||||
notes: v.notes,
|
|
||||||
brandDefined: v.brandId !== undefined,
|
|
||||||
templateName: specification.templateName,
|
templateName: specification.templateName,
|
||||||
sectionsDefined: specification.sections !== undefined,
|
sectionsDefined: specification.sections !== undefined,
|
||||||
},
|
},
|
||||||
@ -332,13 +338,7 @@ export async function writeInventory(
|
|||||||
actorId: revision.actorId,
|
actorId: revision.actorId,
|
||||||
recordedAt: revision.recordedAt,
|
recordedAt: revision.recordedAt,
|
||||||
position: revision.position,
|
position: revision.position,
|
||||||
templateId: previous.templateId,
|
...inventoryValueColumns(previous),
|
||||||
siteId: previous.siteId,
|
|
||||||
brandId: previous.brandId ?? null,
|
|
||||||
name: previous.name,
|
|
||||||
status: previous.status,
|
|
||||||
notes: previous.notes,
|
|
||||||
brandDefined: previous.brandId !== undefined,
|
|
||||||
},
|
},
|
||||||
transaction
|
transaction
|
||||||
);
|
);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export const definitionColumns = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const inventoryColumns = {
|
export const inventoryColumns = {
|
||||||
|
registeredOn: { type: DataTypes.DATEONLY, allowNull: true },
|
||||||
name: title,
|
name: title,
|
||||||
status: { type: DataTypes.STRING(15), allowNull: false },
|
status: { type: DataTypes.STRING(15), allowNull: false },
|
||||||
notes: { type: DataTypes.TEXT, allowNull: false },
|
notes: { type: DataTypes.TEXT, allowNull: false },
|
||||||
|
|||||||
@ -45,4 +45,11 @@ export async function migrateInventory(database: Sequelize) {
|
|||||||
if (!tables.some((table) => table.name === "asset_revisions")) {
|
if (!tables.some((table) => table.name === "asset_revisions")) {
|
||||||
await query.createTable("asset_revisions", revision);
|
await query.createTable("asset_revisions", revision);
|
||||||
}
|
}
|
||||||
|
for (const table of ["assets", "asset_revisions"]) {
|
||||||
|
const columns = await query.describeTable(table);
|
||||||
|
|
||||||
|
if (!("registeredOn" in columns)) {
|
||||||
|
await query.addColumn(table, "registeredOn", inventoryColumns.registeredOn);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -202,6 +202,8 @@
|
|||||||
"editField": "Edit field",
|
"editField": "Edit field",
|
||||||
"newTemplate": "New template",
|
"newTemplate": "New template",
|
||||||
"editTemplate": "Edit template",
|
"editTemplate": "Edit template",
|
||||||
|
"registeredOn": "Registration date",
|
||||||
|
"registeredOnHelp": "Date the asset joined the company. Optional and independent of the purchase date.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"type": "Data type",
|
"type": "Data type",
|
||||||
|
|||||||
@ -202,6 +202,8 @@
|
|||||||
"editField": "Editar campo",
|
"editField": "Editar campo",
|
||||||
"newTemplate": "Nueva plantilla",
|
"newTemplate": "Nueva plantilla",
|
||||||
"editTemplate": "Editar plantilla",
|
"editTemplate": "Editar plantilla",
|
||||||
|
"registeredOn": "Fecha de alta",
|
||||||
|
"registeredOnHelp": "Fecha de incorporación del activo a la compañía. Opcional e independiente de la fecha de compra.",
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"description": "Descripción",
|
"description": "Descripción",
|
||||||
"type": "Tipo de dato",
|
"type": "Tipo de dato",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { FieldSections } from "@repo/rdx-ui/components/field-sections";
|
|||||||
import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title";
|
import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { formatAssetRegistrationDate } from "../../shared/helpers/format-asset-registration-date";
|
||||||
import { useAssetDetailController } from "../controllers";
|
import { useAssetDetailController } from "../controllers";
|
||||||
import type { AssetDetailProps } from "../types";
|
import type { AssetDetailProps } from "../types";
|
||||||
|
|
||||||
@ -45,6 +46,9 @@ export function AssetDetail(props: AssetDetailProps) {
|
|||||||
)}
|
)}
|
||||||
<DetailSection title={t("detailSections.assignment")}>
|
<DetailSection title={t("detailSections.assignment")}>
|
||||||
<dl>
|
<dl>
|
||||||
|
<DetailField label={t("registeredOn")}>
|
||||||
|
{formatAssetRegistrationDate(asset.values.registeredOn, i18n.language) ?? t("noValue")}
|
||||||
|
</DetailField>
|
||||||
<DetailField label={t("site")}>
|
<DetailField label={t("site")}>
|
||||||
{asset.values.siteId ? (
|
{asset.values.siteId ? (
|
||||||
<BrowserLink href={`/companies/${asset.companyId}/sites`}>
|
<BrowserLink href={`/companies/${asset.companyId}/sites`}>
|
||||||
@ -123,6 +127,11 @@ export function AssetDetail(props: AssetDetailProps) {
|
|||||||
<p>
|
<p>
|
||||||
{t("site")}: {nameOf(revision.values.siteId, lookups.sites)}
|
{t("site")}: {nameOf(revision.values.siteId, lookups.sites)}
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
{t("registeredOn")}:{" "}
|
||||||
|
{formatAssetRegistrationDate(revision.values.registeredOn, i18n.language) ??
|
||||||
|
t("noValue")}
|
||||||
|
</p>
|
||||||
<details>
|
<details>
|
||||||
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">
|
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">
|
||||||
<FormSectionTitle as="span">{t("characteristics")}</FormSectionTitle>
|
<FormSectionTitle as="span">{t("characteristics")}</FormSectionTitle>
|
||||||
|
|||||||
@ -20,6 +20,7 @@ const assets = [2, 3].map((n) => {
|
|||||||
name: `Equipo ${n}`,
|
name: `Equipo ${n}`,
|
||||||
templateId: id(n + 10),
|
templateId: id(n + 10),
|
||||||
siteId: id(4),
|
siteId: id(4),
|
||||||
|
registeredOn: n === 2 ? "2024-02-29" : null,
|
||||||
status: "operational",
|
status: "operational",
|
||||||
notes: "",
|
notes: "",
|
||||||
characteristics: {},
|
characteristics: {},
|
||||||
@ -90,6 +91,10 @@ test("selector: comparte búsqueda, pestañas por identidad y columnas del inven
|
|||||||
assert.ok(html.includes("Portátiles"));
|
assert.ok(html.includes("Portátiles"));
|
||||||
assert.ok(html.includes("Monitores"));
|
assert.ok(html.includes("Monitores"));
|
||||||
assert.ok(html.includes("Sede Central"));
|
assert.ok(html.includes("Sede Central"));
|
||||||
|
assert.ok(html.includes(language === "es" ? "Fecha de alta" : "Registration date"));
|
||||||
|
assert.ok(html.includes(language === "es" ? "29 feb 2024" : "Feb 29, 2024"));
|
||||||
|
assert.ok(html.includes(">—</td>"));
|
||||||
|
assert.equal(html.includes(language === "es" ? "Sin indicar" : "Not specified"), false);
|
||||||
assert.ok(html.includes(language === "es" ? "Seleccionar Equipo 2" : "Select Equipo 2"));
|
assert.ok(html.includes(language === "es" ? "Seleccionar Equipo 2" : "Select Equipo 2"));
|
||||||
const firstHeader = html.match(/<th\b[^>]*>[\s\S]*?<\/th>/)?.[0];
|
const firstHeader = html.match(/<th\b[^>]*>[\s\S]*?<\/th>/)?.[0];
|
||||||
const firstCell = html.match(/<tbody[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>/)?.[1];
|
const firstCell = html.match(/<tbody[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>/)?.[1];
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
|
|||||||
name: "",
|
name: "",
|
||||||
templateId: "",
|
templateId: "",
|
||||||
siteId: null,
|
siteId: null,
|
||||||
|
registeredOn: null,
|
||||||
status: "operational",
|
status: "operational",
|
||||||
notes: "",
|
notes: "",
|
||||||
characteristics: {},
|
characteristics: {},
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
/** Formatea una fecha de calendario sin desplazar el día por la zona horaria. */
|
||||||
|
export function formatAssetRegistrationDate(value: string | null | undefined, locale: string) {
|
||||||
|
return value
|
||||||
|
? new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(
|
||||||
|
new Date(`${value}T00:00:00Z`)
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@ -215,6 +215,10 @@ test("El activo ofrece altas relacionadas, ayuda inferior y no encadena una terc
|
|||||||
for (const label of ["Nueva plantilla", "Nueva sede"]) {
|
for (const label of ["Nueva plantilla", "Nueva sede"]) {
|
||||||
assert.ok(html.includes(`aria-label="${label}"`));
|
assert.ok(html.includes(`aria-label="${label}"`));
|
||||||
}
|
}
|
||||||
|
assert.ok(html.includes("Fecha de alta"));
|
||||||
|
assert.ok(html.includes('type="date"'));
|
||||||
|
assert.ok(html.includes('name="registeredOn"'));
|
||||||
|
assert.ok(html.includes("independiente de la fecha de compra"));
|
||||||
assert.ok(html.indexOf('name="notes"') < html.indexOf("<aside"));
|
assert.ok(html.indexOf('name="notes"') < html.indexOf("<aside"));
|
||||||
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
|
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
|
||||||
const child = await render(
|
const child = await render(
|
||||||
@ -391,6 +395,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
|
registeredOn: "2024-02-29",
|
||||||
name: "Impresora de administración",
|
name: "Impresora de administración",
|
||||||
templateId: fieldId,
|
templateId: fieldId,
|
||||||
siteId,
|
siteId,
|
||||||
@ -415,6 +420,22 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const language of ["es", "en"]) {
|
||||||
|
const form = await render(
|
||||||
|
language,
|
||||||
|
<AssetForm
|
||||||
|
asset={asset}
|
||||||
|
companyId={companyId}
|
||||||
|
items={[]}
|
||||||
|
lookups={lookups}
|
||||||
|
onSaved={() => undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(form.includes(language === "es" ? "Fecha de alta" : "Registration date"));
|
||||||
|
assert.ok(form.includes('name="registeredOn"'));
|
||||||
|
}
|
||||||
const html = await render(
|
const html = await render(
|
||||||
"es",
|
"es",
|
||||||
<AssetSelectionPanel
|
<AssetSelectionPanel
|
||||||
@ -479,6 +500,8 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
|
|||||||
assert.ok(html.includes(`href="/companies/${companyId}/sites"`));
|
assert.ok(html.includes(`href="/companies/${companyId}/sites"`));
|
||||||
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
|
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
|
||||||
assert.ok(html.includes("ABC123"));
|
assert.ok(html.includes("ABC123"));
|
||||||
|
assert.ok(html.includes("Fecha de alta"));
|
||||||
|
assert.ok(html.includes("29 feb 2024"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("texto largo: área editable con ayuda accesible", async () => {
|
test("texto largo: área editable con ayuda accesible", async () => {
|
||||||
|
|||||||
@ -57,7 +57,14 @@ export function AssetForm(props: AssetFormProps) {
|
|||||||
}
|
}
|
||||||
await submit(event);
|
await submit(event);
|
||||||
const invalidGeneralField = (
|
const invalidGeneralField = (
|
||||||
["name", "templateId", "status", "siteId", "notes"] satisfies (keyof typeof values)[]
|
[
|
||||||
|
"name",
|
||||||
|
"templateId",
|
||||||
|
"status",
|
||||||
|
"siteId",
|
||||||
|
"registeredOn",
|
||||||
|
"notes",
|
||||||
|
] satisfies (keyof typeof values)[]
|
||||||
).find((name) => form.getFieldState(name).error);
|
).find((name) => form.getFieldState(name).error);
|
||||||
|
|
||||||
if (invalidGeneralField) {
|
if (invalidGeneralField) {
|
||||||
@ -162,6 +169,24 @@ export function AssetForm(props: AssetFormProps) {
|
|||||||
value={values.siteId ?? ""}
|
value={values.siteId ?? ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel htmlFor={`${prefix}-registered-on`}>{t("registeredOn")}</FormLabel>
|
||||||
|
<Input
|
||||||
|
aria-describedby={`${prefix}-registered-on-help`}
|
||||||
|
aria-invalid={!!form.formState.errors.registeredOn}
|
||||||
|
id={`${prefix}-registered-on`}
|
||||||
|
type="date"
|
||||||
|
{...form.register("registeredOn", { setValueAs: (value) => value || null })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground" id={`${prefix}-registered-on-help`}>
|
||||||
|
{t("registeredOnHelp")}
|
||||||
|
</p>
|
||||||
|
{form.formState.errors.registeredOn && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{t("inventoryErrors.INVALID_REQUEST")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
{fields.length > 0 && (
|
{fields.length > 0 && (
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type { CatalogItemDTO } from "../../../../common";
|
|||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import type { AssetDTO } from "../../../../common/inventory";
|
||||||
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
||||||
import { assetCharacteristicsSummary } from "../helpers/asset-characteristics-summary";
|
import { assetCharacteristicsSummary } from "../helpers/asset-characteristics-summary";
|
||||||
|
import { formatAssetRegistrationDate } from "../helpers/format-asset-registration-date";
|
||||||
|
|
||||||
/** Inventario y selectores comparten columnas, búsqueda, pestañas y paginación. */
|
/** Inventario y selectores comparten columnas, búsqueda, pestañas y paginación. */
|
||||||
export function AssetList({
|
export function AssetList({
|
||||||
@ -55,8 +56,7 @@ export function AssetList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const siteName = (asset: AssetDTO) =>
|
const siteName = (asset: AssetDTO) =>
|
||||||
sites.find((site) => site.id === asset.values.siteId)?.name ??
|
sites.find((site) => site.id === asset.values.siteId)?.name ?? "";
|
||||||
(asset.values.siteId ? t("noValue") : t("noAssignment"));
|
|
||||||
|
|
||||||
const term = search.trim().toLocaleLowerCase();
|
const term = search.trim().toLocaleLowerCase();
|
||||||
const items = assets.filter(
|
const items = assets.filter(
|
||||||
@ -72,7 +72,7 @@ export function AssetList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
className={layout === "fill" ? "min-h-[28rem] flex-1 sm:min-h-96" : undefined}
|
className={layout === "fill" ? "min-h-112 flex-1 sm:min-h-96" : undefined}
|
||||||
onValueChange={onTemplateChange}
|
onValueChange={onTemplateChange}
|
||||||
value={templateId ?? "all"}
|
value={templateId ?? "all"}
|
||||||
>
|
>
|
||||||
@ -145,6 +145,13 @@ export function AssetList({
|
|||||||
</EntityStatus>
|
</EntityStatus>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "registeredOn",
|
||||||
|
label: t("registeredOn"),
|
||||||
|
value: (asset) => asset.values.registeredOn ?? "",
|
||||||
|
render: (asset) =>
|
||||||
|
formatAssetRegistrationDate(asset.values.registeredOn, i18n.language),
|
||||||
|
},
|
||||||
{ id: "site", label: t("site"), value: siteName, render: siteName },
|
{ id: "site", label: t("site"), value: siteName, render: siteName },
|
||||||
...extraColumns,
|
...extraColumns,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@ -57,11 +57,7 @@ export function BrandCatalogPage() {
|
|||||||
id: "logo",
|
id: "logo",
|
||||||
label: t("brands.logo.label"),
|
label: t("brands.logo.label"),
|
||||||
render: (brand) =>
|
render: (brand) =>
|
||||||
brand.logoSvg ? (
|
brand.logoSvg ? <BrandLogo logoSvg={brand.logoSvg} name={brand.name} /> : null,
|
||||||
<BrandLogo logoSvg={brand.logoSvg} name={brand.name} />
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-muted-foreground">{t("brands.logo.none")}</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "name",
|
id: "name",
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export function CatalogDetails({ item }: { item: CatalogItemDTO }) {
|
|||||||
return t(values.type);
|
return t(values.type);
|
||||||
}
|
}
|
||||||
if (values.kind === "relation") {
|
if (values.kind === "relation") {
|
||||||
return values.inverseName;
|
return values.inverseName || "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
return t("fieldCount", { count: values.fields.length });
|
return t("fieldCount", { count: values.fields.length });
|
||||||
|
|||||||
@ -85,14 +85,16 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
|||||||
{
|
{
|
||||||
id: "site",
|
id: "site",
|
||||||
label: t("site"),
|
label: t("site"),
|
||||||
value: (item) => siteName(item.values.siteId),
|
value: (item) => lookups.sites.find((site) => site.id === item.values.siteId)?.name,
|
||||||
render: (item) => <>{siteName(item.values.siteId)}</>,
|
render: (item) => lookups.sites.find((site) => site.id === item.values.siteId)?.name,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "person",
|
id: "person",
|
||||||
label: t("workstationPerson"),
|
label: t("workstationPerson"),
|
||||||
value: (item) => personName(item.values.personId),
|
value: (item) =>
|
||||||
render: (item) => <>{personName(item.values.personId)}</>,
|
lookups.people.find((person) => person.id === item.values.personId)?.name,
|
||||||
|
render: (item) =>
|
||||||
|
lookups.people.find((person) => person.id === item.values.personId)?.name,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "state",
|
id: "state",
|
||||||
@ -107,7 +109,8 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
|||||||
{
|
{
|
||||||
id: "exclusive",
|
id: "exclusive",
|
||||||
label: t("workstationAssets"),
|
label: t("workstationAssets"),
|
||||||
render: (item) => <>{members(item.values.assetIds)}</>,
|
render: (item) =>
|
||||||
|
item.values.assetIds.length ? members(item.values.assetIds) : null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "count",
|
id: "count",
|
||||||
|
|||||||
@ -37,9 +37,8 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
|
|||||||
id: "address",
|
id: "address",
|
||||||
label: t("addressOptional"),
|
label: t("addressOptional"),
|
||||||
value: (site) => site.address,
|
value: (site) => site.address,
|
||||||
render: (site) => (
|
render: (site) =>
|
||||||
<span className="whitespace-pre-wrap">{site.address ?? t("noAddress")}</span>
|
site.address ? <span className="whitespace-pre-wrap">{site.address}</span> : null,
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "phone",
|
id: "phone",
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import type { TeleworkAuthorizationDTO } from "../../../../common/telework-autho
|
|||||||
|
|
||||||
export function TeleworkAuthorizationSummary({
|
export function TeleworkAuthorizationSummary({
|
||||||
authorization,
|
authorization,
|
||||||
|
emptyDateLabel,
|
||||||
}: {
|
}: {
|
||||||
authorization: TeleworkAuthorizationDTO | undefined;
|
authorization: TeleworkAuthorizationDTO | undefined;
|
||||||
|
emptyDateLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
|
|
||||||
@ -18,7 +20,7 @@ export function TeleworkAuthorizationSummary({
|
|||||||
? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeZone: "UTC" }).format(
|
? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeZone: "UTC" }).format(
|
||||||
new Date(`${value}T00:00:00Z`)
|
new Date(`${value}T00:00:00Z`)
|
||||||
)
|
)
|
||||||
: t("telework.noDate");
|
: (emptyDateLabel ?? t("telework.noDate"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
|
|||||||
@ -98,18 +98,20 @@ export function TeleworkAuthorizationsPage(props: TeleworkAuthorizationsPageProp
|
|||||||
id: "start",
|
id: "start",
|
||||||
label: t("telework.startsOn"),
|
label: t("telework.startsOn"),
|
||||||
value: (item) => item.values.startsOn,
|
value: (item) => item.values.startsOn,
|
||||||
render: (item) => <>{item.values.startsOn ?? t("telework.noDate")}</>,
|
render: (item) => item.values.startsOn,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "end",
|
id: "end",
|
||||||
label: t("telework.expiresOn"),
|
label: t("telework.expiresOn"),
|
||||||
value: (item) => item.values.expiresOn,
|
value: (item) => item.values.expiresOn,
|
||||||
render: (item) => <>{item.values.expiresOn ?? t("telework.noDate")}</>,
|
render: (item) => item.values.expiresOn,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "details",
|
id: "details",
|
||||||
label: t("telework.title"),
|
label: t("telework.title"),
|
||||||
render: (item) => <TeleworkAuthorizationSummary authorization={item} />,
|
render: (item) => (
|
||||||
|
<TeleworkAuthorizationSummary authorization={item} emptyDateLabel="—" />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
|||||||
@ -125,8 +125,8 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
|
|||||||
{
|
{
|
||||||
id: "account",
|
id: "account",
|
||||||
label: t("profiles.account"),
|
label: t("profiles.account"),
|
||||||
value: (item) => account(item.values.accountId),
|
value: (item) => (item.values.accountId ? account(item.values.accountId) : ""),
|
||||||
render: (item) => <>{account(item.values.accountId)}</>,
|
render: (item) => (item.values.accountId ? account(item.values.accountId) : null),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "state",
|
id: "state",
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export function WorkstationAssets({
|
|||||||
workstation: WorkstationDTO;
|
workstation: WorkstationDTO;
|
||||||
assets: readonly AssetDTO[];
|
assets: readonly AssetDTO[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
const root = `/companies/${companyId}`;
|
const root = `/companies/${companyId}`;
|
||||||
const assetIds = new Set(workstation.values.assetIds);
|
const assetIds = new Set(workstation.values.assetIds);
|
||||||
|
|
||||||
@ -56,6 +56,14 @@ export function WorkstationAssets({
|
|||||||
{t(asset.displayStatus)}
|
{t(asset.displayStatus)}
|
||||||
</EntityStatus>
|
</EntityStatus>
|
||||||
<dl>
|
<dl>
|
||||||
|
<DetailField label={t("registeredOn")}>
|
||||||
|
{asset.values.registeredOn
|
||||||
|
? new Intl.DateTimeFormat(i18n.language, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeZone: "UTC",
|
||||||
|
}).format(new Date(`${asset.values.registeredOn}T00:00:00Z`))
|
||||||
|
: t("noValue")}
|
||||||
|
</DetailField>
|
||||||
{asset.specification.fields
|
{asset.specification.fields
|
||||||
.filter((field) => asset.values.characteristics[field.fieldId] !== undefined)
|
.filter((field) => asset.values.characteristics[field.fieldId] !== undefined)
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@repo/shadcn-ui/components/table";
|
} from "@repo/shadcn-ui/components/table";
|
||||||
import type { Table as TableModel } from "@tanstack/react-table";
|
import type { Table as TableModel } from "@tanstack/react-table";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Fragment, isValidElement } from "react";
|
||||||
|
|
||||||
import type { EntityColumn } from "./entity-list";
|
import type { EntityColumn } from "./entity-list";
|
||||||
|
|
||||||
@ -110,15 +112,23 @@ export function EntityTableContent<T extends { id: string }>({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column) => (
|
{visibleColumns.map((column) => {
|
||||||
<TableCell
|
const content = columns.find((item) => item.id === column.id)?.render(row.original);
|
||||||
className={entityColumnClass(column.id, column.getIsPinned() === "right", false)}
|
|
||||||
data-pinned={column.getIsPinned() || undefined}
|
return (
|
||||||
key={column.id}
|
<TableCell
|
||||||
>
|
className={entityColumnClass(
|
||||||
{columns.find((item) => item.id === column.id)?.render(row.original)}
|
column.id,
|
||||||
</TableCell>
|
column.getIsPinned() === "right",
|
||||||
))}
|
false
|
||||||
|
)}
|
||||||
|
data-pinned={column.getIsPinned() || undefined}
|
||||||
|
key={column.id}
|
||||||
|
>
|
||||||
|
{hasCellContent(content) ? content : "—"}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -153,3 +163,20 @@ function entityColumnClass(columnId: string, pinned: boolean, header: boolean) {
|
|||||||
? `${nameWidth} bg-background font-medium`
|
? `${nameWidth} bg-background font-medium`
|
||||||
: `h-14 ${nameWidth} max-w-lg whitespace-normal break-words px-3 py-3`;
|
: `h-14 ${nameWidth} max-w-lg whitespace-normal break-words px-3 py-3`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasCellContent(content: ReactNode): boolean {
|
||||||
|
if (content === null || content === undefined || typeof content === "boolean") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return content.trim().length > 0;
|
||||||
|
}
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content.some(hasCellContent);
|
||||||
|
}
|
||||||
|
if (isValidElement<{ children?: ReactNode }>(content) && content.type === Fragment) {
|
||||||
|
return hasCellContent(content.props.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -270,3 +271,57 @@ test("los textos comunes mantienen claves, idiomas y parámetros del rango", ()
|
|||||||
assert.equal(entityListLabels("fr").actions, spanish.actions);
|
assert.equal(entityListLabels("fr").actions, spanish.actions);
|
||||||
assert.equal(english.range(1, 1, 1, "$&"), "Showing 1-1 of 1 $&");
|
assert.equal(english.range(1, 1, 1, "$&"), "Showing 1-1 of 1 $&");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("las celdas vacías muestran un guion largo en es/en y conservan cero, textos y controles", () => {
|
||||||
|
const cases: readonly { content: ReactNode; expected: string }[] = [
|
||||||
|
{ content: null, expected: "—" },
|
||||||
|
{ content: undefined, expected: "—" },
|
||||||
|
{ content: false, expected: "—" },
|
||||||
|
{ content: true, expected: "—" },
|
||||||
|
{ content: "", expected: "—" },
|
||||||
|
{ content: " \n\t ", expected: "—" },
|
||||||
|
{ content: [], expected: "—" },
|
||||||
|
{ content: [null, false, ""], expected: "—" },
|
||||||
|
{ content: <></>, expected: "—" },
|
||||||
|
{
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
{null}
|
||||||
|
{false}{" "}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
expected: "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<>{undefined}</>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
expected: "—",
|
||||||
|
},
|
||||||
|
{ content: 0, expected: "0" },
|
||||||
|
{ content: <>0</>, expected: "0" },
|
||||||
|
{ content: "No", expected: "No" },
|
||||||
|
{
|
||||||
|
content: <button type="button">Editar</button>,
|
||||||
|
expected: '<button type="button">Editar</button>',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const locale of ["es", "en"]) {
|
||||||
|
for (const { content, expected } of cases) {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<EntityTable
|
||||||
|
columns={[{ id: "value", label: "Valor", render: () => content }]}
|
||||||
|
items={[{ id: "row" }]}
|
||||||
|
locale={locale}
|
||||||
|
title="Datos"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const cell = html.match(/<td\b[^>]*>([\s\S]*?)<\/td>/)?.[1];
|
||||||
|
|
||||||
|
assert.equal(cell, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user