This commit is contained in:
David Arranz 2026-10-07 11:41:56 +02:00
parent ea1e5e450f
commit 0a4d3e1b62
38 changed files with 2758 additions and 16 deletions

View File

@ -36,6 +36,7 @@ import { verifyResourceMigration } from "./resource-migration.integration";
import { verifyResources } from "./resources.integration";
import { verifySupport } from "./support.integration";
import { verifyTelework } from "./telework.integration";
import { verifyTickets } from "./tickets.integration";
import { verifyWorkProfiles } from "./work-profiles.integration";
import { verifyWorkstations } from "./workstations.integration";
@ -52,6 +53,9 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
await once(server, "listening");
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
await database.query("DELETE FROM http_sessions");
await database.query("DELETE FROM ticket_revisions");
await database.query("DELETE FROM tickets");
await database.query("UPDATE ticket_counter SET nextNumber=1 WHERE id=1");
await database.query("DELETE FROM mail_configuration_revisions");
await database.query("DELETE FROM mail_configurations");
await database.query("DELETE FROM mailbox_use_revisions");
@ -818,6 +822,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
database
);
await verifySupport((...args) => operator.request(...args), customer.id, secondCompany.id);
await verifyTickets((...args) => operator.request(...args), customer.id, secondCompany.id);
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
const migratedRuntime = await createIdentityRuntime(database, environment);
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
@ -914,6 +919,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
401,
"UNAUTHENTICATED"
);
await expectError(await operator.request("/tickets"), 401, "UNAUTHENTICATED");
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
await assert.rejects(

View File

@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { InventoryResponseSchema } from "@metis/assets/common";
import {
CompanyResponseSchema,
type TicketDTO,
type TicketInput,
TicketListSchema,
TicketLookupsSchema,
TicketResponseSchema,
} from "@metis/companies/common";
import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifyTickets(request: Request, companyId: string, otherId: string) {
const path = `/tickets/${companyId}`;
const key = randomUUID();
const lookupsResponse = await request(`${path}/lookups`);
assert.equal(lookupsResponse.status, 200);
const lookups = TicketLookupsSchema.parse(await lookupsResponse.json());
const list = TicketListSchema.parse(await (await request("/tickets")).json());
const person = lookups.people.find((item) => item.active);
const asset = lookups.assets.find((item) => item.active);
const technician = list.technicians.find((item) => item.active);
assert.ok(person && asset && technician);
const values: TicketInput = {
subject: "Ticket de prueba",
description: "Correo no recibe mensajes",
requesterId: person.id,
assigneeId: technician.id,
state: "open",
priority: "normal",
channel: "phone",
affected: [
{ kind: "asset", targetId: asset.id },
{ kind: "person", targetId: person.id },
],
};
const body = { values, requestKey: key, expectedRevisionId: null };
async function error(url: string, payload: unknown, status: number, code: string) {
const response = await request(url, payload);
assert.equal(response.status, status, await response.clone().text());
assert.equal(z.object({ code: z.string() }).parse(await response.json()).code, code);
}
const createdResponse = await request(path, body);
assert.equal(createdResponse.status, 201, await createdResponse.clone().text());
const created = TicketResponseSchema.parse(await createdResponse.json()).ticket;
assert.equal(created.id[14], "7");
assert.match(created.reference, /^T-[0-9]{6,}$/);
assert.equal(created.history.length, 1);
assert.equal(created.requesterName, person.name);
assert.equal(created.assigneeName, technician.name);
const repeatedResponse = await request(path, body);
assert.equal(repeatedResponse.status, 201);
assert.equal(TicketResponseSchema.parse(await repeatedResponse.json()).ticket.id, created.id);
await error(path, { ...body, values: { ...values, subject: "Otro" } }, 409, "REQUEST_KEY_REUSED");
await error(`/tickets/${otherId}`, body, 409, "REQUEST_KEY_REUSED");
const otherPerson = TicketLookupsSchema.parse(
await (await request(`/tickets/${otherId}/lookups`)).json()
).people[0];
if (otherPerson) {
await error(
path,
{ ...body, requestKey: randomUUID(), values: { ...values, requesterId: otherPerson.id } },
409,
"REQUESTER_UNAVAILABLE"
);
}
await error(
`/tickets/${otherId}/${created.id}/update`,
{ ...body, expectedRevisionId: created.history[0]?.id },
404,
"TICKET_NOT_FOUND"
);
await error(
path,
{
...body,
requestKey: randomUUID(),
values: { ...values, affected: [{ kind: "resource", targetId: asset.id }] },
},
409,
"TARGET_UNAVAILABLE"
);
await error(
path,
{ ...body, requestKey: randomUUID(), values: { ...values, assigneeId: randomUUID() } },
409,
"ASSIGNEE_UNAVAILABLE"
);
await error(path, { ...body, values: { ...values, subject: "" } }, 400, "INVALID_REQUEST");
assert.equal((await request(path, body, "http://evil.example")).status, 403);
assert.equal((await request(path, body, undefined, "invalid")).status, 403);
assert.equal((await request("/tickets?unknown=1")).status, 400);
async function update(ticket: TicketDTO, next: TicketInput) {
const response = await request(`${path}/${ticket.id}/update`, {
values: next,
requestKey: randomUUID(),
expectedRevisionId: ticket.history.at(-1)?.id,
});
assert.equal(response.status, 200, await response.clone().text());
return TicketResponseSchema.parse(await response.json()).ticket;
}
const same = await update(created, values);
assert.equal(same.history.length, 1);
const updated = await update(same, { ...values, state: "in_progress", priority: "high" });
assert.equal(updated.history.length, 2);
assert.equal(updated.reference, created.reference);
assert.equal(updated.history[0]?.values.state, "open");
await error(
`${path}/${created.id}/update`,
{ ...body, expectedRevisionId: created.history[0]?.id },
409,
"TICKET_CHANGED"
);
const concurrent = await Promise.all(
["resolved", "waiting"].map((state) =>
request(`${path}/${created.id}/update`, {
...body,
requestKey: randomUUID(),
expectedRevisionId: updated.history.at(-1)?.id,
values: { ...updated.values, state },
})
)
);
assert.deepEqual(concurrent.map((response) => response.status).sort(), [200, 409]);
const other = await request(`/tickets/${otherId}`, {
values: { ...values, requesterId: null, assigneeId: null, affected: [] },
requestKey: randomUUID(),
expectedRevisionId: null,
});
assert.equal(other.status, 201);
const otherTicket = TicketResponseSchema.parse(await other.json()).ticket;
assert.notEqual(otherTicket.reference, created.reference);
const global = TicketListSchema.parse(await (await request("/tickets")).json());
assert.ok(global.tickets.some((item) => item.id === created.id));
assert.ok(global.tickets.some((item) => item.id === otherTicket.id));
const scoped = TicketListSchema.parse(await (await request(path)).json());
assert.ok(scoped.tickets.every((item) => item.companyId === companyId));
const originalPerson = CompanyResponseSchema.parse(
await (await request(`/companies/${companyId}`)).json()
).company.people.find((item) => item.id === person.id);
assert.ok(originalPerson);
const personValues = {
name: originalPerson.name,
position: originalPerson.position,
email: originalPerson.email,
phone: originalPerson.phone,
active: originalPerson.active,
isContact: originalPerson.isContact,
};
const disabledPerson = await request(`/companies/${companyId}/people/${person.id}/update`, {
...personValues,
active: false,
});
assert.equal(disabledPerson.status, 200);
CompanyResponseSchema.parse(await disabledPerson.json());
const current = scoped.tickets.find((item) => item.id === created.id);
assert.ok(current);
const retained = await update(current, {
...current.values,
description: "Referencia histórica retenida",
});
assert.equal(retained.values.requesterId, person.id);
await error(path, { ...body, requestKey: randomUUID() }, 409, "REQUESTER_UNAVAILABLE");
assert.equal(
(await request(`/companies/${companyId}/people/${person.id}/update`, personValues)).status,
200
);
const host = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
).assets.find((item) => item.id === asset.id);
assert.ok(host);
}

View File

@ -90,3 +90,7 @@ ProcessTemplate y PersonProcess son AggregateRoot; sus revisiones y las tareas P
## Asistencia técnica remota
SupportConnection delimita cada método de asistencia de un activo de una compañía. SupportValues encapsula la referencia UniqueID a herramienta y los valores de configuración; SupportConnectionRevision conserva las instantáneas con actor y fecha. SupportTool es la raíz del catálogo global y utiliza Name. Todas las raíces y revisiones nuevas tienen UUID v7. Consultar [Asistencia técnica remota](../asistencia-tecnica-remota.md).
## Tickets
Ticket es AggregateRoot con compañía inmutable, referencia global y TicketValues (Name, UniqueID/Maybe y referencias tipadas TicketTarget). TicketRevision conserva cada instantánea con UUID propio, actor y fecha. Las referencias afectadas son valores sin ciclo de vida independiente, no entidades. Alta idempotente por clave de solicitud y edición condicionada a la última revisión. Consultar [Tickets](../tickets.md).

View File

@ -85,7 +85,7 @@ Varios métodos por activo, catálogo global ampliable con herramientas de serie
## Siguientes entregas: orden actualizado
1. Tickets e intervenciones: concretar las peticiones recibidas por email/teléfono, solicitante y elementos afectados; múltiples intervenciones por ticket e intervenciones internas sin ticket.
1. Intervenciones: trabajo realizado, técnico, tiempos y elementos afectados; múltiples intervenciones por ticket e intervenciones internas sin ticket.
2. Vinculación explícita entre un acceso remoto y su VPN previa, si resulta necesaria.
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
@ -96,10 +96,13 @@ Activos por compañía, plantilla capturada, sede y persona opcionales, cuatro e
Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas: catálogo global y local, cinco tipos de serie, vínculos en ambos sentidos y retirada con auditoría. Los [puestos de trabajo](puestos-de-trabajo.md) están implementados, sin anidación, con sede y persona opcionales, composición exclusiva, recursos compartidos entre puestos vigentes e historial.
## Tickets e intervenciones: pendiente
## Tickets: implementados
Cliente, sede, activo afectado, técnico, fecha, tiempo empleado, diagnóstico, trabajo realizado y resolución.
Un ticket puede tener varias intervenciones. No confundir la solicitud de soporte con cada trabajo realizado.
Tickets por compañía y consulta global con búsqueda/filtros, referencia corta global además de UUID, solicitante y responsable opcionales, canales, estados, prioridades y varios elementos afectados. Modales, altas rápidas, historial, altas idempotentes y protección de edición concurrente. Consultar [Tickets](tickets.md).
## Intervenciones: pendientes
Fecha, técnico, tiempo empleado, diagnóstico, trabajo realizado, resultado y elementos afectados. Un ticket puede generar varias intervenciones; también pueden existir intervenciones internas sin ticket.
## Servicios y documentación: pendiente
@ -108,4 +111,4 @@ Las credenciales se resolverán con una solución específica; no almacenarlas c
## Decisiones pendientes
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; los tickets siguen pendientes.
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; los tickets están implementados; las intervenciones siguen pendientes.

48
docs/tickets.md Normal file
View File

@ -0,0 +1,48 @@
# Tickets
Cada ticket pertenece a una única compañía, que no puede cambiar tras el alta. Tiene UUID v7 y referencia corta global T-000001. La referencia se reserva en una transacción; no sustituye al UUID.
## Información y comportamiento
Asunto obligatorio y descripción opcional. Solicitante opcional entre las personas de la compañía; responsable opcional entre los usuarios internos de Metis Desk. Las personas de las compañías no acceden a esta aplicación.
Canales: email, teléfono, interno y otro. Estados: abierto, en curso, en espera, resuelto y cancelado. Prioridades: baja, normal, alta y urgente. El formulario empieza abierto, prioridad normal y canal email. Se puede reabrir un ticket editando su estado; siempre se conserva el historial.
Elementos afectados opcionales y múltiples: personas, puestos de trabajo, activos y recursos de la misma compañía. No se repite el mismo tipo e identificador. Solo se añaden elementos disponibles y personas/usuarios activos; las referencias existentes que se desactivan se pueden conservar para consulta e historial.
Cada cambio semántico crea TicketRevision con UUID, usuario y fecha. Guardar sin cambios no añade otra revisión. El último UUID de revisión protege la edición frente a cambios simultáneos: el formulario muestra un conflicto y permite cerrarlo y abrir la versión actual.
Las altas incluyen una clave de solicitud para evitar duplicar un ticket al reintentar. La misma clave y datos originales devuelve el ticket existente; reutilizarla con otros datos, actor o compañía se rechaza. El navegador genera la clave con el generador compartido, compatible con HTTP por IP local.
No se eliminan tickets. Cancelar o resolver conserva toda la información. Esta entrega no incluye intervenciones ni tiempos de trabajo.
## Interfaz
Tickets dentro de cada compañía muestra únicamente sus registros. Todos los tickets se abre desde el acceso T de la barra de espacios; reúne todas las compañías. Permite buscar por referencia/asunto/compañía/solicitante y filtrar por compañía (vista global), estado, prioridad y responsable, incluyendo sin asignar. Orden inicial: último cambio más reciente.
Nuevo ticket está a la derecha de la cabecera. Alta y edición en modal; la ayuda aparece inmediatamente antes del pie Guardar/Cancelar. En la compañía, esta queda fijada. En la vista global se selecciona; cambiarla antes de guardar limpia solicitante y elementos afectados, conservando el resto del formulario.
SelectWithAction permite crear compañía, solicitante y elementos afectados desde el propio formulario. Las altas rápidas reutilizan los formularios de persona, activo, recurso y puesto, conservan los datos del ticket y seleccionan el elemento creado.
Ficha e historial muestra datos, revisiones, autor y fecha. Los nombres de referencias se consultan en los catálogos actuales; las revisiones conservan los identificadores originales.
## Arquitectura y persistencia
Ticket es AggregateRoot, TicketValues encapsula Name, referencias UniqueID/Maybe y TicketTarget como valor de referencia tipada. TicketRevision es DomainEntity. Las referencias afectadas no tienen un ciclo de vida independiente; se comparan como valores sin importar el orden.
Migración explícita e idempotente: tickets, ticket_revisions y ticket_counter (contador técnico). FK a compañía, solicitantes, responsables y actores, unicidad de referencia, clave de solicitud y posición de revisión. Escritos en una transacción, con bloqueo de compañía y contador para garantizar coherencia y numeración global.
Los activos/puestos y recursos se consultan mediante servicios públicos de sus módulos; no se importan modelos ORM ajenos. Repositorio y mapper propios en Infrastructure, puertos y adaptación en Application.
API autenticada:
- GET /api/tickets: listado global.
- GET /api/tickets/:companyId: listado de compañía.
- GET /api/tickets/:companyId/lookups: opciones de esa compañía.
- POST /api/tickets/:companyId: alta.
- POST /api/tickets/:companyId/:id/update: edición.
Entradas y salidas estrictas. Escrituras protegidas por sesión, CSRF y origen. La compañía del ticket y las referencias se validan en el servidor.
## Validación
Pruebas de dominio/mapper, filtros y renderizado de formularios, e integración HTTP/MariaDB: pertenencia, campos opcionales, afectados únicos, inactividad, referencias conservadas, UUID, referencia global, historial, idempotencia, conflictos concurrentes, autenticación, CSRF y origen. Las pruebas usan la base dedicada y no crean registros de prueba en desarrollo.

View File

@ -55,6 +55,17 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
return {
services: {
inventory: {
workstations: async (companyId: UniqueID) => {
const result = await assetRepository.get(companyId);
if (!result.isSuccess) {
throw new Error("Puestos no disponibles.");
}
return result.data.workstations.map((item) => ({
id: item.id,
name: item.values.toPrimitive().name,
active: item.values.toPrimitive().active,
}));
},
list: async (companyId: UniqueID) => {
const result = await assetRepository.get(companyId);
if (!result.isSuccess) {

View File

@ -5,4 +5,5 @@ export { AssetCatalogPage } from "./catalog-page";
export { assetsEnglish, assetsSpanish } from "./i18n";
export { inventoryApi, inventoryKeys } from "./inventory-api";
export { AssetInventoryPage } from "./inventory-page";
export { WorkstationForm } from "./workstation-form";
export { WorkstationsPage } from "./workstations-page";

View File

@ -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 src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote/remote.test.tsx src/web/processes/processes.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 src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote/remote.test.tsx src/web/processes/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/tickets.test.tsx"
},
"dependencies": {
"@repo/rdx-ddd": "workspace:*",

View File

@ -0,0 +1,43 @@
import { Result } from "@repo/rdx-utils";
import type { Ticket } from "../domain/ticket.aggregate";
import { TicketError, type TicketInput } from "../domain/ticket-values";
import { TicketValues } from "../domain/value-objects/ticket-values";
import { type TicketOption, type TicketSaveContext, targetOptions } from "./ticket.repository";
function available(
options: readonly TicketOption[],
next: string | null,
current: string | null | undefined
) {
if (next === null) {
return true;
}
const item = options.find((option) => option.id.toPrimitive() === next);
return Boolean(item && (item.active || next === current));
}
export function mapTicketInput(input: TicketInput, context: TicketSaveContext, previous?: Ticket) {
const values = TicketValues.create(input);
if (!values.isSuccess) {
return values;
}
const next = values.data.toPrimitive();
const current = previous?.values.toPrimitive();
if (!available(context.lookups.people, next.requesterId, current?.requesterId)) {
return Result.fail(new TicketError("REQUESTER_UNAVAILABLE"));
}
if (!available(context.technicians, next.assigneeId, current?.assigneeId)) {
return Result.fail(new TicketError("ASSIGNEE_UNAVAILABLE"));
}
for (const target of values.data.affected) {
const option = targetOptions(context.lookups, target.kind).find((item) =>
item.id.equals(target.targetId)
);
const retained = previous?.values.affected.some((item) => item.equals(target));
if (!(option && (option.active || retained))) {
return Result.fail(new TicketError("TARGET_UNAVAILABLE"));
}
}
return values;
}

View File

@ -0,0 +1,46 @@
import type { TicketDTO, TicketListDTO, TicketLookupsDTO } from "../../common/tickets";
import type { Ticket } from "../domain/ticket.aggregate";
import type { TicketContext, TicketLookups, TicketOption } from "./ticket.repository";
function option(item: TicketOption) {
return { id: item.id.toPrimitive(), name: item.name, active: item.active };
}
export function buildTicket(ticket: Ticket, context: TicketContext): TicketDTO {
const values = ticket.values.toPrimitive();
return {
id: ticket.id.toPrimitive(),
companyId: ticket.companyId.toPrimitive(),
companyName:
context.companies.find((item) => item.id.equals(ticket.companyId))?.name ??
ticket.companyId.toPrimitive(),
reference: ticket.reference.toPrimitive(),
values,
requesterName:
context.people.find((item) => item.id.toPrimitive() === values.requesterId)?.name ?? null,
assigneeName:
context.technicians.find((item) => item.id.toPrimitive() === values.assigneeId)?.name ?? null,
history: ticket.history.map((item) => ({
id: item.id.toPrimitive(),
values: item.values.toPrimitive(),
actorId: item.actorId.toPrimitive(),
actorName: context.actorNames.get(item.actorId.toPrimitive()) ?? item.actorId.toPrimitive(),
recordedAt: item.recordedAt,
})),
};
}
export function buildTickets(context: TicketContext): TicketListDTO {
return {
tickets: context.tickets.map((ticket) => buildTicket(ticket, context)),
companies: context.companies.map(option),
technicians: context.technicians.map(option),
};
}
export function buildTicketLookups(context: TicketLookups): TicketLookupsDTO {
return {
people: context.people.map(option),
assets: context.assets.map(option),
resources: context.resources.map(option),
workstations: context.workstations.map(option),
};
}

View File

@ -0,0 +1,51 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { Ticket } from "../domain/ticket.aggregate";
import type { TicketError, TicketTargetInput } from "../domain/ticket-values";
export type TicketOption = Readonly<{ id: UniqueID; name: string; active: boolean }>;
export type TicketContext = Readonly<{
tickets: readonly Ticket[];
companies: readonly TicketOption[];
technicians: readonly TicketOption[];
people: readonly (TicketOption & { companyId: UniqueID })[];
actorNames: ReadonlyMap<string, string>;
}>;
export type TicketLookups = Readonly<{
people: readonly TicketOption[];
assets: readonly TicketOption[];
resources: readonly TicketOption[];
workstations: readonly TicketOption[];
}>;
export type TicketSaveContext = TicketContext & {
lookups: TicketLookups;
nextNumber: number;
requestTicket: Ticket | undefined;
};
export interface ITicketInventoryService {
list(company: UniqueID): Promise<readonly { id: UniqueID; name: string; available: boolean }[]>;
workstations(
company: UniqueID
): Promise<readonly { id: UniqueID; name: string; active: boolean }[]>;
}
export interface ITicketRepository {
list(company?: UniqueID): Promise<Result<TicketContext, TicketError>>;
lookups(company: UniqueID): Promise<Result<TicketLookups, TicketError>>;
save(
company: UniqueID,
requestKey: UniqueID,
action: (context: TicketSaveContext) => Result<Ticket, TicketError>
): Promise<Result<{ ticket: Ticket; context: TicketContext }, TicketError>>;
}
export function targetOptions(lookups: TicketLookups, kind: TicketTargetInput["kind"]) {
switch (kind) {
case "person":
return lookups.people;
case "asset":
return lookups.assets;
case "resource":
return lookups.resources;
case "workstation":
return lookups.workstations;
}
}

View File

@ -0,0 +1,97 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { SaveTicketInput } from "../../common/tickets";
import { Ticket } from "../domain/ticket.aggregate";
import { TicketError } from "../domain/ticket-values";
import { TicketValues } from "../domain/value-objects/ticket-values";
import type { ITicketRepository } from "./ticket.repository";
import { mapTicketInput } from "./ticket-input.mapper";
export class TicketUseCases {
constructor(private readonly repository: ITicketRepository) {}
list(companyId?: string) {
const company = companyId ? UniqueID.create(companyId) : undefined;
if (company && !company.isSuccess) {
return Promise.resolve(Result.fail(new TicketError("INVALID_REQUEST")));
}
return this.repository.list(company?.isSuccess ? company.data : undefined);
}
lookups(companyId: string) {
const company = UniqueID.create(companyId);
return company.isSuccess
? this.repository.lookups(company.data)
: Promise.resolve(Result.fail(new TicketError("INVALID_REQUEST")));
}
save(companyId: string, id: string | undefined, input: SaveTicketInput, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);
const key = UniqueID.create(input.requestKey);
const identity = id ? UniqueID.create(id) : undefined;
if (
!(company.isSuccess && actor.isSuccess && key.isSuccess) ||
(identity && !identity.isSuccess) ||
(!id && input.expectedRevisionId !== null)
) {
return Promise.resolve(Result.fail(new TicketError("INVALID_REQUEST")));
}
const normalizedId = identity?.isSuccess ? identity.data.toPrimitive() : undefined;
return this.repository.save(company.data, key.data, (context) => {
const previous = context.tickets.find((item) => item.id.toPrimitive() === normalizedId);
if (id && !previous) {
return Result.fail(new TicketError("TICKET_NOT_FOUND"));
}
if (!id && context.requestTicket) {
return replayTicket(context.requestTicket, company.data, actor.data, input);
}
if (previous && previous.history.at(-1)?.id.toPrimitive() !== input.expectedRevisionId) {
return Result.fail(new TicketError("TICKET_CHANGED"));
}
const values = mapTicketInput(input.values, context, previous);
if (!values.isSuccess) {
return Result.fail(values.error);
}
const now = new Date().toISOString();
if (previous) {
return previous.update(values.data, actor.data, now);
}
return createTicket(company.data, key.data, values.data, actor.data, context.nextNumber, now);
});
}
}
function replayTicket(
original: Ticket,
company: UniqueID,
actor: UniqueID,
input: SaveTicketInput
): Result<Ticket, TicketError> {
const values = TicketValues.create(input.values);
const first = original.history[0];
if (
!(
original.companyId.equals(company) &&
first?.actorId.equals(actor) &&
values.isSuccess &&
first.values.equals(values.data)
)
) {
return Result.fail(new TicketError("REQUEST_KEY_REUSED"));
}
return Result.ok(original);
}
function createTicket(
company: UniqueID,
key: UniqueID,
values: TicketValues,
actor: UniqueID,
number: number,
now: string
): Result<Ticket, TicketError> {
const reference = Name.create(`T-${String(number).padStart(6, "0")}`, 40);
if (!reference.isSuccess) {
return Result.fail(new TicketError("INVALID_REQUEST"));
}
return Ticket.create(company, reference.data, key, values, actor, now);
}

View File

@ -0,0 +1,25 @@
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
import type { TicketValues } from "./value-objects/ticket-values";
export type TicketRevisionProps = Readonly<{
values: TicketValues;
actorId: UniqueID;
recordedAt: string;
}>;
export class TicketRevision extends DomainEntity<TicketRevisionProps> {
static create(props: TicketRevisionProps) {
return new TicketRevision(Object.freeze({ ...props }));
}
static rehydrate(props: TicketRevisionProps, id: UniqueID) {
return new TicketRevision(Object.freeze({ ...props }), id);
}
get values() {
return this.props.values;
}
get actorId() {
return this.props.actorId;
}
get recordedAt() {
return this.props.recordedAt;
}
}

View File

@ -0,0 +1,37 @@
import { z } from "zod/v4";
export const TicketStateSchema = z.enum([
"open",
"in_progress",
"waiting",
"resolved",
"cancelled",
]);
export const TicketPrioritySchema = z.enum(["low", "normal", "high", "urgent"]);
export const TicketChannelSchema = z.enum(["email", "phone", "internal", "other"]);
export const TicketTargetSchema = z.strictObject({
kind: z.enum(["person", "workstation", "asset", "resource"]),
targetId: z.uuid(),
});
export const TicketInputSchema = z
.strictObject({
subject: z.string().trim().min(1).max(200),
description: z.string().trim().max(10000),
requesterId: z.uuid().nullable(),
assigneeId: z.uuid().nullable(),
channel: TicketChannelSchema,
state: TicketStateSchema,
priority: TicketPrioritySchema,
affected: z.array(TicketTargetSchema).max(200),
})
.refine(
(value) =>
new Set(value.affected.map((item) => `${item.kind}:${item.targetId.toLowerCase()}`)).size ===
value.affected.length
);
export type TicketInput = z.infer<typeof TicketInputSchema>;
export type TicketTargetInput = z.infer<typeof TicketTargetSchema>;
export class TicketError extends Error {
constructor(public readonly code: string) {
super(code);
}
}

View File

@ -0,0 +1,80 @@
import { AggregateRoot, type Name, type UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { TicketRevision } from "./ticket-revision.entity";
import { TicketError } from "./ticket-values";
import type { TicketValues } from "./value-objects/ticket-values";
type TicketProps = Readonly<{
companyId: UniqueID;
reference: Name;
requestKey: UniqueID;
values: TicketValues;
history: readonly TicketRevision[];
}>;
export class Ticket extends AggregateRoot<TicketProps> {
private constructor(props: TicketProps, id?: UniqueID) {
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
}
static create(
companyId: UniqueID,
reference: Name,
requestKey: UniqueID,
values: TicketValues,
actorId: UniqueID,
recordedAt: string
): Result<Ticket, TicketError> {
if (!z.iso.datetime().safeParse(recordedAt).success) {
return Result.fail(new TicketError("INVALID_REQUEST"));
}
return Result.ok(
new Ticket({
companyId,
reference,
requestKey,
values,
history: [TicketRevision.create({ values, actorId, recordedAt })],
})
);
}
static rehydrate(props: TicketProps, id: UniqueID) {
return new Ticket(props, id);
}
get reference() {
return this.props.reference;
}
get requestKey() {
return this.props.requestKey;
}
get companyId() {
return this.props.companyId;
}
get values() {
return this.props.values;
}
get history() {
return this.props.history;
}
update(values: TicketValues, actorId: UniqueID, recordedAt: string): Result<Ticket, TicketError> {
if (this.values.equals(values)) {
return Result.ok(this);
}
if (
!z.iso.datetime().safeParse(recordedAt).success ||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
) {
return Result.fail(new TicketError("INVALID_REQUEST"));
}
return Result.ok(
new Ticket(
{
...this.props,
values,
history: [...this.history, TicketRevision.create({ values, actorId, recordedAt })],
},
this.id
)
);
}
}

View File

@ -0,0 +1,167 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Name, UniqueID } from "@repo/rdx-ddd";
import { mapTicketInput } from "../application/ticket-input.mapper";
import { TicketPersistenceMapper } from "../infrastructure/persistence/ticket-persistence.mapper";
import { Ticket } from "./ticket.aggregate";
import { TicketInputSchema } from "./ticket-values";
import { TicketValues } from "./value-objects/ticket-values";
const company = UniqueID.generateNewID();
const actor = UniqueID.generateNewID();
const target = UniqueID.generateNewID();
const input = {
subject: "Revisar correo",
description: "",
requesterId: null,
assigneeId: null,
channel: "email" as const,
state: "open" as const,
priority: "normal" as const,
affected: [],
};
function ticket() {
const values = TicketValues.create(input);
const reference = Name.create("T-000001", 40);
assert.ok(values.isSuccess && reference.isSuccess);
const result = Ticket.create(
company,
reference.data,
UniqueID.generateNewID(),
values.data,
actor,
"2026-10-07T12:00:00.000Z"
);
assert.ok(result.isSuccess);
return result.data;
}
test("tickets: identidad, historial, idempotencia semántica y fecha", () => {
const original = ticket();
assert.equal(original.id.toPrimitive()[14], "7");
assert.equal(original.history[0]?.id.toPrimitive()[14], "7");
const same = original.update(original.values, actor, "2026-10-07T13:00:00.000Z");
assert.ok(same.isSuccess);
assert.equal(same.data, original);
const changed = TicketValues.create({ ...input, state: "resolved" });
assert.ok(changed.isSuccess);
assert.equal(original.update(changed.data, actor, "2026-10-06T12:00:00.000Z").isSuccess, false);
const updated = original.update(changed.data, actor, "2026-10-07T13:00:00.000Z");
assert.ok(updated.isSuccess);
assert.ok(updated.data.id.equals(original.id));
assert.equal(updated.data.reference.toPrimitive(), "T-000001");
assert.equal(updated.data.history.length, 2);
assert.equal(original.values.toPrimitive().state, "open");
});
test("tickets: entradas estrictas, campos opcionales y afectados únicos", () => {
assert.equal(TicketInputSchema.safeParse({ ...input, subject: " " }).success, false);
assert.equal(TicketInputSchema.safeParse({ ...input, subject: "x".repeat(201) }).success, false);
assert.equal(
TicketInputSchema.safeParse({ ...input, companyId: company.toPrimitive() }).success,
false
);
assert.equal(
TicketInputSchema.safeParse({
...input,
affected: [
{ kind: "asset", targetId: target.toPrimitive() },
{ kind: "asset", targetId: target.toPrimitive() },
],
}).success,
false
);
assert.equal(
TicketInputSchema.safeParse({
...input,
affected: [{ kind: "other", targetId: target.toPrimitive() }],
}).success,
false
);
assert.ok(TicketInputSchema.safeParse(input).success);
});
test("tickets: afectados son referencias semánticas sin orden", () => {
const first = TicketValues.create({
...input,
affected: [
{ kind: "asset", targetId: target.toPrimitive() },
{ kind: "person", targetId: actor.toPrimitive() },
],
});
const second = TicketValues.create({
...input,
affected: [
{ kind: "person", targetId: actor.toPrimitive() },
{ kind: "asset", targetId: target.toPrimitive() },
],
});
assert.ok(first.isSuccess && second.isSuccess);
assert.ok(first.data.equals(second.data));
});
test("tickets: pertenencia, inactividad y conservación de referencias existentes", () => {
const context = {
tickets: [],
companies: [],
technicians: [],
people: [],
actorNames: new Map(),
nextNumber: 1,
requestTicket: undefined,
lookups: {
people: [],
assets: [{ id: target, name: "Portátil", active: true }],
resources: [],
workstations: [],
},
};
assert.ok(
mapTicketInput(
{ ...input, affected: [{ kind: "asset", targetId: target.toPrimitive() }] },
context
).isSuccess
);
assert.equal(
mapTicketInput({ ...input, requesterId: actor.toPrimitive() }, context).isSuccess,
false
);
assert.equal(
mapTicketInput({ ...input, assigneeId: actor.toPrimitive() }, context).isSuccess,
false
);
assert.equal(
mapTicketInput(
{ ...input, affected: [{ kind: "person", targetId: target.toPrimitive() }] },
context
).isSuccess,
false
);
const unavailable = {
...context,
lookups: { ...context.lookups, assets: [{ id: target, name: "Portátil", active: false }] },
};
assert.equal(
mapTicketInput(
{ ...input, affected: [{ kind: "asset", targetId: target.toPrimitive() }] },
unavailable
).isSuccess,
false
);
});
test("tickets: mapper preserva revisiones y rechaza inconsistencia", () => {
const original = ticket();
const mapper = new TicketPersistenceMapper();
const rows = mapper.toPersistence(original);
const read = mapper.toDomain(rows.root, rows.revisions);
assert.ok(read.id.equals(original.id));
assert.ok(read.requestKey.equals(original.requestKey));
assert.ok(read.values.equals(original.values));
assert.throws(() => mapper.toDomain(rows.root, []));
assert.throws(() => mapper.toDomain({ ...rows.root, reference: "BAD" }, rows.revisions));
assert.throws(() =>
mapper.toDomain(
rows.root,
rows.revisions.map((row) => ({ ...row, position: 2 }))
)
);
});

View File

@ -0,0 +1,97 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import {
TicketError,
type TicketInput,
TicketInputSchema,
type TicketTargetInput,
} from "../ticket-values";
export class TicketTarget {
private constructor(
public readonly kind: TicketTargetInput["kind"],
public readonly targetId: UniqueID
) {
Object.freeze(this);
}
static create(input: TicketTargetInput): Result<TicketTarget, TicketError> {
const id = UniqueID.create(input.targetId);
if (!id.isSuccess) {
return Result.fail(new TicketError("INVALID_REQUEST"));
}
return Result.ok(new TicketTarget(input.kind, id.data));
}
toPrimitive(): TicketTargetInput {
return { kind: this.kind, targetId: this.targetId.toPrimitive() };
}
equals(other: TicketTarget) {
return this.kind === other.kind && this.targetId.equals(other.targetId);
}
}
function optionalId(value: string | null): Result<Maybe<UniqueID>, TicketError> {
if (value === null) {
return Result.ok(Maybe.none());
}
const id = UniqueID.create(value);
return id.isSuccess
? Result.ok(Maybe.some(id.data))
: Result.fail(new TicketError("INVALID_REQUEST"));
}
export class TicketValues {
private constructor(
public readonly subject: Name,
public readonly requesterId: Maybe<UniqueID>,
public readonly assigneeId: Maybe<UniqueID>,
private readonly data: Omit<TicketInput, "subject" | "requesterId" | "assigneeId" | "affected">,
public readonly affected: readonly TicketTarget[]
) {
Object.freeze(data);
Object.freeze(affected);
Object.freeze(this);
}
static create(input: TicketInput): Result<TicketValues, TicketError> {
const parsed = TicketInputSchema.safeParse(input);
const name = Name.create(input.subject, 200);
const person = optionalId(input.requesterId);
const assignee = optionalId(input.assigneeId);
if (!(parsed.success && name.isSuccess && person.isSuccess && assignee.isSuccess)) {
return Result.fail(new TicketError("INVALID_REQUEST"));
}
const targets: TicketTarget[] = [];
for (const item of parsed.data.affected) {
const target = TicketTarget.create(item);
if (!target.isSuccess) {
return Result.fail(target.error);
}
targets.push(target.data);
}
const { subject: _, requesterId: __, assigneeId: ___, affected: ____, ...data } = parsed.data;
return Result.ok(new TicketValues(name.data, person.data, assignee.data, data, targets));
}
toPrimitive(): TicketInput {
return {
...this.data,
subject: this.subject.toPrimitive(),
requesterId: this.requesterId.getOrUndefined()?.toPrimitive() ?? null,
assigneeId: this.assigneeId.getOrUndefined()?.toPrimitive() ?? null,
affected: this.affected.map((item) => item.toPrimitive()),
};
}
equals(other: TicketValues) {
const a = this.data;
const b = other.data;
return (
this.subject.equals(other.subject) &&
this.requesterId.getOrUndefined()?.toPrimitive() ===
other.requesterId.getOrUndefined()?.toPrimitive() &&
this.assigneeId.getOrUndefined()?.toPrimitive() ===
other.assigneeId.getOrUndefined()?.toPrimitive() &&
a.description === b.description &&
a.channel === b.channel &&
a.state === b.state &&
a.priority === b.priority &&
this.affected.length === other.affected.length &&
this.affected.every((target) => other.affected.some((item) => target.equals(item)))
);
}
}

View File

@ -0,0 +1,98 @@
import type { Application, RequestHandler, Response } from "express";
import { Router } from "express";
import { z } from "zod/v4";
import { SaveTicketSchema } from "../../../common/tickets";
import type { TicketUseCases } from "../../application/ticket.use-cases";
import {
buildTicket,
buildTicketLookups,
buildTickets,
} from "../../application/ticket-response.builder";
const Empty = z.strictObject({});
const Company = z.strictObject({ companyId: z.uuid() });
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 registerTicketRoutes(
app: Application,
useCases: TicketUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:companyId/lookups", (request, response, next) => {
const params = Company.safeParse(request.params);
if (!(params.success && Empty.safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.lookups(params.data.companyId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json(buildTicketLookups(result.data));
})
.catch(next);
});
for (const path of ["/", "/:companyId"]) {
router.get(path, (request, response, next) => {
const params = (path === "/" ? Empty : Company).safeParse(request.params);
if (!(params.success && Empty.safeParse(request.query).success)) {
fail(response, "INVALID_REQUEST");
return;
}
const companyId =
"companyId" in params.data && typeof params.data.companyId === "string"
? params.data.companyId
: undefined;
useCases
.list(companyId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json(buildTickets(result.data));
})
.catch(next);
});
}
for (const path of ["/:companyId", "/:companyId/:id/update"]) {
router.post(path, (request, response, next) => {
const params = (
path.endsWith("update") ? Company.extend({ id: z.uuid() }) : Company
).safeParse(request.params);
const body = SaveTicketSchema.safeParse(request.body);
const actor = z
.object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined);
if (
!(params.success && body.success && actor.success && Empty.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, actor.data.userId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response
.status(id ? 200 : 201)
.json({ ticket: buildTicket(result.data.ticket, result.data.context) });
})
.catch(next);
});
}
app.use("/api/tickets", authenticate, router);
}

View File

@ -0,0 +1,189 @@
import type { IResourceInventoryService } from "@metis/resources/services";
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
import { z } from "zod/v4";
import type {
ITicketInventoryService,
ITicketRepository,
TicketContext,
TicketLookups,
TicketSaveContext,
} from "../../application/ticket.repository";
import type { Ticket } from "../../domain/ticket.aggregate";
import { TicketError } from "../../domain/ticket-values";
import { TicketPersistenceMapper, ticketIdentity } from "./ticket-persistence.mapper";
export class SequelizeTicketRepository implements ITicketRepository {
private readonly mapper = new TicketPersistenceMapper();
constructor(
private readonly database: Sequelize,
private readonly inventory: ITicketInventoryService,
private readonly resources: IResourceInventoryService
) {}
private async read(
company: UniqueID | undefined,
transaction: Transaction
): Promise<Result<TicketContext, TicketError>> {
const companies = await this.database.query<{ id: string; name: string; active: boolean }>(
"SELECT id,name,active FROM companies ORDER BY name",
{ transaction, type: QueryTypes.SELECT }
);
if (company && !companies.some((item) => item.id === company.toPrimitive())) {
return Result.fail(new TicketError("COMPANY_NOT_FOUND"));
}
const users = await this.database.query<{ id: string; name: string; active: boolean }>(
"SELECT id,name,active FROM users ORDER BY name",
{ transaction, type: QueryTypes.SELECT }
);
const people = await this.database.query<{
id: string;
companyId: string;
name: string;
active: boolean;
}>("SELECT id,companyId,name,active FROM company_contacts", {
transaction,
type: QueryTypes.SELECT,
});
const where = company ? " WHERE companyId=:companyId" : "";
const roots = await this.database.query<Record<string, unknown>>(
"SELECT id,companyId,reference,requestKey,requesterId,assigneeId,`values` FROM tickets" +
where,
{
transaction,
type: QueryTypes.SELECT,
replacements: { companyId: company?.toPrimitive() ?? null },
}
);
const revisions = await this.database.query<Record<string, unknown>>(
"SELECT r.id,r.ticketId,r.actorId,r.requesterId,r.assigneeId,r.`values`,r.recordedAt,r.position FROM ticket_revisions r JOIN tickets t ON t.id=r.ticketId" +
(company ? " WHERE t.companyId=:companyId" : ""),
{
transaction,
type: QueryTypes.SELECT,
replacements: { companyId: company?.toPrimitive() ?? null },
}
);
const option = (item: { id: string; name: string; active: boolean }) => ({
id: ticketIdentity(item.id),
name: item.name,
active: Boolean(item.active),
});
return Result.ok({
companies: companies.map(option),
technicians: users.map(option),
people: people.map((item) => ({
...option(item),
companyId: ticketIdentity(item.companyId),
})),
actorNames: new Map(users.map((item) => [item.id, item.name])),
tickets: roots.map((row) =>
this.mapper.toDomain(
row,
revisions.filter((item) => item.ticketId === row.id)
)
),
});
}
list(company?: UniqueID) {
return this.database.transaction((transaction) => this.read(company, transaction));
}
async lookups(company: UniqueID): Promise<Result<TicketLookups, TicketError>> {
const rows = await this.database.query<{ id: string }>(
"SELECT id FROM companies WHERE id=:id",
{ replacements: { id: company.toPrimitive() }, type: QueryTypes.SELECT }
);
if (!rows.length) {
return Result.fail(new TicketError("COMPANY_NOT_FOUND"));
}
return Result.ok(await this.readLookups(company));
}
private async readLookups(company: UniqueID): Promise<TicketLookups> {
const people = await this.database.query<{ id: string; name: string; active: boolean }>(
"SELECT id,name,active FROM company_contacts WHERE companyId=:id",
{ replacements: { id: company.toPrimitive() }, type: QueryTypes.SELECT }
);
const [assets, resources, workstations] = await Promise.all([
this.inventory.list(company),
this.resources.list(company),
this.inventory.workstations(company),
]);
return {
people: people.map((item) => ({
id: ticketIdentity(item.id),
name: item.name,
active: Boolean(item.active),
})),
assets: assets.map((item) => ({ id: item.id, name: item.name, active: item.available })),
resources: resources.map((item) => ({ id: item.id, name: item.name, active: item.active })),
workstations,
};
}
save(
company: UniqueID,
requestKey: UniqueID,
action: (context: TicketSaveContext) => Result<Ticket, TicketError>
) {
return this.database.transaction(async (transaction) => {
await this.database.query("SELECT id FROM companies WHERE id=:id FOR UPDATE", {
replacements: { id: company.toPrimitive() },
transaction,
type: QueryTypes.SELECT,
});
await this.database.query("SELECT id FROM ticket_counter WHERE id=1 FOR UPDATE", {
transaction,
type: QueryTypes.SELECT,
});
const context = await this.read(undefined, transaction);
if (!context.isSuccess) {
return Result.fail(context.error);
}
if (!context.data.companies.some((item) => item.id.equals(company))) {
return Result.fail(new TicketError("COMPANY_NOT_FOUND"));
}
const sequence = await this.database.query<{ nextNumber: number }>(
"SELECT nextNumber FROM ticket_counter WHERE id=1",
{ transaction, type: QueryTypes.SELECT }
);
const nextNumber = z.number().int().positive().parse(sequence[0]?.nextNumber);
const scoped = {
...context.data,
tickets: context.data.tickets.filter((item) => item.companyId.equals(company)),
lookups: await this.readLookups(company),
nextNumber,
requestTicket: context.data.tickets.find((item) => item.requestKey.equals(requestKey)),
};
const result = action(scoped);
if (!result.isSuccess) {
return Result.fail(result.error);
}
const previous = context.data.tickets.find((item) => item.id.equals(result.data.id));
await this.write(result.data, previous, transaction);
return Result.ok({ ticket: result.data, context: context.data });
});
}
private async write(ticket: Ticket, previous: Ticket | undefined, transaction: Transaction) {
const data = this.mapper.toPersistence(ticket);
await this.database.query(
previous
? "UPDATE tickets SET requesterId=:requesterId,assigneeId=:assigneeId,`values`=:values WHERE id=:id"
: "INSERT INTO tickets (id,companyId,reference,requestKey,requesterId,assigneeId,`values`) VALUES (:id,:companyId,:reference,:requestKey,:requesterId,:assigneeId,:values)",
{ replacements: data.root, transaction }
);
const existing = new Set(previous?.history.map((item) => item.id.toPrimitive()) ?? []);
for (const row of data.revisions) {
if (!existing.has(row.id)) {
await this.database.query(
"INSERT INTO ticket_revisions (id,ticketId,actorId,requesterId,assigneeId,`values`,recordedAt,position) VALUES (:id,:ticketId,:actorId,:requesterId,:assigneeId,:values,:recordedAt,:position)",
{ replacements: row, transaction }
);
}
}
if (!previous) {
await this.database.query("UPDATE ticket_counter SET nextNumber=nextNumber+1 WHERE id=1", {
transaction,
});
}
}
}

View File

@ -0,0 +1,64 @@
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
export async function migrateTickets(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 fk = (model: string, nullable = false) => ({
type: DataTypes.UUID,
allowNull: nullable,
references: { model, key: "id" },
onDelete: "RESTRICT",
});
const definitions = [
[
"ticket_counter",
{
id: { type: DataTypes.INTEGER, primaryKey: true },
nextNumber: { type: DataTypes.INTEGER, allowNull: false },
},
],
[
"tickets",
{
id: { type: DataTypes.UUID, primaryKey: true },
companyId: fk("companies"),
reference: { type: DataTypes.STRING(40), allowNull: false, unique: true },
requestKey: { type: DataTypes.UUID, allowNull: false, unique: true },
requesterId: fk("company_contacts", true),
assigneeId: fk("users", true),
values: { type: DataTypes.JSON, allowNull: false },
},
],
[
"ticket_revisions",
{
id: { type: DataTypes.UUID, primaryKey: true },
ticketId: fk("tickets"),
actorId: fk("users"),
requesterId: fk("company_contacts", true),
assigneeId: fk("users", true),
values: { type: DataTypes.JSON, allowNull: false },
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
position: { type: DataTypes.INTEGER, allowNull: false },
},
],
] as const;
for (const [table, columns] of definitions) {
if (!tables.some((item) => item.name === table)) {
await query.createTable(table, columns);
}
}
await database.query("INSERT IGNORE INTO ticket_counter (id,nextNumber) VALUES (1,1)");
const indexes = await database.query<{ name: string }>(
"SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ticket_revisions'",
{ type: QueryTypes.SELECT }
);
if (!indexes.some((item) => item.name === "ticket_revision_position")) {
await query.addIndex("ticket_revisions", ["ticketId", "position"], {
name: "ticket_revision_position",
unique: true,
});
}
}

View File

@ -0,0 +1,117 @@
import { Name, UniqueID } from "@repo/rdx-ddd";
import { z } from "zod/v4";
import { Ticket } from "../../domain/ticket.aggregate";
import { TicketRevision } from "../../domain/ticket-revision.entity";
import { TicketInputSchema } from "../../domain/ticket-values";
import { TicketValues } from "../../domain/value-objects/ticket-values";
const Root = z.strictObject({
id: z.uuid(),
companyId: z.uuid(),
reference: z.string().regex(/^T-[0-9]{6,}$/),
requestKey: z.uuid(),
requesterId: z.uuid().nullable(),
assigneeId: z.uuid().nullable(),
values: TicketInputSchema,
});
const Revision = z.strictObject({
id: z.uuid(),
ticketId: z.uuid(),
actorId: z.uuid(),
requesterId: z.uuid().nullable(),
assigneeId: z.uuid().nullable(),
values: TicketInputSchema,
recordedAt: z.iso.datetime(),
position: z.number().int().nonnegative(),
});
export function ticketIdentity(value: string) {
const id = UniqueID.create(value);
if (!id.isSuccess) {
throw new Error("Identidad de ticket inconsistente.");
}
return id.data;
}
function json(value: unknown): unknown {
return typeof value === "string" ? JSON.parse(value) : value;
}
function values(value: unknown) {
const result = TicketValues.create(TicketInputSchema.parse(json(value)));
if (!result.isSuccess) {
throw new Error("Valores de ticket inconsistentes.");
}
return result.data;
}
export class TicketPersistenceMapper {
toDomain(root: Record<string, unknown>, rows: readonly Record<string, unknown>[]) {
const current = Root.parse({ ...root, values: json(root.values) });
const history = rows
.map((row) => Revision.parse({ ...row, values: json(row.values) }))
.sort((a, b) => a.position - b.position);
const reference = Name.create(current.reference, 40);
if (
!(reference.isSuccess && history.length) ||
new Set(history.map((item) => item.id)).size !== history.length ||
history.some(
(item, index) =>
item.ticketId !== current.id ||
item.position !== index ||
item.requesterId !== item.values.requesterId ||
item.assigneeId !== item.values.assigneeId ||
(index > 0 && item.recordedAt < (history[index - 1]?.recordedAt ?? ""))
) ||
current.requesterId !== current.values.requesterId ||
current.assigneeId !== current.values.assigneeId
) {
throw new Error("Historial de ticket inconsistente.");
}
const revisions = history.map((item) =>
TicketRevision.rehydrate(
{
values: values(item.values),
actorId: ticketIdentity(item.actorId),
recordedAt: item.recordedAt,
},
ticketIdentity(item.id)
)
);
const currentValues = values(current.values);
if (!revisions.at(-1)?.values.equals(currentValues)) {
throw new Error("Instantánea de ticket inconsistente.");
}
return Ticket.rehydrate(
{
companyId: ticketIdentity(current.companyId),
reference: reference.data,
requestKey: ticketIdentity(current.requestKey),
values: currentValues,
history: revisions,
},
ticketIdentity(current.id)
);
}
toPersistence(ticket: Ticket) {
const input = ticket.values.toPrimitive();
return {
root: {
id: ticket.id.toPrimitive(),
companyId: ticket.companyId.toPrimitive(),
reference: ticket.reference.toPrimitive(),
requestKey: ticket.requestKey.toPrimitive(),
requesterId: input.requesterId,
assigneeId: input.assigneeId,
values: JSON.stringify(input),
},
revisions: ticket.history.map((item, position) => ({
id: item.id.toPrimitive(),
ticketId: ticket.id.toPrimitive(),
actorId: item.actorId.toPrimitive(),
requesterId: item.values.toPrimitive().requesterId,
assigneeId: item.values.toPrimitive().assigneeId,
values: JSON.stringify(item.values.toPrimitive()),
recordedAt: item.recordedAt,
position,
})),
};
}
}

View File

@ -16,6 +16,8 @@ import type { IProcessAssetInventoryService } from "./application/process.reposi
import { ProcessUseCases } from "./application/process.use-cases";
import { RemoteConnectionUseCases } from "./application/remote-connection.use-cases";
import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.use-cases";
import type { ITicketInventoryService } from "./application/ticket.repository";
import { TicketUseCases } from "./application/ticket.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";
@ -29,6 +31,7 @@ import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printe
import { registerProcessRoutes } from "./infrastructure/http/process.routes";
import { registerRemoteConnectionRoutes } from "./infrastructure/http/remote-connection.routes";
import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.routes";
import { registerTicketRoutes } from "./infrastructure/http/ticket.routes";
import { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes";
import {
AccessPersistenceMapper,
@ -58,8 +61,10 @@ import { SequelizePrinterConfigurationRepository } from "./infrastructure/persis
import { SequelizeProcessRepository } from "./infrastructure/persistence/sequelize-process.repository";
import { SequelizeRemoteConnectionRepository } from "./infrastructure/persistence/sequelize-remote-connection.repository";
import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.repository";
import { SequelizeTicketRepository } from "./infrastructure/persistence/sequelize-ticket.repository";
import { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository";
import { migrateTeleworkAuthorizations } from "./infrastructure/persistence/telework-authorization-migration";
import { migrateTickets } from "./infrastructure/persistence/ticket-migration";
import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration";
export async function createCompanyModule(
@ -83,9 +88,15 @@ export async function createCompanyModule(
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
return {
initializeProfiles: async (
inventory: IWorkProfileInventoryService & IProcessAssetInventoryService,
inventory: IWorkProfileInventoryService &
IProcessAssetInventoryService &
ITicketInventoryService,
resources: IResourceInventoryService
) => {
await migrateTickets(database);
const tickets = new TicketUseCases(
new SequelizeTicketRepository(database, inventory, resources)
);
await migrateProcesses(database);
const processes = new ProcessUseCases(
new SequelizeProcessRepository(database, inventory, resources)
@ -116,6 +127,7 @@ export async function createCompanyModule(
);
return {
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
registerTicketRoutes(app, tickets, authenticate);
registerProcessRoutes(app, processes, authenticate);
registerRemoteConnectionRoutes(app, remote, authenticate);
registerWorkProfileRoutes(app, profiles, authenticate);

View File

@ -14,4 +14,5 @@ export * from "./printer-configurations";
export * from "./processes";
export * from "./remote-connections";
export * from "./telework-authorizations";
export * from "./tickets";
export * from "./work-profiles";

View File

@ -0,0 +1,54 @@
import { z } from "zod/v4";
import {
TicketChannelSchema,
TicketInputSchema,
TicketPrioritySchema,
TicketStateSchema,
} from "../api/domain/ticket-values";
export type { TicketInput, TicketTargetInput } from "../api/domain/ticket-values";
export { TicketChannelSchema, TicketInputSchema, TicketPrioritySchema, TicketStateSchema };
export const SaveTicketSchema = z.strictObject({
values: TicketInputSchema,
requestKey: z.uuid(),
expectedRevisionId: z.uuid().nullable(),
});
export type SaveTicketInput = z.infer<typeof SaveTicketSchema>;
export const TicketSchema = z.strictObject({
id: z.uuid(),
companyId: z.uuid(),
companyName: z.string(),
reference: z.string(),
values: TicketInputSchema,
requesterName: z.string().nullable(),
assigneeName: z.string().nullable(),
history: z
.array(
z.strictObject({
id: z.uuid(),
values: TicketInputSchema,
actorId: z.uuid(),
actorName: z.string(),
recordedAt: z.iso.datetime(),
})
)
.min(1),
});
const OptionSchema = z.strictObject({ id: z.uuid(), name: z.string(), active: z.boolean() });
export const TicketListSchema = z.strictObject({
tickets: z.array(TicketSchema),
companies: z.array(OptionSchema),
technicians: z.array(OptionSchema),
});
export const TicketResponseSchema = z.strictObject({ ticket: TicketSchema });
export const TicketLookupsSchema = z.strictObject({
people: z.array(OptionSchema),
assets: z.array(OptionSchema),
resources: z.array(OptionSchema),
workstations: z.array(OptionSchema),
});
export type TicketDTO = z.infer<typeof TicketSchema>;
export type TicketListDTO = z.infer<typeof TicketListSchema>;
export type TicketLookupsDTO = z.infer<typeof TicketLookupsSchema>;

View File

@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { AccessWorkspacePage } from "../access/access-workspace";
import { companyApi, companyKeys } from "../api/company-api";
import { TicketsPage } from "../tickets/tickets-page";
import { CompaniesPage } from "./companies-page";
import { CompanyDetail } from "./company-detail";
@ -16,6 +17,7 @@ import { CompanyFeedback } from "./company-feedback";
type WorkspaceLocation =
| { area: "company"; id: string }
| { area: "tickets" }
| { area: "directory" }
| { area: "catalogs" };
@ -62,6 +64,16 @@ export function CompaniesWorkspace({
>
<Building2Icon aria-hidden="true" className="size-5" />
</Button>
<Button
aria-label={t("tickets.global")}
aria-pressed={current.area === "tickets"}
className="desk-space-button"
onClick={() => setLocation({ area: "tickets" })}
title={t("tickets.global")}
variant="ghost"
>
T
</Button>
<div className="desk-rail-separator" />
{companies.map((company) => (
<Button
@ -115,9 +127,7 @@ export function CompaniesWorkspace({
) : (
<div className="company-space">
<div className="company-space-heading">
<h1 className="text-lg font-semibold">
{t(current.area === "catalogs" ? "globalCatalogs" : "title")}
</h1>
<h1 className="text-lg font-semibold">{t(workspaceTitle(current.area))}</h1>
<p className="text-sm text-muted-foreground">{t("supportWorkspace")}</p>
</div>
<nav aria-label={t("workspaceSections")} className="company-space-navigation">
@ -147,17 +157,18 @@ export function CompaniesWorkspace({
</>
) : (
<Button aria-pressed variant="ghost">
{t("title")}
{t(current.area === "tickets" ? "tickets.global" : "title")}
</Button>
)}
</nav>
<footer className="company-space-account">{accountNavigation}</footer>
<main className="company-space-main">
{current.area === "directory" ? (
<CompaniesPage csrfToken={csrfToken} onSelectCompany={selectCompany} />
) : (
<GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />
)}
<WorkspaceContent
area={current.area}
catalog={catalog}
csrfToken={csrfToken}
onSelectCompany={selectCompany}
/>
</main>
</div>
)}
@ -182,3 +193,36 @@ function GlobalCatalogContent({
return <ResourceCatalogPage csrfToken={csrfToken} scope="global" />;
}
}
function workspaceTitle(area: WorkspaceLocation["area"]) {
switch (area) {
case "catalogs":
return "globalCatalogs";
case "tickets":
return "tickets.global";
default:
return "title";
}
}
function WorkspaceContent({
area,
catalog,
csrfToken,
onSelectCompany,
}: {
area: WorkspaceLocation["area"];
catalog: "access" | "assets" | "resources";
csrfToken: string;
onSelectCompany: (id: string) => void;
}) {
switch (area) {
case "tickets":
return <TicketsPage csrfToken={csrfToken} />;
case "directory":
return <CompaniesPage csrfToken={csrfToken} onSelectCompany={onSelectCompany} />;
case "catalogs":
return <GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />;
case "company":
return null;
}
}

View File

@ -31,6 +31,7 @@ import { ProcessesPage } from "../processes/processes-page";
import { WorkProfilesPage } from "../profiles/work-profiles-page";
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
import { TicketsPage } from "../tickets/tickets-page";
import { CompanyFeedback } from "./company-feedback";
import { CompanyForm } from "./company-form";
@ -113,6 +114,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
);
}
type CompanyTab =
| "tickets"
| "processes"
| "remote"
| "telework"
@ -225,6 +227,7 @@ export function CompanyDetail({
["telework", t("telework.title"), KeyRoundIcon],
["remote", t("remote.title"), KeyRoundIcon],
["processes", t("processes.title"), LayoutTemplateIcon],
["tickets", t("tickets.title"), LayoutTemplateIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
[
"departments",
@ -274,6 +277,8 @@ function CompanySection({
tab: CompanyTab;
}) {
switch (tab) {
case "tickets":
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
case "processes":
return <ProcessesPage company={company} csrfToken={csrfToken} />;
case "remote":

View File

@ -9,8 +9,10 @@ import { environmentEnglish, environmentSpanish } from "./profiles/environment-i
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
import { remoteEnglish, remoteSpanish } from "./remote/remote-i18n";
import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n";
import { ticketsEnglish, ticketsSpanish } from "./tickets/ticket-i18n";
export const companiesSpanish = {
tickets: ticketsSpanish,
processes: processesSpanish,
remote: remoteSpanish,
telework: teleworkSpanish,
@ -136,6 +138,7 @@ export const companiesSpanish = {
},
};
export const companiesEnglish = {
tickets: ticketsEnglish,
processes: processesEnglish,
remote: remoteEnglish,
telework: teleworkEnglish,

View File

@ -0,0 +1,44 @@
import { z } from "zod/v4";
import {
type SaveTicketInput,
TicketListSchema,
TicketLookupsSchema,
TicketResponseSchema,
} from "../../common/tickets";
export const ticketKeys = {
list: (companyId?: string) => ["tickets", companyId ?? "global"],
lookups: (companyId: string) => ["ticket-lookups", companyId],
};
export class TicketApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(path: string, input?: SaveTicketInput, token?: string) {
const response = await fetch(`/api/tickets${path}`, {
method: input ? "POST" : "GET",
credentials: "same-origin",
headers: input ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {},
...(input ? { body: JSON.stringify(input) } : {}),
});
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(data);
throw new TicketApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return data;
}
export const ticketApi = {
async list(companyId?: string) {
return TicketListSchema.parse(await request(companyId ? `/${companyId}` : ""));
},
async lookups(companyId: string) {
return TicketLookupsSchema.parse(await request(`/${companyId}/lookups`));
},
async save(companyId: string, input: SaveTicketInput, token: string, id?: string) {
return TicketResponseSchema.parse(
await request(`/${companyId}${id ? `/${id}/update` : ""}`, input, token)
);
},
};

View File

@ -0,0 +1,76 @@
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
TicketChannelSchema,
type TicketInput,
TicketPrioritySchema,
TicketStateSchema,
} from "../../common/tickets";
export function TicketClassificationFields({
form,
prefix,
}: {
form: UseFormReturn<TicketInput>;
prefix: string;
}) {
const { t } = useTranslation("companies");
const values = form.watch();
const fields = [
{
name: "channel",
category: "channels",
options: TicketChannelSchema.options,
onChange: (value: string) => {
const parsed = TicketChannelSchema.safeParse(value);
if (parsed.success) {
form.setValue("channel", parsed.data);
}
},
},
{
name: "state",
category: "states",
options: TicketStateSchema.options,
onChange: (value: string) => {
const parsed = TicketStateSchema.safeParse(value);
if (parsed.success) {
form.setValue("state", parsed.data);
}
},
},
{
name: "priority",
category: "priorities",
options: TicketPrioritySchema.options,
onChange: (value: string) => {
const parsed = TicketPrioritySchema.safeParse(value);
if (parsed.success) {
form.setValue("priority", parsed.data);
}
},
},
] as const;
return (
<>
{fields.map((field) => (
<div className="space-y-2" key={field.name}>
<Label htmlFor={`${prefix}-${field.name}`}>{t(`tickets.${field.name}`)}</Label>
<SelectWithAction
id={`${prefix}-${field.name}`}
label={t(`tickets.${field.name}`)}
onValueChange={field.onChange}
options={field.options.map((value) => ({
value,
label: t(`tickets.${field.category}.${value}`),
}))}
placeholder={t("tickets.choose")}
value={values[field.name]}
/>
</div>
))}
</>
);
}

View File

@ -0,0 +1,94 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { TicketDTO, TicketListDTO } from "../../common/tickets";
import { ticketApi, ticketKeys } from "./ticket-api";
import { ticketTargetOptions } from "./ticket-targets";
export function TicketDetail({ ticket, data }: { ticket: TicketDTO; data: TicketListDTO }) {
const { t, i18n } = useTranslation("companies");
const query = useQuery({
queryKey: ticketKeys.lookups(ticket.companyId),
queryFn: () => ticketApi.lookups(ticket.companyId),
});
const date = (value: string) =>
new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(value)
);
const person = (id: string | null) =>
query.data?.people.find((item) => item.id === id)?.name ?? id ?? t("tickets.unassigned");
const user = (id: string | null) =>
data.technicians.find((item) => item.id === id)?.name ?? id ?? t("tickets.unassigned");
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{ticket.reference} · {ticket.companyName}
</p>
<h3 className="text-lg font-semibold">{ticket.values.subject}</h3>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt>{t("tickets.requester")}</dt>
<dd>{ticket.requesterName ?? t("tickets.unassigned")}</dd>
</div>
<div>
<dt>{t("tickets.assignee")}</dt>
<dd>{ticket.assigneeName ?? t("tickets.unassigned")}</dd>
</div>
<div>
<dt>{t("tickets.channel")}</dt>
<dd>{t(`tickets.channels.${ticket.values.channel}`)}</dd>
</div>
<div>
<dt>{t("tickets.state")}</dt>
<dd>{t(`tickets.states.${ticket.values.state}`)}</dd>
</div>
<div>
<dt>{t("tickets.priority")}</dt>
<dd>{t(`tickets.priorities.${ticket.values.priority}`)}</dd>
</div>
</dl>
{ticket.values.description && (
<p className="whitespace-pre-wrap break-words">{ticket.values.description}</p>
)}
<h4 className="font-medium">
{t("tickets.history")} ({ticket.history.length})
</h4>
<ol className="space-y-3">
{[...ticket.history].reverse().map((revision) => (
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
<p className="font-medium">
{date(revision.recordedAt)} · {revision.actorName}
</p>
<p>
{revision.values.subject} · {t(`tickets.states.${revision.values.state}`)} ·{" "}
{t(`tickets.priorities.${revision.values.priority}`)}
</p>
<p>
{t("tickets.requester")}: {person(revision.values.requesterId)} ·{" "}
{t("tickets.assignee")}: {user(revision.values.assigneeId)}
</p>
<p>
{t("tickets.channel")}: {t(`tickets.channels.${revision.values.channel}`)}
</p>
{revision.values.description && (
<p className="whitespace-pre-wrap break-words">{revision.values.description}</p>
)}
<ul>
{revision.values.affected.map((target) => (
<li key={target.kind + target.targetId}>
{t(`tickets.${target.kind}`)} ·{" "}
{query.data
? (ticketTargetOptions(query.data, target.kind).find(
(item) => item.id === target.targetId
)?.name ?? target.targetId)
: target.targetId}
</li>
))}
</ul>
</li>
))}
</ol>
<p className="break-all text-xs text-muted-foreground">ID: {ticket.id}</p>
</div>
);
}

View File

@ -0,0 +1,227 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { UniqueID } from "@repo/rdx-ddd";
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions } 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, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
type TicketDTO,
type TicketInput,
TicketInputSchema,
type TicketListDTO,
type TicketTargetInput,
} from "../../common/tickets";
import { ticketApi, ticketKeys } from "./ticket-api";
import { TicketClassificationFields } from "./ticket-classification-fields";
import { TicketFeedback, TicketHelp } from "./ticket-help";
import { TicketQuickForms } from "./ticket-quick-forms";
import { TicketTargets } from "./ticket-targets";
export function TicketForm({
scope,
ticket,
data,
csrfToken,
onSaved,
}: {
scope?: string;
ticket?: TicketDTO;
data: TicketListDTO;
csrfToken: string;
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const client = useQueryClient();
const [companyId, setCompanyId] = useState(ticket?.companyId ?? scope ?? "");
const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive());
const [quick, setQuick] = useState<"company" | "person" | undefined>();
const form = useForm<TicketInput>({
resolver: zodResolver(TicketInputSchema),
defaultValues: ticket?.values ?? {
subject: "",
description: "",
requesterId: null,
assigneeId: null,
channel: "email",
state: "open",
priority: "normal",
affected: [],
},
});
const values = form.watch();
const lookups = useQuery({
queryKey: ticketKeys.lookups(companyId),
queryFn: () => ticketApi.lookups(companyId),
enabled: Boolean(companyId),
});
const mutation = useMutation({
mutationFn: (input: TicketInput) =>
ticketApi.save(
companyId,
{ values: input, requestKey, expectedRevisionId: ticket?.history.at(-1)?.id ?? null },
csrfToken,
ticket?.id
),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: ["tickets"] });
onSaved();
},
});
const changeCompany = (id: string) => {
setCompanyId(id);
form.setValue("requesterId", null);
form.setValue("affected", []);
};
const selectOptions = (
items: readonly { id: string; name: string; active: boolean }[],
current: string | null
) =>
items
.filter((item) => item.active || item.id === current)
.map((item) => ({ value: item.id, label: item.name }));
const setTargets = (affected: TicketTargetInput[]) => form.setValue("affected", affected);
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}>{t("tickets.company")}</Label>
{scope || ticket ? (
<p>{data.companies.find((item) => item.id === companyId)?.name ?? companyId}</p>
) : (
<>
<SelectWithAction
action={{ label: t("newCompany"), onClick: () => setQuick("company") }}
id={prefix}
label={t("tickets.company")}
onValueChange={changeCompany}
options={data.companies.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("tickets.choose")}
value={companyId}
/>
<p className="text-xs text-muted-foreground">{t("tickets.companyNotice")}</p>
</>
)}
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-subject`}>{t("tickets.subject")}</Label>
<Input
id={`${prefix}-subject`}
maxLength={200}
required
{...form.register("subject")}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-description`}>{t("tickets.description")}</Label>
<Textarea
id={`${prefix}-description`}
maxLength={10000}
{...form.register("description")}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-requester`}>{t("tickets.requester")}</Label>
<SelectWithAction
action={{
label: t("newPerson"),
onClick: () => setQuick("person"),
disabled: !companyId,
}}
disabled={!lookups.data}
id={`${prefix}-requester`}
label={t("tickets.requester")}
onValueChange={(id) => form.setValue("requesterId", id || null)}
options={selectOptions(
lookups.data?.people ?? [],
ticket?.values.requesterId ?? null
)}
placeholder={t("tickets.unassigned")}
value={values.requesterId ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-assignee`}>{t("tickets.assignee")}</Label>
<SelectWithAction
id={`${prefix}-assignee`}
label={t("tickets.assignee")}
onValueChange={(id) => form.setValue("assigneeId", id || null)}
options={selectOptions(data.technicians, ticket?.values.assigneeId ?? null)}
placeholder={t("tickets.unassigned")}
value={values.assigneeId ?? ""}
/>
</div>
<TicketClassificationFields form={form} prefix={prefix} />
</div>
{companyId && lookups.isPending && <p role="status">{t("loading")}</p>}
{lookups.isError && (
<div role="alert">
<TicketFeedback error={lookups.error} />
<Button
onClick={() => {
lookups.refetch().catch(() => undefined);
}}
type="button"
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{lookups.data && (
<TicketTargets
companyId={companyId}
csrfToken={csrfToken}
lookups={lookups.data}
onChange={setTargets}
values={values.affected}
/>
)}
</fieldset>
<TicketFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}
/>
<TicketHelp />
<FormActions>
<Button disabled={mutation.isPending || !companyId || !lookups.data} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
<TicketQuickForms
companyId={companyId}
csrfToken={csrfToken}
kind={quick}
onClose={() => setQuick(undefined)}
onCompany={(id) => {
client
.invalidateQueries({ queryKey: ["tickets"] })
.then(() => {
changeCompany(id);
setQuick(undefined);
})
.catch(() => undefined);
}}
onPerson={(id) => {
client
.invalidateQueries({ queryKey: ticketKeys.lookups(companyId) })
.then(() => {
form.setValue("requesterId", id);
setQuick(undefined);
})
.catch(() => undefined);
}}
/>
</>
);
}

View File

@ -0,0 +1,30 @@
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { useTranslation } from "react-i18next";
import { TicketApiError } from "./ticket-api";
export function TicketHelp() {
const { t } = useTranslation("companies");
return (
<ContextHelp
description={t("tickets.helpDescription")}
examples={t("tickets.examples")}
examplesLabel={t("tickets.examplesLabel")}
guidance={t("tickets.guidance")}
scopeNote={t("tickets.scope")}
title={t("tickets.help")}
/>
);
}
export function TicketFeedback({ error, invalid = false }: { error: unknown; invalid?: boolean }) {
const { t } = useTranslation("companies");
const code = invalid
? "INVALID_REQUEST"
: error instanceof TicketApiError
? error.code
: "INTERNAL_ERROR";
return error || invalid ? (
<p className="text-sm text-destructive" role="alert">
{t(`tickets.errors.${code}`, { defaultValue: t("tickets.errors.INTERNAL_ERROR") })}
</p>
) : null;
}

View File

@ -0,0 +1,148 @@
export const ticketsSpanish = {
title: "Tickets",
global: "Todos los tickets",
new: "Nuevo ticket",
edit: "Editar ticket",
details: "Ficha e historial",
company: "Compañía",
subject: "Asunto",
description: "Descripción de la petición (opcional)",
requester: "Solicitante (opcional)",
assignee: "Responsable (opcional)",
channel: "Canal",
state: "Estado",
priority: "Prioridad",
affected: "Elementos afectados",
kind: "Tipo de elemento",
addTarget: "Añadir elemento",
person: "Persona",
asset: "Activo",
resource: "Recurso",
workstation: "Puesto de trabajo",
newTarget: "Crear elemento",
remove: "Quitar elemento",
unassigned: "Sin asignar",
choose: "Selecciona un elemento",
all: "Todos",
allCompanies: "Todas las compañías",
allStates: "Todos los estados",
allPriorities: "Todas las prioridades",
allAssignees: "Todos los responsables",
search: "Buscar por referencia, asunto, compañía o solicitante",
empty: "No hay tickets que coincidan con los filtros.",
loading: "Cargando tickets…",
reference: "Referencia",
created: "Creado",
updated: "Último cambio",
history: "Historial",
help: "¿Para qué sirve un ticket?",
helpDescription:
"Registra una petición o incidencia de una compañía, recibida por email, teléfono o por iniciativa interna.",
guidance:
"La compañía queda fija tras el alta. El solicitante y los elementos afectados deben pertenecer a ella. El responsable es un usuario de Metis Desk. Los cambios conservan el historial.",
examples:
"Ana llama porque no recibe correo; la persona responsable solicita preparar varios puestos; una revisión interna detecta un problema.",
examplesLabel: "Ejemplos y recomendaciones",
scope:
"Los tickets registran la petición. Las intervenciones y el tiempo trabajado se incorporarán en una entrega posterior.",
companyNotice:
"Cambiar la compañía antes de guardar vacía el solicitante y los elementos afectados.",
inactive: "Inactivo",
loadError: "No se pudieron cargar los tickets.",
conflict:
"Este ticket ha cambiado. Cierra el formulario y abre de nuevo la edición para revisar la versión actual.",
channels: { email: "Email", phone: "Teléfono", internal: "Interno", other: "Otro" },
states: {
open: "Abierto",
in_progress: "En curso",
waiting: "En espera",
resolved: "Resuelto",
cancelled: "Cancelado",
},
priorities: { low: "Baja", normal: "Normal", high: "Alta", urgent: "Urgente" },
errors: {
INVALID_REQUEST: "Revisa los campos del formulario.",
COMPANY_NOT_FOUND: "La compañía ya no está disponible.",
TICKET_NOT_FOUND: "El ticket no pertenece a esta compañía o ya no está disponible.",
REQUESTER_UNAVAILABLE: "Selecciona una persona activa de esta compañía.",
ASSIGNEE_UNAVAILABLE: "Selecciona un usuario activo de Metis Desk.",
TARGET_UNAVAILABLE:
"Los elementos afectados deben estar disponibles y pertenecer a esta compañía.",
TICKET_CHANGED:
"Este ticket ha cambiado. Cierra y vuelve a abrir la edición para revisar la versión actual.",
REQUEST_KEY_REUSED: "Esta solicitud ya se utilizó con otros datos.",
INTERNAL_ERROR: "No se pudo completar la operación.",
},
};
export const ticketsEnglish = {
title: "Tickets",
global: "All tickets",
new: "New ticket",
edit: "Edit ticket",
details: "Details and history",
company: "Company",
subject: "Subject",
description: "Request description (optional)",
requester: "Requester (optional)",
assignee: "Assignee (optional)",
channel: "Channel",
state: "Status",
priority: "Priority",
affected: "Affected items",
kind: "Item type",
addTarget: "Add item",
person: "Person",
asset: "Asset",
resource: "Resource",
workstation: "Workstation",
newTarget: "Create item",
remove: "Remove item",
unassigned: "Unassigned",
choose: "Choose an item",
all: "All",
allCompanies: "All companies",
allStates: "All statuses",
allPriorities: "All priorities",
allAssignees: "All assignees",
search: "Search reference, subject, company or requester",
empty: "No tickets match these filters.",
loading: "Loading tickets…",
reference: "Reference",
created: "Created",
updated: "Last change",
history: "History",
help: "What is a ticket for?",
helpDescription:
"Records a company's request or issue received by email, phone or internal initiative.",
guidance:
"The company is fixed after creation. The requester and affected items must belong to it. The assignee is a Metis Desk user. Changes preserve history.",
examples:
"Ana reports missing email; a manager requests preparing several workstations; an internal review finds an issue.",
examplesLabel: "Examples and guidance",
scope:
"Tickets record the request. Interventions and time tracking will be added in a later release.",
companyNotice: "Changing company before saving clears the requester and affected items.",
inactive: "Inactive",
loadError: "Tickets could not be loaded.",
conflict: "This ticket changed. Close the form and reopen editing to review the current version.",
channels: { email: "Email", phone: "Phone", internal: "Internal", other: "Other" },
states: {
open: "Open",
in_progress: "In progress",
waiting: "Waiting",
resolved: "Resolved",
cancelled: "Cancelled",
},
priorities: { low: "Low", normal: "Normal", high: "High", urgent: "Urgent" },
errors: {
INVALID_REQUEST: "Check the form fields.",
COMPANY_NOT_FOUND: "The company is unavailable.",
TICKET_NOT_FOUND: "The ticket does not belong to this company or is unavailable.",
REQUESTER_UNAVAILABLE: "Choose an active person in this company.",
ASSIGNEE_UNAVAILABLE: "Choose an active Metis Desk user.",
TARGET_UNAVAILABLE: "Affected items must be available and belong to this company.",
TICKET_CHANGED: "This ticket changed. Close and reopen editing to review the current version.",
REQUEST_KEY_REUSED: "This request key was already used with other data.",
INTERNAL_ERROR: "The operation could not be completed.",
},
};

View File

@ -0,0 +1,109 @@
import { AssetForm, WorkstationForm, catalogApi, catalogKeys } from "@metis/assets/client";
import { ResourceEditor } from "@metis/resources/client";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { TicketTargetInput } from "../../common/tickets";
import { companyApi, companyKeys } from "../api/company-api";
import { PersonForm } from "../components/person-form";
import { SiteForm } from "../components/site-form";
import { TicketFeedback } from "./ticket-help";
export function TicketQuickEditor({
companyId,
kind,
csrfToken,
onSaved,
}: {
companyId: string;
kind: TicketTargetInput["kind"];
csrfToken: string;
onSaved: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const company = useQuery({
queryKey: companyKeys.detail(companyId),
queryFn: () => companyApi.get(companyId),
});
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
enabled: kind === "asset",
});
const [nested, setNested] = useState<
{ kind: "person" | "site"; onSaved: (id: string) => void } | undefined
>();
if (company.isError || (kind === "asset" && catalog.isError)) {
return <TicketFeedback error={company.error ?? catalog.error} />;
}
if (!company.data || (kind === "asset" && !catalog.data)) {
return <p role="status">{t("loading")}</p>;
}
const lookups = {
sites: company.data.company.sites,
people: company.data.company.people,
onCreatePerson: (onSaved: (id: string) => void) => setNested({ kind: "person", onSaved }),
onCreateSite: (onSaved: (id: string) => void) => setNested({ kind: "site", onSaved }),
};
return (
<>
{kind === "person" && (
<PersonForm companyId={companyId} csrfToken={csrfToken} onSaved={onSaved} requireActive />
)}
{kind === "resource" && (
<ResourceEditor companyId={companyId} csrfToken={csrfToken} onSaved={onSaved} />
)}
{kind === "asset" && catalog.data && (
<AssetForm
companyId={companyId}
csrfToken={csrfToken}
items={catalog.data.items}
lookups={lookups}
onSaved={onSaved}
/>
)}
{kind === "workstation" && (
<WorkstationForm
companyId={companyId}
csrfToken={csrfToken}
lookups={lookups}
onSaved={onSaved}
/>
)}
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setNested(undefined);
}
}}
open={Boolean(nested)}
title={t(nested?.kind === "person" ? "newPerson" : "newSite")}
>
{nested?.kind === "person" && (
<PersonForm
companyId={companyId}
csrfToken={csrfToken}
onSaved={(id) => {
nested.onSaved(id);
setNested(undefined);
}}
/>
)}
{nested?.kind === "site" && (
<SiteForm
companyId={companyId}
csrfToken={csrfToken}
onSaved={(id) => {
nested.onSaved(id);
setNested(undefined);
}}
/>
)}
</FormDialog>
</>
);
}

View File

@ -0,0 +1,42 @@
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useTranslation } from "react-i18next";
import { CompanyForm } from "../components/company-form";
import { PersonForm } from "../components/person-form";
export function TicketQuickForms({
kind,
companyId,
csrfToken,
onClose,
onCompany,
onPerson,
}: {
kind: "company" | "person" | undefined;
companyId: string;
csrfToken: string;
onClose: () => void;
onCompany: (id: string) => void;
onPerson: (id: string) => void;
}) {
const { t } = useTranslation("companies");
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open={Boolean(kind)}
title={t(kind === "company" ? "newCompany" : "newPerson")}
>
{kind === "company" && (
<CompanyForm csrfToken={csrfToken} onCreated={onCompany} showTitle={false} />
)}
{kind === "person" && (
<PersonForm companyId={companyId} csrfToken={csrfToken} onSaved={onPerson} requireActive />
)}
</FormDialog>
);
}

View File

@ -0,0 +1,156 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TicketLookupsDTO, TicketTargetInput } from "../../common/tickets";
import { ticketKeys } from "./ticket-api";
import { TicketQuickEditor } from "./ticket-quick-editor";
export function ticketTargetOptions(lookups: TicketLookupsDTO, kind: TicketTargetInput["kind"]) {
switch (kind) {
case "person":
return lookups.people;
case "asset":
return lookups.assets;
case "resource":
return lookups.resources;
case "workstation":
return lookups.workstations;
}
}
export function TicketTargets({
companyId,
csrfToken,
values,
lookups,
onChange,
}: {
companyId: string;
csrfToken: string;
values: TicketTargetInput[];
lookups: TicketLookupsDTO;
onChange: (values: TicketTargetInput[]) => void;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const client = useQueryClient();
const [kind, setKind] = useState<TicketTargetInput["kind"]>("asset");
const [quick, setQuick] = useState<TicketTargetInput["kind"] | undefined>();
const add = (targetId: string, targetKind = kind) => {
if (
targetId &&
!values.some((item) => item.kind === targetKind && item.targetId === targetId)
) {
onChange([...values, { kind: targetKind, targetId }]);
}
};
return (
<section className="space-y-3">
<h3 className="font-medium">{t("tickets.affected")}</h3>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={prefix}>{t("tickets.kind")}</Label>
<SelectWithAction
id={prefix}
label={t("tickets.kind")}
onValueChange={(value) => {
if (
value === "person" ||
value === "asset" ||
value === "workstation" ||
value === "resource"
) {
setKind(value);
}
}}
options={["person", "asset", "workstation", "resource"].map((value) => ({
value,
label: t(`tickets.${value}`),
}))}
placeholder={t("tickets.choose")}
value={kind}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-item`}>{t("tickets.addTarget")}</Label>
<SelectWithAction
action={{
label: t("tickets.newTarget"),
onClick: () => setQuick(kind),
disabled: values.length >= 200,
}}
disabled={values.length >= 200}
id={`${prefix}-item`}
label={t("tickets.addTarget")}
onValueChange={(id) => add(id)}
options={ticketTargetOptions(lookups, kind)
.filter(
(item) =>
item.active &&
!values.some((target) => target.kind === kind && target.targetId === item.id)
)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("tickets.choose")}
value=""
/>
</div>
</div>
<ul className="space-y-2">
{values.map((target) => (
<li
className="flex justify-between gap-3 rounded-md border p-3 text-sm"
key={target.kind + target.targetId}
>
<span>
{t(`tickets.${target.kind}`)} ·{" "}
{ticketTargetOptions(lookups, target.kind).find((item) => item.id === target.targetId)
?.name ?? target.targetId}
</span>
<Button
aria-label={t("tickets.remove")}
onClick={() => onChange(values.filter((item) => item !== target))}
size="sm"
type="button"
variant="outline"
>
{t("tickets.remove")}
</Button>
</li>
))}
</ul>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
setQuick(undefined);
}
}}
open={Boolean(quick)}
title={t("tickets.newTarget")}
wide
>
{quick && (
<TicketQuickEditor
companyId={companyId}
csrfToken={csrfToken}
kind={quick}
onSaved={(id) => {
client
.invalidateQueries({ queryKey: ticketKeys.lookups(companyId) })
.then(() => {
add(id, quick);
setQuick(undefined);
})
.catch(() => undefined);
}}
/>
)}
</FormDialog>
</section>
);
}

View File

@ -0,0 +1,223 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { type TicketDTO, TicketPrioritySchema, TicketStateSchema } from "../../common/tickets";
import { ticketApi, ticketKeys } from "./ticket-api";
import { TicketDetail } from "./ticket-detail";
import { TicketForm } from "./ticket-form";
import { TicketFeedback } from "./ticket-help";
export type TicketFilters = {
search: string;
company: string;
state: string;
priority: string;
assignee: string;
};
export function filterTickets(items: readonly TicketDTO[], filters: TicketFilters) {
const search = filters.search.trim().toLocaleLowerCase();
return items
.filter(
(item) =>
(!filters.company || item.companyId === filters.company) &&
(!filters.state || item.values.state === filters.state) &&
(!filters.priority || item.values.priority === filters.priority) &&
(!filters.assignee ||
(filters.assignee === "unassigned"
? item.values.assigneeId === null
: item.values.assigneeId === filters.assignee)) &&
(!search ||
[item.reference, item.values.subject, item.companyName, item.requesterName ?? ""].some(
(text) => text.toLocaleLowerCase().includes(search)
))
)
.sort((a, b) =>
(b.history.at(-1)?.recordedAt ?? "").localeCompare(a.history.at(-1)?.recordedAt ?? "")
);
}
export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrfToken: string }) {
const { t, i18n } = useTranslation("companies");
const query = useQuery({
queryKey: ticketKeys.list(companyId),
queryFn: () => ticketApi.list(companyId),
});
const [filters, setFilters] = useState<TicketFilters>({
search: "",
company: "",
state: "",
priority: "",
assignee: "",
});
const [open, setOpen] = useState(false);
const [edit, setEdit] = useState<TicketDTO | undefined>();
const [view, setView] = useState<TicketDTO | undefined>();
const items = filterTickets(query.data?.tickets ?? [], filters);
const date = (value: string | undefined) =>
value
? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(value)
)
: "";
return (
<div className="space-y-5">
<header className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">
{t(companyId ? "tickets.title" : "tickets.global")}
</h2>
<Button
disabled={!query.data}
onClick={() => {
setEdit(undefined);
setOpen(true);
}}
>
{t("tickets.new")}
</Button>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Input
aria-label={t("tickets.search")}
onChange={(event) => setFilters({ ...filters, search: event.target.value })}
placeholder={t("tickets.search")}
value={filters.search}
/>
{!companyId && (
<SelectWithAction
id="tickets-company-filter"
label={t("tickets.company")}
onValueChange={(company) => setFilters({ ...filters, company })}
options={
query.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? []
}
placeholder={t("tickets.allCompanies")}
value={filters.company}
/>
)}
<SelectWithAction
id="tickets-state-filter"
label={t("tickets.state")}
onValueChange={(state) => setFilters({ ...filters, state })}
options={TicketStateSchema.options.map((value) => ({
value,
label: t(`tickets.states.${value}`),
}))}
placeholder={t("tickets.allStates")}
value={filters.state}
/>
<SelectWithAction
id="tickets-priority-filter"
label={t("tickets.priority")}
onValueChange={(priority) => setFilters({ ...filters, priority })}
options={TicketPrioritySchema.options.map((value) => ({
value,
label: t(`tickets.priorities.${value}`),
}))}
placeholder={t("tickets.allPriorities")}
value={filters.priority}
/>
<SelectWithAction
id="tickets-assignee-filter"
label={t("tickets.assignee")}
onValueChange={(assignee) => setFilters({ ...filters, assignee })}
options={[
{ value: "unassigned", label: t("tickets.unassigned") },
...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ??
[]),
]}
placeholder={t("tickets.allAssignees")}
value={filters.assignee}
/>
</div>
{query.isPending && <p role="status">{t("tickets.loading")}</p>}
{query.isError && (
<div className="space-y-3">
<TicketFeedback error={query.error} />
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{query.data && !items.length && <p className="text-muted-foreground">{t("tickets.empty")}</p>}
<ul className="divide-y">
{items.map((ticket) => (
<li className="space-y-2 py-4" key={ticket.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">
{ticket.reference} · {ticket.companyName}
</p>
<h3 className="font-semibold">{ticket.values.subject}</h3>
</div>
<div className="flex gap-2">
<Button onClick={() => setView(ticket)} size="sm" variant="outline">
{t("tickets.details")}
</Button>
<Button
onClick={() => {
setEdit(ticket);
setOpen(true);
}}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</div>
<p className="text-sm">
{t(`tickets.states.${ticket.values.state}`)} ·{" "}
{t(`tickets.priorities.${ticket.values.priority}`)} ·{" "}
{ticket.requesterName ?? t("tickets.unassigned")} ·{" "}
{ticket.assigneeName ?? t("tickets.unassigned")}
</p>
<p className="text-xs text-muted-foreground">
{t("tickets.updated")}: {date(ticket.history.at(-1)?.recordedAt)}
</p>
</li>
))}
</ul>
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setOpen}
open={open}
title={t(edit ? "tickets.edit" : "tickets.new")}
wide
>
{query.data && (
<TicketForm
key={edit?.id ?? "new"}
{...(companyId ? { scope: companyId } : {})}
{...(edit ? { ticket: edit } : {})}
csrfToken={csrfToken}
data={query.data}
onSaved={() => setOpen(false)}
/>
)}
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={(value) => {
if (!value) {
setView(undefined);
}
}}
open={Boolean(view)}
title={t("tickets.details")}
wide
>
{view && query.data && <TicketDetail data={query.data} ticket={view} />}
</FormDialog>
</div>
);
}

View File

@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
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 type { TicketDTO, TicketListDTO } from "../../common/tickets";
import { companiesEnglish, companiesSpanish } from "../i18n";
import { ticketKeys } from "./ticket-api";
import { TicketForm } from "./ticket-form";
import { TicketsPage, filterTickets } from "./tickets-page";
const companyId = UniqueID.generateNewID().toPrimitive();
const personId = UniqueID.generateNewID().toPrimitive();
const data: TicketListDTO = {
tickets: [],
companies: [{ id: companyId, name: "Compañía test", active: true }],
technicians: [],
};
function ticket(): TicketDTO {
const values = {
subject: "Problema de correo",
description: "",
requesterId: personId,
assigneeId: null,
state: "open" as const,
channel: "phone" as const,
priority: "normal" as const,
affected: [],
};
return {
id: UniqueID.generateNewID().toPrimitive(),
companyId,
companyName: "Compañía test",
reference: "T-000001",
values,
requesterName: "Ana",
assigneeName: null,
history: [
{
id: UniqueID.generateNewID().toPrimitive(),
actorId: personId,
actorName: "Técnico",
values,
recordedAt: "2026-10-07T10:00:00.000Z",
},
],
};
}
async function render(node: ReactNode, language = "es") {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
});
const client = new QueryClient();
client.setQueryData(ticketKeys.list(), data);
client.setQueryData(ticketKeys.list(companyId), data);
client.setQueryData(ticketKeys.lookups(companyId), {
people: [],
assets: [],
workstations: [],
resources: [],
});
try {
return renderToStaticMarkup(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nextProvider>
);
} finally {
client.clear();
}
}
test("tickets: filtros globales por compañía, estado, prioridad, responsable y búsqueda", () => {
const item = ticket();
const empty = { search: "", company: "", state: "", priority: "", assignee: "" };
assert.equal(filterTickets([item], empty).length, 1);
assert.equal(filterTickets([item], { ...empty, search: "ANA" }).length, 1);
assert.equal(
filterTickets([item], { ...empty, company: UniqueID.generateNewID().toPrimitive() }).length,
0
);
assert.equal(filterTickets([item], { ...empty, state: "resolved" }).length, 0);
assert.equal(filterTickets([item], { ...empty, priority: "urgent" }).length, 0);
assert.equal(filterTickets([item], { ...empty, assignee: "unassigned" }).length, 1);
});
test("tickets: alta modal, compañía fija y ayuda inferior", async () => {
const html = await render(
<TicketForm csrfToken="test" data={data} onSaved={() => undefined} scope={companyId} />
);
assert.ok(html.includes("Compañía test"));
assert.ok(html.includes("Nueva persona"));
assert.ok(html.includes("Elementos afectados"));
assert.ok(html.includes("Crear elemento"));
assert.ok(html.indexOf("¿Para qué sirve un ticket?") < html.indexOf("Guardar"));
assert.ok(html.includes("Descripción de la petición (opcional)"));
});
test("tickets: vistas global y por compañía sin formularios junto al listado, es/en", async () => {
for (const [language, expected] of [
["es", "Todos los tickets"],
["en", "All tickets"],
]) {
const html = await render(<TicketsPage csrfToken="test" />, language);
assert.ok(html.includes(expected ?? ""));
assert.equal(html.includes("<form"), false);
}
const scoped = await render(<TicketsPage companyId={companyId} csrfToken="test" />);
assert.equal(scoped.includes("tickets-company-filter"), false);
});