Compare commits
2 Commits
ba562ad06f
...
37110d87ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 37110d87ed | |||
| f6c6b8c95c |
@ -11,6 +11,8 @@ No imprimir, copiar ni versionar .env locales o secretos.
|
||||
|
||||
`packages/shadcn-ui` contiene código de terceros/vendor. No refactorizar, reformatear, reorganizar ni modificar su implementación salvo instrucción explícita del usuario. Puede consultarse su API pública para integrarlo desde código propio.
|
||||
|
||||
`archive/` conserva funcionalidades retiradas y sus archivos de integración. No modificar ni aplicarles las convenciones del código activo. Su recuperación se documenta en [funcionalidades archivadas](docs/funcionalidades-archivadas.md).
|
||||
|
||||
## Convenciones
|
||||
|
||||
El espaciado vertical se define en `.lintrc`, cargado por `eslint.config.mjs`. Separar funciones, clases y métodos con una línea vacía; mantener agrupadas propiedades y firmas de sobrecarga. `pnpm format` combina Biome con estas reglas. Véase [formato y espaciado](docs/formato-y-espaciado-del-codigo.md).
|
||||
@ -84,7 +86,7 @@ Las fuentes normativas se consultan, no se duplican:
|
||||
|
||||
Todo código web nuevo de módulos, entidades y operaciones debe seguir [la estructura web por entidades y operaciones](docs/estructura-web-por-entidades-y-operaciones.md): `modules/<module>/src/web/<entity>/<operation>/`, con ámbitos `shared` según sus consumidores e índices únicamente donde haya contenido exportable para otras unidades. Esta guía es la fuente normativa de la organización interna del frontend; no cambia las capas del servidor ni la implementación vendor.
|
||||
|
||||
React, TanStack Query, Tailwind y shadcn/ui (Base UI, estilo Vega). Los componentes compartidos viven en @repo/shadcn-ui. Incorporar TanStack Table y React Hook Form cuando haya consumidores.
|
||||
React, TanStack Query, Tailwind y shadcn/ui (Base UI, estilo Vega). Los componentes propios compartidos viven en @repo/rdx-ui y reutilizan las primitivas vendor de @repo/shadcn-ui. TanStack Table y React Hook Form ya tienen consumidores; seguir los patrones existentes.
|
||||
Estado remoto en TanStack Query; claves centralizadas e invalidación explícita después de mutaciones.
|
||||
No duplicar estado remoto ni usar efectos para derivar valores calculables durante render.
|
||||
Mantener accesibilidad y estados de carga, error, vacío y acciones deshabilitadas.
|
||||
@ -94,7 +96,8 @@ Antes de crear o modificar pantallas, reconocer activamente patrones visuales y
|
||||
Reutilizar y extender la biblioteca visual existente antes de duplicar estructura, estilos o comportamiento. Extraer componentes compartidos cuando haya repetición real: cabeceras, listados, formularios modales, grupos de campos, selectores con alta, estados y ayuda contextual.
|
||||
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.
|
||||
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/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.
|
||||
|
||||
## Validación y entrega
|
||||
|
||||
@ -21,4 +21,4 @@ Aplicación interna para soporte técnico a pequeñas empresas.
|
||||
- [Traducciones de la interfaz](docs/traducciones.md)
|
||||
- [Alcance y siguientes entregas](docs/plan-de-desarrollo.md)
|
||||
|
||||
Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con personas, departamentos, asignaciones con historial y sedes, junto con catálogos informáticos, cuentas, grupos, recursos, permisos y necesidades con cobertura calculada. También incluye inventario configurable, puestos con activos exclusivos y compartidos, perfiles de trabajo por persona y dispositivo, buzones independientes y correo POP/IMAP por perfil con periodos de uso e historial. Las configuraciones del entorno y los tickets se incorporarán progresivamente.
|
||||
Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con personas, departamentos, asignaciones con historial y sedes, junto con catálogos informáticos, cuentas, grupos, recursos, permisos y necesidades con cobertura calculada. También incluye inventario configurable, puestos con activos exclusivos, perfiles de trabajo por persona y dispositivo, buzones independientes y correo POP/IMAP por perfil con periodos de uso e historial. Incluye configuraciones del entorno, teletrabajo, accesos remotos y procesos de personas. Consultar las [funcionalidades archivadas](docs/funcionalidades-archivadas.md) para el código retirado de la aplicación.
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
|
||||
"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",
|
||||
"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(new Set(entries.map((entry) => entry.requestId)).size, 3);
|
||||
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 {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
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(),
|
||||
register: (app) => {
|
||||
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");
|
||||
@ -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.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);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
@ -6,6 +6,7 @@ import type { Application, ErrorRequestHandler, RequestHandler } from "express";
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
|
||||
import { httpErrorDiagnostics } from "./infrastructure/http/http-error-diagnostic";
|
||||
import type { HttpLogger } from "./infrastructure/http/http-logger";
|
||||
import { logHttpRequest } from "./infrastructure/http/http-logger";
|
||||
|
||||
@ -51,13 +52,23 @@ export function createApp(
|
||||
response.status(404).json(error);
|
||||
});
|
||||
|
||||
const errorHandler: ErrorRequestHandler = (failure: unknown, _request, response, next) => {
|
||||
const errorHandler: ErrorRequestHandler = (failure: unknown, request, response, next) => {
|
||||
if (response.headersSent) {
|
||||
next(failure);
|
||||
|
||||
return;
|
||||
}
|
||||
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 = {
|
||||
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
|
||||
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";
|
||||
requestId: string;
|
||||
method: string;
|
||||
@ -6,9 +8,23 @@ export type HttpLogEntry = Readonly<{
|
||||
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;
|
||||
|
||||
// No se registran URL, cabeceras ni cuerpos: pueden contener datos de clientes o credenciales.
|
||||
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.ok(created.history[0]?.actorName);
|
||||
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`, {});
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@ -8,9 +8,10 @@ Las pruebas verifican comportamiento observable, no detalles internos.
|
||||
- HTTP: middleware, entrada, contratos de salida, estados y autorización.
|
||||
- Frontend: textos traducidos, estados y acciones observables.
|
||||
|
||||
Usar el runner nativo de Node.js con tsx. La primera prueba HTTP abre Express en un puerto efímero y usa fetch real. No necesita una base de datos ni espera tiempos arbitrarios.
|
||||
La prueba web inicial usa renderizado React en servidor para verificar es/en y el estado pendiente.
|
||||
No sustituye una prueba de interacción en navegador.
|
||||
Usar el runner nativo de Node.js con tsx. Las pruebas HTTP técnicas abren Express en un puerto efímero y usan fetch real, sin necesitar base de datos. Las pruebas de persistencia utilizan MariaDB y exclusivamente la base de pruebas. No utilizar esperas arbitrarias.
|
||||
Las pruebas web usan renderizado React en servidor para verificar es/en, estados y contratos observables. No sustituyen una prueba de interacción en navegador.
|
||||
|
||||
`archive/` queda fuera de la suite activa. Las pruebas de retirada comprueban la ausencia de las funciones archivadas y la conservación de los datos de las entidades activas.
|
||||
|
||||
Comprobaciones transversales: pnpm typecheck, pnpm lint, pnpm test, pnpm check y pnpm build.
|
||||
Si una no se ejecuta o falla, documentar exactamente cuál y por qué.
|
||||
|
||||
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@ -582,3 +584,9 @@ un valor esté vacío, de su estado deshabilitado ni de que un booleano sea `fal
|
||||
Los grupos de campos, como permisos, reutilizan `RequiredIndicator` en su leyenda,
|
||||
sin marcar cada checkbox como obligatorio. La configuración de obligatoriedad de las
|
||||
plantillas conserva sus switches y su significado.
|
||||
|
||||
`TemplateFieldSummary` aplica el mismo criterio en la consulta de plantillas: recibe
|
||||
`requiredLabel` con el texto traducido de obligatoriedad solo si el campo es obligatorio.
|
||||
Muestra el asterisco junto al nombre y conserva ese texto para lectores de pantalla.
|
||||
Los campos optativos no muestran indicación de opcionalidad; tipo, unidad y avisos se
|
||||
mantienen como metadatos de consulta.
|
||||
|
||||
@ -15,6 +15,13 @@ El código está en [archive/tickets-and-interventions](../archive/tickets-and-i
|
||||
Son referencias para reintegrar, no archivos que deban sobrescribirse.
|
||||
- `manifest.json`: ruta original y SHA-256 de cada archivo conservado.
|
||||
|
||||
La comprobación documental del 15 de octubre de 2026 detecta dos entradas cuyo SHA-256
|
||||
no coincide con el fichero conservado: `integration/modules/companies/src/common/locales/es.json`
|
||||
y `integration/modules/companies/src/common/locales/en.json`. Ambos ficheros coinciden con
|
||||
su versión en Git; la discrepancia del manifiesto ya existía y esta revisión no modifica
|
||||
el archivo. Las otras 253 entradas coinciden. Revisar esas dos entradas antes de utilizar
|
||||
el manifiesto como comprobación íntegra de una recuperación.
|
||||
|
||||
El archivo queda fuera de los workspaces de PNPM, del ámbito TypeScript y de Biome y
|
||||
ESLint. Ningún archivo activo lo importa. No es un paquete ejecutable ni un flag.
|
||||
|
||||
|
||||
@ -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».
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
- 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: distribución, cabecera, cuatro bandas, márgenes, altura base de filas, búsqueda, limpieza, contador, columnas, ordenación, paginación, accesibilidad y estados.
|
||||
|
||||
@ -29,7 +29,7 @@ Debes:
|
||||
- evitar cambios puramente cosméticos sin beneficio real;
|
||||
- evitar introducir nuevas abstracciones innecesarias.
|
||||
|
||||
La tarea consiste en revisar TODO el código propio relevante del repositorio, no únicamente los archivos modificados recientemente.
|
||||
La tarea consiste en revisar TODO el código propio relevante de la aplicación activa, no únicamente los archivos modificados recientemente. Excluye `archive/`: conserva código retirado y referencias de integración, no implementaciones que deban refactorizarse ni instrucciones vigentes. Consulta [funcionalidades archivadas](../funcionalidades-archivadas.md) para su alcance.
|
||||
|
||||
No te limites a generar un informe de problemas: corrige los problemas que puedan resolverse de forma segura.
|
||||
|
||||
@ -716,4 +716,4 @@ No afirmes:
|
||||
- haber corregido un problema que siga presente;
|
||||
- haber ejecutado una comprobación que no hayas ejecutado.
|
||||
|
||||
El resultado debe dejar el código propio del repositorio progresivamente alineado con `AGENTS.md`, manteniendo el comportamiento existente y evitando refactorizaciones especulativas.
|
||||
El resultado debe dejar el código propio del repositorio progresivamente alineado con `AGENTS.md`, manteniendo el comportamiento existente y evitando refactorizaciones especulativas.
|
||||
|
||||
@ -21,6 +21,7 @@ export const AssetInputSchema = z.strictObject({
|
||||
templateId: z.uuid(),
|
||||
siteId: z.uuid().nullable(),
|
||||
brandId: z.uuid().nullable().optional(),
|
||||
registeredOn: z.iso.date().nullable().optional(),
|
||||
status: AssetStatusSchema,
|
||||
notes: z.string().max(10000),
|
||||
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.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(
|
||||
Object.freeze({
|
||||
...data,
|
||||
registeredOn: data.registeredOn ?? null,
|
||||
name: name.data.toPrimitive(),
|
||||
templateId: data.templateId.toLowerCase(),
|
||||
siteId: data.siteId?.toLowerCase() ?? null,
|
||||
@ -190,6 +191,7 @@ export class AssetValues {
|
||||
first.name === second.name &&
|
||||
first.templateId === second.templateId &&
|
||||
first.siteId === second.siteId &&
|
||||
(first.registeredOn ?? null) === (second.registeredOn ?? null) &&
|
||||
(first.brandId ?? null) === (second.brandId ?? null) &&
|
||||
this.technicalStatus === other.technicalStatus &&
|
||||
first.notes === second.notes &&
|
||||
|
||||
@ -33,6 +33,7 @@ type AssetRow = Omit<InventoryRow, "values" | "specification"> & {
|
||||
status: string;
|
||||
notes: string;
|
||||
brandDefined: boolean;
|
||||
registeredOn: string | null;
|
||||
templateName: string;
|
||||
sectionsDefined: boolean;
|
||||
};
|
||||
@ -42,6 +43,7 @@ type RevisionColumns = Omit<RevisionRow, "values"> & {
|
||||
status: string;
|
||||
notes: string;
|
||||
brandDefined: boolean;
|
||||
registeredOn: string | null;
|
||||
templateId: string;
|
||||
};
|
||||
|
||||
@ -141,12 +143,9 @@ export async function readInventory(
|
||||
companyId: string,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const assets = await readAssetRows<AssetRow>(
|
||||
database,
|
||||
"assets",
|
||||
"companyId",
|
||||
[companyId],
|
||||
transaction
|
||||
const assets = await database.query<AssetRow>(
|
||||
"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",
|
||||
{ replacements: { companyId }, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const ids = assets.map((row) => row.id);
|
||||
const fields = await readAssetRows<SpecificationField>(
|
||||
@ -171,13 +170,12 @@ export async function readInventory(
|
||||
transaction
|
||||
);
|
||||
const current = await readCharacteristics(database, "asset_characteristics", ids, transaction);
|
||||
const histories = await readAssetRows<RevisionColumns>(
|
||||
database,
|
||||
"asset_revisions",
|
||||
"assetId",
|
||||
ids,
|
||||
transaction
|
||||
);
|
||||
const histories = ids.length
|
||||
? await database.query<RevisionColumns>(
|
||||
"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)",
|
||||
{ replacements: { ids }, type: QueryTypes.SELECT, transaction }
|
||||
)
|
||||
: [];
|
||||
const historical = await readCharacteristics(
|
||||
database,
|
||||
"asset_revision_characteristics",
|
||||
@ -188,6 +186,7 @@ export async function readInventory(
|
||||
const values = (row: AssetRow | RevisionColumns, entries: CharacteristicRow[]) =>
|
||||
AssetRecordedInputSchema.parse({
|
||||
name: row.name,
|
||||
registeredOn: row.registeredOn,
|
||||
templateId: row.templateId,
|
||||
siteId: row.siteId,
|
||||
...(row.brandDefined ? { brandId: row.brandId } : {}),
|
||||
@ -243,6 +242,19 @@ export async function readInventory(
|
||||
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(
|
||||
database: Sequelize,
|
||||
rows: ReturnType<AssetPersistenceMapper["toPersistence"]>,
|
||||
@ -259,13 +271,7 @@ export async function writeInventory(
|
||||
{
|
||||
id: asset.id,
|
||||
companyId: asset.companyId,
|
||||
templateId: v.templateId,
|
||||
siteId: v.siteId,
|
||||
brandId: v.brandId ?? null,
|
||||
name: v.name,
|
||||
status: v.status,
|
||||
notes: v.notes,
|
||||
brandDefined: v.brandId !== undefined,
|
||||
...inventoryValueColumns(v),
|
||||
templateName: specification.templateName,
|
||||
sectionsDefined: specification.sections !== undefined,
|
||||
},
|
||||
@ -332,13 +338,7 @@ export async function writeInventory(
|
||||
actorId: revision.actorId,
|
||||
recordedAt: revision.recordedAt,
|
||||
position: revision.position,
|
||||
templateId: previous.templateId,
|
||||
siteId: previous.siteId,
|
||||
brandId: previous.brandId ?? null,
|
||||
name: previous.name,
|
||||
status: previous.status,
|
||||
notes: previous.notes,
|
||||
brandDefined: previous.brandId !== undefined,
|
||||
...inventoryValueColumns(previous),
|
||||
},
|
||||
transaction
|
||||
);
|
||||
|
||||
@ -24,6 +24,7 @@ export const definitionColumns = {
|
||||
};
|
||||
|
||||
export const inventoryColumns = {
|
||||
registeredOn: { type: DataTypes.DATEONLY, allowNull: true },
|
||||
name: title,
|
||||
status: { type: DataTypes.STRING(15), 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")) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,14 +51,14 @@
|
||||
"choose": "Choose a tool",
|
||||
"chooseState": "Choose a status",
|
||||
"newTool": "New tool",
|
||||
"destination": "Identifier or destination (optional)",
|
||||
"destination": "Identifier or destination",
|
||||
"requiresVpn": "Requires VPN",
|
||||
"preferred": "Preferred method",
|
||||
"state": "Status",
|
||||
"pending": "Pending configuration",
|
||||
"available": "Available",
|
||||
"retired": "Retired",
|
||||
"notes": "Notes (optional)",
|
||||
"notes": "Notes",
|
||||
"empty": "No assistance methods recorded for this asset.",
|
||||
"showRetired": "Include retired methods",
|
||||
"history": "History",
|
||||
@ -202,10 +202,12 @@
|
||||
"editField": "Edit field",
|
||||
"newTemplate": "New template",
|
||||
"editTemplate": "Edit template",
|
||||
"registeredOn": "Registration date",
|
||||
"registeredOnHelp": "Date the asset joined the company. Optional and independent of the purchase date.",
|
||||
"name": "Name",
|
||||
"description": "Description (optional)",
|
||||
"description": "Description",
|
||||
"type": "Data type",
|
||||
"unit": "Unit (optional)",
|
||||
"unit": "Unit",
|
||||
"choices": "List values",
|
||||
"choicesHelp": "Enter one value per line. Existing values are retained; you can add others.",
|
||||
"text": "Text",
|
||||
@ -251,10 +253,9 @@
|
||||
"view": "View",
|
||||
"close": "Close",
|
||||
"viewDefinition": "{{name}}",
|
||||
"optional": "Optional",
|
||||
"remove": "Remove field: {{name}}",
|
||||
"noFields": "Add at least one field. All fields are initially optional.",
|
||||
"fieldHelpText": "Help text (optional)",
|
||||
"fieldHelpText": "Help text",
|
||||
"fieldHelpHint": "Shown next to the field when creating or editing an asset.",
|
||||
"protected": "Built-in templates and fields can be edited in the global catalog. A field's data type remains fixed. You can change availability or create a template copy for a company.",
|
||||
"helpTitle": "What is it for?",
|
||||
@ -311,7 +312,7 @@
|
||||
"title": "Template sections",
|
||||
"add": "Add section",
|
||||
"name": "Section name",
|
||||
"description": "Description (optional)",
|
||||
"description": "Description",
|
||||
"ungrouped": "Unsectioned",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
@ -339,7 +340,7 @@
|
||||
"error": "The record could not be deleted. Please try again."
|
||||
},
|
||||
"templateIcon": {
|
||||
"label": "Icon (optional)",
|
||||
"label": "Icon",
|
||||
"none": "No icon",
|
||||
"help": "Visually identifies the template and its assets. You can change or remove it on custom templates.",
|
||||
"options": {
|
||||
@ -402,7 +403,7 @@
|
||||
"broken": "Broken",
|
||||
"technicalStatus": "Technical state",
|
||||
"technicalStatusHelp": "Available and In use are calculated from active workstations. Breakdowns and maintenance retain existing assignments.",
|
||||
"workstationPersonOptional": "Workstation owner (optional)",
|
||||
"workstationPersonOptional": "Workstation owner",
|
||||
"workstationNameBySite": "Workstation at {{site}}",
|
||||
"workstationSteps": {
|
||||
"label": "New workstation steps",
|
||||
|
||||
@ -51,14 +51,14 @@
|
||||
"choose": "Selecciona una herramienta",
|
||||
"chooseState": "Selecciona un estado",
|
||||
"newTool": "Nueva herramienta",
|
||||
"destination": "Identificador o destino (opcional)",
|
||||
"destination": "Identificador o destino",
|
||||
"requiresVpn": "Requiere VPN",
|
||||
"preferred": "Método preferido",
|
||||
"state": "Estado",
|
||||
"pending": "Pendiente de configurar",
|
||||
"available": "Disponible",
|
||||
"retired": "Retirado",
|
||||
"notes": "Notas (opcional)",
|
||||
"notes": "Notas",
|
||||
"empty": "No hay métodos de asistencia registrados para este activo.",
|
||||
"showRetired": "Mostrar métodos retirados",
|
||||
"history": "Historial",
|
||||
@ -202,10 +202,12 @@
|
||||
"editField": "Editar campo",
|
||||
"newTemplate": "Nueva 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",
|
||||
"description": "Descripción (opcional)",
|
||||
"description": "Descripción",
|
||||
"type": "Tipo de dato",
|
||||
"unit": "Unidad (opcional)",
|
||||
"unit": "Unidad",
|
||||
"choices": "Valores de la lista",
|
||||
"choicesHelp": "Escribe un valor por línea. Los valores existentes se conservan; puedes añadir otros.",
|
||||
"text": "Texto",
|
||||
@ -251,10 +253,9 @@
|
||||
"view": "Ver",
|
||||
"close": "Cerrar",
|
||||
"viewDefinition": "{{name}}",
|
||||
"optional": "Opcional",
|
||||
"remove": "Retirar campo: {{name}}",
|
||||
"noFields": "Añade al menos un campo. Todos son opcionales inicialmente.",
|
||||
"fieldHelpText": "Texto de ayuda (opcional)",
|
||||
"fieldHelpText": "Texto de ayuda",
|
||||
"fieldHelpHint": "Se muestra junto al campo al crear o editar un activo.",
|
||||
"protected": "Las plantillas y campos de serie se pueden editar desde el catálogo global. El tipo de dato de un campo se mantiene fijo. Puedes cambiar la disponibilidad o crear una copia de una plantilla para una compañía.",
|
||||
"helpTitle": "¿Qué es y para qué sirve?",
|
||||
@ -311,7 +312,7 @@
|
||||
"title": "Secciones de la plantilla",
|
||||
"add": "Añadir sección",
|
||||
"name": "Nombre de la sección",
|
||||
"description": "Descripción (opcional)",
|
||||
"description": "Descripción",
|
||||
"ungrouped": "Sin sección",
|
||||
"moveUp": "Mover hacia arriba",
|
||||
"moveDown": "Mover hacia abajo",
|
||||
@ -339,7 +340,7 @@
|
||||
"error": "No se ha podido eliminar el registro. Inténtalo de nuevo."
|
||||
},
|
||||
"templateIcon": {
|
||||
"label": "Icono (opcional)",
|
||||
"label": "Icono",
|
||||
"none": "Sin icono",
|
||||
"help": "Identifica visualmente la plantilla y sus activos. Puedes cambiarlo o quitarlo en las plantillas personalizadas.",
|
||||
"options": {
|
||||
@ -402,7 +403,7 @@
|
||||
"broken": "Averiado",
|
||||
"technicalStatus": "Estado técnico",
|
||||
"technicalStatusHelp": "Disponible y En uso se calculan según los puestos activos. Una avería o mantenimiento conserva las asignaciones existentes.",
|
||||
"workstationPersonOptional": "Responsable del puesto (opcional)",
|
||||
"workstationPersonOptional": "Responsable del puesto",
|
||||
"workstationNameBySite": "Puesto en {{site}}",
|
||||
"workstationSteps": {
|
||||
"label": "Pasos del nuevo puesto",
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -39,7 +39,9 @@ export function RelationshipForm(props: RelationshipFormProps) {
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-type`}>{t("relationshipType")}</Label>
|
||||
<FormLabel htmlFor={`${id}-type`} required>
|
||||
{t("relationshipType")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={{ label: t("newRelation"), onClick: () => setQuick(true) }}
|
||||
disabled={mutation.isPending || catalog.isPending}
|
||||
@ -56,7 +58,9 @@ export function RelationshipForm(props: RelationshipFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-target`}>{t("targetAsset")}</Label>
|
||||
<FormLabel htmlFor={`${id}-target`} required>
|
||||
{t("targetAsset")}
|
||||
</FormLabel>
|
||||
<AssetSelect
|
||||
companyId={companyId}
|
||||
disabled={mutation.isPending || inventory.isPending}
|
||||
@ -71,7 +75,7 @@ export function RelationshipForm(props: RelationshipFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-notes`}>{t("notes")}</Label>
|
||||
<FormLabel htmlFor={`${id}-notes`}>{t("notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${id}-notes`}
|
||||
maxLength={10000}
|
||||
|
||||
@ -5,6 +5,7 @@ import { FieldSections } from "@repo/rdx-ui/components/field-sections";
|
||||
import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatAssetRegistrationDate } from "../../shared/helpers/format-asset-registration-date";
|
||||
import { useAssetDetailController } from "../controllers";
|
||||
import type { AssetDetailProps } from "../types";
|
||||
|
||||
@ -45,6 +46,9 @@ export function AssetDetail(props: AssetDetailProps) {
|
||||
)}
|
||||
<DetailSection title={t("detailSections.assignment")}>
|
||||
<dl>
|
||||
<DetailField label={t("registeredOn")}>
|
||||
{formatAssetRegistrationDate(asset.values.registeredOn, i18n.language) ?? t("noValue")}
|
||||
</DetailField>
|
||||
<DetailField label={t("site")}>
|
||||
{asset.values.siteId ? (
|
||||
<BrowserLink href={`/companies/${asset.companyId}/sites`}>
|
||||
@ -123,6 +127,11 @@ export function AssetDetail(props: AssetDetailProps) {
|
||||
<p>
|
||||
{t("site")}: {nameOf(revision.values.siteId, lookups.sites)}
|
||||
</p>
|
||||
<p>
|
||||
{t("registeredOn")}:{" "}
|
||||
{formatAssetRegistrationDate(revision.values.registeredOn, i18n.language) ??
|
||||
t("noValue")}
|
||||
</p>
|
||||
<details>
|
||||
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">
|
||||
<FormSectionTitle as="span">{t("characteristics")}</FormSectionTitle>
|
||||
|
||||
@ -20,6 +20,7 @@ const assets = [2, 3].map((n) => {
|
||||
name: `Equipo ${n}`,
|
||||
templateId: id(n + 10),
|
||||
siteId: id(4),
|
||||
registeredOn: n === 2 ? "2024-02-29" : null,
|
||||
status: "operational",
|
||||
notes: "",
|
||||
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("Monitores"));
|
||||
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"));
|
||||
const firstHeader = html.match(/<th\b[^>]*>[\s\S]*?<\/th>/)?.[0];
|
||||
const firstCell = html.match(/<tbody[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>/)?.[1];
|
||||
|
||||
@ -13,6 +13,8 @@ export function AssetSelect(props: AssetSelectProps) {
|
||||
props;
|
||||
const { t } = useTranslation("assets");
|
||||
const [open, setOpen] = useState(false);
|
||||
const requiredDescriptionId = required ? `${id}-required` : undefined;
|
||||
const descriptionIds = [describedBy, requiredDescriptionId].filter(Boolean).join(" ");
|
||||
const displayValue =
|
||||
props.mode === "multiple"
|
||||
? props.value.length
|
||||
@ -25,7 +27,7 @@ export function AssetSelect(props: AssetSelectProps) {
|
||||
<>
|
||||
<ButtonGroup aria-label={label} className="w-full min-w-0">
|
||||
<Button
|
||||
aria-describedby={describedBy}
|
||||
aria-describedby={descriptionIds || undefined}
|
||||
aria-haspopup="dialog"
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-label={label}
|
||||
@ -37,7 +39,11 @@ export function AssetSelect(props: AssetSelectProps) {
|
||||
variant="outline"
|
||||
>
|
||||
{displayValue}
|
||||
{required && <span className="sr-only">{t("assetPicker.required")}</span>}
|
||||
{required && (
|
||||
<span className="sr-only" id={requiredDescriptionId}>
|
||||
{t("assetPicker.required")}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{props.mode !== "multiple" && !required && props.value && (
|
||||
<Button
|
||||
|
||||
@ -27,6 +27,7 @@ export function useAssetFormController({ companyId, asset, items, onSaved }: Ass
|
||||
name: "",
|
||||
templateId: "",
|
||||
siteId: null,
|
||||
registeredOn: null,
|
||||
status: "operational",
|
||||
notes: "",
|
||||
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"]) {
|
||||
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("<aside") < html.indexOf('type="submit"'));
|
||||
const child = await render(
|
||||
@ -363,11 +367,7 @@ test("alta de puesto: sede única automática, responsable opcional y cuatro pas
|
||||
);
|
||||
|
||||
assert.ok(html.includes("Sede única test"));
|
||||
assert.ok(
|
||||
html.includes(
|
||||
language === "es" ? "Responsable del puesto (opcional)" : "Workstation owner (optional)"
|
||||
)
|
||||
);
|
||||
assert.ok(html.includes(language === "es" ? "Responsable del puesto" : "Workstation owner"));
|
||||
assert.ok(html.includes(language === "es" ? "Paso 1 de 4" : "Step 1 of 4"));
|
||||
assert.ok(html.includes('aria-current="step"'));
|
||||
assert.equal(html.includes(language === "es" ? ">Guardar<" : ">Save<"), false);
|
||||
@ -395,6 +395,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
|
||||
],
|
||||
},
|
||||
values: {
|
||||
registeredOn: "2024-02-29",
|
||||
name: "Impresora de administración",
|
||||
templateId: fieldId,
|
||||
siteId,
|
||||
@ -419,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(
|
||||
"es",
|
||||
<AssetSelectionPanel
|
||||
@ -483,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}/catalogs/assets?tab=templates"`));
|
||||
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 () => {
|
||||
@ -511,6 +530,42 @@ test("texto largo: área editable con ayuda accesible", async () => {
|
||||
assert.match(markup, /Puertos y velocidades admitidas/);
|
||||
});
|
||||
|
||||
test("las características marcan solo los campos obligatorios y conservan unidad y ayuda", async () => {
|
||||
for (const required of [true, false]) {
|
||||
const markup = await render(
|
||||
"es",
|
||||
<AssetCharacteristicControl
|
||||
disabled={false}
|
||||
field={{
|
||||
fieldId: "0199b200-0000-7000-8000-000000000099",
|
||||
name: "Capacidad",
|
||||
type: "number",
|
||||
unit: "GB",
|
||||
choices: [],
|
||||
required,
|
||||
}}
|
||||
helpText="Capacidad total"
|
||||
onChange={() => undefined}
|
||||
prefix="capacity"
|
||||
value={0}
|
||||
/>
|
||||
);
|
||||
const label = markup.match(/<label\b[^>]*>[\s\S]*?<\/label>/)?.[0];
|
||||
const input = markup.match(/<input\b[^>]*>/)?.[0];
|
||||
|
||||
assert.ok(label);
|
||||
assert.ok(label.includes("Capacidad (GB)"));
|
||||
assert.equal(label.includes('data-slot="required-indicator"'), required);
|
||||
assert.ok(input);
|
||||
assert.ok(input.includes('value="0"'));
|
||||
assert.equal(input.includes('required=""'), required);
|
||||
assert.ok(
|
||||
input.includes('aria-describedby="capacity-0199b200-0000-7000-8000-000000000099-help"')
|
||||
);
|
||||
assert.ok(markup.includes("Capacidad total"));
|
||||
}
|
||||
});
|
||||
|
||||
test("el resumen usa los campos propios de la plantilla, omite vacíos y texto largo y limita a cuatro", async () => {
|
||||
const fields = [
|
||||
{ name: "Modelo", type: "text", unit: "", value: " ThinkCentre " },
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { FieldHelp, fieldHelpId } from "@repo/rdx-ui/components/field-help";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -28,11 +28,13 @@ export function AssetCharacteristicControl({
|
||||
const id = `${prefix}-${field.fieldId}`;
|
||||
const describedBy = fieldHelpId(id, helpText);
|
||||
const displayValue = value === undefined ? "" : String(value);
|
||||
const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`;
|
||||
const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<FormLabel htmlFor={id} required={field.required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
{field.type === "choice" || field.type === "boolean" ? (
|
||||
<SelectWithAction
|
||||
describedBy={describedBy}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { TabbedFieldSections } from "@repo/rdx-ui/components/tabbed-field-sections";
|
||||
import { fieldHelpById } from "@repo/rdx-ui/lib/field-help";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import type { FormEvent } from "react";
|
||||
import { useId } from "react";
|
||||
@ -57,7 +57,14 @@ export function AssetForm(props: AssetFormProps) {
|
||||
}
|
||||
await submit(event);
|
||||
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);
|
||||
|
||||
if (invalidGeneralField) {
|
||||
@ -79,7 +86,9 @@ export function AssetForm(props: AssetFormProps) {
|
||||
<FormSection title={t("formLayout.identity")} variant="plain">
|
||||
<div className="grid gap-x-5 gap-y-4 @min-[560px]:grid-cols-2 @min-[1100px]:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-name`}
|
||||
maxLength={100}
|
||||
@ -88,7 +97,9 @@ export function AssetForm(props: AssetFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-template`} required>
|
||||
{t("template")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
asset || !allowQuickCreate
|
||||
@ -115,7 +126,9 @@ export function AssetForm(props: AssetFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-status`}>{t("technicalStatus")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-status`} required>
|
||||
{t("technicalStatus")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
describedBy={`${prefix}-status-help`}
|
||||
disabled={mutation.isPending}
|
||||
@ -135,7 +148,7 @@ export function AssetForm(props: AssetFormProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-site`}>{t("site")}</FormLabel>
|
||||
<SelectWithAction
|
||||
{...selectProps}
|
||||
action={
|
||||
@ -156,6 +169,24 @@ export function AssetForm(props: AssetFormProps) {
|
||||
value={values.siteId ?? ""}
|
||||
/>
|
||||
</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>
|
||||
</FormSection>
|
||||
{fields.length > 0 && (
|
||||
@ -203,7 +234,7 @@ export function AssetForm(props: AssetFormProps) {
|
||||
</FormSection>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={10000}
|
||||
|
||||
@ -8,6 +8,7 @@ import type { CatalogItemDTO } from "../../../../common";
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
|
||||
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. */
|
||||
export function AssetList({
|
||||
@ -55,8 +56,7 @@ export function AssetList({
|
||||
}
|
||||
|
||||
const siteName = (asset: AssetDTO) =>
|
||||
sites.find((site) => site.id === asset.values.siteId)?.name ??
|
||||
(asset.values.siteId ? t("noValue") : t("noAssignment"));
|
||||
sites.find((site) => site.id === asset.values.siteId)?.name ?? "";
|
||||
|
||||
const term = search.trim().toLocaleLowerCase();
|
||||
const items = assets.filter(
|
||||
@ -72,7 +72,7 @@ export function AssetList({
|
||||
|
||||
return (
|
||||
<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}
|
||||
value={templateId ?? "all"}
|
||||
>
|
||||
@ -145,6 +145,13 @@ export function AssetList({
|
||||
</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 },
|
||||
...extraColumns,
|
||||
]}
|
||||
|
||||
@ -57,11 +57,7 @@ export function BrandCatalogPage() {
|
||||
id: "logo",
|
||||
label: t("brands.logo.label"),
|
||||
render: (brand) =>
|
||||
brand.logoSvg ? (
|
||||
<BrandLogo logoSvg={brand.logoSvg} name={brand.name} />
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{t("brands.logo.none")}</span>
|
||||
),
|
||||
brand.logoSvg ? <BrandLogo logoSvg={brand.logoSvg} name={brand.name} /> : null,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -34,7 +34,9 @@ export function BrandForm(props: BrandFormProps) {
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending || isReadingLogo}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={id} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={id}
|
||||
maxLength={100}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -24,7 +24,7 @@ export function BrandLogoField({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("brands.logo.label")}</Label>
|
||||
<FormLabel htmlFor={id}>{t("brands.logo.label")}</FormLabel>
|
||||
<BrandLogo logoSvg={logoSvg} name={name} />
|
||||
<Input
|
||||
accept="image/svg+xml,.svg"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { FieldHelp, fieldHelpId } from "@repo/rdx-ui/components/field-help";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useBrandSelectController } from "../controllers";
|
||||
@ -26,7 +26,9 @@ export function BrandSelect(props: BrandSelectProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<FormLabel htmlFor={id} required={required}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
allowQuickCreate ? { label: t("brands.new"), onClick: () => setOpen(true) } : undefined
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -23,11 +23,15 @@ export function CatalogFieldForm(props: CatalogFieldFormProps) {
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${id}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-type`}>{t("type")}</Label>
|
||||
<FormLabel htmlFor={`${id}-type`} required>
|
||||
{t("type")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
disabled={!!item || mutation.isPending}
|
||||
id={`${id}-type`}
|
||||
@ -46,18 +50,20 @@ export function CatalogFieldForm(props: CatalogFieldFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-unit`}>{t("unit")}</Label>
|
||||
<FormLabel htmlFor={`${id}-unit`}>{t("unit")}</FormLabel>
|
||||
<Input id={`${id}-unit`} maxLength={30} {...form.register("unit")} />
|
||||
</div>
|
||||
{type === "choice" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-choices`}>{t("choices")}</Label>
|
||||
<FormLabel htmlFor={`${id}-choices`} required>
|
||||
{t("choices")}
|
||||
</FormLabel>
|
||||
<Textarea id={`${id}-choices`} required rows={5} {...form.register("choicesText")} />
|
||||
<p className="text-xs text-muted-foreground">{t("choicesHelp")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-description`}>{t("fieldHelpText")}</Label>
|
||||
<FormLabel htmlFor={`${id}-description`}>{t("fieldHelpText")}</FormLabel>
|
||||
<Textarea
|
||||
aria-describedby={`${id}-description-hint`}
|
||||
id={`${id}-description`}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -22,7 +22,9 @@ export function CatalogRelationForm(props: CatalogRelationFormProps) {
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={id} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={id}
|
||||
maxLength={100}
|
||||
@ -50,7 +52,9 @@ export function CatalogRelationForm(props: CatalogRelationFormProps) {
|
||||
/>
|
||||
{!symmetric && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-inverse`}>{t("inverseName")}</Label>
|
||||
<FormLabel htmlFor={`${id}-inverse`} required>
|
||||
{t("inverseName")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`${id}-inverse`}
|
||||
maxLength={100}
|
||||
@ -60,7 +64,7 @@ export function CatalogRelationForm(props: CatalogRelationFormProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
|
||||
<FormLabel htmlFor={`${id}-description`}>{t("description")}</FormLabel>
|
||||
<Textarea id={`${id}-description`} maxLength={1000} {...form.register("description")} />
|
||||
</div>
|
||||
<CatalogError
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { IconPicker } from "@repo/rdx-ui/components/icon-picker";
|
||||
import { TemplateMetadataSection } from "@repo/rdx-ui/components/template-metadata-section";
|
||||
import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-sections-editor";
|
||||
import { XIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -47,7 +47,7 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
<TemplateMetadataSection
|
||||
descriptionField={
|
||||
<>
|
||||
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
|
||||
<FormLabel htmlFor={`${id}-description`}>{t("description")}</FormLabel>
|
||||
<Textarea
|
||||
className="min-h-24 flex-1"
|
||||
id={`${id}-description`}
|
||||
@ -60,7 +60,9 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
primaryFields={
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${id}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<IconPicker
|
||||
|
||||
@ -91,7 +91,7 @@ export function CatalogDefinitionDialog({
|
||||
}
|
||||
name={field?.values.name ?? binding.fieldId}
|
||||
notice={field && !field.effectiveActive ? t("disabledField") : undefined}
|
||||
requirement={t(binding.required ? "required" : "optional")}
|
||||
requiredLabel={binding.required ? t("required") : undefined}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@ -10,7 +10,7 @@ export function CatalogDetails({ item }: { item: CatalogItemDTO }) {
|
||||
return t(values.type);
|
||||
}
|
||||
if (values.kind === "relation") {
|
||||
return values.inverseName;
|
||||
return values.inverseName || "—";
|
||||
}
|
||||
|
||||
return t("fieldCount", { count: values.fields.length });
|
||||
|
||||
@ -86,8 +86,8 @@ test("las plantillas ofrecen selección visual traducida y conservan el icono al
|
||||
|
||||
assert.ok(laptop);
|
||||
for (const [language, label, none] of [
|
||||
["es", "Icono (opcional)", "Sin icono"],
|
||||
["en", "Icon (optional)", "No icon"],
|
||||
["es", "Icono", "Sin icono"],
|
||||
["en", "Icon", "No icon"],
|
||||
]) {
|
||||
assert.ok(language && label && none);
|
||||
const html = await render(
|
||||
|
||||
@ -50,7 +50,7 @@ test("asistencia: formulario con alta rápida y ayuda antes de guardar", async (
|
||||
);
|
||||
|
||||
assert.ok(html.includes("Nueva herramienta"));
|
||||
assert.ok(html.includes("Identificador o destino (opcional)"));
|
||||
assert.ok(html.includes("Identificador o destino"));
|
||||
assert.ok(html.indexOf("¿Para qué sirve este método?") < html.indexOf("Guardar"));
|
||||
assert.equal(html.includes('type="password"'), false);
|
||||
assert.ok(html.includes("Requiere VPN"));
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -26,7 +26,9 @@ export function SupportForm(props: SupportFormProps) {
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={prefix}>{t("support.tool")}</Label>
|
||||
<FormLabel htmlFor={prefix} required>
|
||||
{t("support.tool")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={{ label: t("support.newTool"), onClick: () => setQuick(true) }}
|
||||
id={prefix}
|
||||
@ -36,11 +38,12 @@ export function SupportForm(props: SupportFormProps) {
|
||||
.filter((item) => item.active || item.id === connection?.values.resourceId)
|
||||
.map((item) => ({ value: item.id, label: item.name }))}
|
||||
placeholder={t("support.choose")}
|
||||
required
|
||||
value={values.resourceId}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-destination`}>{t("support.destination")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-destination`}>{t("support.destination")}</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-destination`}
|
||||
maxLength={300}
|
||||
@ -48,7 +51,9 @@ export function SupportForm(props: SupportFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("support.state")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{t("support.state")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("support.state")}
|
||||
@ -65,6 +70,7 @@ export function SupportForm(props: SupportFormProps) {
|
||||
label: t(`support.${state}`),
|
||||
}))}
|
||||
placeholder={t("support.chooseState")}
|
||||
required
|
||||
value={values.state}
|
||||
/>
|
||||
</div>
|
||||
@ -82,7 +88,7 @@ export function SupportForm(props: SupportFormProps) {
|
||||
onCheckedChange={(preferred) => form.setValue("preferred", preferred)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("support.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("support.notes")}</FormLabel>
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
id={`${prefix}-notes`}
|
||||
|
||||
@ -85,14 +85,16 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
||||
{
|
||||
id: "site",
|
||||
label: t("site"),
|
||||
value: (item) => siteName(item.values.siteId),
|
||||
render: (item) => <>{siteName(item.values.siteId)}</>,
|
||||
value: (item) => lookups.sites.find((site) => site.id === item.values.siteId)?.name,
|
||||
render: (item) => lookups.sites.find((site) => site.id === item.values.siteId)?.name,
|
||||
},
|
||||
{
|
||||
id: "person",
|
||||
label: t("workstationPerson"),
|
||||
value: (item) => personName(item.values.personId),
|
||||
render: (item) => <>{personName(item.values.personId)}</>,
|
||||
value: (item) =>
|
||||
lookups.people.find((person) => person.id === item.values.personId)?.name,
|
||||
render: (item) =>
|
||||
lookups.people.find((person) => person.id === item.values.personId)?.name,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
@ -107,7 +109,8 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
|
||||
{
|
||||
id: "exclusive",
|
||||
label: t("workstationAssets"),
|
||||
render: (item) => <>{members(item.values.assetIds)}</>,
|
||||
render: (item) =>
|
||||
item.values.assetIds.length ? members(item.values.assetIds) : null,
|
||||
},
|
||||
{
|
||||
id: "count",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
@ -36,7 +36,7 @@ export function WorkstationAssetFields({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</FormLabel>
|
||||
<AssetSelect
|
||||
action={{ label: t("newAsset"), onClick: onCreate, disabled: !canCreate }}
|
||||
companyId={companyId}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkstationInput } from "../../../../common";
|
||||
@ -29,7 +29,9 @@ export function WorkstationAssociationFields({
|
||||
return (
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-site`} required={required}>
|
||||
{t("site")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={{
|
||||
label: t("newSite"),
|
||||
@ -45,7 +47,7 @@ export function WorkstationAssociationFields({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-person`}>{t("workstationPersonOptional")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-person`}>{t("workstationPersonOptional")}</FormLabel>
|
||||
<SelectWithAction
|
||||
action={{
|
||||
label: t("newPerson"),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkstationInput } from "../../../../common";
|
||||
@ -28,7 +28,9 @@ export function WorkstationComputerField({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-computer`}>{t("workstationComputer")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-computer`} required={required}>
|
||||
{t("workstationComputer")}
|
||||
</FormLabel>
|
||||
<AssetSelect
|
||||
companyId={companyId}
|
||||
disabled={singleComputer}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { FormProgress } from "@repo/rdx-ui/components/form-progress";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -106,7 +106,9 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
||||
{layout.summary && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={prefix}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={prefix} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={prefix} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
{isWizard ? (
|
||||
|
||||
@ -142,7 +142,7 @@
|
||||
"asset": "Specific device",
|
||||
"resource": "Specific resource",
|
||||
"target": "Target",
|
||||
"site": "Site (optional)",
|
||||
"site": "Site",
|
||||
"allCompany": "Company network",
|
||||
"method": "Method or tool",
|
||||
"methodExample": "VPN, RDP, AnyDesk…",
|
||||
@ -184,9 +184,9 @@
|
||||
"edit": "Edit telework authorization",
|
||||
"person": "Person",
|
||||
"active": "Active authorization",
|
||||
"startsOn": "Valid from (optional)",
|
||||
"expiresOn": "Valid through (optional)",
|
||||
"notes": "Notes (optional)",
|
||||
"startsOn": "Valid from",
|
||||
"expiresOn": "Valid through",
|
||||
"notes": "Notes",
|
||||
"authorized": "Authorized",
|
||||
"scheduled": "Scheduled",
|
||||
"expired": "Expired",
|
||||
@ -262,7 +262,7 @@
|
||||
"edit": "Edit certificate configuration",
|
||||
"resource": "Digital certificate",
|
||||
"newResource": "New digital certificate",
|
||||
"location": "Store or location (optional)",
|
||||
"location": "Store or location",
|
||||
"expiryValue": "Expires: {{date}}",
|
||||
"expired": "Expired",
|
||||
"locationValue": "Location: {{location}}",
|
||||
@ -298,7 +298,7 @@
|
||||
"delivery": "Usage",
|
||||
"installed": "Installed on device",
|
||||
"web": "Web access",
|
||||
"version": "Version (optional)",
|
||||
"version": "Version",
|
||||
"versionValue": "Version: {{version}}",
|
||||
"search": "Search application, profile, person or device…",
|
||||
"empty": "No applications have been registered for these profiles yet.",
|
||||
@ -374,7 +374,7 @@
|
||||
"address": "Email address",
|
||||
"method": "Access method",
|
||||
"state": "Status",
|
||||
"notes": "Notes (optional)",
|
||||
"notes": "Notes",
|
||||
"pop": "POP",
|
||||
"imap": "IMAP",
|
||||
"exchange": "Exchange",
|
||||
@ -433,8 +433,8 @@
|
||||
"kind": "Mailbox type",
|
||||
"corporate": "Corporate",
|
||||
"external": "External",
|
||||
"context": "Context (optional)",
|
||||
"account": "Related IT account (optional)",
|
||||
"context": "Context",
|
||||
"account": "Related IT account",
|
||||
"noAccount": "No related account",
|
||||
"active": "Active mailbox",
|
||||
"linkPerson": "Link person",
|
||||
@ -442,7 +442,7 @@
|
||||
"recordUse": "Record external email usage",
|
||||
"useMissing": "First record this mailbox's usage period for the profile's person.",
|
||||
"start": "From",
|
||||
"end": "Until (optional; end day excluded)",
|
||||
"end": "Until (end day excluded)",
|
||||
"openPeriod": "No end date",
|
||||
"closeUse": "End usage",
|
||||
"help": "What is a mailbox?",
|
||||
@ -463,7 +463,7 @@
|
||||
"need": "Access requirement",
|
||||
"newNeed": "New access requirement",
|
||||
"path": "Shared path",
|
||||
"letter": "Drive letter (optional)",
|
||||
"letter": "Drive letter",
|
||||
"noLetter": "Direct path access, no drive letter",
|
||||
"currentPath": "Current resource path",
|
||||
"useCurrentPath": "Use current path",
|
||||
@ -501,12 +501,12 @@
|
||||
"name": "Profile name",
|
||||
"person": "Person",
|
||||
"asset": "Device",
|
||||
"account": "Sign-in account (optional)",
|
||||
"account": "Sign-in account",
|
||||
"noAccount": "No recorded account",
|
||||
"active": "Current profile",
|
||||
"inactive": "Archived profile",
|
||||
"choose": "Choose an item",
|
||||
"notes": "Notes (optional)",
|
||||
"notes": "Notes",
|
||||
"search": "Search by profile, person or device",
|
||||
"empty": "This company has no user profiles yet.",
|
||||
"history": "Details and history",
|
||||
@ -593,9 +593,9 @@
|
||||
"newPerson": "New person",
|
||||
"editPerson": "Edit person",
|
||||
"backToPeople": "Back to people",
|
||||
"generalPosition": "General job title (optional)",
|
||||
"emailOptional": "Email (optional)",
|
||||
"phoneOptional": "Phone (optional)",
|
||||
"generalPosition": "General job title",
|
||||
"emailOptional": "Email",
|
||||
"phoneOptional": "Phone",
|
||||
"activePerson": "Active person",
|
||||
"inactivePerson": "Inactive person",
|
||||
"isContact": "Company contact",
|
||||
@ -621,9 +621,9 @@
|
||||
"editAssignment": "Edit assignment",
|
||||
"noAssignments": "This person has no department assignments yet.",
|
||||
"chooseDepartment": "Choose a department",
|
||||
"departmentPosition": "Department job title (optional)",
|
||||
"departmentPosition": "Department job title",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date (optional)",
|
||||
"endDate": "End date",
|
||||
"primary": "Primary",
|
||||
"primaryDepartment": "Primary department",
|
||||
"assignmentHelp": "Without an end date, the assignment remains open. To record a new job period, close this assignment and create the next one; both may use the same day. Edits retain history.",
|
||||
@ -653,7 +653,7 @@
|
||||
"position": "Job title",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"addressOptional": "Address (optional)",
|
||||
"addressOptional": "Address",
|
||||
"addressHelp": "Enter multiple lines, up to 10,000 characters.",
|
||||
"noAddress": "No address",
|
||||
"empty": "No companies yet. Create the first one to add contacts and sites.",
|
||||
@ -721,7 +721,7 @@
|
||||
"changePhoto": "Change photo",
|
||||
"photoHelp": "PNG, JPEG or WebP. The photo is applied when you save."
|
||||
},
|
||||
"photoOptional": "Photo (optional)",
|
||||
"photoOptional": "Photo",
|
||||
"deleteRecord": {
|
||||
"title": "Delete record",
|
||||
"description": "This record and its history will be deleted. Related assets, resources, accounts and people will be preserved. This action cannot be undone.",
|
||||
@ -761,7 +761,7 @@
|
||||
"examples": "Examples: Windows 11 Pro 24H2, Thunderbird 140 or Microsoft 365 Apps.",
|
||||
"showClosed": "Include removed and never configured installations",
|
||||
"newOs": "New operating system",
|
||||
"license": "Linked license (optional)",
|
||||
"license": "Linked license",
|
||||
"licenseColumn": "License",
|
||||
"noLicense": "No linked license",
|
||||
"licenseHelp": "Informational reference to a license belonging to this company. It does not reserve seats or confirm coverage for this product or device. Leave it empty for free software or when the license is not yet known.",
|
||||
@ -907,7 +907,6 @@
|
||||
"search": "Search",
|
||||
"empty": "No entries in this section yet.",
|
||||
"choose": "Choose an option",
|
||||
"optional": " (optional)",
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
@ -1001,4 +1000,4 @@
|
||||
"INTERNAL_ERROR": "The action could not be completed."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,7 +142,7 @@
|
||||
"asset": "Equipo concreto",
|
||||
"resource": "Recurso concreto",
|
||||
"target": "Destino",
|
||||
"site": "Sede (opcional)",
|
||||
"site": "Sede",
|
||||
"allCompany": "Red de la compañía",
|
||||
"method": "Método o herramienta",
|
||||
"methodExample": "VPN, RDP, AnyDesk…",
|
||||
@ -184,9 +184,9 @@
|
||||
"edit": "Editar autorización de teletrabajo",
|
||||
"person": "Persona",
|
||||
"active": "Autorización activa",
|
||||
"startsOn": "Válida desde (opcional)",
|
||||
"expiresOn": "Válida hasta (opcional)",
|
||||
"notes": "Notas (opcional)",
|
||||
"startsOn": "Válida desde",
|
||||
"expiresOn": "Válida hasta",
|
||||
"notes": "Notas",
|
||||
"authorized": "Autorizada",
|
||||
"scheduled": "Programada",
|
||||
"expired": "Caducada",
|
||||
@ -262,7 +262,7 @@
|
||||
"edit": "Editar configuración de certificado",
|
||||
"resource": "Certificado digital",
|
||||
"newResource": "Nuevo certificado digital",
|
||||
"location": "Almacén o ubicación (opcional)",
|
||||
"location": "Almacén o ubicación",
|
||||
"expiryValue": "Caducidad: {{date}}",
|
||||
"expired": "Caducado",
|
||||
"locationValue": "Ubicación: {{location}}",
|
||||
@ -298,7 +298,7 @@
|
||||
"delivery": "Forma de uso",
|
||||
"installed": "Instalada en el equipo",
|
||||
"web": "Acceso web",
|
||||
"version": "Versión (opcional)",
|
||||
"version": "Versión",
|
||||
"versionValue": "Versión: {{version}}",
|
||||
"search": "Buscar aplicación, perfil, persona o equipo…",
|
||||
"empty": "Todavía no hay aplicaciones registradas en estos perfiles.",
|
||||
@ -374,7 +374,7 @@
|
||||
"address": "Dirección de correo",
|
||||
"method": "Método de acceso",
|
||||
"state": "Estado",
|
||||
"notes": "Notas (opcional)",
|
||||
"notes": "Notas",
|
||||
"pop": "POP",
|
||||
"imap": "IMAP",
|
||||
"exchange": "Exchange",
|
||||
@ -433,8 +433,8 @@
|
||||
"kind": "Tipo de buzón",
|
||||
"corporate": "Corporativo",
|
||||
"external": "Externo",
|
||||
"context": "Contexto (opcional)",
|
||||
"account": "Cuenta informática relacionada (opcional)",
|
||||
"context": "Contexto",
|
||||
"account": "Cuenta informática relacionada",
|
||||
"noAccount": "Sin cuenta relacionada",
|
||||
"active": "Buzón activo",
|
||||
"linkPerson": "Vincular persona",
|
||||
@ -442,7 +442,7 @@
|
||||
"recordUse": "Registrar uso de correo externo",
|
||||
"useMissing": "Registra primero el periodo de uso de este buzón por la persona del perfil.",
|
||||
"start": "Desde",
|
||||
"end": "Hasta (opcional; día de fin excluido)",
|
||||
"end": "Hasta (día de fin excluido)",
|
||||
"openPeriod": "Sin fecha de fin",
|
||||
"closeUse": "Finalizar uso",
|
||||
"help": "¿Qué es un buzón de correo?",
|
||||
@ -463,7 +463,7 @@
|
||||
"need": "Necesidad de acceso",
|
||||
"newNeed": "Nueva necesidad de acceso",
|
||||
"path": "Ruta compartida",
|
||||
"letter": "Letra de unidad (opcional)",
|
||||
"letter": "Letra de unidad",
|
||||
"noLetter": "Acceso por ruta, sin letra",
|
||||
"currentPath": "Ruta actual del recurso",
|
||||
"useCurrentPath": "Utilizar la ruta actual",
|
||||
@ -501,12 +501,12 @@
|
||||
"name": "Nombre del perfil",
|
||||
"person": "Persona",
|
||||
"asset": "Dispositivo",
|
||||
"account": "Cuenta de inicio de sesión (opcional)",
|
||||
"account": "Cuenta de inicio de sesión",
|
||||
"noAccount": "Sin cuenta registrada",
|
||||
"active": "Perfil vigente",
|
||||
"inactive": "Perfil archivado",
|
||||
"choose": "Selecciona un elemento",
|
||||
"notes": "Notas (opcional)",
|
||||
"notes": "Notas",
|
||||
"search": "Buscar por perfil, persona o dispositivo",
|
||||
"empty": "Esta compañía todavía no tiene perfiles de usuario.",
|
||||
"history": "Ficha e historial",
|
||||
@ -593,9 +593,9 @@
|
||||
"newPerson": "Nueva persona",
|
||||
"editPerson": "Editar persona",
|
||||
"backToPeople": "Volver a personas",
|
||||
"generalPosition": "Puesto general (opcional)",
|
||||
"emailOptional": "Email (opcional)",
|
||||
"phoneOptional": "Teléfono (opcional)",
|
||||
"generalPosition": "Puesto general",
|
||||
"emailOptional": "Email",
|
||||
"phoneOptional": "Teléfono",
|
||||
"activePerson": "Persona activa",
|
||||
"inactivePerson": "Persona inactiva",
|
||||
"isContact": "Contacto de la compañía",
|
||||
@ -621,9 +621,9 @@
|
||||
"editAssignment": "Editar asignación",
|
||||
"noAssignments": "Esta persona todavía no tiene departamentos asignados.",
|
||||
"chooseDepartment": "Selecciona un departamento",
|
||||
"departmentPosition": "Puesto en el departamento (opcional)",
|
||||
"departmentPosition": "Puesto en el departamento",
|
||||
"startDate": "Fecha de inicio",
|
||||
"endDate": "Fecha de fin (opcional)",
|
||||
"endDate": "Fecha de fin",
|
||||
"primary": "Principal",
|
||||
"primaryDepartment": "Departamento principal",
|
||||
"assignmentHelp": "Sin fecha de fin, la asignación sigue abierta. Para registrar un cambio de puesto con un nuevo periodo, cierra esta asignación y crea la siguiente; puedes usar el mismo día. Las ediciones conservan su historial.",
|
||||
@ -653,7 +653,7 @@
|
||||
"position": "Puesto",
|
||||
"email": "Email",
|
||||
"phone": "Teléfono",
|
||||
"addressOptional": "Dirección (opcional)",
|
||||
"addressOptional": "Dirección",
|
||||
"addressHelp": "Puedes introducir varias líneas, hasta 10.000 caracteres.",
|
||||
"noAddress": "Sin dirección",
|
||||
"empty": "Todavía no hay compañías. Crea la primera para añadir contactos y sedes.",
|
||||
@ -721,7 +721,7 @@
|
||||
"changePhoto": "Cambiar foto",
|
||||
"photoHelp": "PNG, JPEG o WebP. La foto se aplica al guardar."
|
||||
},
|
||||
"photoOptional": "Foto (opcional)",
|
||||
"photoOptional": "Foto",
|
||||
"deleteRecord": {
|
||||
"title": "Eliminar registro",
|
||||
"description": "Se eliminarán este registro y su historial. Los activos, recursos, cuentas y personas relacionados se conservarán. Esta acción no se puede deshacer.",
|
||||
@ -761,7 +761,7 @@
|
||||
"examples": "Ejemplos: Windows 11 Pro 24H2, Thunderbird 140 o Microsoft 365 Apps.",
|
||||
"showClosed": "Mostrar también las retiradas y nunca configuradas",
|
||||
"newOs": "Nuevo sistema operativo",
|
||||
"license": "Licencia vinculada (opcional)",
|
||||
"license": "Licencia vinculada",
|
||||
"licenseColumn": "Licencia",
|
||||
"noLicense": "Sin licencia vinculada",
|
||||
"licenseHelp": "Referencia informativa a una licencia de esta compañía. No reserva plazas ni confirma que la licencia cubra este producto o equipo. Puedes dejarla vacía para software gratuito o si aún no se conoce.",
|
||||
@ -907,7 +907,6 @@
|
||||
"search": "Buscar",
|
||||
"empty": "Todavía no hay elementos en esta sección.",
|
||||
"choose": "Selecciona una opción",
|
||||
"optional": " (opcional)",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando…",
|
||||
"cancel": "Cancelar",
|
||||
@ -1001,4 +1000,4 @@
|
||||
"INTERNAL_ERROR": "No se ha podido completar la operación."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { FormActions, FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -32,7 +32,9 @@ export function QuickCreateDialog(props: QuickCreateDialogProps) {
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={id} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={id}
|
||||
maxLength={100}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import type { SelectAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -55,6 +55,7 @@ export function AccessFieldControl({
|
||||
form={form}
|
||||
id={id}
|
||||
options={options}
|
||||
required={field.required}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
@ -62,10 +63,9 @@ export function AccessFieldControl({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>
|
||||
<FormLabel htmlFor={id} required={field.required}>
|
||||
{t(field.key)}
|
||||
{field.required ? "" : t("optional")}
|
||||
</Label>
|
||||
</FormLabel>
|
||||
{field.type === "note" ? (
|
||||
<Textarea id={id} maxLength={4000} rows={4} {...form.register(field.key)} />
|
||||
) : field.type === "select" ? (
|
||||
@ -81,6 +81,7 @@ export function AccessFieldControl({
|
||||
onValueChange={onSelected}
|
||||
options={options}
|
||||
placeholder={t("choose")}
|
||||
required={field.required ?? false}
|
||||
value={stringValue(value)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title";
|
||||
import { RequiredIndicator } from "@repo/rdx-ui/components/required-indicator";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -13,19 +14,23 @@ export function PermissionsControl({
|
||||
value,
|
||||
options,
|
||||
form,
|
||||
required,
|
||||
}: {
|
||||
fieldKey: string;
|
||||
id: string;
|
||||
value: string | boolean | string[] | undefined;
|
||||
options: readonly Option[];
|
||||
form: UseFormReturn<AccessFormValues>;
|
||||
required?: boolean | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("access");
|
||||
const values = Array.isArray(value) ? value : [];
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-2">
|
||||
<FormSectionTitle as="legend">{t(fieldKey)}</FormSectionTitle>
|
||||
<FormSectionTitle as="legend">
|
||||
{t(fieldKey)} {required && <RequiredIndicator />}
|
||||
</FormSectionTitle>
|
||||
{options.map((option) => (
|
||||
<div className="flex items-center gap-2" key={option.value}>
|
||||
<Checkbox
|
||||
@ -38,7 +43,7 @@ export function PermissionsControl({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${id}-${option.value}`}>{option.label}</Label>
|
||||
<FormLabel htmlFor={`${id}-${option.value}`}>{option.label}</FormLabel>
|
||||
</div>
|
||||
))}
|
||||
{!options.length && <p className="text-sm text-muted-foreground">{t("noPermissions")}</p>}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -51,7 +51,9 @@ export function ApplicationConfigurationForm(props: ApplicationConfigurationForm
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-profile`} required>
|
||||
{t("mail.profile")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed
|
||||
@ -69,7 +71,9 @@ export function ApplicationConfigurationForm(props: ApplicationConfigurationForm
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-resource`}>{t("applications.resource")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-resource`} required>
|
||||
{t("applications.resource")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
configuration
|
||||
@ -94,7 +98,9 @@ export function ApplicationConfigurationForm(props: ApplicationConfigurationForm
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-delivery`}>{t("applications.delivery")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-delivery`} required>
|
||||
{t("applications.delivery")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-delivery`}
|
||||
label={t("applications.delivery")}
|
||||
@ -109,7 +115,9 @@ export function ApplicationConfigurationForm(props: ApplicationConfigurationForm
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{t("mail.state")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
@ -123,7 +131,7 @@ export function ApplicationConfigurationForm(props: ApplicationConfigurationForm
|
||||
</div>
|
||||
{values.delivery === "installed" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-version`}>{t("applications.version")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-version`}>{t("applications.version")}</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-version`}
|
||||
maxLength={100}
|
||||
@ -133,7 +141,7 @@ export function ApplicationConfigurationForm(props: ApplicationConfigurationForm
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -25,7 +25,9 @@ export function AssignmentForm(props: AssignmentFormProps) {
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-department`}>{t("department")}</Label>
|
||||
<FormLabel htmlFor={`${id}-department`} required>
|
||||
{t("department")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
assignment
|
||||
@ -54,16 +56,18 @@ export function AssignmentForm(props: AssignmentFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-position`}>{t("departmentPosition")}</Label>
|
||||
<FormLabel htmlFor={`${id}-position`}>{t("departmentPosition")}</FormLabel>
|
||||
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
|
||||
</div>
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-start`}>{t("startDate")}</Label>
|
||||
<FormLabel htmlFor={`${id}-start`} required>
|
||||
{t("startDate")}
|
||||
</FormLabel>
|
||||
<Input id={`${id}-start`} required type="date" {...form.register("startDate")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-end`}>{t("endDate")}</Label>
|
||||
<FormLabel htmlFor={`${id}-end`}>{t("endDate")}</FormLabel>
|
||||
<Input
|
||||
id={`${id}-end`}
|
||||
min={form.watch("startDate")}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -50,7 +50,9 @@ export function CertificateConfigurationForm(props: CertificateConfigurationForm
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-profile`} required>
|
||||
{t("mail.profile")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed
|
||||
@ -68,7 +70,9 @@ export function CertificateConfigurationForm(props: CertificateConfigurationForm
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-resource`}>{t("certificates.resource")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-resource`} required>
|
||||
{t("certificates.resource")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
configuration
|
||||
@ -93,7 +97,9 @@ export function CertificateConfigurationForm(props: CertificateConfigurationForm
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{t("mail.state")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
@ -106,7 +112,7 @@ export function CertificateConfigurationForm(props: CertificateConfigurationForm
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-location`}>{t("certificates.location")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-location`}>{t("certificates.location")}</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-location`}
|
||||
maxLength={255}
|
||||
@ -115,7 +121,7 @@ export function CertificateConfigurationForm(props: CertificateConfigurationForm
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -29,7 +29,7 @@ export function CompanyLogoField({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("logo.label")}</Label>
|
||||
<FormLabel htmlFor={id}>{t("logo.label")}</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<CompanyAvatar
|
||||
aria-hidden="true"
|
||||
|
||||
@ -185,8 +185,8 @@ test("la ficha presenta datos de la compañía, contactos y acceso a sedes", asy
|
||||
test("el formulario de sede ofrece dirección y teléfono opcionales", async () => {
|
||||
const markup = await renderPage("es", <SiteForm companyId={companyId} />, () => undefined);
|
||||
|
||||
assert.ok(markup.includes("Dirección (opcional)"));
|
||||
assert.ok(markup.includes("Teléfono (opcional)"));
|
||||
assert.ok(markup.includes("Dirección"));
|
||||
assert.ok(markup.includes("Teléfono"));
|
||||
assert.match(markup, /<textarea[^>]*rows="6"/);
|
||||
assert.match(markup, /<textarea[^>]*name="address"/);
|
||||
const textarea = markup.match(/<textarea[^>]*>/)?.[0];
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -27,7 +27,9 @@ export function CompanyForm(props: CompanyFormProps) {
|
||||
<h2 className="text-lg font-semibold">{t(company ? "editCompany" : "newCompany")}</h2>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${id}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<SwitchField
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -21,7 +21,9 @@ export function DepartmentForm(props: DepartmentFormProps) {
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={id} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={id} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<SwitchField
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -59,7 +59,7 @@ export function FolderMappingForm(props: FolderMappingFormProps) {
|
||||
ready={ready}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-path`}>{t("folders.path")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-path`}>{t("folders.path")}</FormLabel>
|
||||
<Input id={`${prefix}-path`} readOnly value={values.path} />
|
||||
</div>
|
||||
{mapping && currentPath !== values.path && (
|
||||
@ -78,7 +78,7 @@ export function FolderMappingForm(props: FolderMappingFormProps) {
|
||||
)}
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-letter`}>{t("folders.letter")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-letter`}>{t("folders.letter")}</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-letter`}
|
||||
maxLength={2}
|
||||
@ -88,7 +88,9 @@ export function FolderMappingForm(props: FolderMappingFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{t("mail.state")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
@ -108,7 +110,7 @@ export function FolderMappingForm(props: FolderMappingFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -41,7 +41,9 @@ export function FolderMappingTargets({
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-profile`} required>
|
||||
{t("mail.profile")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed || initialProfileId
|
||||
@ -64,7 +66,9 @@ export function FolderMappingTargets({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-need`}>{t("folders.need")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-need`} required>
|
||||
{t("folders.need")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed
|
||||
|
||||
@ -143,7 +143,7 @@ test("Buzones: alta en modal, cuenta opcional y ayuda al final", async () => {
|
||||
}
|
||||
const form = await render("es", <MailboxForm company={company} onSaved={() => undefined} />);
|
||||
|
||||
assert.ok(form.includes("Cuenta relacionada (opcional)"));
|
||||
assert.ok(form.includes("Cuenta relacionada"));
|
||||
assert.ok(form.indexOf("¿Qué es y para qué sirve?") < form.indexOf("Guardar"));
|
||||
assert.equal(form.includes('type="password"'), false);
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -86,7 +86,7 @@ export function MailConfigurationForm(props: MailConfigurationFormProps) {
|
||||
profile={selectedProfile}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -33,7 +33,9 @@ export function MailConfigurationTargets({
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-profile`} required>
|
||||
{t("mail.profile")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed || initialProfileId
|
||||
@ -51,7 +53,9 @@ export function MailConfigurationTargets({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-mailbox`}>{t("mailbox.titleSingle")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-mailbox`} required>
|
||||
{t("mailbox.titleSingle")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed ? undefined : { label: t("mailbox.new"), onClick: () => onQuickCreate("mailbox") }
|
||||
@ -67,7 +71,9 @@ export function MailConfigurationTargets({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-method`}>{t("mail.method")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-method`} required>
|
||||
{t("mail.method")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-method`}
|
||||
label={t("mail.method")}
|
||||
@ -88,7 +94,9 @@ export function MailConfigurationTargets({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{t("mail.state")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -36,7 +36,9 @@ export function MailboxUseForm(props: MailboxUseFormProps) {
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-mailbox`}>{t("mailbox.titleSingle")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-mailbox`} required>
|
||||
{t("mailbox.titleSingle")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
canCreate(!!use, initialMailboxId, allowQuickCreate)
|
||||
@ -56,7 +58,9 @@ export function MailboxUseForm(props: MailboxUseFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-person`}>{t("mail.person")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-person`} required>
|
||||
{t("mail.person")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
canCreate(!!use, initialPersonId, allowQuickCreate)
|
||||
@ -77,7 +81,9 @@ export function MailboxUseForm(props: MailboxUseFormProps) {
|
||||
</div>
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-start`}>{t("mailbox.start")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-start`} required>
|
||||
{t("mailbox.start")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
disabled={!!use}
|
||||
id={`${prefix}-start`}
|
||||
@ -87,7 +93,9 @@ export function MailboxUseForm(props: MailboxUseFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-end`}>{t("mailbox.end")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-end`} required={!!use}>
|
||||
{t("mailbox.end")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-end`}
|
||||
onChange={(event) => form.setValue("endDate", event.target.value || null)}
|
||||
|
||||
@ -6,10 +6,10 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { PhoneLink } from "@repo/rdx-ui/components/phone-link";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -201,7 +201,7 @@ export function PeopleSection({
|
||||
id={`${filterId}-contact`}
|
||||
onCheckedChange={(checked) => setContactsOnly(checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</Label>
|
||||
<FormLabel htmlFor={`${filterId}-contact`}>{t("contactsOnly")}</FormLabel>
|
||||
</div>
|
||||
}
|
||||
filtersApplied={contactsOnly}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -55,11 +55,13 @@ export function PersonForm(props: PersonFormProps) {
|
||||
/>
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${id}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-position`}>{t("generalPosition")}</Label>
|
||||
<FormLabel htmlFor={`${id}-position`}>{t("generalPosition")}</FormLabel>
|
||||
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
|
||||
</div>
|
||||
</div>
|
||||
@ -67,11 +69,11 @@ export function PersonForm(props: PersonFormProps) {
|
||||
<FormSection title={t("personForm.contact")} variant="plain">
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-email`}>{t("emailOptional")}</Label>
|
||||
<FormLabel htmlFor={`${id}-email`}>{t("emailOptional")}</FormLabel>
|
||||
<Input id={`${id}-email`} maxLength={254} type="email" {...form.register("email")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-phone`}>{t("phoneOptional")}</Label>
|
||||
<FormLabel htmlFor={`${id}-phone`}>{t("phoneOptional")}</FormLabel>
|
||||
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { CameraIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@repo/shadcn-ui/components/avatar";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -51,7 +51,7 @@ export function PersonPhotoField({
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Label htmlFor={id}>{t("photoOptional")}</Label>
|
||||
<FormLabel htmlFor={id}>{t("photoOptional")}</FormLabel>
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
aria-describedby={`${id}-help`}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { AssetSelect } from "@metis/assets/client";
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -54,7 +54,9 @@ export function PrinterConfigurationForm(props: PrinterConfigurationFormProps) {
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-profile`} required>
|
||||
{t("mail.profile")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
profileFixed
|
||||
@ -75,7 +77,9 @@ export function PrinterConfigurationForm(props: PrinterConfigurationFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-printer`}>{t("printers.printer")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-printer`} required>
|
||||
{t("printers.printer")}
|
||||
</FormLabel>
|
||||
<AssetSelect
|
||||
action={
|
||||
configuration
|
||||
@ -99,7 +103,9 @@ export function PrinterConfigurationForm(props: PrinterConfigurationFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-connection`}>{t("printers.connection")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-connection`} required>
|
||||
{t("printers.connection")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-connection`}
|
||||
label={t("printers.connection")}
|
||||
@ -121,7 +127,9 @@ export function PrinterConfigurationForm(props: PrinterConfigurationFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{t("mail.state")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
@ -141,7 +149,7 @@ export function PrinterConfigurationForm(props: PrinterConfigurationFormProps) {
|
||||
onCheckedChange={(isDefault) => form.setValue("isDefault", isDefault)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -23,11 +23,15 @@ export function ProcessTemplateForm(props: ProcessTemplateFormProps) {
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${prefix}-name`} maxLength={160} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-kind`}>{t("processes.kind")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-kind`} required>
|
||||
{t("processes.kind")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-kind`}
|
||||
label={t("processes.kind")}
|
||||
@ -43,11 +47,14 @@ export function ProcessTemplateForm(props: ProcessTemplateFormProps) {
|
||||
label: t(`processes.${value}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={form.watch("kind")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-instructions`}>{t("processes.instructions")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-instructions`} required>
|
||||
{t("processes.instructions")}
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-instructions`}
|
||||
maxLength={16100}
|
||||
@ -64,7 +71,7 @@ export function ProcessTemplateForm(props: ProcessTemplateFormProps) {
|
||||
onCheckedChange={(active) => form.setValue("active", active)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -25,11 +25,15 @@ export function ProcessStartForm(props: ProcessStartFormProps) {
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${prefix}-name`} maxLength={160} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-person`}>{t("profiles.person")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-person`} required>
|
||||
{t("profiles.person")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={{ label: t("newPerson"), onClick: () => setQuick("person") }}
|
||||
id={`${prefix}-person`}
|
||||
@ -42,7 +46,9 @@ export function ProcessStartForm(props: ProcessStartFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-template`}>{t("processes.template")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-template`} required>
|
||||
{t("processes.template")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={{ label: t("processes.newTemplate"), onClick: () => setQuick("template") }}
|
||||
disabled={!query.data || query.isError}
|
||||
@ -75,7 +81,7 @@ export function ProcessStartForm(props: ProcessStartFormProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -25,11 +25,15 @@ export function ProcessEditForm(props: ProcessEditFormProps) {
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${prefix}-name`} maxLength={160} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{t("mail.state")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
@ -45,11 +49,12 @@ export function ProcessEditForm(props: ProcessEditFormProps) {
|
||||
label: t(`processes.${value}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={form.watch("state")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
@ -25,7 +25,9 @@ export function ProcessTaskField({
|
||||
return (
|
||||
<section className="rounded-md border p-3 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{processTaskTitle(task, t)}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{processTaskTitle(task, t)}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={task.title}
|
||||
@ -41,15 +43,16 @@ export function ProcessTaskField({
|
||||
label: t(`processes.${value}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="text-sm cursor-pointer">{t("processes.taskNotes")}</summary>
|
||||
<div className="mt-2">
|
||||
<Label className="sr-only" htmlFor={`${prefix}-notes`}>
|
||||
<FormLabel className="sr-only" htmlFor={`${prefix}-notes`}>
|
||||
{t("processes.taskNotes")}: {task.title}
|
||||
</Label>
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -82,6 +82,13 @@ test("remotos: formulario, altas rápidas y ayuda antes del pie", async () => {
|
||||
assert.ok(html.includes(text.remote.personal_computer));
|
||||
assert.ok(html.indexOf(text.remote.help) > html.indexOf(text.mail.notes));
|
||||
assert.ok(html.indexOf(text.save) > html.indexOf(text.remote.help));
|
||||
const labels = html.match(/<label\b[^>]*>[\s\S]*?<\/label>/g) ?? [];
|
||||
const source = labels.find((label) => label.includes(`>${text.remote.sourceAsset}<`));
|
||||
const site = labels.find((label) => label.includes(`>${text.remote.site}<`));
|
||||
|
||||
assert.ok(source?.includes('data-slot="required-indicator"'));
|
||||
assert.ok(site);
|
||||
assert.equal(site.includes('data-slot="required-indicator"'), false);
|
||||
}
|
||||
});
|
||||
test("remotos: origen personal, acceso configurado sin autorización y VPN separada", async () => {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -45,7 +45,9 @@ export function RemoteConnectionForm(props: RemoteConnectionFormProps) {
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-person`}>{t("remote.person")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-person`} required>
|
||||
{t("remote.person")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
personFixed
|
||||
@ -74,7 +76,9 @@ export function RemoteConnectionForm(props: RemoteConnectionFormProps) {
|
||||
setQuick={setQuick}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-method`}>{t("remote.method")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-method`} required>
|
||||
{t("remote.method")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-method`}
|
||||
list={`${prefix}-methods`}
|
||||
@ -90,7 +94,9 @@ export function RemoteConnectionForm(props: RemoteConnectionFormProps) {
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{t("mail.state")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("mail.state")}
|
||||
@ -103,6 +109,7 @@ export function RemoteConnectionForm(props: RemoteConnectionFormProps) {
|
||||
}}
|
||||
options={states.map((value) => ({ value, label: t(`mail.${value}`) }))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={v.state}
|
||||
/>
|
||||
</div>
|
||||
@ -114,7 +121,7 @@ export function RemoteConnectionForm(props: RemoteConnectionFormProps) {
|
||||
onCheckedChange={(requiresVpn) => form.setValue("requiresVpn", requiresVpn)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { AssetSelect } from "@metis/assets/client";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -38,7 +38,9 @@ export function RemoteEndpointFields({
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-source-kind`}>{t("remote.sourceKind")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-source-kind`} required>
|
||||
{t("remote.sourceKind")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
disabled={fixed}
|
||||
id={`${prefix}-source-kind`}
|
||||
@ -56,12 +58,15 @@ export function RemoteEndpointFields({
|
||||
label: t(`remote.${value}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={v.sourceKind}
|
||||
/>
|
||||
</div>
|
||||
{v.sourceKind === "company_asset" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-source`}>{t("remote.sourceAsset")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-source`} required>
|
||||
{t("remote.sourceAsset")}
|
||||
</FormLabel>
|
||||
<AssetSelect
|
||||
action={
|
||||
fixed ? undefined : { label: t("remote.newAsset"), onClick: () => setQuick("source") }
|
||||
@ -80,7 +85,9 @@ export function RemoteEndpointFields({
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-target-kind`}>{t("remote.targetKind")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-target-kind`} required>
|
||||
{t("remote.targetKind")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
disabled={fixed}
|
||||
id={`${prefix}-target-kind`}
|
||||
@ -99,6 +106,7 @@ export function RemoteEndpointFields({
|
||||
label: t(`remote.${value}`),
|
||||
}))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={v.targetKind}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { AssetSelect } from "@metis/assets/client";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -49,9 +49,9 @@ export function RemoteTargetField({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-target`}>
|
||||
<FormLabel htmlFor={`${prefix}-target`} required={v.targetKind !== "network"}>
|
||||
{t(v.targetKind === "network" ? "remote.site" : "remote.target")}
|
||||
</Label>
|
||||
</FormLabel>
|
||||
{v.targetKind === "asset" ? (
|
||||
<AssetSelect
|
||||
action={
|
||||
|
||||
@ -37,9 +37,8 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
|
||||
id: "address",
|
||||
label: t("addressOptional"),
|
||||
value: (site) => site.address,
|
||||
render: (site) => (
|
||||
<span className="whitespace-pre-wrap">{site.address ?? t("noAddress")}</span>
|
||||
),
|
||||
render: (site) =>
|
||||
site.address ? <span className="whitespace-pre-wrap">{site.address}</span> : null,
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -22,11 +22,13 @@ export function SiteForm(props: SiteFormProps) {
|
||||
<FormBody>
|
||||
{showTitle && <h3 className="text-lg font-semibold">{t(site ? "editSite" : "newSite")}</h3>}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${id}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-address`}>{t("addressOptional")}</Label>
|
||||
<FormLabel htmlFor={`${id}-address`}>{t("addressOptional")}</FormLabel>
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
id={`${id}-address`}
|
||||
@ -37,7 +39,7 @@ export function SiteForm(props: SiteFormProps) {
|
||||
<p className="text-xs text-muted-foreground">{t("addressHelp")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-phone`}>{t("phoneOptional")}</Label>
|
||||
<FormLabel htmlFor={`${id}-phone`}>{t("phoneOptional")}</FormLabel>
|
||||
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
|
||||
</div>
|
||||
<CompanyFeedback
|
||||
|
||||
@ -127,7 +127,7 @@ test("formulario software: recurso fijo en edición, estados permitidos, ayuda y
|
||||
|
||||
assert.ok(html.includes("La instalación pertenece al ordenador"));
|
||||
assert.ok(html.includes("Guardar"));
|
||||
assert.ok(html.includes("Licencia vinculada (opcional)"));
|
||||
assert.ok(html.includes("Licencia vinculada"));
|
||||
assert.ok(html.includes("No reserva plazas"));
|
||||
assert.equal(html.includes("software.licenseHelp"), false);
|
||||
assert.equal(html.includes("software.examples"), false);
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
@ -68,7 +68,9 @@ export function SoftwareInstallationForm({
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-resource`}>{t("software.resource")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-resource`} required>
|
||||
{t("software.resource")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
installation
|
||||
@ -92,11 +94,13 @@ export function SoftwareInstallationForm({
|
||||
)}
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-version`}>{t("applications.version")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-version`}>{t("applications.version")}</FormLabel>
|
||||
<Input id={`${prefix}-version`} maxLength={100} {...form.register("version")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("status")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-state`} required>
|
||||
{t("status")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("status")}
|
||||
@ -109,12 +113,13 @@ export function SoftwareInstallationForm({
|
||||
}}
|
||||
options={states.map((value) => ({ value, label: t(`mail.${value}`) }))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={form.watch("state")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-license`}>{t("software.license")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-license`}>{t("software.license")}</FormLabel>
|
||||
<SelectWithAction
|
||||
describedBy={`${prefix}-license-help`}
|
||||
id={`${prefix}-license`}
|
||||
@ -129,7 +134,7 @@ export function SoftwareInstallationForm({
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("mail.notes")}</FormLabel>
|
||||
<Textarea id={`${prefix}-notes`} maxLength={4000} {...form.register("notes")} />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@ -4,8 +4,10 @@ import type { TeleworkAuthorizationDTO } from "../../../../common/telework-autho
|
||||
|
||||
export function TeleworkAuthorizationSummary({
|
||||
authorization,
|
||||
emptyDateLabel,
|
||||
}: {
|
||||
authorization: TeleworkAuthorizationDTO | undefined;
|
||||
emptyDateLabel?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
|
||||
@ -18,7 +20,7 @@ export function TeleworkAuthorizationSummary({
|
||||
? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeZone: "UTC" }).format(
|
||||
new Date(`${value}T00:00:00Z`)
|
||||
)
|
||||
: t("telework.noDate");
|
||||
: (emptyDateLabel ?? t("telework.noDate"));
|
||||
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
|
||||
@ -98,18 +98,20 @@ export function TeleworkAuthorizationsPage(props: TeleworkAuthorizationsPageProp
|
||||
id: "start",
|
||||
label: t("telework.startsOn"),
|
||||
value: (item) => item.values.startsOn,
|
||||
render: (item) => <>{item.values.startsOn ?? t("telework.noDate")}</>,
|
||||
render: (item) => item.values.startsOn,
|
||||
},
|
||||
{
|
||||
id: "end",
|
||||
label: t("telework.expiresOn"),
|
||||
value: (item) => item.values.expiresOn,
|
||||
render: (item) => <>{item.values.expiresOn ?? t("telework.noDate")}</>,
|
||||
render: (item) => item.values.expiresOn,
|
||||
},
|
||||
{
|
||||
id: "details",
|
||||
label: t("telework.title"),
|
||||
render: (item) => <TeleworkAuthorizationSummary authorization={item} />,
|
||||
render: (item) => (
|
||||
<TeleworkAuthorizationSummary authorization={item} emptyDateLabel="—" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -28,7 +28,9 @@ export function TeleworkAuthorizationForm(props: TeleworkAuthorizationFormProps)
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-person`}>{t("telework.person")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-person`} required>
|
||||
{t("telework.person")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed ? undefined : { label: t("newPerson"), onClick: () => setQuick(true) }
|
||||
@ -56,7 +58,7 @@ export function TeleworkAuthorizationForm(props: TeleworkAuthorizationFormProps)
|
||||
/>
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-start`}>{t("telework.startsOn")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-start`}>{t("telework.startsOn")}</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-start`}
|
||||
onInput={(event) => form.setValue("startsOn", event.currentTarget.value || null)}
|
||||
@ -65,7 +67,7 @@ export function TeleworkAuthorizationForm(props: TeleworkAuthorizationFormProps)
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-end`}>{t("telework.expiresOn")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-end`}>{t("telework.expiresOn")}</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-end`}
|
||||
onInput={(event) => form.setValue("expiresOn", event.currentTarget.value || null)}
|
||||
@ -75,7 +77,7 @@ export function TeleworkAuthorizationForm(props: TeleworkAuthorizationFormProps)
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("telework.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("telework.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -125,8 +125,8 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
|
||||
{
|
||||
id: "account",
|
||||
label: t("profiles.account"),
|
||||
value: (item) => account(item.values.accountId),
|
||||
render: (item) => <>{account(item.values.accountId)}</>,
|
||||
value: (item) => (item.values.accountId ? account(item.values.accountId) : ""),
|
||||
render: (item) => (item.values.accountId ? account(item.values.accountId) : null),
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
|
||||
@ -74,12 +74,36 @@ test("perfiles: cuenta opcional, altas rápidas y ayuda anterior al pie", async
|
||||
}
|
||||
assert.equal(html.includes('aria-label="Nuevo dispositivo"'), false);
|
||||
assert.ok(html.includes("Puesto de trabajo"));
|
||||
assert.ok(html.includes("Cuenta de inicio de sesión (opcional)"));
|
||||
assert.ok(html.includes("Cuenta de inicio de sesión"));
|
||||
assert.ok(html.includes("su responsable no se convierte automáticamente en usuario"));
|
||||
assert.ok(html.indexOf("¿Para qué sirve un perfil de usuario?") < html.indexOf("Guardar"));
|
||||
assert.equal(html.includes('type="password"'), false);
|
||||
});
|
||||
|
||||
test("perfiles: las etiquetas marcan los requisitos de cada selector en es/en", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<WorkProfileForm company={company} onSaved={() => undefined} />
|
||||
);
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
const labels = html.match(/<label\b[^>]*>[\s\S]*?<\/label>/g) ?? [];
|
||||
|
||||
for (const name of [text.profiles.name, text.profiles.workstation, text.profiles.person]) {
|
||||
const label = labels.find((label) => label.includes(`>${name}<`));
|
||||
|
||||
assert.ok(label?.includes('data-slot="required-indicator"'), name);
|
||||
}
|
||||
for (const name of [text.profiles.account, text.profiles.notes]) {
|
||||
const label = labels.find((label) => label.includes(`>${name}<`));
|
||||
|
||||
assert.ok(label, name);
|
||||
assert.equal(label.includes('data-slot="required-indicator"'), false, name);
|
||||
}
|
||||
assert.equal(html.includes(language === "es" ? "(opcional)" : "(optional)"), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsable como usuario", async () => {
|
||||
const workstationId = "0199b200-0000-7000-8000-000000000010";
|
||||
const computerId = "0199b200-0000-7000-8000-000000000011";
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -45,12 +45,16 @@ export function WorkProfileForm(props: WorkProfileFormProps) {
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={disabled}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={prefix}>{t("profiles.name")}</Label>
|
||||
<FormLabel htmlFor={prefix} required>
|
||||
{t("profiles.name")}
|
||||
</FormLabel>
|
||||
<Input id={prefix} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
{!profile && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-workstation`}>{t("profiles.workstation")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-workstation`} required>
|
||||
{t("profiles.workstation")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
disabled={disabled || !inventory.data || Boolean(props.workstationId)}
|
||||
id={`${prefix}-workstation`}
|
||||
@ -79,9 +83,12 @@ export function WorkProfileForm(props: WorkProfileFormProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-2" key={selector.field}>
|
||||
<Label htmlFor={`${prefix}-${selector.field}`}>
|
||||
<FormLabel
|
||||
htmlFor={`${prefix}-${selector.field}`}
|
||||
required={selector.field !== "accountId"}
|
||||
>
|
||||
{t(`profiles.${selector.label}`)}
|
||||
</Label>
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed || !allowQuickCreate
|
||||
@ -127,7 +134,7 @@ export function WorkProfileForm(props: WorkProfileFormProps) {
|
||||
onCheckedChange={(active) => form.setValue("active", active)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("profiles.notes")}</Label>
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("profiles.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
|
||||
@ -14,7 +14,7 @@ export function WorkstationAssets({
|
||||
workstation: WorkstationDTO;
|
||||
assets: readonly AssetDTO[];
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const root = `/companies/${companyId}`;
|
||||
const assetIds = new Set(workstation.values.assetIds);
|
||||
|
||||
@ -56,6 +56,14 @@ export function WorkstationAssets({
|
||||
{t(asset.displayStatus)}
|
||||
</EntityStatus>
|
||||
<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
|
||||
.filter((field) => asset.values.characteristics[field.fieldId] !== undefined)
|
||||
.slice(0, 3)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -27,7 +27,9 @@ export function PasswordForm(props: PasswordFormProps) {
|
||||
{!showTitle && target && <p className="text-muted-foreground">{target.name}</p>}
|
||||
{!target && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-current`}>{t("currentPassword")}</Label>
|
||||
<FormLabel htmlFor={`${id}-current`} required>
|
||||
{t("currentPassword")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
id={`${id}-current`}
|
||||
@ -39,7 +41,9 @@ export function PasswordForm(props: PasswordFormProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-new`}>{t("newPassword")}</Label>
|
||||
<FormLabel htmlFor={`${id}-new`} required>
|
||||
{t("newPassword")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id={`${id}-new`}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -18,7 +18,9 @@ export function CredentialForm(props: CredentialFormProps) {
|
||||
<form className="space-y-5" onSubmit={submit}>
|
||||
{setup && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${id}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
autoComplete="name"
|
||||
id={`${id}-name`}
|
||||
@ -29,7 +31,9 @@ export function CredentialForm(props: CredentialFormProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-email`}>{t("email")}</Label>
|
||||
<FormLabel htmlFor={`${id}-email`} required>
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
autoComplete="username"
|
||||
id={`${id}-email`}
|
||||
@ -40,7 +44,9 @@ export function CredentialForm(props: CredentialFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-password`}>{t("password")}</Label>
|
||||
<FormLabel htmlFor={`${id}-password`} required>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
autoComplete={setup ? "new-password" : "current-password"}
|
||||
id={`${id}-password`}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -21,7 +21,9 @@ export function UserForm(props: UserFormProps) {
|
||||
<FormBody>
|
||||
{showTitle && <h3 className="font-semibold">{t(user ? "editUser" : "createUser")}</h3>}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${id}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
autoComplete="name"
|
||||
id={`${id}-name`}
|
||||
@ -31,7 +33,9 @@ export function UserForm(props: UserFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-email`}>{t("email")}</Label>
|
||||
<FormLabel htmlFor={`${id}-email`} required>
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
id={`${id}-email`}
|
||||
@ -43,7 +47,9 @@ export function UserForm(props: UserFormProps) {
|
||||
</div>
|
||||
{!user && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-password`}>{t("password")}</Label>
|
||||
<FormLabel htmlFor={`${id}-password`} required>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id={`${id}-password`}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"templateIcon": {
|
||||
"label": "Icon (optional)",
|
||||
"label": "Icon",
|
||||
"none": "No icon",
|
||||
"help": "Shown in the catalog and in resources using this template.",
|
||||
"options": {
|
||||
@ -24,10 +24,10 @@
|
||||
"mailboxKind": "Mailbox type",
|
||||
"corporate": "Corporate",
|
||||
"external": "External",
|
||||
"relatedAccount": "Related account (optional)",
|
||||
"relatedAccount": "Related account",
|
||||
"resourceTypeId": "Access type",
|
||||
"systemId": "System (optional)",
|
||||
"environmentId": "Environment (optional)",
|
||||
"systemId": "System",
|
||||
"environmentId": "Environment",
|
||||
"newResource": "New resource",
|
||||
"editResource": "Edit resource",
|
||||
"viewResource": "Details and history",
|
||||
@ -35,7 +35,7 @@
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"notes": "Notes (optional)",
|
||||
"notes": "Notes",
|
||||
"characteristics": "Characteristics",
|
||||
"chooseValue": "Select a value",
|
||||
"noValue": "No data",
|
||||
@ -90,9 +90,9 @@
|
||||
},
|
||||
"editTemplate": "Edit template",
|
||||
"name": "Name",
|
||||
"description": "Description (optional)",
|
||||
"description": "Description",
|
||||
"type": "Data type",
|
||||
"unit": "Unit (optional)",
|
||||
"unit": "Unit",
|
||||
"choices": "List values",
|
||||
"choicesHelp": "Enter one value per line. Existing values are retained; you can add others.",
|
||||
"text": "Text",
|
||||
@ -130,10 +130,9 @@
|
||||
"view": "View",
|
||||
"close": "Close",
|
||||
"viewDefinition": "{{name}}",
|
||||
"optional": "Optional",
|
||||
"remove": "Remove field: {{name}}",
|
||||
"noFields": "Add at least one field. All fields are initially optional.",
|
||||
"fieldHelpText": "Help text (optional)",
|
||||
"fieldHelpText": "Help text",
|
||||
"fieldHelpHint": "Shown next to the field when creating or editing a resource.",
|
||||
"protected": "Built-in templates and fields can be edited in the global catalog. A field's data type remains fixed. You can change availability or create a template copy for a company.",
|
||||
"helpTitle": "What is it for?",
|
||||
@ -187,7 +186,7 @@
|
||||
"title": "Template sections",
|
||||
"add": "Add section",
|
||||
"name": "Section name",
|
||||
"description": "Description (optional)",
|
||||
"description": "Description",
|
||||
"ungrouped": "Unsectioned",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"templateIcon": {
|
||||
"label": "Icono (opcional)",
|
||||
"label": "Icono",
|
||||
"none": "Sin icono",
|
||||
"help": "Se muestra en el catálogo y en los recursos de esta plantilla.",
|
||||
"options": {
|
||||
@ -24,10 +24,10 @@
|
||||
"mailboxKind": "Tipo de buzón",
|
||||
"corporate": "Corporativo",
|
||||
"external": "Externo",
|
||||
"relatedAccount": "Cuenta relacionada (opcional)",
|
||||
"relatedAccount": "Cuenta relacionada",
|
||||
"resourceTypeId": "Tipo de acceso",
|
||||
"systemId": "Sistema (opcional)",
|
||||
"environmentId": "Entorno (opcional)",
|
||||
"systemId": "Sistema",
|
||||
"environmentId": "Entorno",
|
||||
"newResource": "Nuevo recurso",
|
||||
"editResource": "Editar recurso",
|
||||
"viewResource": "Ficha e historial",
|
||||
@ -35,7 +35,7 @@
|
||||
"status": "Estado",
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"notes": "Notas (opcional)",
|
||||
"notes": "Notas",
|
||||
"characteristics": "Características",
|
||||
"chooseValue": "Selecciona un valor",
|
||||
"noValue": "Sin datos",
|
||||
@ -90,9 +90,9 @@
|
||||
},
|
||||
"editTemplate": "Editar plantilla",
|
||||
"name": "Nombre",
|
||||
"description": "Descripción (opcional)",
|
||||
"description": "Descripción",
|
||||
"type": "Tipo de dato",
|
||||
"unit": "Unidad (opcional)",
|
||||
"unit": "Unidad",
|
||||
"choices": "Valores de la lista",
|
||||
"choicesHelp": "Escribe un valor por línea. Los valores existentes se conservan; puedes añadir otros.",
|
||||
"text": "Texto",
|
||||
@ -130,10 +130,9 @@
|
||||
"view": "Ver",
|
||||
"close": "Cerrar",
|
||||
"viewDefinition": "{{name}}",
|
||||
"optional": "Opcional",
|
||||
"remove": "Retirar campo: {{name}}",
|
||||
"noFields": "Añade al menos un campo. Todos son opcionales inicialmente.",
|
||||
"fieldHelpText": "Texto de ayuda (opcional)",
|
||||
"fieldHelpText": "Texto de ayuda",
|
||||
"fieldHelpHint": "Se muestra junto al campo al crear o editar un recurso.",
|
||||
"protected": "Las plantillas y campos de serie se pueden editar desde el catálogo global. El tipo de dato de un campo se mantiene fijo. Puedes cambiar la disponibilidad o crear una copia de una plantilla para una compañía.",
|
||||
"helpTitle": "¿Qué es y para qué sirve?",
|
||||
@ -187,7 +186,7 @@
|
||||
"title": "Secciones de la plantilla",
|
||||
"add": "Añadir sección",
|
||||
"name": "Nombre de la sección",
|
||||
"description": "Descripción (opcional)",
|
||||
"description": "Descripción",
|
||||
"ungrouped": "Sin sección",
|
||||
"moveUp": "Mover hacia arriba",
|
||||
"moveDown": "Mover hacia abajo",
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -23,11 +23,15 @@ export function CatalogFieldForm(props: CatalogFieldFormProps) {
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${id}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-type`}>{t("type")}</Label>
|
||||
<FormLabel htmlFor={`${id}-type`} required>
|
||||
{t("type")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
disabled={!!item || mutation.isPending}
|
||||
id={`${id}-type`}
|
||||
@ -46,18 +50,20 @@ export function CatalogFieldForm(props: CatalogFieldFormProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-unit`}>{t("unit")}</Label>
|
||||
<FormLabel htmlFor={`${id}-unit`}>{t("unit")}</FormLabel>
|
||||
<Input id={`${id}-unit`} maxLength={30} {...form.register("unit")} />
|
||||
</div>
|
||||
{type === "choice" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-choices`}>{t("choices")}</Label>
|
||||
<FormLabel htmlFor={`${id}-choices`} required>
|
||||
{t("choices")}
|
||||
</FormLabel>
|
||||
<Textarea id={`${id}-choices`} required rows={5} {...form.register("choicesText")} />
|
||||
<p className="text-xs text-muted-foreground">{t("choicesHelp")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-description`}>{t("fieldHelpText")}</Label>
|
||||
<FormLabel htmlFor={`${id}-description`}>{t("fieldHelpText")}</FormLabel>
|
||||
<Textarea
|
||||
aria-describedby={`${id}-description-hint`}
|
||||
id={`${id}-description`}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { IconPicker } from "@repo/rdx-ui/components/icon-picker";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { TemplateMetadataSection } from "@repo/rdx-ui/components/template-metadata-section";
|
||||
@ -7,7 +8,6 @@ import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-section
|
||||
import { XIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -48,7 +48,7 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
<TemplateMetadataSection
|
||||
descriptionField={
|
||||
<>
|
||||
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
|
||||
<FormLabel htmlFor={`${id}-description`}>{t("description")}</FormLabel>
|
||||
<Textarea
|
||||
className="min-h-24 flex-1"
|
||||
id={`${id}-description`}
|
||||
@ -61,7 +61,9 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
primaryFields={
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<FormLabel htmlFor={`${id}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<IconPicker
|
||||
@ -93,7 +95,7 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
title={t("formLayout.template")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-usage`}>{t("templateUsage")}</Label>
|
||||
<FormLabel htmlFor={`${id}-usage`}>{t("templateUsage")}</FormLabel>
|
||||
<SelectWithAction
|
||||
disabled={!!item || mutation.isPending}
|
||||
id={`${id}-usage`}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user