Tickets
This commit is contained in:
parent
ea1e5e450f
commit
0a4d3e1b62
@ -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(
|
||||
|
||||
175
apps/server/src/tickets.integration.ts
Normal file
175
apps/server/src/tickets.integration.ts
Normal 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);
|
||||
}
|
||||
@ -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).
|
||||
|
||||
@ -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
48
docs/tickets.md
Normal 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.
|
||||
@ -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) {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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:*",
|
||||
|
||||
43
modules/companies/src/api/application/ticket-input.mapper.ts
Normal file
43
modules/companies/src/api/application/ticket-input.mapper.ts
Normal 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;
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
51
modules/companies/src/api/application/ticket.repository.ts
Normal file
51
modules/companies/src/api/application/ticket.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
97
modules/companies/src/api/application/ticket.use-cases.ts
Normal file
97
modules/companies/src/api/application/ticket.use-cases.ts
Normal 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);
|
||||
}
|
||||
25
modules/companies/src/api/domain/ticket-revision.entity.ts
Normal file
25
modules/companies/src/api/domain/ticket-revision.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
modules/companies/src/api/domain/ticket-values.ts
Normal file
37
modules/companies/src/api/domain/ticket-values.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
80
modules/companies/src/api/domain/ticket.aggregate.ts
Normal file
80
modules/companies/src/api/domain/ticket.aggregate.ts
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
167
modules/companies/src/api/domain/ticket.test.ts
Normal file
167
modules/companies/src/api/domain/ticket.test.ts
Normal 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 }))
|
||||
)
|
||||
);
|
||||
});
|
||||
@ -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)))
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
54
modules/companies/src/common/tickets.ts
Normal file
54
modules/companies/src/common/tickets.ts
Normal 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>;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
|
||||
44
modules/companies/src/web/tickets/ticket-api.ts
Normal file
44
modules/companies/src/web/tickets/ticket-api.ts
Normal 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)
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
modules/companies/src/web/tickets/ticket-detail.tsx
Normal file
94
modules/companies/src/web/tickets/ticket-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
modules/companies/src/web/tickets/ticket-form.tsx
Normal file
227
modules/companies/src/web/tickets/ticket-form.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
modules/companies/src/web/tickets/ticket-help.tsx
Normal file
30
modules/companies/src/web/tickets/ticket-help.tsx
Normal 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;
|
||||
}
|
||||
148
modules/companies/src/web/tickets/ticket-i18n.ts
Normal file
148
modules/companies/src/web/tickets/ticket-i18n.ts
Normal 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.",
|
||||
},
|
||||
};
|
||||
109
modules/companies/src/web/tickets/ticket-quick-editor.tsx
Normal file
109
modules/companies/src/web/tickets/ticket-quick-editor.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
modules/companies/src/web/tickets/ticket-quick-forms.tsx
Normal file
42
modules/companies/src/web/tickets/ticket-quick-forms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
modules/companies/src/web/tickets/ticket-targets.tsx
Normal file
156
modules/companies/src/web/tickets/ticket-targets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
223
modules/companies/src/web/tickets/tickets-page.tsx
Normal file
223
modules/companies/src/web/tickets/tickets-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
modules/companies/src/web/tickets/tickets.test.tsx
Normal file
115
modules/companies/src/web/tickets/tickets.test.tsx
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user