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 { verifyResources } from "./resources.integration";
|
||||||
import { verifySupport } from "./support.integration";
|
import { verifySupport } from "./support.integration";
|
||||||
import { verifyTelework } from "./telework.integration";
|
import { verifyTelework } from "./telework.integration";
|
||||||
|
import { verifyTickets } from "./tickets.integration";
|
||||||
import { verifyWorkProfiles } from "./work-profiles.integration";
|
import { verifyWorkProfiles } from "./work-profiles.integration";
|
||||||
import { verifyWorkstations } from "./workstations.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 once(server, "listening");
|
||||||
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
||||||
await database.query("DELETE FROM http_sessions");
|
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_configuration_revisions");
|
||||||
await database.query("DELETE FROM mail_configurations");
|
await database.query("DELETE FROM mail_configurations");
|
||||||
await database.query("DELETE FROM mailbox_use_revisions");
|
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
|
database
|
||||||
);
|
);
|
||||||
await verifySupport((...args) => operator.request(...args), customer.id, secondCompany.id);
|
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í.
|
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
|
||||||
const migratedRuntime = await createIdentityRuntime(database, environment);
|
const migratedRuntime = await createIdentityRuntime(database, environment);
|
||||||
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
|
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,
|
401,
|
||||||
"UNAUTHENTICATED"
|
"UNAUTHENTICATED"
|
||||||
);
|
);
|
||||||
|
await expectError(await operator.request("/tickets"), 401, "UNAUTHENTICATED");
|
||||||
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
||||||
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
||||||
await assert.rejects(
|
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
|
## 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).
|
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
|
## 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.
|
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.
|
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.
|
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.
|
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).
|
||||||
Un ticket puede tener varias intervenciones. No confundir la solicitud de soporte con cada trabajo realizado.
|
|
||||||
|
## 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
|
## 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
|
## 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 {
|
return {
|
||||||
services: {
|
services: {
|
||||||
inventory: {
|
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) => {
|
list: async (companyId: UniqueID) => {
|
||||||
const result = await assetRepository.get(companyId);
|
const result = await assetRepository.get(companyId);
|
||||||
if (!result.isSuccess) {
|
if (!result.isSuccess) {
|
||||||
|
|||||||
@ -5,4 +5,5 @@ export { AssetCatalogPage } from "./catalog-page";
|
|||||||
export { assetsEnglish, assetsSpanish } from "./i18n";
|
export { assetsEnglish, assetsSpanish } from "./i18n";
|
||||||
export { inventoryApi, inventoryKeys } from "./inventory-api";
|
export { inventoryApi, inventoryKeys } from "./inventory-api";
|
||||||
export { AssetInventoryPage } from "./inventory-page";
|
export { AssetInventoryPage } from "./inventory-page";
|
||||||
|
export { WorkstationForm } from "./workstation-form";
|
||||||
export { WorkstationsPage } from "./workstations-page";
|
export { WorkstationsPage } from "./workstations-page";
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@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 { ProcessUseCases } from "./application/process.use-cases";
|
||||||
import { RemoteConnectionUseCases } from "./application/remote-connection.use-cases";
|
import { RemoteConnectionUseCases } from "./application/remote-connection.use-cases";
|
||||||
import { TeleworkAuthorizationUseCases } from "./application/telework-authorization.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 type { IWorkProfileInventoryService } from "./application/work-profile.repository";
|
||||||
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
|
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
|
||||||
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
|
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 { registerProcessRoutes } from "./infrastructure/http/process.routes";
|
||||||
import { registerRemoteConnectionRoutes } from "./infrastructure/http/remote-connection.routes";
|
import { registerRemoteConnectionRoutes } from "./infrastructure/http/remote-connection.routes";
|
||||||
import { registerTeleworkAuthorizationRoutes } from "./infrastructure/http/telework-authorization.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 { registerWorkProfileRoutes } from "./infrastructure/http/work-profile.routes";
|
||||||
import {
|
import {
|
||||||
AccessPersistenceMapper,
|
AccessPersistenceMapper,
|
||||||
@ -58,8 +61,10 @@ import { SequelizePrinterConfigurationRepository } from "./infrastructure/persis
|
|||||||
import { SequelizeProcessRepository } from "./infrastructure/persistence/sequelize-process.repository";
|
import { SequelizeProcessRepository } from "./infrastructure/persistence/sequelize-process.repository";
|
||||||
import { SequelizeRemoteConnectionRepository } from "./infrastructure/persistence/sequelize-remote-connection.repository";
|
import { SequelizeRemoteConnectionRepository } from "./infrastructure/persistence/sequelize-remote-connection.repository";
|
||||||
import { SequelizeTeleworkAuthorizationRepository } from "./infrastructure/persistence/sequelize-telework-authorization.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 { SequelizeWorkProfileRepository } from "./infrastructure/persistence/sequelize-work-profile.repository";
|
||||||
import { migrateTeleworkAuthorizations } from "./infrastructure/persistence/telework-authorization-migration";
|
import { migrateTeleworkAuthorizations } from "./infrastructure/persistence/telework-authorization-migration";
|
||||||
|
import { migrateTickets } from "./infrastructure/persistence/ticket-migration";
|
||||||
import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration";
|
import { migrateWorkProfiles } from "./infrastructure/persistence/work-profile-migration";
|
||||||
|
|
||||||
export async function createCompanyModule(
|
export async function createCompanyModule(
|
||||||
@ -83,9 +88,15 @@ export async function createCompanyModule(
|
|||||||
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
|
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
|
||||||
return {
|
return {
|
||||||
initializeProfiles: async (
|
initializeProfiles: async (
|
||||||
inventory: IWorkProfileInventoryService & IProcessAssetInventoryService,
|
inventory: IWorkProfileInventoryService &
|
||||||
|
IProcessAssetInventoryService &
|
||||||
|
ITicketInventoryService,
|
||||||
resources: IResourceInventoryService
|
resources: IResourceInventoryService
|
||||||
) => {
|
) => {
|
||||||
|
await migrateTickets(database);
|
||||||
|
const tickets = new TicketUseCases(
|
||||||
|
new SequelizeTicketRepository(database, inventory, resources)
|
||||||
|
);
|
||||||
await migrateProcesses(database);
|
await migrateProcesses(database);
|
||||||
const processes = new ProcessUseCases(
|
const processes = new ProcessUseCases(
|
||||||
new SequelizeProcessRepository(database, inventory, resources)
|
new SequelizeProcessRepository(database, inventory, resources)
|
||||||
@ -116,6 +127,7 @@ export async function createCompanyModule(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
||||||
|
registerTicketRoutes(app, tickets, authenticate);
|
||||||
registerProcessRoutes(app, processes, authenticate);
|
registerProcessRoutes(app, processes, authenticate);
|
||||||
registerRemoteConnectionRoutes(app, remote, authenticate);
|
registerRemoteConnectionRoutes(app, remote, authenticate);
|
||||||
registerWorkProfileRoutes(app, profiles, authenticate);
|
registerWorkProfileRoutes(app, profiles, authenticate);
|
||||||
|
|||||||
@ -14,4 +14,5 @@ export * from "./printer-configurations";
|
|||||||
export * from "./processes";
|
export * from "./processes";
|
||||||
export * from "./remote-connections";
|
export * from "./remote-connections";
|
||||||
export * from "./telework-authorizations";
|
export * from "./telework-authorizations";
|
||||||
|
export * from "./tickets";
|
||||||
export * from "./work-profiles";
|
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 { AccessWorkspacePage } from "../access/access-workspace";
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
import { companyApi, companyKeys } from "../api/company-api";
|
||||||
|
import { TicketsPage } from "../tickets/tickets-page";
|
||||||
|
|
||||||
import { CompaniesPage } from "./companies-page";
|
import { CompaniesPage } from "./companies-page";
|
||||||
import { CompanyDetail } from "./company-detail";
|
import { CompanyDetail } from "./company-detail";
|
||||||
@ -16,6 +17,7 @@ import { CompanyFeedback } from "./company-feedback";
|
|||||||
|
|
||||||
type WorkspaceLocation =
|
type WorkspaceLocation =
|
||||||
| { area: "company"; id: string }
|
| { area: "company"; id: string }
|
||||||
|
| { area: "tickets" }
|
||||||
| { area: "directory" }
|
| { area: "directory" }
|
||||||
| { area: "catalogs" };
|
| { area: "catalogs" };
|
||||||
|
|
||||||
@ -62,6 +64,16 @@ export function CompaniesWorkspace({
|
|||||||
>
|
>
|
||||||
<Building2Icon aria-hidden="true" className="size-5" />
|
<Building2Icon aria-hidden="true" className="size-5" />
|
||||||
</Button>
|
</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" />
|
<div className="desk-rail-separator" />
|
||||||
{companies.map((company) => (
|
{companies.map((company) => (
|
||||||
<Button
|
<Button
|
||||||
@ -115,9 +127,7 @@ export function CompaniesWorkspace({
|
|||||||
) : (
|
) : (
|
||||||
<div className="company-space">
|
<div className="company-space">
|
||||||
<div className="company-space-heading">
|
<div className="company-space-heading">
|
||||||
<h1 className="text-lg font-semibold">
|
<h1 className="text-lg font-semibold">{t(workspaceTitle(current.area))}</h1>
|
||||||
{t(current.area === "catalogs" ? "globalCatalogs" : "title")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">{t("supportWorkspace")}</p>
|
<p className="text-sm text-muted-foreground">{t("supportWorkspace")}</p>
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label={t("workspaceSections")} className="company-space-navigation">
|
<nav aria-label={t("workspaceSections")} className="company-space-navigation">
|
||||||
@ -147,17 +157,18 @@ export function CompaniesWorkspace({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button aria-pressed variant="ghost">
|
<Button aria-pressed variant="ghost">
|
||||||
{t("title")}
|
{t(current.area === "tickets" ? "tickets.global" : "title")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<footer className="company-space-account">{accountNavigation}</footer>
|
<footer className="company-space-account">{accountNavigation}</footer>
|
||||||
<main className="company-space-main">
|
<main className="company-space-main">
|
||||||
{current.area === "directory" ? (
|
<WorkspaceContent
|
||||||
<CompaniesPage csrfToken={csrfToken} onSelectCompany={selectCompany} />
|
area={current.area}
|
||||||
) : (
|
catalog={catalog}
|
||||||
<GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />
|
csrfToken={csrfToken}
|
||||||
)}
|
onSelectCompany={selectCompany}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -182,3 +193,36 @@ function GlobalCatalogContent({
|
|||||||
return <ResourceCatalogPage csrfToken={csrfToken} scope="global" />;
|
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 { WorkProfilesPage } from "../profiles/work-profiles-page";
|
||||||
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
|
import { RemoteConnectionsPage } from "../remote/remote-connections-page";
|
||||||
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
|
import { TeleworkAuthorizationsPage } from "../telework/telework-authorizations-page";
|
||||||
|
import { TicketsPage } from "../tickets/tickets-page";
|
||||||
|
|
||||||
import { CompanyFeedback } from "./company-feedback";
|
import { CompanyFeedback } from "./company-feedback";
|
||||||
import { CompanyForm } from "./company-form";
|
import { CompanyForm } from "./company-form";
|
||||||
@ -113,6 +114,7 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
type CompanyTab =
|
type CompanyTab =
|
||||||
|
| "tickets"
|
||||||
| "processes"
|
| "processes"
|
||||||
| "remote"
|
| "remote"
|
||||||
| "telework"
|
| "telework"
|
||||||
@ -225,6 +227,7 @@ export function CompanyDetail({
|
|||||||
["telework", t("telework.title"), KeyRoundIcon],
|
["telework", t("telework.title"), KeyRoundIcon],
|
||||||
["remote", t("remote.title"), KeyRoundIcon],
|
["remote", t("remote.title"), KeyRoundIcon],
|
||||||
["processes", t("processes.title"), LayoutTemplateIcon],
|
["processes", t("processes.title"), LayoutTemplateIcon],
|
||||||
|
["tickets", t("tickets.title"), LayoutTemplateIcon],
|
||||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||||
[
|
[
|
||||||
"departments",
|
"departments",
|
||||||
@ -274,6 +277,8 @@ function CompanySection({
|
|||||||
tab: CompanyTab;
|
tab: CompanyTab;
|
||||||
}) {
|
}) {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
|
case "tickets":
|
||||||
|
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
|
||||||
case "processes":
|
case "processes":
|
||||||
return <ProcessesPage company={company} csrfToken={csrfToken} />;
|
return <ProcessesPage company={company} csrfToken={csrfToken} />;
|
||||||
case "remote":
|
case "remote":
|
||||||
|
|||||||
@ -9,8 +9,10 @@ import { environmentEnglish, environmentSpanish } from "./profiles/environment-i
|
|||||||
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
import { profilesEnglish, profilesSpanish } from "./profiles/profile-i18n";
|
||||||
import { remoteEnglish, remoteSpanish } from "./remote/remote-i18n";
|
import { remoteEnglish, remoteSpanish } from "./remote/remote-i18n";
|
||||||
import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n";
|
import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n";
|
||||||
|
import { ticketsEnglish, ticketsSpanish } from "./tickets/ticket-i18n";
|
||||||
|
|
||||||
export const companiesSpanish = {
|
export const companiesSpanish = {
|
||||||
|
tickets: ticketsSpanish,
|
||||||
processes: processesSpanish,
|
processes: processesSpanish,
|
||||||
remote: remoteSpanish,
|
remote: remoteSpanish,
|
||||||
telework: teleworkSpanish,
|
telework: teleworkSpanish,
|
||||||
@ -136,6 +138,7 @@ export const companiesSpanish = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const companiesEnglish = {
|
export const companiesEnglish = {
|
||||||
|
tickets: ticketsEnglish,
|
||||||
processes: processesEnglish,
|
processes: processesEnglish,
|
||||||
remote: remoteEnglish,
|
remote: remoteEnglish,
|
||||||
telework: teleworkEnglish,
|
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