feat: add telework authorization management functionality
- Implemented RESTful API routes for telework authorizations, including GET and POST methods for retrieving and saving authorizations. - Created Sequelize repository for managing telework authorization data with methods for loading and saving authorizations. - Developed migration scripts to set up necessary database tables for telework authorizations and revisions. - Added persistence mapping for telework authorization entities to facilitate data transformation between domain and database layers. - Introduced common schemas for validating telework authorization requests and responses using Zod. - Built frontend components for managing telework authorizations, including forms for creating and editing authorizations, and summaries for displaying authorization details. - Integrated internationalization support for English and Spanish languages in the telework module. - Added unit tests to ensure functionality and correctness of telework authorization components and API interactions.
This commit is contained in:
parent
beee755f19
commit
2382e0ea20
@ -32,6 +32,7 @@ import { verifyPrinters } from "./printers.integration";
|
||||
import { verifyRelationships } from "./relationships.integration";
|
||||
import { verifyResourceMigration } from "./resource-migration.integration";
|
||||
import { verifyResources } from "./resources.integration";
|
||||
import { verifyTelework } from "./telework.integration";
|
||||
import { verifyWorkProfiles } from "./work-profiles.integration";
|
||||
import { verifyWorkstations } from "./workstations.integration";
|
||||
|
||||
@ -62,6 +63,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await database.query("DELETE FROM application_configurations");
|
||||
await database.query("DELETE FROM certificate_configuration_revisions");
|
||||
await database.query("DELETE FROM certificate_configurations");
|
||||
await database.query("DELETE FROM telework_authorization_revisions");
|
||||
await database.query("DELETE FROM telework_authorizations");
|
||||
await database.query("DELETE FROM work_profile_revisions");
|
||||
await database.query("DELETE FROM work_profiles");
|
||||
await database.query("DELETE FROM workstation_assets");
|
||||
@ -775,6 +778,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyTelework(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyPrinters(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
@ -844,6 +853,16 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(
|
||||
await operator.request(`/telework-authorizations/${customer.id}`),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(
|
||||
await operator.request(`/telework-authorizations/${customer.id}`, {}),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
||||
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
||||
await assert.rejects(
|
||||
|
||||
153
apps/server/src/telework.integration.ts
Normal file
153
apps/server/src/telework.integration.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
CompanyResponseSchema,
|
||||
type TeleworkAuthorizationInput,
|
||||
TeleworkAuthorizationResponseSchema,
|
||||
TeleworkAuthorizationsResponseSchema,
|
||||
} from "@metis/companies/common";
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
export async function verifyTelework(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
database: Sequelize
|
||||
) {
|
||||
const personValues = {
|
||||
name: "Persona teletrabajo",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: true,
|
||||
isContact: false,
|
||||
};
|
||||
const createdPerson = await request(`/companies/${companyId}/people`, personValues);
|
||||
assert.equal(createdPerson.status, 201);
|
||||
const person = CompanyResponseSchema.parse(await createdPerson.json()).company.people.at(-1);
|
||||
assert.ok(person);
|
||||
const path = `/telework-authorizations/${companyId}`;
|
||||
const input: TeleworkAuthorizationInput = {
|
||||
personId: person.id,
|
||||
active: true,
|
||||
startsOn: null,
|
||||
expiresOn: null,
|
||||
notes: null,
|
||||
};
|
||||
const error = async (values: unknown, code: string, id?: string, status = 409) => {
|
||||
const response = await request(path + (id ? `/${id}/update` : ""), { values });
|
||||
assert.equal(response.status, status);
|
||||
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
|
||||
};
|
||||
const save = async (values: TeleworkAuthorizationInput, id: string) => {
|
||||
const response = await request(`${path}/${id}/update`, { values });
|
||||
assert.equal(response.status, 200);
|
||||
return TeleworkAuthorizationResponseSchema.parse(await response.json()).authorization;
|
||||
};
|
||||
const list = async (scope = companyId) =>
|
||||
TeleworkAuthorizationsResponseSchema.parse(
|
||||
await (await request(`/telework-authorizations/${scope}`)).json()
|
||||
).authorizations;
|
||||
await error({ ...input, actorId: person.id }, "INVALID_REQUEST", undefined, 400);
|
||||
await error(
|
||||
{ ...input, startsOn: "2026-12-01", expiresOn: "2026-11-30" },
|
||||
"INVALID_REQUEST",
|
||||
undefined,
|
||||
400
|
||||
);
|
||||
await error({ ...input, personId: randomUUID() }, "TELEWORK_PERSON_NOT_FOUND", undefined, 404);
|
||||
assert.equal((await request(path, { values: input }, "https://other.example")).status, 403);
|
||||
assert.equal((await request(path, { values: input }, undefined, "a".repeat(64))).status, 403);
|
||||
const concurrent = await Promise.all([
|
||||
request(path, { values: input }),
|
||||
request(path, { values: input }),
|
||||
]);
|
||||
assert.deepEqual(concurrent.map((response) => response.status).sort(), [201, 409]);
|
||||
const created = concurrent.find((response) => response.status === 201);
|
||||
assert.ok(created);
|
||||
const authorization = TeleworkAuthorizationResponseSchema.parse(
|
||||
await created.json()
|
||||
).authorization;
|
||||
assert.equal(authorization.id[14], "7");
|
||||
assert.equal(authorization.history[0]?.id[14], "7");
|
||||
assert.equal(authorization.effective, true);
|
||||
assert.equal(authorization.status, "authorized");
|
||||
assert.equal(authorization.needsReview, false);
|
||||
assert.deepEqual(await save(input, authorization.id), authorization);
|
||||
await error({ ...input, personId: randomUUID() }, "TELEWORK_PERSON_IMMUTABLE", authorization.id);
|
||||
const otherCreate = await request(`/telework-authorizations/${otherId}`, { values: input });
|
||||
assert.equal(otherCreate.status, 404);
|
||||
assert.equal((await list(otherId)).length, 0);
|
||||
const foreignUpdate = await request(
|
||||
`/telework-authorizations/${otherId}/${authorization.id}/update`,
|
||||
{ values: input }
|
||||
);
|
||||
assert.equal(foreignUpdate.status, 404);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const oneDay = await save({ ...input, startsOn: today, expiresOn: today }, authorization.id);
|
||||
assert.equal(oneDay.status, "authorized");
|
||||
assert.equal(oneDay.effective, true);
|
||||
const scheduled = await save(
|
||||
{ ...input, startsOn: "9999-12-31", expiresOn: "9999-12-31" },
|
||||
authorization.id
|
||||
);
|
||||
assert.equal(scheduled.status, "scheduled");
|
||||
assert.equal(scheduled.effective, false);
|
||||
const expired = await save(
|
||||
{ ...input, startsOn: "2000-01-01", expiresOn: "2000-01-01" },
|
||||
authorization.id
|
||||
);
|
||||
assert.equal(expired.status, "expired");
|
||||
assert.equal(expired.effective, false);
|
||||
const renewed = await save(input, authorization.id);
|
||||
assert.equal(renewed.status, "authorized");
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/companies/${companyId}/people/${person.id}/update`, {
|
||||
...personValues,
|
||||
active: false,
|
||||
})
|
||||
).status,
|
||||
200
|
||||
);
|
||||
const reviewed = (await list()).find((item) => item.id === authorization.id);
|
||||
assert.ok(reviewed);
|
||||
assert.equal(reviewed.needsReview, true);
|
||||
assert.equal(reviewed.effective, false);
|
||||
await error({ ...input, notes: "Extender" }, "TELEWORK_PERSON_INACTIVE", authorization.id);
|
||||
const revoked = await save({ ...input, active: false, notes: "Salida" }, authorization.id);
|
||||
assert.equal(revoked.status, "revoked");
|
||||
assert.equal(revoked.needsReview, false);
|
||||
assert.equal(revoked.history.length, 6);
|
||||
assert.equal(revoked.history[0]?.values.active, true);
|
||||
await error(input, "TELEWORK_PERSON_INACTIVE", authorization.id);
|
||||
assert.equal(
|
||||
(await request(`/companies/${companyId}/people/${person.id}/update`, personValues)).status,
|
||||
200
|
||||
);
|
||||
await error(input, "TELEWORK_ALREADY_EXISTS");
|
||||
const reactivated = await save({ ...input, notes: "Regresa" }, authorization.id);
|
||||
assert.equal(reactivated.effective, true);
|
||||
assert.equal(reactivated.history.length, 7);
|
||||
assert.ok(reactivated.history.every((revision) => revision.actorId !== person.id));
|
||||
await database.query("UPDATE telework_authorizations SET `values`=:values WHERE id=:id", {
|
||||
replacements: {
|
||||
id: authorization.id,
|
||||
values: JSON.stringify({ ...reactivated.values, personId: randomUUID() }),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const corrupt = await request(path);
|
||||
assert.equal(corrupt.status, 500);
|
||||
const body = await corrupt.text();
|
||||
assert.ok(!body.includes("SELECT"));
|
||||
assert.ok(!body.includes("inconsistente"));
|
||||
} finally {
|
||||
await database.query("UPDATE telework_authorizations SET `values`=:values WHERE id=:id", {
|
||||
replacements: { id: authorization.id, values: JSON.stringify(reactivated.values) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -74,3 +74,7 @@ ApplicationConfiguration es AggregateRoot; ApplicationConfigurationRevision es D
|
||||
## Certificados por perfil
|
||||
|
||||
CertificateConfiguration es AggregateRoot; CertificateConfigurationRevision es DomainEntity con UUID propio. CertificateConfigurationValues es Value Object inmutable con UniqueID para perfil/recurso y Maybe para ubicación/notas. Crear genera UUID v7, rehidratar conserva identidad e historial y editar compara semánticamente antes de añadir revisión. La caducidad se consulta a través del servicio público de Resources, sin duplicarla en el agregado.
|
||||
|
||||
## Autorizaciones de teletrabajo
|
||||
|
||||
TeleworkAuthorization es AggregateRoot y TeleworkAuthorizationRevision es DomainEntity, ambas con UniqueID. TeleworkAuthorizationValues encapsula persona UniqueID, fechas CalendarDate y ausencia Maybe. Renovar y revocar conservan identidad e historial; la vigencia y efectividad se calculan sin duplicar datos. Consultar [Teletrabajo](../teletrabajo.md).
|
||||
|
||||
@ -142,7 +142,7 @@ Pasan typecheck, lint, test, check y build. La compilación conserva el aviso pr
|
||||
|
||||
## Pendiente para futuras entregas
|
||||
|
||||
Configuración por ordenador o móvil, POP/IMAP, teletrabajo y acceso remoto, inventario y modelos de activos, procesos y plantillas de alta/cambio/salida, relación con licencias e integraciones con sistemas externos.
|
||||
Los perfiles y sus configuraciones, el inventario y las plantillas de activos, y la [autorización de teletrabajo](teletrabajo.md) están implementados. Quedan pendientes las conexiones y destinos de acceso remoto, procesos y plantillas de alta/cambio/salida, relación con licencias e integraciones con sistemas externos.
|
||||
|
||||
|
||||
## Migración de recursos básicos
|
||||
|
||||
@ -31,3 +31,7 @@ Pruebas de proyección e interfaz: varios buzones, aislamiento por compañía y
|
||||
Comprobaciones de esta entrega: 122 pruebas pasan, incluidos los cinco casos nuevos de la ficha y la integración con MariaDB de test. Typecheck y build pasan. Los siete archivos de código y configuración de este bloque pasan Biome. Lint y check globales mantienen los errores previos (32 y 90 respectivamente), sin nuevos errores de este bloque.
|
||||
|
||||
En navegador se comprueban los datos reales del perfil, el filtro de atención, las consultas de correos y aplicaciones, el regreso al mismo perfil y el formulario modal con perfil fijo y cancelación. No se crean registros ficticios en desarrollo.
|
||||
|
||||
## Teletrabajo de la persona
|
||||
|
||||
La ficha consulta también la [autorización de teletrabajo](teletrabajo.md), común a los perfiles de la persona y separada de las configuraciones del dispositivo. Muestra vigencia y revisión o ausencia de registro; permite gestionar la autorización y volver al mismo perfil.
|
||||
|
||||
@ -42,4 +42,4 @@ GET /api/assets/:companyId consulta el inventario. POST /api/assets/:companyId y
|
||||
|
||||
Las pruebas verifican características opcionales y obligatorias, tipos, fechas, cero y falso, UUID, igualdad semántica, aislamiento por compañía, historial, desactivaciones, rehidratación y protección de las escrituras. La comprobación en navegador incluye alta rápida, cancelación, conservación de fechas y consulta del historial.
|
||||
|
||||
Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Configuración por dispositivo, teletrabajo, inventario de dispositivos personales y procesos de salida mantienen su orden en el plan.
|
||||
Las [relaciones entre activos](relaciones-entre-activos.md) están disponibles en ambas fichas. Los [puestos de trabajo](puestos-de-trabajo.md) están disponibles como agrupaciones sin anidación. Los perfiles y sus configuraciones están implementados, así como la [autorización de teletrabajo](teletrabajo.md). La conectividad remota, el registro mínimo de equipos personales y los procesos de salida mantienen su orden en el plan.
|
||||
|
||||
@ -58,4 +58,4 @@ Implementado: personas, clasificación como contacto, departamentos, puestos esp
|
||||
|
||||
Cuentas informáticas, grupos, recursos, autorizaciones y necesidades se gestionan en Cuentas y accesos de la compañía. No implican acceso de estas personas a Metis Desk.
|
||||
|
||||
Pendiente: configuración por dispositivos, teletrabajo, procesos y plantillas e inventario de activos. Los datos de estas funciones no se crean todavía.
|
||||
Los perfiles, sus configuraciones y el inventario de activos están implementados. La [autorización de teletrabajo](teletrabajo.md) también está disponible. Quedan pendientes las conexiones remotas y los procesos y plantillas de alta/cambio/salida.
|
||||
|
||||
@ -67,9 +67,13 @@ Recursos de plantilla Certificado digital o plantilla personalizada con ese uso,
|
||||
|
||||
Ficha de consulta por perfil con equipo, características registradas, composición de los puestos vigentes, cuenta de inicio y configuraciones abiertas de correo, carpetas, impresoras, aplicaciones y certificados. Resumen de preparación, retirada y revisión; filtro de atención y acceso a cada gestión. Consultar [Entorno actual](entorno-actual.md).
|
||||
|
||||
## Teletrabajo: autorización implementada
|
||||
|
||||
Autorización por persona, fechas opcionales inclusivas, revocación y renovación con historial, consulta desde Entorno actual y modales con alta rápida de persona. No modifica los permisos ni las conexiones reales. Consultar [Teletrabajo](teletrabajo.md).
|
||||
|
||||
## Siguientes entregas: orden actualizado
|
||||
|
||||
1. Teletrabajo y accesos remotos: autorización, equipo de origen, conectividad VPN y destino. Asistencia técnica remota independiente.
|
||||
1. Accesos remotos: equipo de origen corporativo o personal sin inventario detallado, conectividad VPN y destino (equipo o recurso). La autorización de teletrabajo ya está implementada. Asistencia técnica remota independiente.
|
||||
2. Procesos de alta, cambio y salida con seguimiento de configuraciones y retiradas. Plantillas reutilizables entre compañías; ejecuciones y datos propios.
|
||||
|
||||
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
|
||||
|
||||
55
docs/teletrabajo.md
Normal file
55
docs/teletrabajo.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Teletrabajo: autorización por persona
|
||||
|
||||
Primera parte del bloque de teletrabajo y accesos remotos. Documenta la autorización para trabajar fuera de la sede. Los equipos de origen, las conexiones VPN, los destinos RDP y la asistencia técnica remota todavía no se registran en esta sección.
|
||||
|
||||
## Reglas
|
||||
|
||||
Una autorización pertenece a una persona de una compañía y tiene UUID propio. No depende de un perfil o un equipo: es común a los perfiles de esa persona. Las personas no acceden a Metis Desk.
|
||||
|
||||
Se conserva una sola autorización por persona, incluso al revocarla. Renovar, revocar o reactivar se realiza editando el mismo registro y conservando todas sus revisiones. La persona no se puede cambiar. No hay borrado.
|
||||
|
||||
Los campos son persona, autorización activa, fecha inicial opcional, fecha final opcional y notas opcionales. Sin fechas no hay límite temporal. Si se indican ambas, la inicial no puede ser posterior a la final. Ambos días están incluidos. La fecha actual se calcula en el servidor en UTC, como las demás caducidades del sistema.
|
||||
|
||||
El estado se calcula en lectura: Revocada si está desactivada; Caducada si ha pasado la fecha final; Programada si aún no ha llegado la fecha inicial; Autorizada en los demás casos. La autorización solo es efectiva cuando está Autorizada y la persona está activa. No se persiste una segunda copia del estado calculado.
|
||||
|
||||
No se autoriza ni reactiva una persona inactiva. Una autorización activa de una persona que se desactiva se conserva y muestra revisión, sin ser efectiva. Puede revocarse; una repetición idéntica sigue siendo idempotente. Para modificar notas de una autorización conservada de una persona inactiva, revocarla o reactivar previamente a la persona.
|
||||
|
||||
Revocar no cierra una VPN ni desinstala o retira configuraciones reales. Sirve como información documentada para revisar los accesos existentes. No se eliminan correos, permisos o configuraciones por desactivar la autorización.
|
||||
|
||||
## Interfaz
|
||||
|
||||
Sección Teletrabajo dentro del espacio de compañía: búsqueda por persona, consulta de vigencia, edición e historial. Nueva autorización en la cabecera derecha; alta y edición en modal. La ayuda y los ejemplos aparecen inmediatamente antes del pie con Guardar y Cancelar.
|
||||
|
||||
La persona utiliza SelectWithAction con alta rápida en un segundo modal. No se abre un tercer nivel. Cancelar la persona conserva el formulario padre. Una persona ya guardada se conserva aunque se cancele la autorización.
|
||||
|
||||
Entorno actual consulta la autorización de la persona, separada de los cinco bloques de configuración. Si no existe, lo indica sin presuponer permiso. Gestionar teletrabajo abre una consulta filtrada de esa persona; un alta iniciada desde el perfil la deja fija. Si ya hay autorización, se edita en lugar de crear otra. Volver al entorno conserva el perfil seleccionado.
|
||||
|
||||
Los listados no tienen formularios visibles junto a los registros. Traducciones es/en, carga, error, vacío, reintento y acciones deshabilitadas durante escritura.
|
||||
|
||||
## Dominio y persistencia
|
||||
|
||||
TeleworkAuthorization es AggregateRoot; TeleworkAuthorizationRevision es DomainEntity con UUID propio; TeleworkAuthorizationValues encapsula referencias UniqueID, fechas CalendarDate y opcionales Maybe.
|
||||
|
||||
Crear genera UUID v7. Update conserva la identidad y compara valores semánticamente: una repetición no crea revisión. El destino persona es fijo y las revisiones conservan actor de sesión y fecha UTC. Rehydrate reconstruye identidades e historial sin emitir eventos.
|
||||
|
||||
Migraciones explícitas e idempotentes para telework_authorizations y telework_authorization_revisions. FK a compañía, persona, autorización y actor. Persona única; posición de revisión única dentro de cada autorización.
|
||||
|
||||
Las escrituras bloquean la compañía antes de leer, validar y guardar. Las altas concurrentes no crean dos autorizaciones. El mapper bidireccional detecta referencias incoherentes, historial vacío, posiciones o fechas inválidas, destino alterado y discrepancia entre raíz y última revisión. Se valida pertenencia a compañía también al leer; los fallos de persistencia no exponen SQL ni datos internos.
|
||||
|
||||
## HTTP
|
||||
|
||||
GET /api/telework-authorizations/:companyId devuelve autorizaciones, estado calculado, efectividad, revisión e historial.
|
||||
|
||||
POST en la misma ruta crea. POST /api/telework-authorizations/:companyId/:id/update modifica. Entrada estricta Zod y UUID. Sesión obligatoria; origen y CSRF en escrituras. Actor obtenido de sesión, nunca del formulario. No se pueden leer ni actualizar por otra compañía.
|
||||
|
||||
## Pruebas
|
||||
|
||||
Dominio: fechas y ausencia, límites, contrato estricto, UUID, igualdad semántica, actualización idempotente, destino fijo, historial y renovación, vigencia inclusiva, caducidad, programación, unicidad, persona inactiva y revocación, rehidratación corrupta.
|
||||
|
||||
HTTP con MariaDB de test: persistencia, origen/CSRF, sesión, aislamiento, alta concurrente, actor autenticado, historial, fechas límite, revocación, renovación y fallo cerrado ante corrupción.
|
||||
|
||||
Interfaz: listado sin formularios, traducciones, alta rápida, ayuda inferior, campos opcionales, persona fija al iniciar desde perfil, avisos de revisión y autorización común a perfiles.
|
||||
|
||||
La comprobación en navegador verifica selector de persona, alta rápida y retorno, formulario padre conservado, ayuda antes del pie, cancelación, consulta desde Entorno actual y persona fija desde el perfil. Se corrige la actualización de los calendarios con su evento de entrada: las fechas se conservan al editar notas y el periodo inválido se rechaza en el modal. El registro temporal creado durante la comprobación se retiró; no quedan datos ficticios de teletrabajo en desarrollo.
|
||||
|
||||
Validación final: 132 pruebas pasan en el monorepo, incluidos diez casos nuevos de dominio/interfaz y la integración HTTP con MariaDB de test. Se repite la suite completa tras corregir el calendario y la suite de Companies después del ajuste de textos. Typecheck y build pasan. Los 33 archivos de código y configuración de este bloque pasan Biome; lint y check globales mantienen los errores anteriores (32 y 90), sin errores nuevos del bloque.
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { TeleworkAuthorization } from "../domain/telework-authorization.aggregate";
|
||||
import {
|
||||
TeleworkAuthorizationError,
|
||||
type TeleworkAuthorizationInput,
|
||||
} from "../domain/telework-authorization-values";
|
||||
import { TeleworkAuthorizationValues } from "../domain/value-objects/telework-authorization-values";
|
||||
|
||||
import type { TeleworkAuthorizationContext } from "./telework-authorization.repository";
|
||||
|
||||
export function mapTeleworkAuthorizationInput(
|
||||
input: TeleworkAuthorizationInput,
|
||||
context: TeleworkAuthorizationContext,
|
||||
previous?: TeleworkAuthorization
|
||||
) {
|
||||
const mapped = TeleworkAuthorizationValues.create(input);
|
||||
if (!mapped.isSuccess) {
|
||||
return mapped;
|
||||
}
|
||||
const values = mapped.data;
|
||||
if (previous && !previous.values.sameTarget(values)) {
|
||||
return Result.fail(new TeleworkAuthorizationError("TELEWORK_PERSON_IMMUTABLE"));
|
||||
}
|
||||
const person = context.people.find((item) => item.id.equals(values.personId));
|
||||
if (!person) {
|
||||
return Result.fail(new TeleworkAuthorizationError("TELEWORK_PERSON_NOT_FOUND"));
|
||||
}
|
||||
// Se permite revocar y anotar una autorización de una persona inactiva, sin ocultar sus accesos pendientes.
|
||||
if (
|
||||
values.active &&
|
||||
!person.active &&
|
||||
!(previous?.values.active && previous.values.equals(values))
|
||||
) {
|
||||
return Result.fail(new TeleworkAuthorizationError("TELEWORK_PERSON_INACTIVE"));
|
||||
}
|
||||
if (
|
||||
context.authorizations.some(
|
||||
(item) =>
|
||||
item.id.toPrimitive() !== previous?.id.toPrimitive() && item.values.sameTarget(values)
|
||||
)
|
||||
) {
|
||||
return Result.fail(new TeleworkAuthorizationError("TELEWORK_ALREADY_EXISTS"));
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations";
|
||||
import type { TeleworkAuthorization } from "../domain/telework-authorization.aggregate";
|
||||
|
||||
import type { TeleworkAuthorizationContext } from "./telework-authorization.repository";
|
||||
export function teleworkAuthorizationState(
|
||||
item: TeleworkAuthorization,
|
||||
context: TeleworkAuthorizationContext
|
||||
): Pick<TeleworkAuthorizationDTO, "status" | "effective" | "needsReview"> {
|
||||
const values = item.values.toPrimitive();
|
||||
const available = context.people.some(
|
||||
(person) => person.id.equals(item.values.personId) && person.active
|
||||
);
|
||||
const status = values.active
|
||||
? values.expiresOn && values.expiresOn < context.today
|
||||
? "expired"
|
||||
: values.startsOn && values.startsOn > context.today
|
||||
? "scheduled"
|
||||
: "authorized"
|
||||
: "revoked";
|
||||
return {
|
||||
status,
|
||||
effective: status === "authorized" && available,
|
||||
needsReview: values.active && !available,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations";
|
||||
import type { TeleworkAuthorization } from "../domain/telework-authorization.aggregate";
|
||||
|
||||
import type { TeleworkAuthorizationContext } from "./telework-authorization.repository";
|
||||
import { teleworkAuthorizationState } from "./telework-authorization-policy";
|
||||
|
||||
export function buildTeleworkAuthorizationResponse(
|
||||
item: TeleworkAuthorization,
|
||||
context: TeleworkAuthorizationContext
|
||||
): TeleworkAuthorizationDTO {
|
||||
const { status, effective, needsReview } = teleworkAuthorizationState(item, context);
|
||||
return {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
values: item.values.toPrimitive(),
|
||||
status,
|
||||
effective,
|
||||
needsReview,
|
||||
history: item.history.map((revision) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
values: revision.values.toPrimitive(),
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
actorName:
|
||||
context.actors.get(revision.actorId.toPrimitive()) ?? revision.actorId.toPrimitive(),
|
||||
recordedAt: revision.recordedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { TeleworkAuthorization } from "../domain/telework-authorization.aggregate";
|
||||
import type { TeleworkAuthorizationError } from "../domain/telework-authorization-values";
|
||||
|
||||
export type TeleworkAuthorizationContext = Readonly<{
|
||||
authorizations: readonly TeleworkAuthorization[];
|
||||
people: readonly { id: UniqueID; active: boolean }[];
|
||||
actors: ReadonlyMap<string, string>;
|
||||
today: string;
|
||||
}>;
|
||||
export interface ITeleworkAuthorizationRepository {
|
||||
get(
|
||||
companyId: UniqueID
|
||||
): Promise<Result<TeleworkAuthorizationContext, TeleworkAuthorizationError>>;
|
||||
save(
|
||||
companyId: UniqueID,
|
||||
action: (
|
||||
context: TeleworkAuthorizationContext
|
||||
) => Result<TeleworkAuthorization, TeleworkAuthorizationError>
|
||||
): Promise<
|
||||
Result<
|
||||
{ authorization: TeleworkAuthorization; context: TeleworkAuthorizationContext },
|
||||
TeleworkAuthorizationError
|
||||
>
|
||||
>;
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { TeleworkAuthorization } from "../domain/telework-authorization.aggregate";
|
||||
import type { TeleworkAuthorizationInput } from "../domain/telework-authorization-values";
|
||||
import { TeleworkAuthorizationError } from "../domain/telework-authorization-values";
|
||||
|
||||
import type { ITeleworkAuthorizationRepository } from "./telework-authorization.repository";
|
||||
import { mapTeleworkAuthorizationInput } from "./telework-authorization-input.mapper";
|
||||
export class TeleworkAuthorizationUseCases {
|
||||
constructor(private readonly repository: ITeleworkAuthorizationRepository) {}
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
return company.isSuccess
|
||||
? this.repository.get(company.data)
|
||||
: Promise.resolve(Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST")));
|
||||
}
|
||||
save(
|
||||
companyId: string,
|
||||
id: string | undefined,
|
||||
input: TeleworkAuthorizationInput,
|
||||
actorId: string
|
||||
) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const actor = UniqueID.create(actorId);
|
||||
const identity = id ? UniqueID.create(id) : undefined;
|
||||
if (!(company.isSuccess && actor.isSuccess) || (identity && !identity.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST")));
|
||||
}
|
||||
return this.repository.save(company.data, (context) => {
|
||||
const previous = identity?.isSuccess
|
||||
? context.authorizations.find((item) => item.id.equals(identity.data))
|
||||
: undefined;
|
||||
if (id && !previous) {
|
||||
return Result.fail(new TeleworkAuthorizationError("TELEWORK_AUTHORIZATION_NOT_FOUND"));
|
||||
}
|
||||
const values = mapTeleworkAuthorizationInput(input, context, previous);
|
||||
if (!values.isSuccess) {
|
||||
return Result.fail(values.error);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
return previous
|
||||
? previous.update(values.data, actor.data, now)
|
||||
: TeleworkAuthorization.create(company.data, values.data, actor.data, now);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { TeleworkAuthorizationValues } from "./value-objects/telework-authorization-values";
|
||||
export type TeleworkAuthorizationRevisionProps = Readonly<{
|
||||
values: TeleworkAuthorizationValues;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
export class TeleworkAuthorizationRevision extends DomainEntity<TeleworkAuthorizationRevisionProps> {
|
||||
static create(props: TeleworkAuthorizationRevisionProps) {
|
||||
return new TeleworkAuthorizationRevision(Object.freeze({ ...props }));
|
||||
}
|
||||
static rehydrate(props: TeleworkAuthorizationRevisionProps, id: UniqueID) {
|
||||
return new TeleworkAuthorizationRevision(Object.freeze({ ...props }), id);
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get actorId() {
|
||||
return this.props.actorId;
|
||||
}
|
||||
get recordedAt() {
|
||||
return this.props.recordedAt;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const TeleworkAuthorizationInputSchema = z
|
||||
.strictObject({
|
||||
personId: z.uuid(),
|
||||
active: z.boolean(),
|
||||
startsOn: z.iso.date().nullable(),
|
||||
expiresOn: z.iso.date().nullable(),
|
||||
notes: z.string().trim().max(4000).nullable(),
|
||||
})
|
||||
.refine((value) => !(value.startsOn && value.expiresOn) || value.startsOn <= value.expiresOn, {
|
||||
path: ["expiresOn"],
|
||||
message: "INVALID_REQUEST",
|
||||
});
|
||||
export type TeleworkAuthorizationInput = z.infer<typeof TeleworkAuthorizationInputSchema>;
|
||||
export class TeleworkAuthorizationError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { TeleworkAuthorizationRevision } from "./telework-authorization-revision.entity";
|
||||
import { TeleworkAuthorizationError } from "./telework-authorization-values";
|
||||
import type { TeleworkAuthorizationValues } from "./value-objects/telework-authorization-values";
|
||||
|
||||
type TeleworkAuthorizationProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
values: TeleworkAuthorizationValues;
|
||||
history: readonly TeleworkAuthorizationRevision[];
|
||||
}>;
|
||||
export class TeleworkAuthorization extends AggregateRoot<TeleworkAuthorizationProps> {
|
||||
private constructor(props: TeleworkAuthorizationProps, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||
}
|
||||
static create(
|
||||
companyId: UniqueID,
|
||||
values: TeleworkAuthorizationValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<TeleworkAuthorization, TeleworkAuthorizationError> {
|
||||
if (!z.iso.datetime().safeParse(recordedAt).success) {
|
||||
return Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new TeleworkAuthorization({
|
||||
companyId,
|
||||
values,
|
||||
history: [TeleworkAuthorizationRevision.create({ values, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: TeleworkAuthorizationProps, id: UniqueID) {
|
||||
return new TeleworkAuthorization(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get history() {
|
||||
return this.props.history;
|
||||
}
|
||||
update(
|
||||
values: TeleworkAuthorizationValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<TeleworkAuthorization, TeleworkAuthorizationError> {
|
||||
if (this.values.equals(values)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
if (!this.values.sameTarget(values)) {
|
||||
return Result.fail(new TeleworkAuthorizationError("TELEWORK_PERSON_IMMUTABLE"));
|
||||
}
|
||||
if (
|
||||
!z.iso.datetime().safeParse(recordedAt).success ||
|
||||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
|
||||
) {
|
||||
return Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new TeleworkAuthorization(
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [
|
||||
...this.history,
|
||||
TeleworkAuthorizationRevision.create({ values, actorId, recordedAt }),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
183
modules/companies/src/api/domain/telework-authorization.test.ts
Normal file
183
modules/companies/src/api/domain/telework-authorization.test.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { TeleworkAuthorizationContext } from "../application/telework-authorization.repository";
|
||||
import { mapTeleworkAuthorizationInput } from "../application/telework-authorization-input.mapper";
|
||||
import { teleworkAuthorizationState } from "../application/telework-authorization-policy";
|
||||
import { TeleworkAuthorizationPersistenceMapper } from "../infrastructure/persistence/telework-authorization-persistence.mapper";
|
||||
|
||||
import { TeleworkAuthorization } from "./telework-authorization.aggregate";
|
||||
import {
|
||||
type TeleworkAuthorizationInput,
|
||||
TeleworkAuthorizationInputSchema,
|
||||
} from "./telework-authorization-values";
|
||||
import { TeleworkAuthorizationValues } from "./value-objects/telework-authorization-values";
|
||||
|
||||
const company = UniqueID.generateNewID();
|
||||
const person = UniqueID.generateNewID();
|
||||
const actor = UniqueID.generateNewID();
|
||||
const input: TeleworkAuthorizationInput = {
|
||||
personId: person.toPrimitive(),
|
||||
active: true,
|
||||
startsOn: null,
|
||||
expiresOn: null,
|
||||
notes: null,
|
||||
};
|
||||
const now = "2026-10-06T09:00:00.000Z";
|
||||
const context: TeleworkAuthorizationContext = {
|
||||
authorizations: [],
|
||||
people: [{ id: person, active: true }],
|
||||
actors: new Map([[actor.toPrimitive(), "Técnico"]]),
|
||||
today: "2026-10-06",
|
||||
};
|
||||
function values(value: TeleworkAuthorizationInput) {
|
||||
const result = TeleworkAuthorizationValues.create(value);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
function create(value: TeleworkAuthorizationInput = input) {
|
||||
const result = TeleworkAuthorization.create(company, values(value), actor, now);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
test("teletrabajo: ausencia, límites, fechas reales y contrato estricto", () => {
|
||||
assert.ok(TeleworkAuthorizationInputSchema.safeParse(input).success);
|
||||
assert.ok(
|
||||
!TeleworkAuthorizationInputSchema.safeParse({ ...input, startsOn: "2026-02-30" }).success
|
||||
);
|
||||
assert.ok(
|
||||
!TeleworkAuthorizationInputSchema.safeParse({
|
||||
...input,
|
||||
startsOn: "2026-10-07",
|
||||
expiresOn: "2026-10-06",
|
||||
}).success
|
||||
);
|
||||
assert.ok(
|
||||
!TeleworkAuthorizationInputSchema.safeParse({ ...input, notes: "a".repeat(4001) }).success
|
||||
);
|
||||
assert.ok(!TeleworkAuthorizationInputSchema.safeParse({ ...input, password: "secret" }).success);
|
||||
assert.equal(values({ ...input, notes: " " }).toPrimitive().notes, null);
|
||||
assert.equal(values({ ...input, notes: " Condiciones " }).toPrimitive().notes, "Condiciones");
|
||||
});
|
||||
test("teletrabajo: UUID, renovación y revocación auditadas, igualdad y destino fijo", () => {
|
||||
const item = create();
|
||||
assert.equal(item.id.toPrimitive()[14], "7");
|
||||
assert.equal(item.history[0]?.id.toPrimitive()[14], "7");
|
||||
const repeated = item.update(values(input), actor, "invalid");
|
||||
assert.ok(repeated.isSuccess);
|
||||
assert.equal(repeated.data, item);
|
||||
const revoked = item.update(
|
||||
values({ ...input, active: false, notes: "Salida" }),
|
||||
actor,
|
||||
"2026-10-07T09:00:00.000Z"
|
||||
);
|
||||
assert.ok(revoked.isSuccess);
|
||||
assert.ok(revoked.data.id.equals(item.id));
|
||||
assert.equal(revoked.data.history.length, 2);
|
||||
assert.notEqual(
|
||||
revoked.data.history[0]?.id.toPrimitive(),
|
||||
revoked.data.history[1]?.id.toPrimitive()
|
||||
);
|
||||
assert.equal(item.values.active, true);
|
||||
const renewed = revoked.data.update(
|
||||
values({ ...input, expiresOn: "2026-12-31" }),
|
||||
actor,
|
||||
"2026-10-08T09:00:00.000Z"
|
||||
);
|
||||
assert.ok(renewed.isSuccess);
|
||||
assert.equal(renewed.data.history.length, 3);
|
||||
const foreign = item.update(
|
||||
values({ ...input, personId: UniqueID.generateNewID().toPrimitive() }),
|
||||
actor,
|
||||
now
|
||||
);
|
||||
assert.ok(!foreign.isSuccess);
|
||||
assert.equal(foreign.error.code, "TELEWORK_PERSON_IMMUTABLE");
|
||||
assert.ok(
|
||||
!item.update(values({ ...input, active: false }), actor, "2026-10-05T09:00:00.000Z").isSuccess
|
||||
);
|
||||
assert.ok(!TeleworkAuthorization.create(company, values(input), actor, "invalid").isSuccess);
|
||||
});
|
||||
test("teletrabajo: vigencia inclusiva, programación, caducidad y persona inactiva", () => {
|
||||
const day = { ...input, startsOn: "2026-10-06", expiresOn: "2026-10-06" };
|
||||
assert.deepEqual(teleworkAuthorizationState(create(day), context), {
|
||||
status: "authorized",
|
||||
effective: true,
|
||||
needsReview: false,
|
||||
});
|
||||
assert.equal(
|
||||
teleworkAuthorizationState(create(day), { ...context, today: "2026-10-05" }).status,
|
||||
"scheduled"
|
||||
);
|
||||
assert.equal(
|
||||
teleworkAuthorizationState(create(day), { ...context, today: "2026-10-07" }).status,
|
||||
"expired"
|
||||
);
|
||||
assert.equal(
|
||||
teleworkAuthorizationState(create({ ...day, active: false }), context).status,
|
||||
"revoked"
|
||||
);
|
||||
assert.deepEqual(
|
||||
teleworkAuthorizationState(create(), { ...context, people: [{ id: person, active: false }] }),
|
||||
{ status: "authorized", effective: false, needsReview: true }
|
||||
);
|
||||
});
|
||||
test("teletrabajo: ámbito, unicidad incluso revocada, disponibilidad y retirada", () => {
|
||||
const missing = mapTeleworkAuthorizationInput(input, { ...context, people: [] });
|
||||
assert.ok(!missing.isSuccess);
|
||||
assert.equal(missing.error.code, "TELEWORK_PERSON_NOT_FOUND");
|
||||
const inactive = { ...context, people: [{ id: person, active: false }] };
|
||||
const rejected = mapTeleworkAuthorizationInput(input, inactive);
|
||||
assert.ok(!rejected.isSuccess);
|
||||
assert.equal(rejected.error.code, "TELEWORK_PERSON_INACTIVE");
|
||||
const item = create();
|
||||
assert.ok(mapTeleworkAuthorizationInput({ ...input, active: false }, inactive, item).isSuccess);
|
||||
assert.ok(mapTeleworkAuthorizationInput(input, inactive, item).isSuccess);
|
||||
const duplicate = mapTeleworkAuthorizationInput(input, {
|
||||
...context,
|
||||
authorizations: [create({ ...input, active: false })],
|
||||
});
|
||||
assert.ok(!duplicate.isSuccess);
|
||||
assert.equal(duplicate.error.code, "TELEWORK_ALREADY_EXISTS");
|
||||
const foreign = mapTeleworkAuthorizationInput(
|
||||
{ ...input, personId: UniqueID.generateNewID().toPrimitive() },
|
||||
context,
|
||||
item
|
||||
);
|
||||
assert.ok(!foreign.isSuccess);
|
||||
assert.equal(foreign.error.code, "TELEWORK_PERSON_IMMUTABLE");
|
||||
});
|
||||
test("teletrabajo: rehidratación preserva identidad y detecta corrupción de raíz e historial", () => {
|
||||
const mapper = new TeleworkAuthorizationPersistenceMapper();
|
||||
const item = create();
|
||||
const record = mapper.toPersistence(item);
|
||||
const restored = mapper.toDomain(record.root, record.revisions);
|
||||
assert.ok(restored.id.equals(item.id));
|
||||
assert.ok(restored.values.equals(item.values));
|
||||
assert.ok(item.history[0]);
|
||||
assert.ok(restored.history[0]?.id.equals(item.history[0].id));
|
||||
assert.throws(() => mapper.toDomain(record.root, []));
|
||||
assert.throws(() =>
|
||||
mapper.toDomain({ ...record.root, personId: actor.toPrimitive() }, record.revisions)
|
||||
);
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
record.root,
|
||||
record.revisions.map((r) => ({ ...r, authorizationId: actor.toPrimitive() }))
|
||||
)
|
||||
);
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
record.root,
|
||||
record.revisions.map((r) => ({ ...r, position: 1 }))
|
||||
)
|
||||
);
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
record.root,
|
||||
record.revisions.map((r) => ({ ...r, recordedAt: "invalid" }))
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,68 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
TeleworkAuthorizationError,
|
||||
type TeleworkAuthorizationInput,
|
||||
TeleworkAuthorizationInputSchema,
|
||||
} from "../telework-authorization-values";
|
||||
|
||||
import { CalendarDate } from "./calendar-date";
|
||||
|
||||
export class TeleworkAuthorizationValues {
|
||||
private constructor(
|
||||
public readonly personId: UniqueID,
|
||||
public readonly active: boolean,
|
||||
public readonly startsOn: Maybe<CalendarDate>,
|
||||
public readonly expiresOn: Maybe<CalendarDate>,
|
||||
public readonly notes: Maybe<string>
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(
|
||||
input: TeleworkAuthorizationInput
|
||||
): Result<TeleworkAuthorizationValues, TeleworkAuthorizationError> {
|
||||
const parsed = TeleworkAuthorizationInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST"));
|
||||
}
|
||||
const person = UniqueID.create(parsed.data.personId);
|
||||
const start = parsed.data.startsOn ? CalendarDate.create(parsed.data.startsOn) : undefined;
|
||||
const expiry = parsed.data.expiresOn ? CalendarDate.create(parsed.data.expiresOn) : undefined;
|
||||
if (!person.isSuccess || (start && !start.isSuccess) || (expiry && !expiry.isSuccess)) {
|
||||
return Result.fail(new TeleworkAuthorizationError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new TeleworkAuthorizationValues(
|
||||
person.data,
|
||||
parsed.data.active,
|
||||
start?.isSuccess ? Maybe.some(start.data) : Maybe.none(),
|
||||
expiry?.isSuccess ? Maybe.some(expiry.data) : Maybe.none(),
|
||||
parsed.data.notes ? Maybe.some(parsed.data.notes) : Maybe.none()
|
||||
)
|
||||
);
|
||||
}
|
||||
toPrimitive(): TeleworkAuthorizationInput {
|
||||
return {
|
||||
personId: this.personId.toPrimitive(),
|
||||
active: this.active,
|
||||
startsOn: this.startsOn.getOrUndefined()?.toPrimitive() ?? null,
|
||||
expiresOn: this.expiresOn.getOrUndefined()?.toPrimitive() ?? null,
|
||||
notes: this.notes.getOrUndefined() ?? null,
|
||||
};
|
||||
}
|
||||
sameTarget(other: TeleworkAuthorizationValues) {
|
||||
return this.personId.equals(other.personId);
|
||||
}
|
||||
equals(other: TeleworkAuthorizationValues) {
|
||||
const a = this.toPrimitive();
|
||||
const b = other.toPrimitive();
|
||||
return (
|
||||
this.sameTarget(other) &&
|
||||
a.active === b.active &&
|
||||
a.startsOn === b.startsOn &&
|
||||
a.expiresOn === b.expiresOn &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import { type Application, type RequestHandler, type Response, Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
SaveTeleworkAuthorizationRequestSchema,
|
||||
TeleworkItemParamsSchema,
|
||||
TeleworkParamsSchema,
|
||||
} from "../../../common/telework-authorizations";
|
||||
import type { TeleworkAuthorizationUseCases } from "../../application/telework-authorization.use-cases";
|
||||
import { buildTeleworkAuthorizationResponse } from "../../application/telework-authorization-response.builder";
|
||||
|
||||
function fail(response: Response, code: string) {
|
||||
response
|
||||
.status(code === "INVALID_REQUEST" ? 400 : code.endsWith("NOT_FOUND") ? 404 : 409)
|
||||
.json({ code, requestId: response.getHeader("x-request-id") });
|
||||
}
|
||||
export function registerTeleworkAuthorizationRoutes(
|
||||
app: Application,
|
||||
useCases: TeleworkAuthorizationUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
router.get("/:companyId", (request, response, next) => {
|
||||
const params = TeleworkParamsSchema.safeParse(request.params);
|
||||
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.get(params.data.companyId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({
|
||||
authorizations: result.data.authorizations.map((item) =>
|
||||
buildTeleworkAuthorizationResponse(item, result.data)
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
|
||||
router.post(path, (request, response, next) => {
|
||||
const params = (
|
||||
path.endsWith("update") ? TeleworkItemParamsSchema : TeleworkParamsSchema
|
||||
).safeParse(request.params);
|
||||
const body = SaveTeleworkAuthorizationRequestSchema.safeParse(request.body);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
body.success &&
|
||||
actor.success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
"id" in params.data && typeof params.data.id === "string" ? params.data.id : undefined;
|
||||
useCases
|
||||
.save(params.data.companyId, id, body.data.values, actor.data.userId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.status(id ? 200 : 201).json({
|
||||
authorization: buildTeleworkAuthorizationResponse(
|
||||
result.data.authorization,
|
||||
result.data.context
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/telework-authorizations", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
|
||||
|
||||
import type {
|
||||
ITeleworkAuthorizationRepository,
|
||||
TeleworkAuthorizationContext,
|
||||
} from "../../application/telework-authorization.repository";
|
||||
import { TeleworkAuthorizationError } from "../../domain/telework-authorization-values";
|
||||
|
||||
import {
|
||||
TeleworkAuthorizationPersistenceMapper,
|
||||
type TeleworkRevisionRow,
|
||||
type TeleworkRow,
|
||||
teleworkIdentity,
|
||||
} from "./telework-authorization-persistence.mapper";
|
||||
|
||||
export class SequelizeTeleworkAuthorizationRepository implements ITeleworkAuthorizationRepository {
|
||||
private readonly mapper = new TeleworkAuthorizationPersistenceMapper();
|
||||
constructor(private readonly database: Sequelize) {}
|
||||
private async load(
|
||||
company: UniqueID,
|
||||
transaction: Transaction,
|
||||
write: boolean
|
||||
): Promise<Result<TeleworkAuthorizationContext, TeleworkAuthorizationError>> {
|
||||
const companyId = company.toPrimitive();
|
||||
// El bloqueo compartido de compañía serializa las altas y los cambios de personas.
|
||||
const companies = await this.database.query<{ id: string }>(
|
||||
`SELECT id FROM companies WHERE id=:companyId${write ? " FOR UPDATE" : ""}`,
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!companies.length) {
|
||||
return Result.fail(new TeleworkAuthorizationError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
const rows = await this.database.query<TeleworkRow>(
|
||||
"SELECT * FROM telework_authorizations WHERE companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const revisions = await this.database.query<TeleworkRevisionRow>(
|
||||
"SELECT r.* FROM telework_authorization_revisions r JOIN telework_authorizations a ON a.id=r.authorizationId WHERE a.companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const people = await this.database.query<{ id: string; active: boolean }>(
|
||||
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
|
||||
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const actors = await this.database.query<{ id: string; name: string }>(
|
||||
"SELECT id,name FROM users",
|
||||
{ transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const authorizations = rows.map((row) =>
|
||||
this.mapper.toDomain(
|
||||
row,
|
||||
revisions.filter((revision) => revision.authorizationId === row.id)
|
||||
)
|
||||
);
|
||||
if (
|
||||
authorizations.some(
|
||||
(item) => !people.some((person) => person.id === item.values.personId.toPrimitive())
|
||||
)
|
||||
) {
|
||||
throw new Error("Pertenencia de teletrabajo persistida inconsistente.");
|
||||
}
|
||||
return Result.ok({
|
||||
authorizations,
|
||||
people: people.map((person) => ({
|
||||
id: teleworkIdentity(person.id),
|
||||
active: Boolean(person.active),
|
||||
})),
|
||||
actors: new Map(actors.map((actor) => [actor.id, actor.name])),
|
||||
today: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
get(companyId: UniqueID) {
|
||||
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
|
||||
}
|
||||
save(companyId: UniqueID, action: Parameters<ITeleworkAuthorizationRepository["save"]>[1]) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const context = await this.load(companyId, transaction, true);
|
||||
if (!context.isSuccess) {
|
||||
return context;
|
||||
}
|
||||
const candidate = action(context.data);
|
||||
if (!candidate.isSuccess) {
|
||||
return candidate;
|
||||
}
|
||||
const previous = context.data.authorizations.find((item) =>
|
||||
item.id.equals(candidate.data.id)
|
||||
);
|
||||
if (previous !== candidate.data) {
|
||||
const record = this.mapper.toPersistence(candidate.data);
|
||||
await this.database.query(
|
||||
"INSERT INTO telework_authorizations (id,companyId,personId,`values`) VALUES (:id,:companyId,:personId,:values) ON DUPLICATE KEY UPDATE `values`=VALUES(`values`)",
|
||||
{ replacements: record.root, transaction }
|
||||
);
|
||||
const stored = new Set(
|
||||
previous?.history.map((revision) => revision.id.toPrimitive()) ?? []
|
||||
);
|
||||
for (const revision of record.revisions.filter((item) => !stored.has(item.id))) {
|
||||
await this.database.query(
|
||||
"INSERT INTO telework_authorization_revisions (id,authorizationId,position,actorId,recordedAt,`values`) VALUES (:id,:authorizationId,:position,:actorId,:recordedAt,:values)",
|
||||
{ replacements: revision, transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
return Result.ok({ authorization: candidate.data, context: context.data });
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
|
||||
export async function migrateTeleworkAuthorizations(database: Sequelize) {
|
||||
const query = database.getQueryInterface();
|
||||
const tables = await database.query<{ name: string }>(
|
||||
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
const reference = (table: string) => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: table, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
if (!tables.some((row) => row.name === "telework_authorizations")) {
|
||||
await query.createTable("telework_authorizations", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: reference("companies"),
|
||||
personId: { ...reference("company_contacts"), unique: true },
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
});
|
||||
}
|
||||
if (!tables.some((row) => row.name === "telework_authorization_revisions")) {
|
||||
await query.createTable("telework_authorization_revisions", {
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
authorizationId: reference("telework_authorizations"),
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
actorId: reference("users"),
|
||||
recordedAt: { type: DataTypes.STRING(24), allowNull: false },
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
});
|
||||
}
|
||||
const indexes = await database.query<{ name: string }>(
|
||||
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='telework_authorization_revisions'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!indexes.some((item) => item.name === "telework_revision_position")) {
|
||||
await query.addIndex("telework_authorization_revisions", ["authorizationId", "position"], {
|
||||
unique: true,
|
||||
name: "telework_revision_position",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { TeleworkAuthorization } from "../../domain/telework-authorization.aggregate";
|
||||
import { TeleworkAuthorizationRevision } from "../../domain/telework-authorization-revision.entity";
|
||||
import { TeleworkAuthorizationInputSchema } from "../../domain/telework-authorization-values";
|
||||
import { TeleworkAuthorizationValues } from "../../domain/value-objects/telework-authorization-values";
|
||||
|
||||
export function teleworkIdentity(value: string) {
|
||||
const id = UniqueID.create(value);
|
||||
if (!id.isSuccess) {
|
||||
throw new Error("Identidad de teletrabajo persistida inconsistente.");
|
||||
}
|
||||
return id.data;
|
||||
}
|
||||
export type TeleworkRow = { id: string; companyId: string; personId: string; values: unknown };
|
||||
export type TeleworkRevisionRow = {
|
||||
id: string;
|
||||
authorizationId: string;
|
||||
position: number;
|
||||
actorId: string;
|
||||
recordedAt: string;
|
||||
values: unknown;
|
||||
};
|
||||
function valuesOf(raw: unknown) {
|
||||
const input = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
const values = TeleworkAuthorizationValues.create(TeleworkAuthorizationInputSchema.parse(input));
|
||||
if (!values.isSuccess) {
|
||||
throw new Error("Valores de teletrabajo persistidos inconsistentes.");
|
||||
}
|
||||
return values.data;
|
||||
}
|
||||
export class TeleworkAuthorizationPersistenceMapper {
|
||||
toDomain(row: TeleworkRow, revisions: readonly TeleworkRevisionRow[]) {
|
||||
const values = valuesOf(row.values);
|
||||
const ordered = [...revisions].sort((a, b) => a.position - b.position);
|
||||
const history = ordered.map((revision, position) => {
|
||||
const revisionValues = valuesOf(revision.values);
|
||||
if (
|
||||
revision.authorizationId !== row.id ||
|
||||
revision.position !== position ||
|
||||
!z.iso.datetime().safeParse(revision.recordedAt).success ||
|
||||
revision.recordedAt < (ordered[position - 1]?.recordedAt ?? "") ||
|
||||
!values.sameTarget(revisionValues)
|
||||
) {
|
||||
throw new Error("Historial de teletrabajo persistido inconsistente.");
|
||||
}
|
||||
return TeleworkAuthorizationRevision.rehydrate(
|
||||
{
|
||||
values: revisionValues,
|
||||
actorId: teleworkIdentity(revision.actorId),
|
||||
recordedAt: revision.recordedAt,
|
||||
},
|
||||
teleworkIdentity(revision.id)
|
||||
);
|
||||
});
|
||||
if (!history.at(-1)?.values.equals(values) || row.personId !== values.personId.toPrimitive()) {
|
||||
throw new Error("Referencias de teletrabajo persistidas inconsistentes.");
|
||||
}
|
||||
return TeleworkAuthorization.rehydrate(
|
||||
{ companyId: teleworkIdentity(row.companyId), values, history },
|
||||
teleworkIdentity(row.id)
|
||||
);
|
||||
}
|
||||
toPersistence(item: TeleworkAuthorization) {
|
||||
return {
|
||||
root: {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
personId: item.values.personId.toPrimitive(),
|
||||
values: JSON.stringify(item.values.toPrimitive()),
|
||||
},
|
||||
revisions: item.history.map((revision, position) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
authorizationId: item.id.toPrimitive(),
|
||||
position,
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
recordedAt: revision.recordedAt,
|
||||
values: JSON.stringify(revision.values.toPrimitive()),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import type { IMailInventoryService } from "./application/mail-configuration.rep
|
||||
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
||||
import { MailboxUseCases } from "./application/mailbox.use-cases";
|
||||
import { PrinterConfigurationUseCases } from "./application/printer-configuration.use-cases";
|
||||
import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.use-cases";
|
||||
import type { IWorkProfileInventoryService } from "./application/work-profile.repository";
|
||||
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
|
||||
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
|
||||
@ -22,6 +23,7 @@ import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mappin
|
||||
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
|
||||
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
|
||||
import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.routes";
|
||||
import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes";
|
||||
import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes";
|
||||
import {
|
||||
AccessPersistenceMapper,
|
||||
@ -46,7 +48,9 @@ import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/s
|
||||
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
|
||||
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
|
||||
import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository";
|
||||
import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.repository";
|
||||
import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository";
|
||||
import { migrateTeleworkAuthorizations } from "./infrastructure/persistence/telework-authorization-migration";
|
||||
import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration";
|
||||
|
||||
export async function createCompanyModule(
|
||||
@ -59,6 +63,10 @@ export async function createCompanyModule(
|
||||
initializeCompanyModels(database);
|
||||
initializePeopleModels(database);
|
||||
const repository = new SequelizeCompanyRepository(database, new CompanyPersistenceMapper());
|
||||
await migrateTeleworkAuthorizations(database);
|
||||
const telework = new TeleworkAuthorizationUseCases(
|
||||
new SequelizeTeleworkAuthorizationRepository(database)
|
||||
);
|
||||
await migrateAccess(database);
|
||||
const access = new AccessUseCases(
|
||||
new SequelizeAccessRepository(database, repository, new AccessPersistenceMapper())
|
||||
@ -117,6 +125,7 @@ export async function createCompanyModule(
|
||||
},
|
||||
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
||||
registerCompanyRoutes(app, useCases, authenticate);
|
||||
registerTeleworkAuthorizationRoutes(app, telework, authenticate);
|
||||
registerAccessRoutes(app, access, authenticate, legacyResourceWrites);
|
||||
},
|
||||
};
|
||||
|
||||
@ -11,4 +11,5 @@ export * from "./folder-mappings";
|
||||
export * from "./mail-configurations";
|
||||
export * from "./mailboxes";
|
||||
export * from "./printer-configurations";
|
||||
export * from "./telework-authorizations";
|
||||
export * from "./work-profiles";
|
||||
|
||||
38
modules/companies/src/common/telework-authorizations.ts
Normal file
38
modules/companies/src/common/telework-authorizations.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { TeleworkAuthorizationInputSchema } from "../api/domain/telework-authorization-values";
|
||||
|
||||
export type { TeleworkAuthorizationInput } from "../api/domain/telework-authorization-values";
|
||||
|
||||
export { TeleworkAuthorizationInputSchema };
|
||||
export const TeleworkParamsSchema = z.strictObject({ companyId: z.uuid() });
|
||||
export const TeleworkItemParamsSchema = TeleworkParamsSchema.extend({ id: z.uuid() });
|
||||
export const SaveTeleworkAuthorizationRequestSchema = z.strictObject({
|
||||
values: TeleworkAuthorizationInputSchema,
|
||||
});
|
||||
export const TeleworkAuthorizationSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
values: TeleworkAuthorizationInputSchema,
|
||||
status: z.enum(["authorized", "scheduled", "expired", "revoked"]),
|
||||
effective: z.boolean(),
|
||||
needsReview: z.boolean(),
|
||||
history: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: TeleworkAuthorizationInputSchema,
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string().min(1),
|
||||
recordedAt: z.iso.datetime(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
export const TeleworkAuthorizationsResponseSchema = z.strictObject({
|
||||
authorizations: z.array(TeleworkAuthorizationSchema),
|
||||
});
|
||||
export const TeleworkAuthorizationResponseSchema = z.strictObject({
|
||||
authorization: TeleworkAuthorizationSchema,
|
||||
});
|
||||
export type TeleworkAuthorizationDTO = z.infer<typeof TeleworkAuthorizationSchema>;
|
||||
@ -28,6 +28,7 @@ import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||
import { MailboxRelations } from "../mail/mailbox-relations";
|
||||
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||
import { WorkProfilesPage } from "../profiles/work-profiles-page";
|
||||
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
|
||||
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
import { CompanyForm } from "./company-form";
|
||||
@ -110,6 +111,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
||||
);
|
||||
}
|
||||
type CompanyTab =
|
||||
| "telework"
|
||||
| "people"
|
||||
| "departments"
|
||||
| "sites"
|
||||
@ -216,6 +218,7 @@ export function CompanyDetail({
|
||||
["resources", resourceTranslation("inventory"), LayersIcon],
|
||||
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
||||
["profiles", t("profiles.title"), UsersIcon],
|
||||
["telework", t("telework.title"), KeyRoundIcon],
|
||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||
[
|
||||
"departments",
|
||||
@ -265,6 +268,8 @@ function CompanySection({
|
||||
tab: CompanyTab;
|
||||
}) {
|
||||
switch (tab) {
|
||||
case "telework":
|
||||
return <TeleworkAuthorizationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "people":
|
||||
return <PeopleSection company={company} csrfToken={csrfToken} />;
|
||||
case "departments":
|
||||
|
||||
@ -6,8 +6,10 @@ import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
||||
import { printersEnglish, printersSpanish } from "./printers/printer-i18n";
|
||||
import { environmentEnglish, environmentSpanish } from "./profiles/environment-i18n";
|
||||
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
||||
import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n";
|
||||
|
||||
export const companiesSpanish = {
|
||||
telework: teleworkSpanish,
|
||||
environment: environmentSpanish,
|
||||
certificates: certificatesSpanish,
|
||||
applications: applicationsSpanish,
|
||||
@ -130,6 +132,7 @@ export const companiesSpanish = {
|
||||
},
|
||||
};
|
||||
export const companiesEnglish = {
|
||||
telework: teleworkEnglish,
|
||||
environment: environmentEnglish,
|
||||
certificates: certificatesEnglish,
|
||||
applications: applicationsEnglish,
|
||||
|
||||
@ -11,6 +11,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO, WorkProfileDTO } from "../../common";
|
||||
import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations";
|
||||
import { accessApi, accessKeys } from "../access/access-api";
|
||||
import { accessLabel } from "../access/access-labels";
|
||||
import { applicationApi, applicationKeys } from "../applications/application-api";
|
||||
@ -18,6 +19,8 @@ import { certificateApi, certificateKeys } from "../certificates/certificate-api
|
||||
import { folderApi, folderKeys } from "../folders/folder-api";
|
||||
import { mailApi, mailKeys } from "../mail/mail-api";
|
||||
import { printerApi, printerKeys } from "../printers/printer-api";
|
||||
import { teleworkApi, teleworkKeys } from "../telework/telework-api";
|
||||
import { TeleworkAuthorizationSummary } from "../telework/telework-authorization-summary";
|
||||
|
||||
import {
|
||||
type EnvironmentRow,
|
||||
@ -64,7 +67,12 @@ function useEnvironmentQueries(companyId: string) {
|
||||
queryKey: certificateKeys.company(companyId),
|
||||
queryFn: () => certificateApi.get(companyId),
|
||||
});
|
||||
const telework = useQuery({
|
||||
queryKey: teleworkKeys.company(companyId),
|
||||
queryFn: () => teleworkApi.get(companyId),
|
||||
});
|
||||
const queries = [
|
||||
telework,
|
||||
profiles,
|
||||
assets,
|
||||
resources,
|
||||
@ -80,6 +88,7 @@ function useEnvironmentQueries(companyId: string) {
|
||||
const retry = () => Promise.all(queries.map((query) => query.refetch()));
|
||||
if (
|
||||
!(
|
||||
telework.data &&
|
||||
profiles.data &&
|
||||
assets.data &&
|
||||
resources.data &&
|
||||
@ -104,7 +113,16 @@ function useEnvironmentQueries(companyId: string) {
|
||||
applications: applications.data.configurations,
|
||||
certificates: certificates.data.configurations,
|
||||
};
|
||||
return { pending, error, retry, data: { profiles: profiles.data.profiles, sources } };
|
||||
return {
|
||||
pending,
|
||||
error,
|
||||
retry,
|
||||
data: {
|
||||
profiles: profiles.data.profiles,
|
||||
sources,
|
||||
authorizations: telework.data.authorizations,
|
||||
},
|
||||
};
|
||||
}
|
||||
export function WorkEnvironmentPage({
|
||||
company,
|
||||
@ -114,7 +132,7 @@ export function WorkEnvironmentPage({
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
profileId: string;
|
||||
onManage: (kind: ProfileConfigurationKind) => void;
|
||||
onManage: (kind: ProfileConfigurationKind | "telework") => void;
|
||||
onProfile: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
@ -145,6 +163,7 @@ export function WorkEnvironmentPage({
|
||||
}
|
||||
return (
|
||||
<EnvironmentContent
|
||||
authorizations={query.data.authorizations}
|
||||
company={company}
|
||||
key={profile.id}
|
||||
onManage={onManage}
|
||||
@ -156,6 +175,7 @@ export function WorkEnvironmentPage({
|
||||
);
|
||||
}
|
||||
function EnvironmentContent({
|
||||
authorizations,
|
||||
company,
|
||||
profile,
|
||||
profiles,
|
||||
@ -165,9 +185,10 @@ function EnvironmentContent({
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
profile: WorkProfileDTO;
|
||||
authorizations: readonly TeleworkAuthorizationDTO[];
|
||||
profiles: readonly WorkProfileDTO[];
|
||||
sources: EnvironmentSources;
|
||||
onManage: (kind: ProfileConfigurationKind) => void;
|
||||
onManage: (kind: ProfileConfigurationKind | "telework") => void;
|
||||
onProfile: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
@ -271,6 +292,22 @@ function EnvironmentContent({
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="space-y-3 pt-5">
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-semibold">{t("telework.title")}</h3>
|
||||
<Button onClick={() => onManage("telework")} size="sm" variant="outline">
|
||||
{t("telework.manage")}
|
||||
</Button>
|
||||
</header>
|
||||
<TeleworkAuthorizationSummary
|
||||
authorization={authorizations.find(
|
||||
(item) =>
|
||||
item.companyId === company.id && item.values.personId === profile.values.personId
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section aria-label={t("environment.configurations")} className="space-y-4">
|
||||
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{(["configured", "pending", "removal", "review"] as const).map((key) => (
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
PrinterConfigurationSchema,
|
||||
WorkProfileSchema,
|
||||
} from "../../common";
|
||||
import { TeleworkAuthorizationSchema } from "../../common/telework-authorizations";
|
||||
import { accessKeys } from "../access/access-api";
|
||||
import { applicationKeys } from "../applications/application-api";
|
||||
import { certificateKeys } from "../certificates/certificate-api";
|
||||
@ -25,6 +26,7 @@ import { folderKeys } from "../folders/folder-api";
|
||||
import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||
import { mailKeys } from "../mail/mail-api";
|
||||
import { printerKeys } from "../printers/printer-api";
|
||||
import { teleworkKeys } from "../telework/telework-api";
|
||||
|
||||
import { type EnvironmentSources, buildEnvironment } from "./work-environment.model";
|
||||
import { WorkEnvironmentPage } from "./work-environment-page";
|
||||
@ -261,7 +263,7 @@ test("entorno: composición vigente del puesto y datos actuales sin duplicación
|
||||
});
|
||||
async function render(
|
||||
language: string,
|
||||
mode: "ready" | "pending" | "error" | "missing" | "archived" = "ready"
|
||||
mode: "ready" | "pending" | "error" | "missing" | "archived" | "telework" = "ready"
|
||||
) {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({
|
||||
@ -272,6 +274,42 @@ async function render(
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
});
|
||||
if (mode !== "pending") {
|
||||
client.setQueryData(teleworkKeys.company(company.id), {
|
||||
authorizations:
|
||||
mode === "telework"
|
||||
? [
|
||||
TeleworkAuthorizationSchema.parse({
|
||||
id: id(80),
|
||||
companyId: company.id,
|
||||
values: {
|
||||
personId: profile.values.personId,
|
||||
active: true,
|
||||
startsOn: null,
|
||||
expiresOn: null,
|
||||
notes: "Teletrabajo de Ana",
|
||||
},
|
||||
status: "authorized",
|
||||
effective: true,
|
||||
needsReview: false,
|
||||
history: [
|
||||
{
|
||||
id: id(81),
|
||||
actorId,
|
||||
actorName: "Técnico",
|
||||
recordedAt,
|
||||
values: {
|
||||
personId: profile.values.personId,
|
||||
active: true,
|
||||
startsOn: null,
|
||||
expiresOn: null,
|
||||
notes: "Teletrabajo de Ana",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
});
|
||||
client.setQueryData(profileKeys.company(company.id), {
|
||||
profiles:
|
||||
mode === "missing"
|
||||
@ -346,3 +384,12 @@ test("entorno: carga, fallo parcial sin falsa ficha vacía, perfil inexistente y
|
||||
const archived = await render("es", "archived");
|
||||
assert.ok(archived.includes(companiesSpanish.environment.archived));
|
||||
});
|
||||
|
||||
test("entorno: consulta autorización por persona, común a sus perfiles y sin confundir preparación", async () => {
|
||||
const html = await render("es", "telework");
|
||||
assert.ok(html.includes("Teletrabajo de Ana"));
|
||||
assert.ok(html.includes(companiesSpanish.telework.effective));
|
||||
assert.ok(html.includes(companiesSpanish.telework.manage));
|
||||
const empty = await render("es");
|
||||
assert.ok(empty.includes(companiesSpanish.telework.notRecorded));
|
||||
});
|
||||
|
||||
@ -15,6 +15,7 @@ import { CertificateConfigurationsPage } from "../certificates/certificate-confi
|
||||
import { FolderMappingsPage } from "../folders/folder-mappings-page";
|
||||
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
|
||||
|
||||
import { ProfileFeedback } from "./profile-feedback";
|
||||
import type { ProfileConfigurationKind } from "./work-environment.model";
|
||||
@ -23,7 +24,7 @@ import { profileApi, profileKeys } from "./work-profile-api";
|
||||
import { WorkProfileForm } from "./work-profile-form";
|
||||
|
||||
type ProfileSelection = {
|
||||
kind: ProfileConfigurationKind | "environment";
|
||||
kind: ProfileConfigurationKind | "environment" | "telework";
|
||||
profileId: string;
|
||||
fromEnvironment?: boolean;
|
||||
};
|
||||
@ -86,6 +87,7 @@ export function WorkProfilesPage({
|
||||
)
|
||||
}
|
||||
onSelection={setConfiguration}
|
||||
personId={profiles.find((item) => item.id === configuration.profileId)?.values.personId}
|
||||
selection={configuration}
|
||||
/>
|
||||
);
|
||||
@ -291,6 +293,7 @@ export function WorkProfilesPage({
|
||||
}
|
||||
|
||||
function ProfileConfigurations({
|
||||
personId,
|
||||
company,
|
||||
csrfToken,
|
||||
selection,
|
||||
@ -299,6 +302,7 @@ function ProfileConfigurations({
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
personId: string | undefined;
|
||||
selection: ProfileSelection;
|
||||
onSelection: (selection: ProfileSelection) => void;
|
||||
onBack: () => void;
|
||||
@ -320,6 +324,13 @@ function ProfileConfigurations({
|
||||
profileId={selection.profileId}
|
||||
/>
|
||||
)}
|
||||
{selection.kind === "telework" && (
|
||||
<TeleworkAuthorizationsPage
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
initialPersonId={personId}
|
||||
/>
|
||||
)}
|
||||
{selection.kind === "folders" && <FolderMappingsPage {...props} />}
|
||||
{selection.kind === "mail" && <MailConfigurationsPage {...props} />}
|
||||
{selection.kind === "certificates" && <CertificateConfigurationsPage {...props} />}
|
||||
|
||||
43
modules/companies/src/web/telework/telework-api.ts
Normal file
43
modules/companies/src/web/telework/telework-api.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
type TeleworkAuthorizationInput,
|
||||
TeleworkAuthorizationResponseSchema,
|
||||
TeleworkAuthorizationsResponseSchema,
|
||||
} from "../../common/telework-authorizations";
|
||||
export const teleworkKeys = { company: (id: string) => ["telework-authorizations", id] };
|
||||
export class TeleworkApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
async function request(
|
||||
companyId: string,
|
||||
values?: TeleworkAuthorizationInput,
|
||||
token?: string,
|
||||
id?: string
|
||||
) {
|
||||
const response = await fetch(
|
||||
`/api/telework-authorizations/${companyId}${id ? `/${id}/update` : ""}`,
|
||||
{
|
||||
method: values ? "POST" : "GET",
|
||||
credentials: "same-origin",
|
||||
headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {},
|
||||
...(values ? { body: JSON.stringify({ values }) } : {}),
|
||||
}
|
||||
);
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = z.object({ code: z.string() }).safeParse(body);
|
||||
throw new TeleworkApiError(error.success ? error.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
export const teleworkApi = {
|
||||
async get(companyId: string) {
|
||||
return TeleworkAuthorizationsResponseSchema.parse(await request(companyId));
|
||||
},
|
||||
async save(companyId: string, values: TeleworkAuthorizationInput, token: string, id?: string) {
|
||||
return TeleworkAuthorizationResponseSchema.parse(await request(companyId, values, token, id));
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,185 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
import {
|
||||
type TeleworkAuthorizationDTO,
|
||||
type TeleworkAuthorizationInput,
|
||||
TeleworkAuthorizationInputSchema,
|
||||
} from "../../common/telework-authorizations";
|
||||
import { PersonForm } from "../components/person-form";
|
||||
|
||||
import { teleworkApi, teleworkKeys } from "./telework-api";
|
||||
import { TeleworkFeedback } from "./telework-feedback";
|
||||
|
||||
export function TeleworkAuthorizationForm({
|
||||
company,
|
||||
csrfToken,
|
||||
authorization,
|
||||
authorizations,
|
||||
initialPersonId,
|
||||
onSaved,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
authorization?: TeleworkAuthorizationDTO | undefined;
|
||||
authorizations: readonly TeleworkAuthorizationDTO[];
|
||||
initialPersonId?: string | undefined;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const [quick, setQuick] = useState(false);
|
||||
const form = useForm<TeleworkAuthorizationInput>({
|
||||
resolver: zodResolver(TeleworkAuthorizationInputSchema),
|
||||
defaultValues: authorization?.values ?? {
|
||||
personId: initialPersonId ?? "",
|
||||
active: true,
|
||||
startsOn: null,
|
||||
expiresOn: null,
|
||||
notes: null,
|
||||
},
|
||||
});
|
||||
const values = form.watch();
|
||||
const fixed = Boolean(authorization || initialPersonId);
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: TeleworkAuthorizationInput) =>
|
||||
teleworkApi.save(company.id, input, csrfToken, authorization?.id),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: teleworkKeys.company(company.id) });
|
||||
if (mounted.current) {
|
||||
onSaved();
|
||||
}
|
||||
},
|
||||
});
|
||||
const reserved = new Set(authorizations.map((item) => item.values.personId));
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-person`}>{t("telework.person")}</Label>
|
||||
<SelectWithAction
|
||||
action={fixed ? undefined : { label: t("newPerson"), onClick: () => setQuick(true) }}
|
||||
disabled={fixed}
|
||||
id={`${prefix}-person`}
|
||||
label={t("telework.person")}
|
||||
onValueChange={(value) => form.setValue("personId", value)}
|
||||
options={company.people
|
||||
.filter(
|
||||
(person) =>
|
||||
person.id === values.personId || (person.active && !reserved.has(person.id))
|
||||
)
|
||||
.map((person) => ({ value: person.id, label: person.name }))}
|
||||
placeholder={t("mail.choose")}
|
||||
required
|
||||
value={values.personId}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={values.active}
|
||||
id={`${prefix}-active`}
|
||||
onCheckedChange={(checked) => form.setValue("active", checked === true)}
|
||||
/>
|
||||
<Label htmlFor={`${prefix}-active`}>{t("telework.active")}</Label>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-start`}>{t("telework.startsOn")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-start`}
|
||||
onInput={(event) => form.setValue("startsOn", event.currentTarget.value || null)}
|
||||
type="date"
|
||||
value={values.startsOn ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-end`}>{t("telework.expiresOn")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-end`}
|
||||
onInput={(event) => form.setValue("expiresOn", event.currentTarget.value || null)}
|
||||
type="date"
|
||||
value={values.expiresOn ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-notes`}>{t("telework.notes")}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
onChange={(event) => form.setValue("notes", event.target.value || null)}
|
||||
rows={3}
|
||||
value={values.notes ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{authorization && <p className="text-sm text-muted-foreground">{t("telework.fixed")}</p>}
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("telework.errors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
<TeleworkFeedback error={mutation.error} />
|
||||
<ContextHelp
|
||||
description={t("telework.description")}
|
||||
examples={t("telework.examples")}
|
||||
examplesLabel={t("mail.examplesLabel")}
|
||||
guidance={t("telework.guidance")}
|
||||
scopeNote={t("telework.scope")}
|
||||
title={t("telework.help")}
|
||||
/>
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{quick && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setQuick(false);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("newPerson")}
|
||||
>
|
||||
<PersonForm
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(id) => {
|
||||
if (mounted.current) {
|
||||
form.setValue("personId", id);
|
||||
setQuick(false);
|
||||
}
|
||||
}}
|
||||
requireActive
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { TeleworkAuthorizationDTO } from "../../common/telework-authorizations";
|
||||
|
||||
export function TeleworkAuthorizationSummary({
|
||||
authorization,
|
||||
}: {
|
||||
authorization: TeleworkAuthorizationDTO | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
if (!authorization) {
|
||||
return <p className="text-sm text-muted-foreground">{t("telework.notRecorded")}</p>;
|
||||
}
|
||||
const date = (value: string | null) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeZone: "UTC" }).format(
|
||||
new Date(`${value}T00:00:00Z`)
|
||||
)
|
||||
: t("telework.noDate");
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p
|
||||
className={
|
||||
authorization.effective ? "font-medium text-emerald-700" : "font-medium text-amber-700"
|
||||
}
|
||||
>
|
||||
{t(`telework.${authorization.status}`)}
|
||||
</p>
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("telework.startsOn")}</dt>
|
||||
<dd>{date(authorization.values.startsOn)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("telework.expiresOn")}</dt>
|
||||
<dd>{date(authorization.values.expiresOn)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{authorization.needsReview && <p className="text-amber-700">{t("telework.review")}</p>}
|
||||
<p className="text-muted-foreground">
|
||||
{t(authorization.effective ? "telework.effective" : "telework.ineffective")}
|
||||
</p>
|
||||
{authorization.values.notes && (
|
||||
<p className="whitespace-pre-wrap">{authorization.values.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,166 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../common";
|
||||
|
||||
import { teleworkApi, teleworkKeys } from "./telework-api";
|
||||
import { TeleworkAuthorizationForm } from "./telework-authorization-form";
|
||||
import { TeleworkAuthorizationSummary } from "./telework-authorization-summary";
|
||||
import { TeleworkFeedback } from "./telework-feedback";
|
||||
|
||||
export function TeleworkAuthorizationsPage({
|
||||
company,
|
||||
csrfToken,
|
||||
initialPersonId,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
initialPersonId?: string | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
const query = useQuery({
|
||||
queryKey: teleworkKeys.company(company.id),
|
||||
queryFn: () => teleworkApi.get(company.id),
|
||||
});
|
||||
const authorizations = query.data?.authorizations ?? [];
|
||||
const edited = authorizations.find((item) => item.id === editor);
|
||||
const viewed = authorizations.find((item) => item.id === view);
|
||||
const person = (id: string) => company.people.find((item) => item.id === id)?.name ?? id;
|
||||
const filtered = authorizations.filter(
|
||||
(item) =>
|
||||
(!initialPersonId || item.values.personId === initialPersonId) &&
|
||||
person(item.values.personId).toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("telework.title")}</h2>
|
||||
<Button
|
||||
disabled={
|
||||
!query.data ||
|
||||
query.isError ||
|
||||
Boolean(
|
||||
initialPersonId &&
|
||||
authorizations.some((item) => item.values.personId === initialPersonId)
|
||||
)
|
||||
}
|
||||
onClick={() => setEditor("new")}
|
||||
>
|
||||
{t("telework.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<Input
|
||||
aria-label={t("telework.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("telework.search")}
|
||||
value={search}
|
||||
/>
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<TeleworkFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
query.refetch().catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{query.data && !query.isError && (
|
||||
<Card>
|
||||
<CardContent className="divide-y pt-3">
|
||||
{!filtered.length && (
|
||||
<p className="py-4 text-muted-foreground">
|
||||
{t(authorizations.length && !initialPersonId ? "noResults" : "telework.empty")}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((item) => (
|
||||
<div className="space-y-3 py-4" key={item.id}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="font-medium">{person(item.values.personId)}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(item.id)} size="sm" variant="outline">
|
||||
{t("telework.history")}
|
||||
</Button>
|
||||
<Button onClick={() => setEditor(item.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TeleworkAuthorizationSummary authorization={item} />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{editor && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(edited ? "telework.edit" : "telework.new")}
|
||||
>
|
||||
<TeleworkAuthorizationForm
|
||||
authorization={edited}
|
||||
authorizations={authorizations}
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
initialPersonId={initialPersonId}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
{viewed && (
|
||||
<FormDialog
|
||||
cancelLabel={t("close")}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setView(undefined);
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("telework.history")}
|
||||
>
|
||||
<p className="font-medium">{person(viewed.values.personId)}</p>
|
||||
<ol className="space-y-3">
|
||||
{[...viewed.history].reverse().map((revision) => (
|
||||
<li className="rounded-md border p-3 text-sm" key={revision.id}>
|
||||
<p className="font-medium">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {revision.actorName}
|
||||
</p>
|
||||
<p>{t(revision.values.active ? "telework.active" : "telework.revoked")}</p>
|
||||
<p>
|
||||
{t("telework.startsOn")}: {revision.values.startsOn ?? t("telework.noDate")}
|
||||
</p>
|
||||
<p>
|
||||
{t("telework.expiresOn")}: {revision.values.expiresOn ?? t("telework.noDate")}
|
||||
</p>
|
||||
{revision.values.notes && (
|
||||
<p className="mt-2 whitespace-pre-wrap">{revision.values.notes}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</FormDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
modules/companies/src/web/telework/telework-feedback.tsx
Normal file
18
modules/companies/src/web/telework/telework-feedback.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { TeleworkApiError } from "./telework-api";
|
||||
export function TeleworkFeedback({ error }: { error: unknown }) {
|
||||
const { t } = useTranslation("companies");
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
const key =
|
||||
error instanceof TeleworkApiError
|
||||
? `telework.errors.${error.code}`
|
||||
: "telework.errors.INTERNAL_ERROR";
|
||||
return (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t(key, { defaultValue: t("telework.errors.INTERNAL_ERROR") })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
95
modules/companies/src/web/telework/telework-i18n.ts
Normal file
95
modules/companies/src/web/telework/telework-i18n.ts
Normal file
@ -0,0 +1,95 @@
|
||||
export const teleworkSpanish = {
|
||||
title: "Teletrabajo",
|
||||
new: "Nueva autorización",
|
||||
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)",
|
||||
authorized: "Autorizada",
|
||||
scheduled: "Programada",
|
||||
expired: "Caducada",
|
||||
revoked: "Revocada",
|
||||
empty: "Todavía no hay autorizaciones de teletrabajo.",
|
||||
notRecorded: "No hay autorización de teletrabajo registrada para esta persona.",
|
||||
search: "Buscar persona…",
|
||||
review: "Revisar: la persona está inactiva. Esta autorización no es efectiva.",
|
||||
ineffective:
|
||||
"La autorización no está vigente. Revisa los accesos y configuraciones remotos existentes.",
|
||||
effective:
|
||||
"Autorización vigente para la persona; no demuestra que la conexión remota esté preparada.",
|
||||
noDate: "Sin límite de fecha",
|
||||
fixed: "La persona no puede cambiar. Edita esta autorización para renovarla o revocarla.",
|
||||
history: "Historial de autorización",
|
||||
manage: "Gestionar teletrabajo",
|
||||
help: "¿Para qué sirve esta autorización?",
|
||||
description: "Documenta si una persona puede trabajar fuera de la sede y durante qué periodo.",
|
||||
scope:
|
||||
"La autorización pertenece a la persona de esta compañía y es común a sus perfiles. No concede acceso a Metis Desk ni nuevos permisos sobre los recursos.",
|
||||
examples:
|
||||
"Ana está autorizada sin fecha de fin; Rubén tiene permiso entre el 1 y el 31 de diciembre; al salir de la compañía se revoca conservando el historial.",
|
||||
guidance:
|
||||
"Ambas fechas son opcionales y el último día está incluido. Desactiva la autorización para revocarla. Este registro no configura VPN, no corta conexiones ni retira correos. Una persona nueva guardada permanece aunque canceles este formulario.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Revisa los datos y el orden de las fechas.",
|
||||
TELEWORK_PERSON_IMMUTABLE: "La persona de la autorización no se puede cambiar.",
|
||||
TELEWORK_PERSON_NOT_FOUND: "Selecciona una persona de esta compañía.",
|
||||
TELEWORK_PERSON_INACTIVE:
|
||||
"No puedes autorizar teletrabajo a una persona inactiva. Puedes revocar su autorización.",
|
||||
TELEWORK_ALREADY_EXISTS:
|
||||
"Esta persona ya tiene una autorización. Edítala para renovarla o revocarla.",
|
||||
TELEWORK_AUTHORIZATION_NOT_FOUND: "No se ha encontrado la autorización.",
|
||||
COMPANY_NOT_FOUND: "No se ha encontrado la compañía.",
|
||||
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
|
||||
FORBIDDEN: "La sesión no permite esta operación. Vuelve a iniciar sesión.",
|
||||
UNAUTHENTICATED: "Vuelve a iniciar sesión.",
|
||||
},
|
||||
};
|
||||
export const teleworkEnglish = {
|
||||
title: "Telework",
|
||||
new: "New authorization",
|
||||
edit: "Edit telework authorization",
|
||||
person: "Person",
|
||||
active: "Active authorization",
|
||||
startsOn: "Valid from (optional)",
|
||||
expiresOn: "Valid through (optional)",
|
||||
notes: "Notes (optional)",
|
||||
authorized: "Authorized",
|
||||
scheduled: "Scheduled",
|
||||
expired: "Expired",
|
||||
revoked: "Revoked",
|
||||
empty: "No telework authorizations have been recorded yet.",
|
||||
notRecorded: "No telework authorization is recorded for this person.",
|
||||
search: "Search person…",
|
||||
review: "Review: the person is inactive. This authorization is not effective.",
|
||||
ineffective: "Authorization is not current. Review existing remote access and configurations.",
|
||||
effective:
|
||||
"Current authorization for the person; this does not prove remote connectivity is ready.",
|
||||
noDate: "No date limit",
|
||||
fixed: "The person cannot change. Edit this authorization to renew or revoke it.",
|
||||
history: "Authorization history",
|
||||
manage: "Manage telework",
|
||||
help: "What is this authorization for?",
|
||||
description: "Document whether a person may work away from the site and for which period.",
|
||||
scope:
|
||||
"Authorization belongs to the company person and applies across their profiles. It does not grant Metis Desk access or new resource permissions.",
|
||||
examples:
|
||||
"Ana is authorized without an end date; Ruben is authorized from December 1 through December 31; on departure, revoke authorization while retaining history.",
|
||||
guidance:
|
||||
"Both dates are optional and the final day is included. Deactivate to revoke. This record does not configure VPN, disconnect sessions or remove mail. A saved new person remains if this form is cancelled.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Check the data and date order.",
|
||||
TELEWORK_PERSON_IMMUTABLE: "The authorization person cannot change.",
|
||||
TELEWORK_PERSON_NOT_FOUND: "Select a person from this company.",
|
||||
TELEWORK_PERSON_INACTIVE:
|
||||
"An inactive person cannot be authorized. You can revoke their authorization.",
|
||||
TELEWORK_ALREADY_EXISTS:
|
||||
"This person already has an authorization. Edit it to renew or revoke.",
|
||||
TELEWORK_AUTHORIZATION_NOT_FOUND: "Authorization not found.",
|
||||
COMPANY_NOT_FOUND: "Company not found.",
|
||||
INTERNAL_ERROR: "The operation could not be completed. Try again.",
|
||||
FORBIDDEN: "Your session does not allow this operation. Sign in again.",
|
||||
UNAUTHENTICATED: "Sign in again.",
|
||||
},
|
||||
};
|
||||
126
modules/companies/src/web/telework/telework.test.tsx
Normal file
126
modules/companies/src/web/telework/telework.test.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { TeleworkAuthorizationSchema } from "../../common/telework-authorizations";
|
||||
import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||
|
||||
import { teleworkKeys } from "./telework-api";
|
||||
import { TeleworkAuthorizationForm } from "./telework-authorization-form";
|
||||
import { TeleworkAuthorizationSummary } from "./telework-authorization-summary";
|
||||
import { TeleworkAuthorizationsPage } from "./telework-authorizations-page";
|
||||
|
||||
const company = {
|
||||
id: "0199b200-0000-7000-8000-000000000001",
|
||||
name: "Empresa test",
|
||||
active: true,
|
||||
people: [],
|
||||
contacts: [],
|
||||
sites: [],
|
||||
departments: [],
|
||||
assignments: [],
|
||||
};
|
||||
async function render(language: string, node: ReactNode) {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
|
||||
});
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
});
|
||||
client.setQueryData(teleworkKeys.company(company.id), { authorizations: [] });
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("teletrabajo: consulta sin formularios y traducciones", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<TeleworkAuthorizationsPage company={company} csrfToken="test" />
|
||||
);
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
assert.ok(html.includes(text.telework.title));
|
||||
assert.ok(html.includes(text.telework.new));
|
||||
assert.ok(!html.includes("<form"));
|
||||
assert.ok(!html.includes("telework.title"));
|
||||
}
|
||||
});
|
||||
test("teletrabajo: formulario con alta rápida de persona, fechas opcionales y ayuda inferior", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<TeleworkAuthorizationForm
|
||||
authorizations={[]}
|
||||
company={company}
|
||||
csrfToken="test"
|
||||
onSaved={() => undefined}
|
||||
/>
|
||||
);
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
assert.ok(html.includes(text.newPerson));
|
||||
assert.ok(html.includes(text.telework.startsOn));
|
||||
assert.ok(html.includes(text.telework.expiresOn));
|
||||
assert.ok(html.indexOf(text.telework.help) > html.indexOf(text.telework.notes));
|
||||
assert.ok(html.indexOf(text.save) > html.indexOf(text.telework.help));
|
||||
assert.equal((html.match(/type="date"/g) ?? []).length, 2);
|
||||
}
|
||||
});
|
||||
test("teletrabajo: sin autorización no se presupone permiso; persona inactiva requiere revisión", async () => {
|
||||
const authorization = TeleworkAuthorizationSchema.parse({
|
||||
id: company.id,
|
||||
companyId: company.id,
|
||||
values: { personId: company.id, active: true, startsOn: null, expiresOn: null, notes: null },
|
||||
status: "authorized",
|
||||
effective: false,
|
||||
needsReview: true,
|
||||
history: [
|
||||
{
|
||||
id: company.id,
|
||||
actorId: company.id,
|
||||
actorName: "Técnico",
|
||||
recordedAt: "2026-10-06T09:00:00.000Z",
|
||||
values: {
|
||||
personId: company.id,
|
||||
active: true,
|
||||
startsOn: null,
|
||||
expiresOn: null,
|
||||
notes: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const empty = await render("es", <TeleworkAuthorizationSummary authorization={undefined} />);
|
||||
assert.ok(empty.includes(companiesSpanish.telework.notRecorded));
|
||||
const html = await render("es", <TeleworkAuthorizationSummary authorization={authorization} />);
|
||||
assert.ok(html.includes(companiesSpanish.telework.review));
|
||||
assert.ok(!html.includes(companiesSpanish.telework.effective));
|
||||
});
|
||||
|
||||
test("teletrabajo: alta desde un perfil fija la persona y no abre otra alta rápida", async () => {
|
||||
const html = await render(
|
||||
"es",
|
||||
<TeleworkAuthorizationForm
|
||||
authorizations={[]}
|
||||
company={company}
|
||||
csrfToken="test"
|
||||
initialPersonId={company.id}
|
||||
onSaved={() => undefined}
|
||||
/>
|
||||
);
|
||||
assert.ok(!html.includes(companiesSpanish.newPerson));
|
||||
assert.ok(html.includes("disabled"));
|
||||
assert.ok(html.includes(`value="${company.id}"`));
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user