Intervenciones y plantillas
This commit is contained in:
parent
0a4d3e1b62
commit
1989bbff89
@ -19,6 +19,8 @@ import { createResourceModule } from "@metis/resources/module";
|
||||
import { QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration";
|
||||
|
||||
import { verifyAccess } from "./access.integration";
|
||||
import { createApp } from "./app";
|
||||
import { verifyApplications } from "./applications.integration";
|
||||
@ -26,6 +28,8 @@ import { verifyAssets } from "./assets.integration";
|
||||
import { verifyCertificates } from "./certificates.integration";
|
||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||
import { verifyInterventionTemplates } from "./intervention-templates.integration";
|
||||
import { verifyInterventions } from "./interventions.integration";
|
||||
import { verifyInventory } from "./inventory.integration";
|
||||
import { verifyMail } from "./mail.integration";
|
||||
import { verifyPrinters } from "./printers.integration";
|
||||
@ -53,6 +57,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
await once(server, "listening");
|
||||
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
||||
await database.query("DELETE FROM http_sessions");
|
||||
await database.query("DELETE FROM intervention_revisions");
|
||||
await database.query("DELETE FROM interventions");
|
||||
await database.query("UPDATE intervention_counter SET nextNumber=1 WHERE id=1");
|
||||
await database.query("DELETE FROM intervention_template_revisions");
|
||||
await database.query("DELETE FROM intervention_templates");
|
||||
await migrateInterventionTemplates(database);
|
||||
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");
|
||||
@ -822,7 +832,18 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
database
|
||||
);
|
||||
await verifySupport((...args) => operator.request(...args), customer.id, secondCompany.id);
|
||||
await verifyInterventionTemplates(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id,
|
||||
() => migrateInterventionTemplates(database)
|
||||
);
|
||||
await verifyTickets((...args) => operator.request(...args), customer.id, secondCompany.id);
|
||||
await verifyInterventions(
|
||||
(...args) => operator.request(...args),
|
||||
customer.id,
|
||||
secondCompany.id
|
||||
);
|
||||
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
|
||||
const migratedRuntime = await createIdentityRuntime(database, environment);
|
||||
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
|
||||
@ -919,6 +940,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(
|
||||
await operator.request("/intervention-templates/global"),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
await expectError(await operator.request("/interventions"), 401, "UNAUTHENTICATED");
|
||||
await expectError(await operator.request("/tickets"), 401, "UNAUTHENTICATED");
|
||||
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
||||
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
||||
|
||||
123
apps/server/src/intervention-templates.integration.ts
Normal file
123
apps/server/src/intervention-templates.integration.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
type InterventionTemplateDTO,
|
||||
InterventionTemplateResponseSchema,
|
||||
InterventionTemplatesSchema,
|
||||
} from "@metis/companies/common";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
export async function verifyInterventionTemplates(
|
||||
request: Request,
|
||||
companyId: string,
|
||||
otherId: string,
|
||||
migrate: () => Promise<void>
|
||||
) {
|
||||
const path = "/intervention-templates/global";
|
||||
const list = InterventionTemplatesSchema.parse(await (await request(path)).json());
|
||||
assert.equal(list.templates.length, 6);
|
||||
assert.ok(list.templates.every((template) => template.companyId === null));
|
||||
const values = {
|
||||
name: "Procedimiento de prueba",
|
||||
description: "Prueba",
|
||||
active: true,
|
||||
steps: [
|
||||
{ id: randomUUID(), title: "Primer paso", instructions: "", required: false },
|
||||
{ id: randomUUID(), title: "Segundo paso", instructions: "Verificar", required: true },
|
||||
],
|
||||
};
|
||||
const body = { values, requestKey: randomUUID(), expectedRevisionId: null };
|
||||
async function save(url: string, payload: unknown, status = 201) {
|
||||
const response = await request(url, payload);
|
||||
assert.equal(response.status, status, await response.clone().text());
|
||||
return InterventionTemplateResponseSchema.parse(await response.json()).template;
|
||||
}
|
||||
const created = await save(path, body);
|
||||
assert.equal(created.id[14], "7");
|
||||
assert.equal(created.history.length, 1);
|
||||
assert.equal((await save(path, body)).id, created.id);
|
||||
assert.equal((await request(path, { ...body, values: { ...values, name: "Otro" } })).status, 409);
|
||||
assert.equal((await request(path, { ...body, requestKey: randomUUID() })).status, 409);
|
||||
assert.equal((await request(`/intervention-templates/${companyId}`, body)).status, 409);
|
||||
const local = await save(`/intervention-templates/${companyId}`, {
|
||||
...body,
|
||||
requestKey: randomUUID(),
|
||||
});
|
||||
assert.equal(local.companyId, companyId);
|
||||
const scoped = InterventionTemplatesSchema.parse(
|
||||
await (await request(`/intervention-templates/${companyId}`)).json()
|
||||
);
|
||||
assert.ok(scoped.templates.some((item) => item.id === created.id));
|
||||
assert.ok(scoped.templates.some((item) => item.id === local.id));
|
||||
const other = InterventionTemplatesSchema.parse(
|
||||
await (await request(`/intervention-templates/${otherId}`)).json()
|
||||
);
|
||||
assert.ok(other.templates.every((item) => item.companyId === null));
|
||||
const updateBody = (template: InterventionTemplateDTO) => ({
|
||||
values: { ...template.values, active: false, steps: [...template.values.steps].reverse() },
|
||||
requestKey: randomUUID(),
|
||||
expectedRevisionId: template.history.at(-1)?.id,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
`/intervention-templates/${companyId}/${created.id}/update`,
|
||||
updateBody(created)
|
||||
)
|
||||
).status,
|
||||
404
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`/intervention-templates/${otherId}/${local.id}/update`, updateBody(local)))
|
||||
.status,
|
||||
404
|
||||
);
|
||||
const updated = await save(`${path}/${created.id}/update`, updateBody(created), 200);
|
||||
assert.equal(updated.history.length, 2);
|
||||
assert.equal(updated.values.active, false);
|
||||
assert.equal(updated.history[0]?.values.active, true);
|
||||
assert.equal(updated.values.steps[0]?.id, values.steps[1]?.id);
|
||||
const same = await save(
|
||||
`${path}/${created.id}/update`,
|
||||
{ ...body, values: updated.values, expectedRevisionId: updated.history.at(-1)?.id },
|
||||
200
|
||||
);
|
||||
assert.equal(same.history.length, 2);
|
||||
assert.equal((await request(`${path}/${created.id}/update`, updateBody(created))).status, 409);
|
||||
const responses = await Promise.all(
|
||||
["Una edición", "Otra edición"].map((description) =>
|
||||
request(`${path}/${created.id}/update`, {
|
||||
...updateBody(updated),
|
||||
values: { ...updated.values, description },
|
||||
})
|
||||
)
|
||||
);
|
||||
assert.deepEqual(responses.map((response) => response.status).sort(), [200, 409]);
|
||||
assert.equal(
|
||||
(await request(path, { ...body, requestKey: randomUUID(), values: { ...values, steps: [] } }))
|
||||
.status,
|
||||
400
|
||||
);
|
||||
assert.equal((await request(path, { ...body, unknown: true })).status, 400);
|
||||
assert.equal((await request(`${path}?unknown=1`)).status, 400);
|
||||
assert.equal((await request(`/intervention-templates/${randomUUID()}`)).status, 404);
|
||||
assert.equal((await request(path, body, "http://evil.example")).status, 403);
|
||||
assert.equal((await request(path, body, undefined, "invalid")).status, 403);
|
||||
const seed = list.templates[0];
|
||||
assert.ok(seed);
|
||||
const modifiedSeed = await save(
|
||||
`${path}/${seed.id}/update`,
|
||||
{
|
||||
...updateBody(seed),
|
||||
values: { ...seed.values, active: false, description: "Cambio conservado tras migración" },
|
||||
},
|
||||
200
|
||||
);
|
||||
await migrate();
|
||||
await migrate();
|
||||
const afterMigration = InterventionTemplatesSchema.parse(await (await request(path)).json());
|
||||
const retained = afterMigration.templates.find((item) => item.id === seed.id);
|
||||
assert.deepEqual(retained, modifiedSeed);
|
||||
assert.equal(afterMigration.templates.length, 7);
|
||||
}
|
||||
240
apps/server/src/interventions.integration.ts
Normal file
240
apps/server/src/interventions.integration.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
type InterventionDTO,
|
||||
type InterventionInput,
|
||||
InterventionListSchema,
|
||||
InterventionLookupsSchema,
|
||||
InterventionResponseSchema,
|
||||
InterventionTemplateResponseSchema,
|
||||
TicketListSchema,
|
||||
} from "@metis/companies/common";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
|
||||
export async function verifyInterventions(request: Request, companyId: string, otherId: string) {
|
||||
const path = `/interventions/${companyId}`;
|
||||
const lookupResponse = await request(`${path}/lookups`);
|
||||
assert.equal(lookupResponse.status, 200);
|
||||
const lookups = InterventionLookupsSchema.parse(await lookupResponse.json());
|
||||
const contextResponse = await request("/interventions");
|
||||
assert.equal(contextResponse.status, 200, await contextResponse.clone().text());
|
||||
const context = InterventionListSchema.parse(await contextResponse.json());
|
||||
const technician = context.technicians.find((item) => item.active);
|
||||
const asset = lookups.assets.find((item) => item.active);
|
||||
const ticket = lookups.tickets[0];
|
||||
assert.ok(technician && asset && ticket);
|
||||
const values: InterventionInput = {
|
||||
subject: "Actuación sencilla",
|
||||
ticketId: null,
|
||||
assigneeId: null,
|
||||
state: "pending",
|
||||
modality: "remote",
|
||||
plannedDate: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
minutes: null,
|
||||
diagnosis: "",
|
||||
workPerformed: "",
|
||||
resolution: "",
|
||||
affected: [],
|
||||
steps: [],
|
||||
};
|
||||
const body = { values, template: null, requestKey: randomUUID(), expectedRevisionId: null };
|
||||
async function save(url: string, payload: unknown, status = 201) {
|
||||
const response = await request(url, payload);
|
||||
assert.equal(response.status, status, await response.clone().text());
|
||||
return InterventionResponseSchema.parse(await response.json()).intervention;
|
||||
}
|
||||
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 simple = await save(path, body);
|
||||
assert.equal(simple.id[14], "7");
|
||||
assert.match(simple.reference, /^I-[0-9]{6,}$/);
|
||||
assert.equal(simple.history.length, 1);
|
||||
assert.equal(simple.values.minutes, null);
|
||||
assert.deepEqual(simple.values.steps, []);
|
||||
assert.equal(simple.procedure, null);
|
||||
assert.equal((await save(path, body)).id, simple.id);
|
||||
await error(path, { ...body, values: { ...values, subject: "Otra" } }, 409, "REQUEST_KEY_REUSED");
|
||||
await error(`/interventions/${otherId}`, body, 409, "REQUEST_KEY_REUSED");
|
||||
const update = (item: InterventionDTO, next: InterventionInput) => ({
|
||||
values: next,
|
||||
template: null,
|
||||
requestKey: randomUUID(),
|
||||
expectedRevisionId: item.history.at(-1)?.id,
|
||||
});
|
||||
const same = await save(`${path}/${simple.id}/update`, update(simple, values), 200);
|
||||
assert.equal(same.history.length, 1);
|
||||
await error(
|
||||
path,
|
||||
{ ...body, requestKey: randomUUID(), values: { ...values, state: "in_progress" } },
|
||||
400,
|
||||
"INVALID_REQUEST"
|
||||
);
|
||||
const finished = await save(
|
||||
`${path}/${simple.id}/update`,
|
||||
update(simple, {
|
||||
...values,
|
||||
state: "finished",
|
||||
assigneeId: technician.id,
|
||||
workPerformed: "Atasco resuelto",
|
||||
minutes: 5,
|
||||
}),
|
||||
200
|
||||
);
|
||||
assert.equal(finished.history.length, 2);
|
||||
assert.equal(finished.values.state, "finished");
|
||||
await error(`${path}/${simple.id}/update`, update(simple, values), 409, "INTERVENTION_CHANGED");
|
||||
await error(
|
||||
`/interventions/${otherId}/${simple.id}/update`,
|
||||
update(finished, values),
|
||||
404,
|
||||
"INTERVENTION_NOT_FOUND"
|
||||
);
|
||||
const concurrent = await Promise.all(
|
||||
["Nota A", "Nota B"].map((resolution) =>
|
||||
request(`${path}/${simple.id}/update`, update(finished, { ...finished.values, resolution }))
|
||||
)
|
||||
);
|
||||
assert.deepEqual(concurrent.map((response) => response.status).sort(), [200, 409]);
|
||||
const linked = await save(path, {
|
||||
...body,
|
||||
requestKey: randomUUID(),
|
||||
values: {
|
||||
...values,
|
||||
ticketId: ticket.id,
|
||||
assigneeId: technician.id,
|
||||
affected: [{ kind: "asset", targetId: asset.id }],
|
||||
},
|
||||
});
|
||||
assert.equal(linked.ticketReference, ticket.reference);
|
||||
await error(
|
||||
`/interventions/${otherId}`,
|
||||
{ ...body, requestKey: randomUUID(), values: { ...values, ticketId: ticket.id } },
|
||||
404,
|
||||
"TICKET_NOT_FOUND"
|
||||
);
|
||||
await error(
|
||||
`/interventions/${otherId}`,
|
||||
{
|
||||
...body,
|
||||
requestKey: randomUUID(),
|
||||
values: { ...values, affected: [{ kind: "asset", targetId: asset.id }] },
|
||||
},
|
||||
409,
|
||||
"TARGET_UNAVAILABLE"
|
||||
);
|
||||
await error(
|
||||
path,
|
||||
{ ...body, requestKey: randomUUID(), values: { ...values, assigneeId: randomUUID() } },
|
||||
409,
|
||||
"ASSIGNEE_UNAVAILABLE"
|
||||
);
|
||||
const globalTemplate = lookups.templates.find(
|
||||
(item) => item.values.active && item.companyId === null
|
||||
);
|
||||
assert.ok(globalTemplate);
|
||||
const step = {
|
||||
id: randomUUID(),
|
||||
title: "Comprobar correo",
|
||||
instructions: "Probar envío",
|
||||
required: true,
|
||||
state: "pending" as const,
|
||||
notes: "",
|
||||
};
|
||||
const templatedBody = {
|
||||
...body,
|
||||
requestKey: randomUUID(),
|
||||
template: { id: globalTemplate.id, revisionId: globalTemplate.history.at(-1)?.id },
|
||||
values: { ...values, subject: "Revisar con procedimiento", ticketId: ticket.id, steps: [step] },
|
||||
};
|
||||
const templated = await save(path, templatedBody);
|
||||
assert.equal(templated.procedure?.values.name, globalTemplate.values.name);
|
||||
await error(
|
||||
path,
|
||||
{
|
||||
...templatedBody,
|
||||
requestKey: randomUUID(),
|
||||
template: { ...templatedBody.template, revisionId: randomUUID() },
|
||||
},
|
||||
409,
|
||||
"TEMPLATE_CHANGED"
|
||||
);
|
||||
const beforeTemplateUpdate = await request(
|
||||
`/intervention-templates/global/${globalTemplate.id}/update`,
|
||||
{
|
||||
values: { ...globalTemplate.values, active: false, description: "Plantilla cambiada" },
|
||||
requestKey: randomUUID(),
|
||||
expectedRevisionId: globalTemplate.history.at(-1)?.id,
|
||||
}
|
||||
);
|
||||
assert.equal(beforeTemplateUpdate.status, 200);
|
||||
InterventionTemplateResponseSchema.parse(await beforeTemplateUpdate.json());
|
||||
assert.equal((await save(path, templatedBody)).id, templated.id);
|
||||
await error(path, { ...templatedBody, requestKey: randomUUID() }, 409, "TEMPLATE_UNAVAILABLE");
|
||||
const persisted = InterventionListSchema.parse(
|
||||
await (await request(path)).json()
|
||||
).interventions.find((item) => item.id === templated.id);
|
||||
assert.deepEqual(persisted?.procedure, templated.procedure);
|
||||
await error(
|
||||
`${path}/${templated.id}/update`,
|
||||
update(templated, {
|
||||
...templated.values,
|
||||
state: "finished",
|
||||
assigneeId: technician.id,
|
||||
workPerformed: "Comprobado",
|
||||
}),
|
||||
400,
|
||||
"INVALID_REQUEST"
|
||||
);
|
||||
await error(
|
||||
`${path}/${templated.id}/update`,
|
||||
update(templated, {
|
||||
...templated.values,
|
||||
state: "finished",
|
||||
assigneeId: technician.id,
|
||||
workPerformed: "Comprobado",
|
||||
steps: [{ ...step, state: "not_applicable" }],
|
||||
}),
|
||||
400,
|
||||
"INVALID_REQUEST"
|
||||
);
|
||||
const done = await save(
|
||||
`${path}/${templated.id}/update`,
|
||||
update(templated, {
|
||||
...templated.values,
|
||||
state: "finished",
|
||||
assigneeId: technician.id,
|
||||
workPerformed: "Comprobado",
|
||||
steps: [{ ...step, state: "not_applicable", notes: "No utiliza correo" }],
|
||||
}),
|
||||
200
|
||||
);
|
||||
assert.equal(done.values.steps[0]?.notes, "No utiliza correo");
|
||||
assert.equal(done.procedure?.revisionId, templated.procedure?.revisionId);
|
||||
const ticketAfter = TicketListSchema.parse(
|
||||
await (await request(`/tickets/${companyId}`)).json()
|
||||
).tickets.find((item) => item.id === ticket.id);
|
||||
assert.equal(ticketAfter?.values.state, ticket.values.state);
|
||||
await error(
|
||||
`${path}/${done.id}/update`,
|
||||
{ ...update(done, done.values), template: templatedBody.template },
|
||||
400,
|
||||
"INVALID_REQUEST"
|
||||
);
|
||||
const other = await save(`/interventions/${otherId}`, { ...body, requestKey: randomUUID() });
|
||||
assert.notEqual(other.reference, simple.reference);
|
||||
const all = InterventionListSchema.parse(await (await request("/interventions")).json());
|
||||
assert.ok(all.interventions.some((item) => item.id === other.id));
|
||||
const scoped = InterventionListSchema.parse(await (await request(path)).json());
|
||||
assert.ok(scoped.interventions.every((item) => item.companyId === companyId));
|
||||
assert.equal((await request(path, { ...body, unexpected: true })).status, 400);
|
||||
assert.equal((await request("/interventions?unknown=1")).status, 400);
|
||||
assert.equal((await request(path, body, "http://evil.example")).status, 403);
|
||||
assert.equal((await request(path, body, undefined, "invalid")).status, 403);
|
||||
}
|
||||
@ -94,3 +94,11 @@ SupportConnection delimita cada método de asistencia de un activo de una compa
|
||||
## 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).
|
||||
|
||||
## Plantillas de intervención
|
||||
|
||||
InterventionTemplate es un agregado con ámbito inmutable, clave idempotente y revisiones. InterventionTemplateValues compara semánticamente nombre, descripción, estado y pasos en orden. InterventionTemplateStep e InterventionTemplateRevision son entidades con UUID. Las copias generan nuevos identificadores de pasos; editar o reordenar los conserva. Es un catálogo separado de ProcessTemplate y de las plantillas de activos/recursos.
|
||||
|
||||
## Intervenciones
|
||||
|
||||
Intervention es AggregateRoot con compañía, referencia, clave idempotente y procedimiento opcional inmutables. InterventionValues es Value Object, InterventionStep e InterventionRevision son DomainEntity. Los pasos mantienen UUID al actualizar/reordenar y generan identidades nuevas al aplicar plantillas. InterventionProcedure captura la identidad, revisión y valores originales de una plantilla mediante un Value Object inmutable. El historial permite conocer quién completó cada paso, incluso tras editar sus notas.
|
||||
|
||||
104
docs/intervenciones-y-plantillas.md
Normal file
104
docs/intervenciones-y-plantillas.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Intervenciones y plantillas
|
||||
|
||||
## Alcance implementado
|
||||
|
||||
Catálogo independiente de las plantillas de procesos de personas y de los catálogos de activos y recursos. Cada plantilla representa un procedimiento repetible. No almacena personas, equipos, cuentas ni accesos concretos de una compañía.
|
||||
|
||||
Disponible en Catálogos globales y en la sección Plantillas de intervención de cada compañía. La compañía ve las globales y sus propias plantillas. Las globales solo se editan desde su catálogo; copiarlas desde una compañía crea una variante local independiente.
|
||||
|
||||
## Datos y reglas
|
||||
|
||||
- Nombre obligatorio, hasta 160 caracteres, único en su ámbito incluso estando inactiva.
|
||||
- Descripción opcional, hasta 4.000 caracteres.
|
||||
- Activa/inactiva. No hay borrado; se conserva el historial.
|
||||
- Entre 1 y 100 pasos ordenados. Cada paso tiene UUID, título obligatorio hasta 200 caracteres, instrucciones opcionales hasta 4.000 caracteres e indicador de comprobación obligatoria antes de finalizar.
|
||||
- Los pasos admiten títulos iguales: se identifican por UUID, nunca por su texto.
|
||||
- Crear y copiar genera UUID v7 para agregados, revisiones y pasos desde la interfaz. Reordenar o editar mantiene la identidad del paso.
|
||||
- Copiar crea pasos con identidades nuevas y una plantilla activa, sin copiar su historial. Guardar exige un nombre que no exista en el ámbito destino.
|
||||
- Compañía inmutable. Una variante se crea por copia.
|
||||
- Las ediciones conservan instantáneas completas y ordenadas con actor y fecha. Un guardado sin cambios no añade revisiones.
|
||||
- Alta idempotente mediante requestKey: el reintento devuelve la plantilla existente si coincide con los valores originales, actor y ámbito. Reutilizar la clave con otra información devuelve conflicto.
|
||||
- Edición condicionada a expectedRevisionId: una edición desactualizada se rechaza sin sobrescribir cambios.
|
||||
- Operaciones serializadas mediante bloqueo transaccional de catálogo y restricciones de claves en MariaDB.
|
||||
|
||||
## Datos iniciales
|
||||
|
||||
Se suministran seis procedimientos orientativos: alta de persona, baja de persona, sustitución de equipo, configuración de correo, preparación de portátil para teletrabajo y revisión periódica de equipo. Sus comprobaciones son inicialmente opcionales para permitir adaptación. Se pueden editar y desactivar.
|
||||
|
||||
Las claves de incorporación son estables: reiniciar o repetir la migración no duplica ni restaura los datos iniciales modificados. Nunca se incorporan datos operativos de compañías.
|
||||
|
||||
## UI
|
||||
|
||||
Listado con búsqueda, filtro de inactivas, ámbito y acciones de editar/copiar. Alta mediante botón en la cabecera derecha. Alta y edición en modal con pasos que se pueden añadir, quitar y mover arriba/abajo. Ayuda contextual inmediatamente antes del pie Guardar/Cancelar. Historial desplegable con las instantáneas completas. Textos es/en.
|
||||
|
||||
## Arquitectura y API
|
||||
|
||||
InterventionTemplate es AggregateRoot; InterventionTemplateValues es Value Object; InterventionTemplateStep e InterventionTemplateRevision son DomainEntity. Todo tiene identidad UniqueID.
|
||||
|
||||
- GET /api/intervention-templates/global
|
||||
- GET /api/intervention-templates/:companyId (globales y propias)
|
||||
- POST /api/intervention-templates/:scope
|
||||
- POST /api/intervention-templates/:scope/:id/update
|
||||
|
||||
scope es global o UUID de compañía. Escritura: values, requestKey y expectedRevisionId (null en alta). Entrada estricta, autenticación, protección de origen y CSRF del servidor. La edición debe corresponder exactamente al ámbito de la URL.
|
||||
|
||||
Persistencia en intervention_templates e intervention_template_revisions; intervention_template_lock es un bloqueo técnico, no una entidad de negocio. Migración explícita e idempotente. Mapper bidireccional con validación del historial, posiciones, fechas y coherencia de última revisión.
|
||||
|
||||
## Intervenciones implementadas
|
||||
|
||||
Intervenciones por compañía y vista global en la barra de espacios (I). Búsqueda por referencia, asunto, compañía y técnico; filtros de estado, técnico, compañía y rango de fecha prevista. Sin filtros se muestran todas las pendientes, en curso, finalizadas y canceladas.
|
||||
|
||||
### Datos
|
||||
|
||||
- Compañía obligatoria e inmutable.
|
||||
- UUID v7 y referencia global I-000001.
|
||||
- Ticket opcional de la misma compañía. Un ticket admite varias intervenciones; una actuación interna no exige ticket.
|
||||
- Asunto obligatorio (200 caracteres).
|
||||
- Estado: pendiente, en curso, finalizada o cancelada.
|
||||
- Técnico responsable opcional mientras esté pendiente/cancelada; obligatorio en curso/finalizada.
|
||||
- Modalidad: remota, presencial u otra.
|
||||
- Fecha prevista, inicio/fin reales opcionales. El fin no puede ser anterior al inicio.
|
||||
- Tiempo manual en minutos enteros, opcional; null significa desconocido, cero es un valor conocido. No se calcula automáticamente por diferencia de fechas.
|
||||
- Diagnóstico, trabajo realizado y resolución (10.000 caracteres cada uno). Trabajo realizado obligatorio para finalizar.
|
||||
- Múltiples personas, puestos de trabajo, activos y recursos afectados de la misma compañía.
|
||||
- Pasos opcionales con UUID, título, instrucciones, indicador obligatorio, estado (pendiente/en curso/hecho/no aplica) y notas.
|
||||
|
||||
### Procedimientos
|
||||
|
||||
Una intervención sencilla puede existir sin pasos ni plantilla. Los pasos pueden añadirse manualmente. En el alta se puede aplicar una plantilla global o de la compañía; Aplicar sustituye los pasos actuales, como indica el formulario, y genera nuevas identidades para la ejecución. Se pueden adaptar los pasos.
|
||||
|
||||
Se captura en InterventionProcedure la identidad y revisión de la plantilla junto con todos sus valores originales. Esta procedencia es inmutable; editar o desactivar la plantilla no cambia la intervención. La plantilla debe estar activa y la revisión elegida vigente al crear la intervención. El reintento de un alta ya guardada sigue devolviendo su intervención aunque la plantilla haya cambiado.
|
||||
|
||||
Finalizar exige técnico, trabajo realizado y que cada paso obligatorio esté hecho o marcado no aplica con motivo. Los pasos opcionales pendientes no bloquean. La ejecución de los pasos queda reflejada en el historial; la ficha identifica quién y cuándo pasó cada paso a hecho. Editar sus notas no cambia ese autor/fecha, pero reabrirlo y completarlo de nuevo sí.
|
||||
|
||||
No se modifican automáticamente cuentas, activos ni recursos al marcar un paso. Finalizar una intervención no cambia el estado del ticket. Se puede reabrir una intervención conservando su historial.
|
||||
|
||||
### Formularios y tickets
|
||||
|
||||
Alta y edición en modales con ayuda antes del pie. La selección de compañía queda fija al editar o iniciar desde un ticket. Cambiar la compañía en el alta limpia ticket, afectados, procedimiento y pasos. Seleccionar ticket propone asunto, afectados y técnico solo cuando esos campos están vacíos.
|
||||
|
||||
Desde un ticket se puede crear una intervención con asunto y afectados propuestos. La ficha del ticket consulta sus intervenciones relacionadas. Altas rápidas de compañía, ticket, plantilla y elementos afectados mediante SelectWithAction, manteniendo el borrador principal.
|
||||
|
||||
### Arquitectura y persistencia
|
||||
|
||||
Intervention es AggregateRoot; InterventionValues e InterventionProcedure son Value Objects; InterventionStep e InterventionRevision son DomainEntity con UUID. Igualdad semántica; el orden de pasos importa, el de afectados no.
|
||||
|
||||
Application valida compañía, ticket, técnico y afectados, admitiendo referencias inactivas ya existentes pero rechazando nuevas asignaciones a elementos inactivos. Collaboración con puertos del mismo módulo; el inventario se obtiene a través de los servicios públicos ya utilizados por tickets.
|
||||
|
||||
Persistencia en interventions e intervention_revisions con FK a compañía, ticket y técnico. intervention_counter es una secuencia técnica. Alta idempotente mediante requestKey, revisión esperada al editar y bloqueo transaccional para evitar sobrescrituras concurrentes. Snapshot e historial coherentes mediante mapper bidireccional. Migración explícita e idempotente.
|
||||
|
||||
API autenticada, protección de origen/CSRF y entrada estricta:
|
||||
- GET /api/interventions (global)
|
||||
- GET /api/interventions/:companyId
|
||||
- GET /api/interventions/:companyId/lookups
|
||||
- POST /api/interventions/:companyId
|
||||
- POST /api/interventions/:companyId/:id/update
|
||||
|
||||
Escritura: values, requestKey, expectedRevisionId (null en alta) y template (null o id/revisionId durante el alta; null al editar). La procedencia aplicada no se cambia durante una edición.
|
||||
|
||||
## Validación
|
||||
|
||||
Pruebas de dominio y persistencia: límites, pasos repetidos por UUID, igualdad semántica, orden, no-op, fechas e historial. Pruebas web es/en, ayuda inferior, listado sin formulario y copia con identidades nuevas. Integración HTTP/MariaDB: autenticación, CSRF, origen, ámbitos, altas, duplicados, reintentos, edición, concurrencia, historial y conservación de datos iniciales tras repetir migraciones.
|
||||
|
||||
|
||||
Las pruebas de intervenciones cubren actuaciones sencillas, requisitos de cierre, no aplicación justificada, fechas, minutos, historial, snapshots, idempotencia, concurrencia, compañía de ticket/afectados, procedimientos versionados, no cierre automático del ticket, filtros es/en y autor de finalización de pasos.
|
||||
@ -85,8 +85,7 @@ Varios métodos por activo, catálogo global ampliable con herramientas de serie
|
||||
|
||||
## Siguientes entregas: orden actualizado
|
||||
|
||||
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.
|
||||
1. 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.
|
||||
|
||||
@ -100,9 +99,9 @@ Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas
|
||||
|
||||
Tickets por compañía y consulta global con búsqueda/filtros, referencia corta global además de UUID, solicitante y responsable opcionales, canales, estados, prioridades y varios elementos afectados. Modales, altas rápidas, historial, altas idempotentes y protección de edición concurrente. Consultar [Tickets](tickets.md).
|
||||
|
||||
## Intervenciones: pendientes
|
||||
## Intervenciones: implementadas
|
||||
|
||||
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.
|
||||
Consulta global y por compañía, técnico, tiempos, diagnóstico, trabajo realizado, resultado y elementos afectados. Varias intervenciones por ticket o actuaciones internas sin ticket. Pasos opcionales, procedimientos versionados, comprobaciones obligatorias al finalizar, historial, idempotencia y control de concurrencia. Consultar [Intervenciones y plantillas](intervenciones-y-plantillas.md).
|
||||
|
||||
## Servicios y documentación: pendiente
|
||||
|
||||
@ -111,4 +110,8 @@ Las credenciales se resolverán con una solución específica; no almacenarlas c
|
||||
|
||||
## Decisiones pendientes
|
||||
|
||||
Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, personas, departamentos, asignaciones y sedes está implementada. El inventario está implementado; los tickets están implementados; las intervenciones 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 están implementadas.
|
||||
|
||||
## Plantillas de intervención
|
||||
|
||||
Implementadas las plantillas globales y propias por compañía con pasos ordenados, historial y seis procedimientos iniciales. Véase [Intervenciones y plantillas](intervenciones-y-plantillas.md). Las intervenciones concretas y la ejecución de los pasos están implementadas; las actuaciones sencillas admiten registro sin plantilla.
|
||||
|
||||
@ -46,3 +46,7 @@ Entradas y salidas estrictas. Escrituras protegidas por sesión, CSRF y origen.
|
||||
## 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.
|
||||
|
||||
## Intervenciones relacionadas
|
||||
|
||||
Desde el listado se inicia una intervención para el ticket con asunto y afectados propuestos. La ficha consulta las intervenciones relacionadas. Varias intervenciones pueden corresponder al mismo ticket; finalizar una no cierra el ticket automáticamente. Véase [Intervenciones y plantillas](intervenciones-y-plantillas.md).
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote/remote.test.tsx src/web/processes/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/tickets.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 src/api/domain/intervention-template.test.ts src/web/intervention-templates/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/interventions.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Intervention } from "../domain/intervention.aggregate";
|
||||
import { InterventionError, type InterventionInput } from "../domain/intervention-values";
|
||||
import { InterventionValues } from "../domain/value-objects/intervention-values";
|
||||
|
||||
import type { InterventionSaveContext } from "./intervention.repository";
|
||||
import { targetOptions } from "./ticket.repository";
|
||||
export function mapInterventionInput(
|
||||
input: InterventionInput,
|
||||
context: InterventionSaveContext,
|
||||
previous?: Intervention
|
||||
) {
|
||||
const values = InterventionValues.create(input);
|
||||
if (!values.isSuccess) {
|
||||
return values;
|
||||
}
|
||||
const next = values.data.toPrimitive();
|
||||
const current = previous?.values.toPrimitive();
|
||||
if (
|
||||
next.ticketId &&
|
||||
!context.tickets.some((ticket) => ticket.id.toPrimitive() === next.ticketId)
|
||||
) {
|
||||
return Result.fail(new InterventionError("TICKET_NOT_FOUND"));
|
||||
}
|
||||
if (next.assigneeId) {
|
||||
const user = context.technicians.find((item) => item.id.toPrimitive() === next.assigneeId);
|
||||
if (!user || (!user.active && next.assigneeId !== current?.assigneeId)) {
|
||||
return Result.fail(new InterventionError("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 InterventionError("TARGET_UNAVAILABLE"));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import type {
|
||||
InterventionDTO,
|
||||
InterventionListDTO,
|
||||
InterventionLookupsDTO,
|
||||
} from "../../common/interventions";
|
||||
import type { Intervention } from "../domain/intervention.aggregate";
|
||||
|
||||
import type { InterventionContext, InterventionLookups } from "./intervention.repository";
|
||||
import { buildInterventionTemplate } from "./intervention-template-response.builder";
|
||||
import { buildTicket, buildTicketLookups } from "./ticket-response.builder";
|
||||
export function buildIntervention(
|
||||
intervention: Intervention,
|
||||
context: InterventionContext
|
||||
): InterventionDTO {
|
||||
const values = intervention.values.toPrimitive();
|
||||
return {
|
||||
id: intervention.id.toPrimitive(),
|
||||
companyId: intervention.companyId.toPrimitive(),
|
||||
companyName:
|
||||
context.companies.find((item) => item.id.equals(intervention.companyId))?.name ??
|
||||
intervention.companyId.toPrimitive(),
|
||||
reference: intervention.reference.toPrimitive(),
|
||||
values,
|
||||
assigneeName:
|
||||
context.technicians.find((item) => item.id.toPrimitive() === values.assigneeId)?.name ?? null,
|
||||
ticketReference:
|
||||
context.tickets
|
||||
.find((item) => item.id.toPrimitive() === values.ticketId)
|
||||
?.reference.toPrimitive() ?? null,
|
||||
procedure: intervention.procedure.getOrUndefined()?.toPrimitive() ?? null,
|
||||
history: intervention.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 buildInterventions(context: InterventionContext): InterventionListDTO {
|
||||
const option = (item: { id: { toPrimitive: () => string }; name: string; active: boolean }) => ({
|
||||
id: item.id.toPrimitive(),
|
||||
name: item.name,
|
||||
active: item.active,
|
||||
});
|
||||
return {
|
||||
interventions: context.interventions.map((item) => buildIntervention(item, context)),
|
||||
companies: context.companies.map(option),
|
||||
technicians: context.technicians.map(option),
|
||||
};
|
||||
}
|
||||
export function buildInterventionLookups(data: InterventionLookups): InterventionLookupsDTO {
|
||||
return {
|
||||
...buildTicketLookups(data.lookups),
|
||||
tickets: data.context.tickets.map((item) => buildTicket(item, data.context)),
|
||||
templates: data.templates.map((item) => buildInterventionTemplate(item, data.templateActors)),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import type { InterventionTemplateDTO } from "../../common/intervention-templates";
|
||||
import type { InterventionTemplate } from "../domain/intervention-template.aggregate";
|
||||
export function buildInterventionTemplate(
|
||||
template: InterventionTemplate,
|
||||
actorNames: Map<string, string>
|
||||
): InterventionTemplateDTO {
|
||||
return {
|
||||
id: template.id.toPrimitive(),
|
||||
companyId: template.companyId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
values: template.values.toPrimitive(),
|
||||
history: template.history.map((revision) => {
|
||||
const actorId = revision.actorId.getOrUndefined()?.toPrimitive() ?? null;
|
||||
return {
|
||||
id: revision.id.toPrimitive(),
|
||||
values: revision.values.toPrimitive(),
|
||||
actorId,
|
||||
actorName: actorId ? (actorNames.get(actorId) ?? actorId) : "Metis Desk",
|
||||
recordedAt: revision.recordedAt,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { InterventionTemplate } from "../domain/intervention-template.aggregate";
|
||||
import type { InterventionTemplateError } from "../domain/intervention-template-values";
|
||||
export type InterventionTemplateContext = {
|
||||
templates: InterventionTemplate[];
|
||||
actorNames: Map<string, string>;
|
||||
};
|
||||
export interface IInterventionTemplateRepository {
|
||||
list(
|
||||
companyId: UniqueID | undefined
|
||||
): Promise<Result<InterventionTemplateContext, InterventionTemplateError>>;
|
||||
save(
|
||||
companyId: UniqueID | undefined,
|
||||
action: (
|
||||
templates: InterventionTemplate[]
|
||||
) => Result<InterventionTemplate, InterventionTemplateError>
|
||||
): Promise<
|
||||
Result<
|
||||
InterventionTemplateContext & { template: InterventionTemplate },
|
||||
InterventionTemplateError
|
||||
>
|
||||
>;
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
type SaveInterventionTemplateInput,
|
||||
SaveInterventionTemplateSchema,
|
||||
} from "../../common/intervention-templates";
|
||||
import { InterventionTemplate } from "../domain/intervention-template.aggregate";
|
||||
import { InterventionTemplateError } from "../domain/intervention-template-values";
|
||||
import { InterventionTemplateValues } from "../domain/value-objects/intervention-template-values";
|
||||
|
||||
import type { IInterventionTemplateRepository } from "./intervention-template.repository";
|
||||
|
||||
export class InterventionTemplateUseCases {
|
||||
constructor(private readonly repository: IInterventionTemplateRepository) {}
|
||||
async list(scope: string) {
|
||||
const company = scope === "global" ? undefined : UniqueID.create(scope);
|
||||
if (company && !company.isSuccess) {
|
||||
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
|
||||
}
|
||||
return this.repository.list(company?.data);
|
||||
}
|
||||
async save(
|
||||
scope: string,
|
||||
id: string | undefined,
|
||||
input: SaveInterventionTemplateInput,
|
||||
actorId: string
|
||||
) {
|
||||
const parsed = SaveInterventionTemplateSchema.safeParse(input);
|
||||
const company = scope === "global" ? undefined : UniqueID.create(scope);
|
||||
const actor = UniqueID.create(actorId);
|
||||
const templateId = id === undefined ? undefined : UniqueID.create(id);
|
||||
if (
|
||||
!parsed.success ||
|
||||
(company && !company.isSuccess) ||
|
||||
!actor.isSuccess ||
|
||||
(templateId && !templateId.isSuccess)
|
||||
) {
|
||||
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
|
||||
}
|
||||
const values = InterventionTemplateValues.create(parsed.data.values);
|
||||
const key = UniqueID.create(parsed.data.requestKey);
|
||||
if (!(values.isSuccess && key.isSuccess) || (!id && parsed.data.expectedRevisionId !== null)) {
|
||||
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
|
||||
}
|
||||
const scopeId = company?.data;
|
||||
return this.repository.save(scopeId, (templates) => {
|
||||
const previous = findScopedTemplate(templates, templateId?.data, scopeId);
|
||||
if (id) {
|
||||
if (!previous) {
|
||||
return Result.fail(new InterventionTemplateError("TEMPLATE_NOT_FOUND"));
|
||||
}
|
||||
if (
|
||||
previous.history.at(-1)?.id.toPrimitive() !==
|
||||
parsed.data.expectedRevisionId?.toLowerCase()
|
||||
) {
|
||||
return Result.fail(new InterventionTemplateError("TEMPLATE_CHANGED"));
|
||||
}
|
||||
} else {
|
||||
const replay = templates.find((item) => item.requestKey.equals(key.data));
|
||||
if (replay) {
|
||||
return replayTemplate(replay, scopeId, values.data, actor.data);
|
||||
}
|
||||
}
|
||||
if (hasDuplicateName(templates, scopeId, previous, values.data)) {
|
||||
return Result.fail(new InterventionTemplateError("TEMPLATE_NAME_EXISTS"));
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
return previous
|
||||
? previous.update(values.data, actor.data, now)
|
||||
: createTemplate(scopeId, key.data, values.data, actor.data, now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function replayTemplate(
|
||||
template: InterventionTemplate,
|
||||
companyId: UniqueID | undefined,
|
||||
values: InterventionTemplateValues,
|
||||
actorId: UniqueID
|
||||
): Result<InterventionTemplate, InterventionTemplateError> {
|
||||
const original = template.history[0];
|
||||
if (
|
||||
template.companyId.getOrUndefined()?.toPrimitive() !== companyId?.toPrimitive() ||
|
||||
!original?.values.equals(values) ||
|
||||
original.actorId.getOrUndefined()?.toPrimitive() !== actorId.toPrimitive()
|
||||
) {
|
||||
return Result.fail(new InterventionTemplateError("REQUEST_KEY_REUSED"));
|
||||
}
|
||||
return Result.ok(template);
|
||||
}
|
||||
|
||||
function hasDuplicateName(
|
||||
templates: InterventionTemplate[],
|
||||
companyId: UniqueID | undefined,
|
||||
previous: InterventionTemplate | undefined,
|
||||
values: InterventionTemplateValues
|
||||
) {
|
||||
const name = values.name.toPrimitive().toLocaleLowerCase();
|
||||
return templates.some(
|
||||
(item) =>
|
||||
item.id.toPrimitive() !== previous?.id.toPrimitive() &&
|
||||
item.companyId.getOrUndefined()?.toPrimitive() === companyId?.toPrimitive() &&
|
||||
item.values.name.toPrimitive().toLocaleLowerCase() === name
|
||||
);
|
||||
}
|
||||
|
||||
function findScopedTemplate(
|
||||
templates: InterventionTemplate[],
|
||||
id: UniqueID | undefined,
|
||||
companyId: UniqueID | undefined
|
||||
) {
|
||||
return templates.find(
|
||||
(item) =>
|
||||
item.id.toPrimitive() === id?.toPrimitive() &&
|
||||
item.companyId.getOrUndefined()?.toPrimitive() === companyId?.toPrimitive()
|
||||
);
|
||||
}
|
||||
|
||||
function createTemplate(
|
||||
companyId: UniqueID | undefined,
|
||||
key: UniqueID,
|
||||
values: InterventionTemplateValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
) {
|
||||
return InterventionTemplate.create(
|
||||
companyId ? Maybe.some(companyId) : Maybe.none(),
|
||||
key,
|
||||
values,
|
||||
Maybe.some(actorId),
|
||||
recordedAt
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { Intervention } from "../domain/intervention.aggregate";
|
||||
import type { InterventionTemplate } from "../domain/intervention-template.aggregate";
|
||||
import type { InterventionError } from "../domain/intervention-values";
|
||||
|
||||
import type { TicketContext, TicketLookups } from "./ticket.repository";
|
||||
export type InterventionContext = TicketContext & { interventions: readonly Intervention[] };
|
||||
export type InterventionLookups = {
|
||||
context: TicketContext;
|
||||
lookups: TicketLookups;
|
||||
templates: readonly InterventionTemplate[];
|
||||
templateActors: Map<string, string>;
|
||||
};
|
||||
export type InterventionSaveContext = InterventionContext &
|
||||
InterventionLookups & { nextNumber: number; requestIntervention: Intervention | undefined };
|
||||
export interface IInterventionRepository {
|
||||
list(company?: UniqueID): Promise<Result<InterventionContext, InterventionError>>;
|
||||
lookups(company: UniqueID): Promise<Result<InterventionLookups, InterventionError>>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
requestKey: UniqueID,
|
||||
action: (context: InterventionSaveContext) => Result<Intervention, InterventionError>
|
||||
): Promise<
|
||||
Result<{ intervention: Intervention; context: InterventionContext }, InterventionError>
|
||||
>;
|
||||
}
|
||||
148
modules/companies/src/api/application/intervention.use-cases.ts
Normal file
148
modules/companies/src/api/application/intervention.use-cases.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { SaveInterventionInput } from "../../common/interventions";
|
||||
import { SaveInterventionSchema } from "../../common/interventions";
|
||||
import { Intervention } from "../domain/intervention.aggregate";
|
||||
import { InterventionError } from "../domain/intervention-values";
|
||||
import { InterventionProcedure } from "../domain/value-objects/intervention-procedure";
|
||||
import { InterventionValues } from "../domain/value-objects/intervention-values";
|
||||
|
||||
import type { IInterventionRepository, InterventionSaveContext } from "./intervention.repository";
|
||||
import { mapInterventionInput } from "./intervention-input.mapper";
|
||||
export class InterventionUseCases {
|
||||
constructor(private readonly repository: IInterventionRepository) {}
|
||||
list(companyId?: string) {
|
||||
const company = companyId ? UniqueID.create(companyId) : undefined;
|
||||
if (company && !company.isSuccess) {
|
||||
return Promise.resolve(Result.fail(new InterventionError("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 InterventionError("INVALID_REQUEST")));
|
||||
}
|
||||
save(companyId: string, id: string | undefined, input: SaveInterventionInput, actorId: string) {
|
||||
const parsed = SaveInterventionSchema.safeParse(input);
|
||||
if (!parsed.success || (id && input.template !== null)) {
|
||||
return Promise.resolve(Result.fail(new InterventionError("INVALID_REQUEST")));
|
||||
}
|
||||
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 InterventionError("INVALID_REQUEST")));
|
||||
}
|
||||
const normalizedId = identity?.isSuccess ? identity.data.toPrimitive() : undefined;
|
||||
return this.repository.save(company.data, key.data, (context) => {
|
||||
const previous = context.interventions.find((item) => item.id.toPrimitive() === normalizedId);
|
||||
if (id && !previous) {
|
||||
return Result.fail(new InterventionError("INTERVENTION_NOT_FOUND"));
|
||||
}
|
||||
if (!id && context.requestIntervention) {
|
||||
return replayIntervention(context.requestIntervention, company.data, actor.data, input);
|
||||
}
|
||||
if (previous && previous.history.at(-1)?.id.toPrimitive() !== input.expectedRevisionId) {
|
||||
return Result.fail(new InterventionError("INTERVENTION_CHANGED"));
|
||||
}
|
||||
const values = mapInterventionInput(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);
|
||||
}
|
||||
const procedure = appliedProcedure(input, context);
|
||||
if (!procedure.isSuccess) {
|
||||
return Result.fail(procedure.error);
|
||||
}
|
||||
return createIntervention(
|
||||
company.data,
|
||||
key.data,
|
||||
values.data,
|
||||
actor.data,
|
||||
context.nextNumber,
|
||||
now,
|
||||
procedure.data
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function replayIntervention(
|
||||
original: Intervention,
|
||||
company: UniqueID,
|
||||
actor: UniqueID,
|
||||
input: SaveInterventionInput
|
||||
): Result<Intervention, InterventionError> {
|
||||
const values = InterventionValues.create(input.values);
|
||||
const first = original.history[0];
|
||||
if (
|
||||
!(
|
||||
sameProcedure(original, input) &&
|
||||
original.companyId.equals(company) &&
|
||||
first?.actorId.equals(actor) &&
|
||||
values.isSuccess &&
|
||||
first.values.equals(values.data)
|
||||
)
|
||||
) {
|
||||
return Result.fail(new InterventionError("REQUEST_KEY_REUSED"));
|
||||
}
|
||||
return Result.ok(original);
|
||||
}
|
||||
|
||||
function createIntervention(
|
||||
company: UniqueID,
|
||||
key: UniqueID,
|
||||
values: InterventionValues,
|
||||
actor: UniqueID,
|
||||
number: number,
|
||||
now: string,
|
||||
procedure: Maybe<InterventionProcedure>
|
||||
): Result<Intervention, InterventionError> {
|
||||
const reference = Name.create(`I-${String(number).padStart(6, "0")}`, 40);
|
||||
if (!reference.isSuccess) {
|
||||
return Result.fail(new InterventionError("INVALID_REQUEST"));
|
||||
}
|
||||
return Intervention.create(company, reference.data, key, values, procedure, actor, now);
|
||||
}
|
||||
|
||||
function sameProcedure(intervention: Intervention, input: SaveInterventionInput) {
|
||||
const source = intervention.procedure.getOrUndefined();
|
||||
return source
|
||||
? source.templateId.toPrimitive() === input.template?.id.toLowerCase() &&
|
||||
source.revisionId.toPrimitive() === input.template.revisionId.toLowerCase()
|
||||
: input.template === null;
|
||||
}
|
||||
function appliedProcedure(
|
||||
input: SaveInterventionInput,
|
||||
context: InterventionSaveContext
|
||||
): Result<Maybe<InterventionProcedure>, InterventionError> {
|
||||
if (!input.template) {
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
const template = context.templates.find(
|
||||
(item) => item.id.toPrimitive() === input.template?.id.toLowerCase()
|
||||
);
|
||||
if (!(template && template.values.active)) {
|
||||
return Result.fail(new InterventionError("TEMPLATE_UNAVAILABLE"));
|
||||
}
|
||||
if (template.history.at(-1)?.id.toPrimitive() !== input.template.revisionId.toLowerCase()) {
|
||||
return Result.fail(new InterventionError("TEMPLATE_CHANGED"));
|
||||
}
|
||||
const procedure = InterventionProcedure.create({
|
||||
templateId: template.id.toPrimitive(),
|
||||
revisionId: input.template.revisionId,
|
||||
values: template.values.toPrimitive(),
|
||||
});
|
||||
return procedure.isSuccess ? Result.ok(Maybe.some(procedure.data)) : Result.fail(procedure.error);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import type { InterventionValues } from "./value-objects/intervention-values";
|
||||
export type InterventionRevisionProps = Readonly<{
|
||||
values: InterventionValues;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
export class InterventionRevision extends DomainEntity<InterventionRevisionProps> {
|
||||
static create(props: InterventionRevisionProps) {
|
||||
return new InterventionRevision(Object.freeze({ ...props }));
|
||||
}
|
||||
static rehydrate(props: InterventionRevisionProps, id: UniqueID) {
|
||||
return new InterventionRevision(Object.freeze({ ...props }), id);
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get actorId() {
|
||||
return this.props.actorId;
|
||||
}
|
||||
get recordedAt() {
|
||||
return this.props.recordedAt;
|
||||
}
|
||||
}
|
||||
56
modules/companies/src/api/domain/intervention-step.entity.ts
Normal file
56
modules/companies/src/api/domain/intervention-step.entity.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { DomainEntity, Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
type InterventionCheckInput,
|
||||
InterventionError,
|
||||
InterventionStepSchema,
|
||||
} from "./intervention-values";
|
||||
|
||||
type Props = Readonly<{
|
||||
title: Name;
|
||||
instructions: string;
|
||||
required: boolean;
|
||||
state: InterventionCheckInput["state"];
|
||||
notes: string;
|
||||
}>;
|
||||
export class InterventionStep extends DomainEntity<Props> {
|
||||
private constructor(props: Props, id: UniqueID) {
|
||||
super(Object.freeze({ ...props }), id);
|
||||
}
|
||||
static create(input: InterventionCheckInput): Result<InterventionStep, InterventionError> {
|
||||
const parsed = InterventionStepSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new InterventionError("INVALID_REQUEST"));
|
||||
}
|
||||
const id = UniqueID.create(parsed.data.id);
|
||||
const title = Name.create(parsed.data.title, 200);
|
||||
if (!(id.isSuccess && title.isSuccess)) {
|
||||
return Result.fail(new InterventionError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(new InterventionStep({ ...parsed.data, title: title.data }, id.data));
|
||||
}
|
||||
static rehydrate(props: Props, id: UniqueID) {
|
||||
return new InterventionStep(props, id);
|
||||
}
|
||||
toPrimitive(): InterventionCheckInput {
|
||||
return {
|
||||
id: this.id.toPrimitive(),
|
||||
title: this.props.title.toPrimitive(),
|
||||
instructions: this.props.instructions,
|
||||
required: this.props.required,
|
||||
state: this.props.state,
|
||||
notes: this.props.notes,
|
||||
};
|
||||
}
|
||||
equals(other: InterventionStep) {
|
||||
return (
|
||||
this.id.equals(other.id) &&
|
||||
this.props.title.equals(other.props.title) &&
|
||||
this.props.instructions === other.props.instructions &&
|
||||
this.props.required === other.props.required &&
|
||||
this.props.state === other.props.state &&
|
||||
this.props.notes === other.props.notes
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
import type { InterventionTemplateValues } from "./value-objects/intervention-template-values";
|
||||
|
||||
type Props = Readonly<{
|
||||
values: InterventionTemplateValues;
|
||||
actorId: Maybe<UniqueID>;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
export class InterventionTemplateRevision extends DomainEntity<Props> {
|
||||
static create(props: Props) {
|
||||
return new InterventionTemplateRevision(Object.freeze({ ...props }));
|
||||
}
|
||||
static rehydrate(props: Props, id: UniqueID) {
|
||||
return new InterventionTemplateRevision(Object.freeze({ ...props }), id);
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get actorId() {
|
||||
return this.props.actorId;
|
||||
}
|
||||
get recordedAt() {
|
||||
return this.props.recordedAt;
|
||||
}
|
||||
}
|
||||
101
modules/companies/src/api/domain/intervention-template-seed.ts
Normal file
101
modules/companies/src/api/domain/intervention-template-seed.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
import { InterventionTemplate } from "./intervention-template.aggregate";
|
||||
import { InterventionTemplateValues } from "./value-objects/intervention-template-values";
|
||||
|
||||
const definitions = [
|
||||
[
|
||||
"Alta de persona",
|
||||
[
|
||||
"Revisar las necesidades y el perfil de usuario",
|
||||
"Asignar y preparar los equipos",
|
||||
"Configurar cuentas y accesos autorizados",
|
||||
"Configurar correos, aplicaciones e impresoras",
|
||||
"Comprobar el funcionamiento con la persona",
|
||||
],
|
||||
],
|
||||
[
|
||||
"Baja de persona",
|
||||
[
|
||||
"Revisar cuentas, recursos y dispositivos utilizados",
|
||||
"Retirar accesos, incluidos los configurados en dispositivos externos",
|
||||
"Preservar o transferir los datos según las indicaciones de la compañía",
|
||||
"Recuperar activos y cerrar asignaciones",
|
||||
"Comprobar que no quedan accesos pendientes",
|
||||
],
|
||||
],
|
||||
[
|
||||
"Sustitución de equipo",
|
||||
[
|
||||
"Revisar el perfil de usuario y sus necesidades",
|
||||
"Comprobar la copia de datos del equipo anterior",
|
||||
"Configurar el inicio de sesión",
|
||||
"Configurar correos, aplicaciones, carpetas e impresoras",
|
||||
"Revisar certificados y conectividad remota",
|
||||
"Validar con la persona y actualizar asignaciones",
|
||||
],
|
||||
],
|
||||
[
|
||||
"Configuración de correo",
|
||||
[
|
||||
"Confirmar buzones y autorizaciones",
|
||||
"Configurar cada cuenta y su método de acceso",
|
||||
"Comprobar envío y recepción",
|
||||
"Revisar carpetas y conservación de mensajes",
|
||||
"Actualizar la configuración registrada",
|
||||
],
|
||||
],
|
||||
[
|
||||
"Preparación de portátil para teletrabajo",
|
||||
[
|
||||
"Comprobar la autorización y el equipo",
|
||||
"Revisar actualizaciones y protección del equipo",
|
||||
"Configurar la conectividad remota necesaria",
|
||||
"Probar los recursos autorizados desde el exterior",
|
||||
"Actualizar la documentación del perfil",
|
||||
],
|
||||
],
|
||||
[
|
||||
"Revisión periódica de equipo",
|
||||
[
|
||||
"Revisar actualizaciones y antivirus",
|
||||
"Comprobar almacenamiento y estado general",
|
||||
"Revisar copias de seguridad y cifrado",
|
||||
"Comprobar aplicaciones y periféricos",
|
||||
"Registrar incidencias y trabajo realizado",
|
||||
],
|
||||
],
|
||||
] as const;
|
||||
/** Las claves estables hacen que un reinicio no reponga plantillas editadas o desactivadas. */
|
||||
export function interventionTemplateSeeds() {
|
||||
return definitions.map(([name, titles], index) => {
|
||||
const key = UniqueID.create(`019a0000-0000-7000-8000-${String(index + 1).padStart(12, "0")}`);
|
||||
const values = InterventionTemplateValues.create({
|
||||
name,
|
||||
description:
|
||||
"Procedimiento orientativo. Adapta los pasos a las necesidades y autorizaciones de la compañía.",
|
||||
active: true,
|
||||
steps: titles.map((title) => ({
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title,
|
||||
instructions: "",
|
||||
required: false,
|
||||
})),
|
||||
});
|
||||
if (!(key.isSuccess && values.isSuccess)) {
|
||||
throw new Error("Invalid intervention template seed");
|
||||
}
|
||||
const result = InterventionTemplate.create(
|
||||
Maybe.none(),
|
||||
key.data,
|
||||
values.data,
|
||||
Maybe.none(),
|
||||
"2026-10-07T00:00:00.000Z"
|
||||
);
|
||||
if (!result.isSuccess) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.data;
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { DomainEntity, Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
type InterventionStepInput,
|
||||
InterventionStepInputSchema,
|
||||
InterventionTemplateError,
|
||||
} from "./intervention-template-values";
|
||||
|
||||
type Props = Readonly<{ title: Name; instructions: string; required: boolean }>;
|
||||
export class InterventionTemplateStep extends DomainEntity<Props> {
|
||||
private constructor(props: Props, id: UniqueID) {
|
||||
super(Object.freeze({ ...props }), id);
|
||||
}
|
||||
static create(
|
||||
input: InterventionStepInput
|
||||
): Result<InterventionTemplateStep, InterventionTemplateError> {
|
||||
const parsed = InterventionStepInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
|
||||
}
|
||||
const title = Name.create(parsed.data.title, 200);
|
||||
const id = UniqueID.create(parsed.data.id);
|
||||
if (!(title.isSuccess && id.isSuccess)) {
|
||||
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new InterventionTemplateStep(
|
||||
{
|
||||
title: title.data,
|
||||
instructions: parsed.data.instructions,
|
||||
required: parsed.data.required,
|
||||
},
|
||||
id.data
|
||||
)
|
||||
);
|
||||
}
|
||||
static rehydrate(props: Props, id: UniqueID) {
|
||||
return new InterventionTemplateStep(props, id);
|
||||
}
|
||||
equals(other: InterventionTemplateStep) {
|
||||
return (
|
||||
this.id.equals(other.id) &&
|
||||
this.props.title.equals(other.props.title) &&
|
||||
this.props.instructions === other.props.instructions &&
|
||||
this.props.required === other.props.required
|
||||
);
|
||||
}
|
||||
toPrimitive(): InterventionStepInput {
|
||||
return {
|
||||
id: this.id.toPrimitive(),
|
||||
title: this.props.title.toPrimitive(),
|
||||
instructions: this.props.instructions,
|
||||
required: this.props.required,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export class InterventionTemplateError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
export const InterventionStepInputSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
instructions: z.string().trim().max(4000),
|
||||
required: z.boolean(),
|
||||
});
|
||||
export const InterventionTemplateInputSchema = z.strictObject({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
description: z.string().trim().max(4000),
|
||||
active: z.boolean(),
|
||||
steps: z
|
||||
.array(InterventionStepInputSchema)
|
||||
.min(1)
|
||||
.max(100)
|
||||
.refine((steps) => new Set(steps.map((step) => step.id.toLowerCase())).size === steps.length),
|
||||
});
|
||||
export type InterventionStepInput = z.infer<typeof InterventionStepInputSchema>;
|
||||
export type InterventionTemplateInput = z.infer<typeof InterventionTemplateInputSchema>;
|
||||
@ -0,0 +1,85 @@
|
||||
import { AggregateRoot, type UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { InterventionTemplateRevision } from "./intervention-template-revision.entity";
|
||||
import { InterventionTemplateError } from "./intervention-template-values";
|
||||
import type { InterventionTemplateValues } from "./value-objects/intervention-template-values";
|
||||
|
||||
type Props = Readonly<{
|
||||
companyId: Maybe<UniqueID>;
|
||||
requestKey: UniqueID;
|
||||
values: InterventionTemplateValues;
|
||||
history: readonly InterventionTemplateRevision[];
|
||||
}>;
|
||||
export class InterventionTemplate extends AggregateRoot<Props> {
|
||||
private constructor(props: Props, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||
}
|
||||
static create(
|
||||
companyId: Maybe<UniqueID>,
|
||||
requestKey: UniqueID,
|
||||
values: InterventionTemplateValues,
|
||||
actorId: Maybe<UniqueID>,
|
||||
recordedAt: string
|
||||
): Result<InterventionTemplate, InterventionTemplateError> {
|
||||
if (!z.iso.datetime().safeParse(recordedAt).success) {
|
||||
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new InterventionTemplate({
|
||||
companyId,
|
||||
requestKey,
|
||||
values,
|
||||
history: [InterventionTemplateRevision.create({ values, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: Props, id: UniqueID) {
|
||||
return new InterventionTemplate(props, id);
|
||||
}
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
get requestKey() {
|
||||
return this.props.requestKey;
|
||||
}
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
get history() {
|
||||
return this.props.history;
|
||||
}
|
||||
update(
|
||||
values: InterventionTemplateValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<InterventionTemplate, InterventionTemplateError> {
|
||||
if (
|
||||
!z.iso.datetime().safeParse(recordedAt).success ||
|
||||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
|
||||
) {
|
||||
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
|
||||
}
|
||||
if (this.values.equals(values)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
return Result.ok(
|
||||
new InterventionTemplate(
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [
|
||||
...this.history,
|
||||
InterventionTemplateRevision.create({
|
||||
values,
|
||||
actorId: Maybe.some(actorId),
|
||||
recordedAt,
|
||||
}),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
172
modules/companies/src/api/domain/intervention-template.test.ts
Normal file
172
modules/companies/src/api/domain/intervention-template.test.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
import { SaveInterventionTemplateSchema } from "../../common/intervention-templates";
|
||||
import { InterventionTemplatePersistenceMapper } from "../infrastructure/persistence/intervention-template-persistence.mapper";
|
||||
|
||||
import { InterventionTemplate } from "./intervention-template.aggregate";
|
||||
import { interventionTemplateSeeds } from "./intervention-template-seed";
|
||||
import {
|
||||
type InterventionTemplateInput,
|
||||
InterventionTemplateInputSchema,
|
||||
} from "./intervention-template-values";
|
||||
import { InterventionTemplateValues } from "./value-objects/intervention-template-values";
|
||||
|
||||
const actor = UniqueID.generateNewID();
|
||||
const date = "2026-10-07T10:00:00.000Z";
|
||||
function input(): InterventionTemplateInput {
|
||||
return {
|
||||
name: " Preparar equipo ",
|
||||
description: "",
|
||||
active: true,
|
||||
steps: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: " Copiar datos ",
|
||||
instructions: "",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: "Comprobar correo",
|
||||
instructions: "Probar todas las cuentas",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function values(data = input()) {
|
||||
const result = InterventionTemplateValues.create(data);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
function template() {
|
||||
const result = InterventionTemplate.create(
|
||||
Maybe.none(),
|
||||
UniqueID.generateNewID(),
|
||||
values(),
|
||||
Maybe.some(actor),
|
||||
date
|
||||
);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
test("plantillas de intervención: límites, pasos únicos y entrada estricta", () => {
|
||||
const data = input();
|
||||
assert.equal(InterventionTemplateInputSchema.parse(data).name, "Preparar equipo");
|
||||
for (const invalid of [
|
||||
{ ...data, name: "" },
|
||||
{ ...data, name: "x".repeat(161) },
|
||||
{ ...data, steps: [] },
|
||||
{ ...data, steps: [data.steps[0], data.steps[0]] },
|
||||
{ ...data, unknown: true },
|
||||
]) {
|
||||
assert.equal(InterventionTemplateInputSchema.safeParse(invalid).success, false);
|
||||
}
|
||||
assert.equal(
|
||||
InterventionTemplateInputSchema.safeParse({
|
||||
...data,
|
||||
steps: Array.from({ length: 100 }, () => ({
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: "Paso",
|
||||
instructions: "",
|
||||
required: false,
|
||||
})),
|
||||
}).success,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
InterventionTemplateInputSchema.safeParse({
|
||||
...data,
|
||||
steps: Array.from({ length: 101 }, () => ({
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: "Paso",
|
||||
instructions: "",
|
||||
required: false,
|
||||
})),
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
SaveInterventionTemplateSchema.safeParse({
|
||||
values: data,
|
||||
requestKey: UniqueID.generateNewID().toPrimitive(),
|
||||
expectedRevisionId: null,
|
||||
extra: 1,
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("plantillas de intervención: identidad v7, no-op, orden e historial inmutable", () => {
|
||||
const original = template();
|
||||
assert.equal(original.id.toPrimitive()[14], "7");
|
||||
assert.equal(original.history[0]?.id.toPrimitive()[14], "7");
|
||||
assert.ok(original.values.steps.every((step) => step.id.toPrimitive()[14] === "7"));
|
||||
const same = original.update(values(original.values.toPrimitive()), actor, date);
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, original);
|
||||
const changed = original.update(
|
||||
values({
|
||||
...original.values.toPrimitive(),
|
||||
active: false,
|
||||
steps: [...original.values.toPrimitive().steps].reverse(),
|
||||
}),
|
||||
actor,
|
||||
"2026-10-07T11:00:00.000Z"
|
||||
);
|
||||
assert.ok(changed.isSuccess);
|
||||
assert.equal(changed.data.id.toPrimitive(), original.id.toPrimitive());
|
||||
assert.equal(changed.data.history.length, 2);
|
||||
assert.equal(original.history.length, 1);
|
||||
assert.equal(changed.data.history[0]?.values.active, true);
|
||||
assert.equal(changed.data.history[1]?.values.active, false);
|
||||
assert.equal(original.update(original.values, actor, "invalid").isSuccess, false);
|
||||
assert.equal(
|
||||
original.update(original.values, actor, "2026-10-06T00:00:00.000Z").isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("plantillas de intervención: mapper conserva pasos, revisiones y ámbito", () => {
|
||||
const original = template();
|
||||
const mapper = new InterventionTemplatePersistenceMapper();
|
||||
const persisted = mapper.toPersistence(original);
|
||||
const rehydrated = mapper.toDomain(persisted.root, persisted.revisions);
|
||||
assert.ok(rehydrated.values.equals(original.values));
|
||||
assert.equal(rehydrated.history[0]?.id.toPrimitive(), original.history[0]?.id.toPrimitive());
|
||||
assert.throws(() => mapper.toDomain(persisted.root, []));
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
{
|
||||
...persisted.root,
|
||||
values: JSON.stringify({ ...original.values.toPrimitive(), active: false }),
|
||||
},
|
||||
persisted.revisions
|
||||
)
|
||||
);
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
persisted.root,
|
||||
persisted.revisions.map((revision) => ({ ...revision, position: 2 }))
|
||||
)
|
||||
);
|
||||
});
|
||||
test("plantillas de intervención: seis procedimientos iniciales sin datos de compañías", () => {
|
||||
const seeds = interventionTemplateSeeds();
|
||||
const again = interventionTemplateSeeds();
|
||||
assert.equal(seeds.length, 6);
|
||||
assert.deepEqual(
|
||||
seeds.map((item) => item.requestKey.toPrimitive()),
|
||||
again.map((item) => item.requestKey.toPrimitive())
|
||||
);
|
||||
assert.ok(
|
||||
seeds.every(
|
||||
(item) =>
|
||||
item.companyId.getOrUndefined() === undefined &&
|
||||
item.history[0]?.actorId.getOrUndefined() === undefined &&
|
||||
item.values.active
|
||||
)
|
||||
);
|
||||
});
|
||||
73
modules/companies/src/api/domain/intervention-values.ts
Normal file
73
modules/companies/src/api/domain/intervention-values.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { TicketTargetSchema } from "./ticket-values";
|
||||
export const InterventionStateSchema = z.enum(["pending", "in_progress", "finished", "cancelled"]);
|
||||
export const InterventionModalitySchema = z.enum(["remote", "onsite", "other"]);
|
||||
export const InterventionStepStateSchema = z.enum([
|
||||
"pending",
|
||||
"in_progress",
|
||||
"done",
|
||||
"not_applicable",
|
||||
]);
|
||||
export const InterventionStepSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
instructions: z.string().trim().max(4000),
|
||||
required: z.boolean(),
|
||||
state: InterventionStepStateSchema,
|
||||
notes: z.string().trim().max(4000),
|
||||
});
|
||||
export const InterventionInputSchema = z
|
||||
.strictObject({
|
||||
subject: z.string().trim().min(1).max(200),
|
||||
ticketId: z.uuid().nullable(),
|
||||
assigneeId: z.uuid().nullable(),
|
||||
state: InterventionStateSchema,
|
||||
modality: InterventionModalitySchema,
|
||||
plannedDate: z.iso.date().nullable(),
|
||||
startedAt: z.iso.datetime().nullable(),
|
||||
endedAt: z.iso.datetime().nullable(),
|
||||
minutes: z.number().int().min(0).max(1000000).nullable(),
|
||||
diagnosis: z.string().trim().max(10000),
|
||||
workPerformed: z.string().trim().max(10000),
|
||||
resolution: z.string().trim().max(10000),
|
||||
affected: z.array(TicketTargetSchema).max(200),
|
||||
steps: z.array(InterventionStepSchema).max(100),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
new Set(value.affected.map((item) => `${item.kind}:${item.targetId.toLowerCase()}`)).size !==
|
||||
value.affected.length
|
||||
) {
|
||||
ctx.addIssue({ code: "custom", path: ["affected"], message: "Duplicate targets" });
|
||||
}
|
||||
if (new Set(value.steps.map((item) => item.id.toLowerCase())).size !== value.steps.length) {
|
||||
ctx.addIssue({ code: "custom", path: ["steps"], message: "Duplicate steps" });
|
||||
}
|
||||
if (value.startedAt && value.endedAt && value.endedAt < value.startedAt) {
|
||||
ctx.addIssue({ code: "custom", path: ["endedAt"], message: "End before start" });
|
||||
}
|
||||
if ((value.state === "in_progress" || value.state === "finished") && !value.assigneeId) {
|
||||
ctx.addIssue({ code: "custom", path: ["assigneeId"], message: "Technician required" });
|
||||
}
|
||||
if (value.state === "finished" && !value.workPerformed) {
|
||||
ctx.addIssue({ code: "custom", path: ["workPerformed"], message: "Work required" });
|
||||
}
|
||||
if (
|
||||
value.state === "finished" &&
|
||||
value.steps.some(
|
||||
(step) =>
|
||||
step.required &&
|
||||
!(step.state === "done" || (step.state === "not_applicable" && step.notes))
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({ code: "custom", path: ["steps"], message: "Required checks pending" });
|
||||
}
|
||||
});
|
||||
export type InterventionInput = z.infer<typeof InterventionInputSchema>;
|
||||
export type InterventionCheckInput = z.infer<typeof InterventionStepSchema>;
|
||||
export class InterventionError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
91
modules/companies/src/api/domain/intervention.aggregate.ts
Normal file
91
modules/companies/src/api/domain/intervention.aggregate.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { AggregateRoot, type Name, type UniqueID } from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { InterventionRevision } from "./intervention-revision.entity";
|
||||
import { InterventionError } from "./intervention-values";
|
||||
import type { InterventionProcedure } from "./value-objects/intervention-procedure";
|
||||
import type { InterventionValues } from "./value-objects/intervention-values";
|
||||
|
||||
type InterventionProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
procedure: Maybe<InterventionProcedure>;
|
||||
reference: Name;
|
||||
requestKey: UniqueID;
|
||||
values: InterventionValues;
|
||||
history: readonly InterventionRevision[];
|
||||
}>;
|
||||
export class Intervention extends AggregateRoot<InterventionProps> {
|
||||
private constructor(props: InterventionProps, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||
}
|
||||
static create(
|
||||
companyId: UniqueID,
|
||||
reference: Name,
|
||||
requestKey: UniqueID,
|
||||
values: InterventionValues,
|
||||
procedure: Maybe<InterventionProcedure>,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<Intervention, InterventionError> {
|
||||
if (!z.iso.datetime().safeParse(recordedAt).success) {
|
||||
return Result.fail(new InterventionError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(
|
||||
new Intervention({
|
||||
companyId,
|
||||
procedure,
|
||||
reference,
|
||||
requestKey,
|
||||
values,
|
||||
history: [InterventionRevision.create({ values, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
static rehydrate(props: InterventionProps, id: UniqueID) {
|
||||
return new Intervention(props, id);
|
||||
}
|
||||
get procedure() {
|
||||
return this.props.procedure;
|
||||
}
|
||||
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: InterventionValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
): Result<Intervention, InterventionError> {
|
||||
if (
|
||||
!z.iso.datetime().safeParse(recordedAt).success ||
|
||||
recordedAt < (this.history.at(-1)?.recordedAt ?? "")
|
||||
) {
|
||||
return Result.fail(new InterventionError("INVALID_REQUEST"));
|
||||
}
|
||||
if (this.values.equals(values)) {
|
||||
return Result.ok(this);
|
||||
}
|
||||
return Result.ok(
|
||||
new Intervention(
|
||||
{
|
||||
...this.props,
|
||||
values,
|
||||
history: [...this.history, InterventionRevision.create({ values, actorId, recordedAt })],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
213
modules/companies/src/api/domain/intervention.test.ts
Normal file
213
modules/companies/src/api/domain/intervention.test.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
import { InterventionPersistenceMapper } from "../infrastructure/persistence/intervention-persistence.mapper";
|
||||
|
||||
import { Intervention } from "./intervention.aggregate";
|
||||
import { type InterventionInput, InterventionInputSchema } from "./intervention-values";
|
||||
import { InterventionProcedure } from "./value-objects/intervention-procedure";
|
||||
import { InterventionValues } from "./value-objects/intervention-values";
|
||||
|
||||
const actor = UniqueID.generateNewID();
|
||||
function input(): InterventionInput {
|
||||
return {
|
||||
subject: "Revisar impresora",
|
||||
ticketId: null,
|
||||
assigneeId: null,
|
||||
state: "pending",
|
||||
modality: "onsite",
|
||||
plannedDate: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
minutes: null,
|
||||
diagnosis: "",
|
||||
workPerformed: "",
|
||||
resolution: "",
|
||||
affected: [],
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
function values(data = input()) {
|
||||
const result = InterventionValues.create(data);
|
||||
assert.ok(result.isSuccess);
|
||||
return result.data;
|
||||
}
|
||||
function intervention() {
|
||||
const name = Name.create("I-000001", 40);
|
||||
assert.ok(name.isSuccess);
|
||||
const created = Intervention.create(
|
||||
UniqueID.generateNewID(),
|
||||
name.data,
|
||||
UniqueID.generateNewID(),
|
||||
values(),
|
||||
Maybe.none(),
|
||||
actor,
|
||||
"2026-10-07T10:00:00.000Z"
|
||||
);
|
||||
assert.ok(created.isSuccess);
|
||||
return created.data;
|
||||
}
|
||||
test("intervenciones: actuación sencilla y finalización mínima", () => {
|
||||
assert.equal(InterventionInputSchema.safeParse(input()).success, true);
|
||||
const done = {
|
||||
...input(),
|
||||
state: "finished",
|
||||
assigneeId: actor.toPrimitive(),
|
||||
workPerformed: "Atasco resuelto",
|
||||
};
|
||||
assert.equal(InterventionInputSchema.safeParse(done).success, true);
|
||||
assert.equal(InterventionInputSchema.safeParse({ ...done, assigneeId: null }).success, false);
|
||||
assert.equal(InterventionInputSchema.safeParse({ ...done, workPerformed: " " }).success, false);
|
||||
assert.equal(
|
||||
InterventionInputSchema.safeParse({ ...input(), state: "in_progress" }).success,
|
||||
false
|
||||
);
|
||||
assert.equal(values().toPrimitive().minutes, null);
|
||||
});
|
||||
test("intervenciones: comprobaciones obligatorias y no aplica justificado", () => {
|
||||
const step = {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: "Comprobar copia",
|
||||
instructions: "",
|
||||
required: true,
|
||||
state: "pending",
|
||||
notes: "",
|
||||
};
|
||||
const done = {
|
||||
...input(),
|
||||
state: "finished",
|
||||
assigneeId: actor.toPrimitive(),
|
||||
workPerformed: "Revisado",
|
||||
steps: [step],
|
||||
};
|
||||
assert.equal(InterventionInputSchema.safeParse(done).success, false);
|
||||
assert.equal(
|
||||
InterventionInputSchema.safeParse({ ...done, steps: [{ ...step, state: "done" }] }).success,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
InterventionInputSchema.safeParse({ ...done, steps: [{ ...step, state: "not_applicable" }] })
|
||||
.success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
InterventionInputSchema.safeParse({
|
||||
...done,
|
||||
steps: [{ ...step, state: "not_applicable", notes: "No almacena datos" }],
|
||||
}).success,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
InterventionInputSchema.safeParse({ ...done, steps: [{ ...step, required: false }] }).success,
|
||||
true
|
||||
);
|
||||
});
|
||||
test("intervenciones: límites, fechas, minutos y entrada estricta", () => {
|
||||
for (const data of [
|
||||
{ ...input(), minutes: -1 },
|
||||
{ ...input(), minutes: 1.5 },
|
||||
{ ...input(), extra: true },
|
||||
{ ...input(), startedAt: "2026-10-07T12:00:00.000Z", endedAt: "2026-10-07T11:00:00.000Z" },
|
||||
{ ...input(), subject: "x".repeat(201) },
|
||||
]) {
|
||||
assert.equal(InterventionInputSchema.safeParse(data).success, false);
|
||||
}
|
||||
assert.equal(InterventionInputSchema.safeParse({ ...input(), minutes: 0 }).success, true);
|
||||
const step = {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: "Paso",
|
||||
instructions: "",
|
||||
required: false,
|
||||
state: "pending",
|
||||
notes: "",
|
||||
};
|
||||
assert.equal(
|
||||
InterventionInputSchema.safeParse({ ...input(), steps: [step, step] }).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("intervenciones: identidad, no-op e historial inmutable", () => {
|
||||
const original = intervention();
|
||||
assert.equal(original.id.toPrimitive()[14], "7");
|
||||
const same = original.update(
|
||||
values(original.values.toPrimitive()),
|
||||
actor,
|
||||
"2026-10-07T11:00:00.000Z"
|
||||
);
|
||||
assert.ok(same.isSuccess);
|
||||
assert.equal(same.data, original);
|
||||
const changed = original.update(
|
||||
values({ ...input(), diagnosis: "Papel atascado" }),
|
||||
actor,
|
||||
"2026-10-07T11:00:00.000Z"
|
||||
);
|
||||
assert.ok(changed.isSuccess);
|
||||
assert.equal(changed.data.history.length, 2);
|
||||
assert.equal(original.history.length, 1);
|
||||
assert.equal(changed.data.id.toPrimitive(), original.id.toPrimitive());
|
||||
assert.equal(changed.data.history[0]?.values.toPrimitive().diagnosis, "");
|
||||
assert.equal(
|
||||
original.update(original.values, actor, "2026-10-06T00:00:00.000Z").isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
test("intervenciones: reconstrucción e historial coherente", () => {
|
||||
const original = intervention();
|
||||
const mapper = new InterventionPersistenceMapper();
|
||||
const rows = mapper.toPersistence(original);
|
||||
const restored = mapper.toDomain(rows.root, rows.revisions);
|
||||
assert.ok(restored.values.equals(original.values));
|
||||
assert.equal(restored.history[0]?.id.toPrimitive(), original.history[0]?.id.toPrimitive());
|
||||
assert.throws(() => mapper.toDomain(rows.root, []));
|
||||
assert.throws(() =>
|
||||
mapper.toDomain({ ...rows.root, assigneeId: actor.toPrimitive() }, rows.revisions)
|
||||
);
|
||||
assert.throws(() =>
|
||||
mapper.toDomain(
|
||||
rows.root,
|
||||
rows.revisions.map((row) => ({ ...row, position: 3 }))
|
||||
)
|
||||
);
|
||||
});
|
||||
test("intervenciones: procedimiento inmutable y versionado", () => {
|
||||
const procedure = InterventionProcedure.create({
|
||||
templateId: UniqueID.generateNewID().toPrimitive(),
|
||||
revisionId: UniqueID.generateNewID().toPrimitive(),
|
||||
values: {
|
||||
name: "Preparar equipo",
|
||||
description: "",
|
||||
active: true,
|
||||
steps: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: "Comprobar",
|
||||
instructions: "",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.ok(procedure.isSuccess);
|
||||
const original = intervention();
|
||||
const snapshot = Intervention.rehydrate(
|
||||
{
|
||||
companyId: original.companyId,
|
||||
requestKey: original.requestKey,
|
||||
reference: original.reference,
|
||||
values: original.values,
|
||||
history: original.history,
|
||||
procedure: Maybe.some(procedure.data),
|
||||
},
|
||||
original.id
|
||||
);
|
||||
const mapper = new InterventionPersistenceMapper();
|
||||
const rows = mapper.toPersistence(snapshot);
|
||||
const restored = mapper.toDomain(rows.root, rows.revisions);
|
||||
assert.deepEqual(
|
||||
restored.procedure.getOrUndefined()?.toPrimitive(),
|
||||
procedure.data.toPrimitive()
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { InterventionTemplateInput } from "../intervention-template-values";
|
||||
import { InterventionError } from "../intervention-values";
|
||||
|
||||
import { InterventionTemplateValues } from "./intervention-template-values";
|
||||
export class InterventionProcedure {
|
||||
private constructor(
|
||||
public readonly templateId: UniqueID,
|
||||
public readonly revisionId: UniqueID,
|
||||
public readonly values: InterventionTemplateValues
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(input: {
|
||||
templateId: string;
|
||||
revisionId: string;
|
||||
values: InterventionTemplateInput;
|
||||
}): Result<InterventionProcedure, InterventionError> {
|
||||
const template = UniqueID.create(input.templateId);
|
||||
const revision = UniqueID.create(input.revisionId);
|
||||
const values = InterventionTemplateValues.create(input.values);
|
||||
if (!(template.isSuccess && revision.isSuccess && values.isSuccess)) {
|
||||
return Result.fail(new InterventionError("INVALID_REQUEST"));
|
||||
}
|
||||
return Result.ok(new InterventionProcedure(template.data, revision.data, values.data));
|
||||
}
|
||||
toPrimitive() {
|
||||
return {
|
||||
templateId: this.templateId.toPrimitive(),
|
||||
revisionId: this.revisionId.toPrimitive(),
|
||||
values: this.values.toPrimitive(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import { Name } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { InterventionTemplateStep } from "../intervention-template-step.entity";
|
||||
import {
|
||||
InterventionTemplateError,
|
||||
type InterventionTemplateInput,
|
||||
InterventionTemplateInputSchema,
|
||||
} from "../intervention-template-values";
|
||||
|
||||
export class InterventionTemplateValues {
|
||||
private constructor(
|
||||
public readonly name: Name,
|
||||
public readonly description: string,
|
||||
public readonly active: boolean,
|
||||
public readonly steps: readonly InterventionTemplateStep[]
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(
|
||||
input: InterventionTemplateInput
|
||||
): Result<InterventionTemplateValues, InterventionTemplateError> {
|
||||
const parsed = InterventionTemplateInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
|
||||
}
|
||||
const name = Name.create(parsed.data.name, 160);
|
||||
if (!name.isSuccess) {
|
||||
return Result.fail(new InterventionTemplateError("INVALID_REQUEST"));
|
||||
}
|
||||
const steps: InterventionTemplateStep[] = [];
|
||||
for (const inputStep of parsed.data.steps) {
|
||||
const step = InterventionTemplateStep.create(inputStep);
|
||||
if (!step.isSuccess) {
|
||||
return Result.fail(step.error);
|
||||
}
|
||||
steps.push(step.data);
|
||||
}
|
||||
return Result.ok(
|
||||
new InterventionTemplateValues(
|
||||
name.data,
|
||||
parsed.data.description,
|
||||
parsed.data.active,
|
||||
Object.freeze(steps)
|
||||
)
|
||||
);
|
||||
}
|
||||
equals(other: InterventionTemplateValues) {
|
||||
return (
|
||||
this.name.equals(other.name) &&
|
||||
this.description === other.description &&
|
||||
this.active === other.active &&
|
||||
this.steps.length === other.steps.length &&
|
||||
this.steps.every((step, index) => {
|
||||
const value = other.steps[index];
|
||||
return value !== undefined && step.equals(value);
|
||||
})
|
||||
);
|
||||
}
|
||||
toPrimitive(): InterventionTemplateInput {
|
||||
return {
|
||||
name: this.name.toPrimitive(),
|
||||
description: this.description,
|
||||
active: this.active,
|
||||
steps: this.steps.map((step) => step.toPrimitive()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { InterventionStep } from "../intervention-step.entity";
|
||||
import {
|
||||
InterventionError,
|
||||
type InterventionInput,
|
||||
InterventionInputSchema,
|
||||
} from "../intervention-values";
|
||||
|
||||
import { TicketTarget } from "./ticket-values";
|
||||
|
||||
type Data = Omit<InterventionInput, "subject" | "ticketId" | "assigneeId" | "affected" | "steps">;
|
||||
const keys: readonly (keyof Data)[] = [
|
||||
"state",
|
||||
"modality",
|
||||
"plannedDate",
|
||||
"startedAt",
|
||||
"endedAt",
|
||||
"minutes",
|
||||
"diagnosis",
|
||||
"workPerformed",
|
||||
"resolution",
|
||||
];
|
||||
function optionalId(input: string | null): Result<Maybe<UniqueID>, InterventionError> {
|
||||
if (input === null) {
|
||||
return Result.ok(Maybe.none());
|
||||
}
|
||||
const result = UniqueID.create(input);
|
||||
return result.isSuccess
|
||||
? Result.ok(Maybe.some(result.data))
|
||||
: Result.fail(new InterventionError("INVALID_REQUEST"));
|
||||
}
|
||||
export class InterventionValues {
|
||||
private constructor(
|
||||
public readonly subject: Name,
|
||||
public readonly ticketId: Maybe<UniqueID>,
|
||||
public readonly assigneeId: Maybe<UniqueID>,
|
||||
private readonly data: Data,
|
||||
public readonly affected: readonly TicketTarget[],
|
||||
public readonly steps: readonly InterventionStep[]
|
||||
) {
|
||||
Object.freeze(data);
|
||||
Object.freeze(affected);
|
||||
Object.freeze(steps);
|
||||
Object.freeze(this);
|
||||
}
|
||||
static create(input: InterventionInput): Result<InterventionValues, InterventionError> {
|
||||
const parsed = InterventionInputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new InterventionError("INVALID_REQUEST"));
|
||||
}
|
||||
const name = Name.create(parsed.data.subject, 200);
|
||||
const ticket = optionalId(parsed.data.ticketId);
|
||||
const assignee = optionalId(parsed.data.assigneeId);
|
||||
if (!(name.isSuccess && ticket.isSuccess && assignee.isSuccess)) {
|
||||
return Result.fail(new InterventionError("INVALID_REQUEST"));
|
||||
}
|
||||
const targets: TicketTarget[] = [];
|
||||
for (const item of parsed.data.affected) {
|
||||
const target = TicketTarget.create(item);
|
||||
if (!target.isSuccess) {
|
||||
return Result.fail(new InterventionError(target.error.code));
|
||||
}
|
||||
targets.push(target.data);
|
||||
}
|
||||
const steps: InterventionStep[] = [];
|
||||
for (const item of parsed.data.steps) {
|
||||
const step = InterventionStep.create(item);
|
||||
if (!step.isSuccess) {
|
||||
return Result.fail(step.error);
|
||||
}
|
||||
steps.push(step.data);
|
||||
}
|
||||
const {
|
||||
subject: _,
|
||||
ticketId: __,
|
||||
assigneeId: ___,
|
||||
affected: ____,
|
||||
steps: _____,
|
||||
...data
|
||||
} = parsed.data;
|
||||
return Result.ok(
|
||||
new InterventionValues(name.data, ticket.data, assignee.data, data, targets, steps)
|
||||
);
|
||||
}
|
||||
toPrimitive(): InterventionInput {
|
||||
return {
|
||||
...this.data,
|
||||
subject: this.subject.toPrimitive(),
|
||||
ticketId: this.ticketId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
assigneeId: this.assigneeId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
affected: this.affected.map((item) => item.toPrimitive()),
|
||||
steps: this.steps.map((item) => item.toPrimitive()),
|
||||
};
|
||||
}
|
||||
equals(other: InterventionValues) {
|
||||
return (
|
||||
this.subject.equals(other.subject) &&
|
||||
this.ticketId.getOrUndefined()?.toPrimitive() ===
|
||||
other.ticketId.getOrUndefined()?.toPrimitive() &&
|
||||
this.assigneeId.getOrUndefined()?.toPrimitive() ===
|
||||
other.assigneeId.getOrUndefined()?.toPrimitive() &&
|
||||
keys.every((key) => this.data[key] === other.data[key]) &&
|
||||
this.affected.length === other.affected.length &&
|
||||
this.affected.every((target) => other.affected.some((item) => item.equals(target))) &&
|
||||
this.steps.length === other.steps.length &&
|
||||
this.steps.every((step, index) => {
|
||||
const item = other.steps[index];
|
||||
return item !== undefined && step.equals(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import type { Application, RequestHandler, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { SaveInterventionTemplateSchema } from "../../../common/intervention-templates";
|
||||
import type { InterventionTemplateUseCases } from "../../application/intervention-template.use-cases";
|
||||
import { buildInterventionTemplate } from "../../application/intervention-template-response.builder";
|
||||
|
||||
const Empty = z.strictObject({});
|
||||
const Scope = z.strictObject({ scope: z.union([z.literal("global"), 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 registerInterventionTemplateRoutes(
|
||||
app: Application,
|
||||
useCases: InterventionTemplateUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
router.get("/:scope", (request, response, next) => {
|
||||
const params = Scope.safeParse(request.params);
|
||||
if (!(params.success && Empty.safeParse(request.query).success)) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.list(params.data.scope)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.json({
|
||||
templates: result.data.templates.map((template) =>
|
||||
buildInterventionTemplate(template, result.data.actorNames)
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
for (const path of ["/:scope", "/:scope/:id/update"]) {
|
||||
router.post(path, (request, response, next) => {
|
||||
const params = (path.endsWith("update") ? Scope.extend({ id: z.uuid() }) : Scope).safeParse(
|
||||
request.params
|
||||
);
|
||||
const body = SaveInterventionTemplateSchema.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.scope, id, body.data, actor.data.userId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
return;
|
||||
}
|
||||
response.status(id ? 200 : 201).json({
|
||||
template: buildInterventionTemplate(result.data.template, result.data.actorNames),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/intervention-templates", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
import type { Application, RequestHandler, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { SaveInterventionSchema } from "../../../common/interventions";
|
||||
import type { InterventionUseCases } from "../../application/intervention.use-cases";
|
||||
import {
|
||||
buildIntervention,
|
||||
buildInterventionLookups,
|
||||
buildInterventions,
|
||||
} from "../../application/intervention-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 registerInterventionRoutes(
|
||||
app: Application,
|
||||
useCases: InterventionUseCases,
|
||||
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(buildInterventionLookups(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(buildInterventions(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 = SaveInterventionSchema.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({
|
||||
intervention: buildIntervention(result.data.intervention, result.data.context),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/interventions", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
|
||||
export async function migrateInterventions(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 = [
|
||||
[
|
||||
"intervention_counter",
|
||||
{
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true },
|
||||
nextNumber: { type: DataTypes.INTEGER, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"interventions",
|
||||
{
|
||||
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 },
|
||||
ticketId: fk("tickets", true),
|
||||
assigneeId: fk("users", true),
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
procedure: { type: DataTypes.JSON, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"intervention_revisions",
|
||||
{
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
interventionId: fk("interventions"),
|
||||
actorId: fk("users"),
|
||||
ticketId: fk("tickets", 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 intervention_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='intervention_revisions'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!indexes.some((item) => item.name === "intervention_revision_position")) {
|
||||
await query.addIndex("intervention_revisions", ["interventionId", "position"], {
|
||||
name: "intervention_revision_position",
|
||||
unique: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { InterventionProcedureSchema } from "../../../common/interventions";
|
||||
import { Intervention } from "../../domain/intervention.aggregate";
|
||||
import { InterventionRevision } from "../../domain/intervention-revision.entity";
|
||||
import { InterventionInputSchema } from "../../domain/intervention-values";
|
||||
import { InterventionProcedure } from "../../domain/value-objects/intervention-procedure";
|
||||
import { InterventionValues } from "../../domain/value-objects/intervention-values";
|
||||
|
||||
const Root = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
reference: z.string().regex(/^I-[0-9]{6,}$/),
|
||||
requestKey: z.uuid(),
|
||||
ticketId: z.uuid().nullable(),
|
||||
assigneeId: z.uuid().nullable(),
|
||||
values: InterventionInputSchema,
|
||||
procedure: InterventionProcedureSchema.nullable(),
|
||||
});
|
||||
const Revision = z.strictObject({
|
||||
id: z.uuid(),
|
||||
interventionId: z.uuid(),
|
||||
actorId: z.uuid(),
|
||||
ticketId: z.uuid().nullable(),
|
||||
assigneeId: z.uuid().nullable(),
|
||||
values: InterventionInputSchema,
|
||||
recordedAt: z.iso.datetime(),
|
||||
position: z.number().int().nonnegative(),
|
||||
});
|
||||
export function interventionIdentity(value: string) {
|
||||
const id = UniqueID.create(value);
|
||||
if (!id.isSuccess) {
|
||||
throw new Error("Identidad de intervention inconsistente.");
|
||||
}
|
||||
return id.data;
|
||||
}
|
||||
function json(value: unknown): unknown {
|
||||
return typeof value === "string" ? JSON.parse(value) : value;
|
||||
}
|
||||
function values(value: unknown) {
|
||||
const result = InterventionValues.create(InterventionInputSchema.parse(json(value)));
|
||||
if (!result.isSuccess) {
|
||||
throw new Error("Valores de intervention inconsistentes.");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
export class InterventionPersistenceMapper {
|
||||
toDomain(root: Record<string, unknown>, rows: readonly Record<string, unknown>[]) {
|
||||
const current = Root.parse({
|
||||
...root,
|
||||
values: json(root.values),
|
||||
procedure: json(root.procedure),
|
||||
});
|
||||
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.interventionId !== current.id ||
|
||||
item.position !== index ||
|
||||
item.ticketId !== item.values.ticketId ||
|
||||
item.assigneeId !== item.values.assigneeId ||
|
||||
(index > 0 && item.recordedAt < (history[index - 1]?.recordedAt ?? ""))
|
||||
) ||
|
||||
current.ticketId !== current.values.ticketId ||
|
||||
current.assigneeId !== current.values.assigneeId
|
||||
) {
|
||||
throw new Error("Historial de intervention inconsistente.");
|
||||
}
|
||||
const revisions = history.map((item) =>
|
||||
InterventionRevision.rehydrate(
|
||||
{
|
||||
values: values(item.values),
|
||||
actorId: interventionIdentity(item.actorId),
|
||||
recordedAt: item.recordedAt,
|
||||
},
|
||||
interventionIdentity(item.id)
|
||||
)
|
||||
);
|
||||
const currentValues = values(current.values);
|
||||
if (!revisions.at(-1)?.values.equals(currentValues)) {
|
||||
throw new Error("Instantánea de intervention inconsistente.");
|
||||
}
|
||||
return Intervention.rehydrate(
|
||||
{
|
||||
companyId: interventionIdentity(current.companyId),
|
||||
procedure: restoreProcedure(current.procedure),
|
||||
reference: reference.data,
|
||||
requestKey: interventionIdentity(current.requestKey),
|
||||
values: currentValues,
|
||||
history: revisions,
|
||||
},
|
||||
interventionIdentity(current.id)
|
||||
);
|
||||
}
|
||||
toPersistence(intervention: Intervention) {
|
||||
const input = intervention.values.toPrimitive();
|
||||
return {
|
||||
root: {
|
||||
id: intervention.id.toPrimitive(),
|
||||
companyId: intervention.companyId.toPrimitive(),
|
||||
reference: intervention.reference.toPrimitive(),
|
||||
requestKey: intervention.requestKey.toPrimitive(),
|
||||
ticketId: input.ticketId,
|
||||
assigneeId: input.assigneeId,
|
||||
values: JSON.stringify(input),
|
||||
procedure: JSON.stringify(intervention.procedure.getOrUndefined()?.toPrimitive() ?? null),
|
||||
},
|
||||
revisions: intervention.history.map((item, position) => ({
|
||||
id: item.id.toPrimitive(),
|
||||
interventionId: intervention.id.toPrimitive(),
|
||||
actorId: item.actorId.toPrimitive(),
|
||||
ticketId: item.values.toPrimitive().ticketId,
|
||||
assigneeId: item.values.toPrimitive().assigneeId,
|
||||
values: JSON.stringify(item.values.toPrimitive()),
|
||||
recordedAt: item.recordedAt,
|
||||
position,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function restoreProcedure(
|
||||
input: ReturnType<typeof InterventionProcedureSchema.parse> | null
|
||||
): Maybe<InterventionProcedure> {
|
||||
if (!input) {
|
||||
return Maybe.none();
|
||||
}
|
||||
const result = InterventionProcedure.create(input);
|
||||
if (!result.isSuccess) {
|
||||
throw new Error("Procedimiento de intervención inconsistente.");
|
||||
}
|
||||
return Maybe.some(result.data);
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import { DataTypes, QueryTypes, type Sequelize } from "sequelize";
|
||||
|
||||
import { interventionTemplateSeeds } from "../../domain/intervention-template-seed";
|
||||
|
||||
import { InterventionTemplatePersistenceMapper } from "./intervention-template-persistence.mapper";
|
||||
export async function migrateInterventionTemplates(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) => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: { model, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
const definitions = [
|
||||
["intervention_template_lock", { id: { type: DataTypes.INTEGER, primaryKey: true } }],
|
||||
[
|
||||
"intervention_templates",
|
||||
{
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
companyId: fk("companies"),
|
||||
requestKey: { type: DataTypes.UUID, allowNull: false, unique: true },
|
||||
values: { type: DataTypes.JSON, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"intervention_template_revisions",
|
||||
{
|
||||
id: { type: DataTypes.UUID, primaryKey: true },
|
||||
templateId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: "intervention_templates", key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
},
|
||||
actorId: fk("users"),
|
||||
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 intervention_template_lock (id) VALUES (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='intervention_template_revisions'",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
if (!indexes.some((item) => item.name === "intervention_template_revision_position")) {
|
||||
await query.addIndex("intervention_template_revisions", ["templateId", "position"], {
|
||||
name: "intervention_template_revision_position",
|
||||
unique: true,
|
||||
});
|
||||
}
|
||||
await database.transaction(async (transaction) => {
|
||||
await database.query("SELECT id FROM intervention_template_lock WHERE id=1 FOR UPDATE", {
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
});
|
||||
const existing = await database.query<{ requestKey: string }>(
|
||||
"SELECT requestKey FROM intervention_templates",
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const mapper = new InterventionTemplatePersistenceMapper();
|
||||
for (const seed of interventionTemplateSeeds()) {
|
||||
if (existing.some((item) => item.requestKey === seed.requestKey.toPrimitive())) {
|
||||
continue;
|
||||
}
|
||||
const persisted = mapper.toPersistence(seed);
|
||||
await database.query(
|
||||
"INSERT INTO intervention_templates (id,companyId,requestKey,`values`) VALUES (:id,:companyId,:requestKey,:values)",
|
||||
{ replacements: persisted.root, transaction }
|
||||
);
|
||||
for (const revision of persisted.revisions) {
|
||||
await database.query(
|
||||
"INSERT INTO intervention_template_revisions (id,templateId,`values`,actorId,recordedAt,position) VALUES (:id,:templateId,:values,:actorId,:recordedAt,:position)",
|
||||
{ replacements: revision, transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { InterventionTemplate } from "../../domain/intervention-template.aggregate";
|
||||
import { InterventionTemplateRevision } from "../../domain/intervention-template-revision.entity";
|
||||
import { InterventionTemplateInputSchema } from "../../domain/intervention-template-values";
|
||||
import { InterventionTemplateValues } from "../../domain/value-objects/intervention-template-values";
|
||||
|
||||
const JsonValues = z.preprocess(
|
||||
(value) => (typeof value === "string" ? JSON.parse(value) : value),
|
||||
InterventionTemplateInputSchema
|
||||
);
|
||||
const Root = z.object({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid().nullable(),
|
||||
requestKey: z.uuid(),
|
||||
values: JsonValues,
|
||||
});
|
||||
const Revision = z.object({
|
||||
id: z.uuid(),
|
||||
templateId: z.uuid(),
|
||||
values: JsonValues,
|
||||
actorId: z.uuid().nullable(),
|
||||
recordedAt: z.iso.datetime(),
|
||||
position: z.number().int().nonnegative(),
|
||||
});
|
||||
function identifier(value: string) {
|
||||
const result = UniqueID.create(value);
|
||||
if (!result.isSuccess) {
|
||||
throw new Error("Invalid intervention template identifier");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
function values(input: z.infer<typeof InterventionTemplateInputSchema>) {
|
||||
const result = InterventionTemplateValues.create(input);
|
||||
if (!result.isSuccess) {
|
||||
throw new Error("Invalid intervention template values");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
export class InterventionTemplatePersistenceMapper {
|
||||
toDomain(row: unknown, revisionRows: unknown[]): InterventionTemplate {
|
||||
const root = Root.parse(row);
|
||||
const revisions = revisionRows
|
||||
.map((item) => Revision.parse(item))
|
||||
.sort((a, b) => a.position - b.position);
|
||||
if (
|
||||
!revisions.length ||
|
||||
new Set(revisions.map((item) => item.id)).size !== revisions.length ||
|
||||
revisions.some(
|
||||
(item, index) =>
|
||||
item.templateId !== root.id ||
|
||||
item.position !== index ||
|
||||
item.recordedAt < (revisions[index - 1]?.recordedAt ?? "")
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid intervention template history");
|
||||
}
|
||||
const current = values(root.values);
|
||||
const history = revisions.map((item) =>
|
||||
InterventionTemplateRevision.rehydrate(
|
||||
{
|
||||
values: values(item.values),
|
||||
actorId: item.actorId ? Maybe.some(identifier(item.actorId)) : Maybe.none(),
|
||||
recordedAt: item.recordedAt,
|
||||
},
|
||||
identifier(item.id)
|
||||
)
|
||||
);
|
||||
if (!history.at(-1)?.values.equals(current)) {
|
||||
throw new Error("Inconsistent intervention template snapshot");
|
||||
}
|
||||
return InterventionTemplate.rehydrate(
|
||||
{
|
||||
companyId: root.companyId ? Maybe.some(identifier(root.companyId)) : Maybe.none(),
|
||||
requestKey: identifier(root.requestKey),
|
||||
values: current,
|
||||
history,
|
||||
},
|
||||
identifier(root.id)
|
||||
);
|
||||
}
|
||||
toPersistence(template: InterventionTemplate) {
|
||||
return {
|
||||
root: {
|
||||
id: template.id.toPrimitive(),
|
||||
companyId: template.companyId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
requestKey: template.requestKey.toPrimitive(),
|
||||
values: JSON.stringify(template.values.toPrimitive()),
|
||||
},
|
||||
revisions: template.history.map((revision, position) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
templateId: template.id.toPrimitive(),
|
||||
values: JSON.stringify(revision.values.toPrimitive()),
|
||||
actorId: revision.actorId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
recordedAt: revision.recordedAt,
|
||||
position,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { QueryTypes, type Sequelize, type Transaction } from "sequelize";
|
||||
|
||||
import type { IInterventionTemplateRepository } from "../../application/intervention-template.repository";
|
||||
import type { InterventionTemplate } from "../../domain/intervention-template.aggregate";
|
||||
import { InterventionTemplateError } from "../../domain/intervention-template-values";
|
||||
|
||||
import { InterventionTemplatePersistenceMapper } from "./intervention-template-persistence.mapper";
|
||||
export class SequelizeInterventionTemplateRepository implements IInterventionTemplateRepository {
|
||||
private readonly mapper = new InterventionTemplatePersistenceMapper();
|
||||
constructor(private readonly database: Sequelize) {}
|
||||
private async exists(companyId: UniqueID | undefined, transaction?: Transaction) {
|
||||
if (!companyId) {
|
||||
return true;
|
||||
}
|
||||
const rows = await this.database.query("SELECT id FROM companies WHERE id=:id", {
|
||||
replacements: { id: companyId.toPrimitive() },
|
||||
type: QueryTypes.SELECT,
|
||||
...(transaction ? { transaction } : {}),
|
||||
});
|
||||
return rows.length > 0;
|
||||
}
|
||||
private async read(transaction?: Transaction) {
|
||||
const rows = await this.database.query("SELECT * FROM intervention_templates", {
|
||||
type: QueryTypes.SELECT,
|
||||
...(transaction ? { transaction } : {}),
|
||||
});
|
||||
const revisions = await this.database.query<{ templateId: string }>(
|
||||
"SELECT * FROM intervention_template_revisions ORDER BY position",
|
||||
{ type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) }
|
||||
);
|
||||
const users = await this.database.query<{ id: string; name: string }>(
|
||||
"SELECT id,name FROM users",
|
||||
{ type: QueryTypes.SELECT, ...(transaction ? { transaction } : {}) }
|
||||
);
|
||||
return {
|
||||
templates: rows.map((row) => {
|
||||
const parsed = this.rowIdentifier(row);
|
||||
return this.mapper.toDomain(
|
||||
row,
|
||||
revisions.filter((item) => item.templateId === parsed)
|
||||
);
|
||||
}),
|
||||
actorNames: new Map(users.map((user) => [user.id, user.name])),
|
||||
};
|
||||
}
|
||||
private rowIdentifier(row: object) {
|
||||
if (!("id" in row) || typeof row.id !== "string") {
|
||||
throw new Error("Invalid intervention template row");
|
||||
}
|
||||
return row.id;
|
||||
}
|
||||
async list(companyId: UniqueID | undefined) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
if (!(await this.exists(companyId, transaction))) {
|
||||
return Result.fail(new InterventionTemplateError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
const context = await this.read(transaction);
|
||||
return Result.ok({
|
||||
...context,
|
||||
templates: context.templates.filter(
|
||||
(item) =>
|
||||
item.companyId.getOrUndefined() === undefined ||
|
||||
(companyId !== undefined && item.companyId.getOrUndefined()?.equals(companyId) === true)
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
async save(
|
||||
companyId: UniqueID | undefined,
|
||||
action: (
|
||||
templates: InterventionTemplate[]
|
||||
) => Result<InterventionTemplate, InterventionTemplateError>
|
||||
) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
await this.database.query("SELECT id FROM intervention_template_lock WHERE id=1 FOR UPDATE", {
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
});
|
||||
if (!(await this.exists(companyId, transaction))) {
|
||||
return Result.fail(new InterventionTemplateError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
const context = await this.read(transaction);
|
||||
const result = action(context.templates);
|
||||
if (!result.isSuccess) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
const template = result.data;
|
||||
const previous = context.templates.find((item) => item.id.equals(template.id));
|
||||
await this.write(template, previous, transaction);
|
||||
return Result.ok({ ...context, template });
|
||||
});
|
||||
}
|
||||
private async write(
|
||||
template: InterventionTemplate,
|
||||
previous: InterventionTemplate | undefined,
|
||||
transaction: Transaction
|
||||
) {
|
||||
if (previous === template) {
|
||||
return;
|
||||
}
|
||||
const persisted = this.mapper.toPersistence(template);
|
||||
if (previous) {
|
||||
await this.database.query("UPDATE intervention_templates SET `values`=:values WHERE id=:id", {
|
||||
replacements: persisted.root,
|
||||
transaction,
|
||||
});
|
||||
} else {
|
||||
await this.database.query(
|
||||
"INSERT INTO intervention_templates (id,companyId,requestKey,`values`) VALUES (:id,:companyId,:requestKey,:values)",
|
||||
{ replacements: persisted.root, transaction }
|
||||
);
|
||||
}
|
||||
for (const revision of persisted.revisions.slice(previous?.history.length ?? 0)) {
|
||||
await this.database.query(
|
||||
"INSERT INTO intervention_template_revisions (id,templateId,`values`,actorId,recordedAt,position) VALUES (:id,:templateId,:values,:actorId,:recordedAt,:position)",
|
||||
{ replacements: revision, transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
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 {
|
||||
IInterventionRepository,
|
||||
InterventionContext,
|
||||
InterventionLookups,
|
||||
InterventionSaveContext,
|
||||
} from "../../application/intervention.repository";
|
||||
import type { IInterventionTemplateRepository } from "../../application/intervention-template.repository";
|
||||
import type { ITicketRepository } from "../../application/ticket.repository";
|
||||
import type { Intervention } from "../../domain/intervention.aggregate";
|
||||
import { InterventionError } from "../../domain/intervention-values";
|
||||
|
||||
import { InterventionPersistenceMapper } from "./intervention-persistence.mapper";
|
||||
export class SequelizeInterventionRepository implements IInterventionRepository {
|
||||
private readonly mapper = new InterventionPersistenceMapper();
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
private readonly tickets: ITicketRepository,
|
||||
private readonly templates: IInterventionTemplateRepository
|
||||
) {}
|
||||
private async read(
|
||||
company: UniqueID | undefined,
|
||||
transaction: Transaction
|
||||
): Promise<Result<InterventionContext, InterventionError>> {
|
||||
const context = await this.tickets.list(company);
|
||||
if (!context.isSuccess) {
|
||||
return Result.fail(new InterventionError(context.error.code));
|
||||
}
|
||||
const roots = await this.database.query<Record<string, unknown>>(
|
||||
"SELECT id,companyId,reference,requestKey,ticketId,assigneeId,`values`,`procedure` FROM interventions" +
|
||||
(company ? " WHERE companyId=:company" : ""),
|
||||
{
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { company: company?.toPrimitive() ?? null },
|
||||
}
|
||||
);
|
||||
const revisions = await this.database.query<Record<string, unknown>>(
|
||||
"SELECT r.id,r.interventionId,r.actorId,r.ticketId,r.assigneeId,r.`values`,r.recordedAt,r.position FROM intervention_revisions r JOIN interventions i ON i.id=r.interventionId" +
|
||||
(company ? " WHERE i.companyId=:company" : ""),
|
||||
{
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: { company: company?.toPrimitive() ?? null },
|
||||
}
|
||||
);
|
||||
return Result.ok({
|
||||
...context.data,
|
||||
interventions: roots.map((row) =>
|
||||
this.mapper.toDomain(
|
||||
row,
|
||||
revisions.filter((item) => item.interventionId === row.id)
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
list(company?: UniqueID) {
|
||||
return this.database.transaction((transaction) => this.read(company, transaction));
|
||||
}
|
||||
async lookups(company: UniqueID): Promise<Result<InterventionLookups, InterventionError>> {
|
||||
const [context, lookups, templates] = await Promise.all([
|
||||
this.tickets.list(company),
|
||||
this.tickets.lookups(company),
|
||||
this.templates.list(company),
|
||||
]);
|
||||
if (!context.isSuccess) {
|
||||
return Result.fail(new InterventionError(context.error.code));
|
||||
}
|
||||
if (!lookups.isSuccess) {
|
||||
return Result.fail(new InterventionError(lookups.error.code));
|
||||
}
|
||||
if (!templates.isSuccess) {
|
||||
return Result.fail(new InterventionError(templates.error.code));
|
||||
}
|
||||
return Result.ok({
|
||||
context: context.data,
|
||||
lookups: lookups.data,
|
||||
templates: templates.data.templates,
|
||||
templateActors: templates.data.actorNames,
|
||||
});
|
||||
}
|
||||
save(
|
||||
company: UniqueID,
|
||||
requestKey: UniqueID,
|
||||
action: (context: InterventionSaveContext) => Result<Intervention, InterventionError>
|
||||
) {
|
||||
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 intervention_counter WHERE id=1 FOR UPDATE", {
|
||||
transaction,
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
const all = await this.read(undefined, transaction);
|
||||
const lookups = await this.lookups(company);
|
||||
if (!all.isSuccess) {
|
||||
return Result.fail(all.error);
|
||||
}
|
||||
if (!lookups.isSuccess) {
|
||||
return Result.fail(lookups.error);
|
||||
}
|
||||
const sequence = await this.database.query<{ nextNumber: number }>(
|
||||
"SELECT nextNumber FROM intervention_counter WHERE id=1",
|
||||
{ transaction, type: QueryTypes.SELECT }
|
||||
);
|
||||
const scoped = {
|
||||
...all.data,
|
||||
...lookups.data,
|
||||
tickets: lookups.data.context.tickets,
|
||||
interventions: all.data.interventions.filter((item) => item.companyId.equals(company)),
|
||||
nextNumber: z.number().int().positive().parse(sequence[0]?.nextNumber),
|
||||
requestIntervention: all.data.interventions.find((item) =>
|
||||
item.requestKey.equals(requestKey)
|
||||
),
|
||||
};
|
||||
const result = action(scoped);
|
||||
if (!result.isSuccess) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
const previous = all.data.interventions.find((item) => item.id.equals(result.data.id));
|
||||
await this.write(result.data, previous, transaction);
|
||||
return Result.ok({ intervention: result.data, context: all.data });
|
||||
});
|
||||
}
|
||||
private async write(
|
||||
intervention: Intervention,
|
||||
previous: Intervention | undefined,
|
||||
transaction: Transaction
|
||||
) {
|
||||
if (previous === intervention) {
|
||||
return;
|
||||
}
|
||||
const persisted = this.mapper.toPersistence(intervention);
|
||||
await this.database.query(
|
||||
previous
|
||||
? "UPDATE interventions SET ticketId=:ticketId,assigneeId=:assigneeId,`values`=:values WHERE id=:id"
|
||||
: "INSERT INTO interventions (id,companyId,reference,requestKey,ticketId,assigneeId,`values`,`procedure`) VALUES (:id,:companyId,:reference,:requestKey,:ticketId,:assigneeId,:values,:procedure)",
|
||||
{ replacements: persisted.root, transaction }
|
||||
);
|
||||
for (const row of persisted.revisions.slice(previous?.history.length ?? 0)) {
|
||||
await this.database.query(
|
||||
"INSERT INTO intervention_revisions (id,interventionId,actorId,ticketId,assigneeId,`values`,recordedAt,position) VALUES (:id,:interventionId,:actorId,:ticketId,:assigneeId,:values,:recordedAt,:position)",
|
||||
{ replacements: row, transaction }
|
||||
);
|
||||
}
|
||||
if (!previous) {
|
||||
await this.database.query(
|
||||
"UPDATE intervention_counter SET nextNumber=nextNumber+1 WHERE id=1",
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,8 @@ import { CertificateConfigurationUseCases } from "./application/certificate-conf
|
||||
import { CompanyUseCases } from "./application/company.use-cases";
|
||||
import { CompanyInputMapper } from "./application/company-input.mapper";
|
||||
import { FolderMappingUseCases } from "./application/folder-mapping.use-cases";
|
||||
import { InterventionUseCases } from "./application/intervention.use-cases";
|
||||
import { InterventionTemplateUseCases } from "./application/intervention-template.use-cases";
|
||||
import type { IMailInventoryService } from "./application/mail-configuration.repository";
|
||||
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
||||
import { MailboxUseCases } from "./application/mailbox.use-cases";
|
||||
@ -25,6 +27,8 @@ import { registerApplicationConfigurationRoutes } from "./infrastructure/http/ap
|
||||
import { registerCertificateConfigurationRoutes } from "./infrastructure/http/certificate-configuration.routes";
|
||||
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
|
||||
import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes";
|
||||
import { registerInterventionRoutes } from "./infrastructure/http/intervention.routes";
|
||||
import { registerInterventionTemplateRoutes } from "./infrastructure/http/intervention-template.routes";
|
||||
import { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
|
||||
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
|
||||
import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.routes";
|
||||
@ -43,6 +47,8 @@ import { migrateCertificateConfigurations } from "./infrastructure/persistence/c
|
||||
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
|
||||
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
|
||||
import { migrateFolderMappings } from "./infrastructure/persistence/folder-mapping-migration";
|
||||
import { migrateInterventions } from "./infrastructure/persistence/intervention-migration";
|
||||
import { migrateInterventionTemplates } from "./infrastructure/persistence/intervention-template-migration";
|
||||
import { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration";
|
||||
import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration";
|
||||
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
|
||||
@ -55,6 +61,8 @@ import { SequelizeApplicationConfigurationRepository } from "./infrastructure/pe
|
||||
import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository";
|
||||
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
||||
import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository";
|
||||
import { SequelizeInterventionRepository } from "./infrastructure/persistence/sequelize-intervention.repository";
|
||||
import { SequelizeInterventionTemplateRepository } from "./infrastructure/persistence/sequelize-intervention-template.repository";
|
||||
import { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
|
||||
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
|
||||
import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository";
|
||||
@ -74,6 +82,9 @@ export async function createCompanyModule(
|
||||
) {
|
||||
await migrateCompanies(database);
|
||||
await migratePeople(database);
|
||||
await migrateInterventionTemplates(database);
|
||||
const interventionTemplateRepository = new SequelizeInterventionTemplateRepository(database);
|
||||
const interventionTemplates = new InterventionTemplateUseCases(interventionTemplateRepository);
|
||||
initializeCompanyModels(database);
|
||||
initializePeopleModels(database);
|
||||
const repository = new SequelizeCompanyRepository(database, new CompanyPersistenceMapper());
|
||||
@ -94,8 +105,15 @@ export async function createCompanyModule(
|
||||
resources: IResourceInventoryService
|
||||
) => {
|
||||
await migrateTickets(database);
|
||||
const tickets = new TicketUseCases(
|
||||
new SequelizeTicketRepository(database, inventory, resources)
|
||||
const ticketRepository = new SequelizeTicketRepository(database, inventory, resources);
|
||||
const tickets = new TicketUseCases(ticketRepository);
|
||||
await migrateInterventions(database);
|
||||
const interventions = new InterventionUseCases(
|
||||
new SequelizeInterventionRepository(
|
||||
database,
|
||||
ticketRepository,
|
||||
interventionTemplateRepository
|
||||
)
|
||||
);
|
||||
await migrateProcesses(database);
|
||||
const processes = new ProcessUseCases(
|
||||
@ -127,6 +145,7 @@ export async function createCompanyModule(
|
||||
);
|
||||
return {
|
||||
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
||||
registerInterventionRoutes(app, interventions, authenticate);
|
||||
registerTicketRoutes(app, tickets, authenticate);
|
||||
registerProcessRoutes(app, processes, authenticate);
|
||||
registerRemoteConnectionRoutes(app, remote, authenticate);
|
||||
@ -155,6 +174,7 @@ export async function createCompanyModule(
|
||||
};
|
||||
},
|
||||
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
||||
registerInterventionTemplateRoutes(app, interventionTemplates, authenticate);
|
||||
registerCompanyRoutes(app, useCases, authenticate);
|
||||
registerTeleworkAuthorizationRoutes(app, telework, authenticate);
|
||||
registerAccessRoutes(app, access, authenticate, legacyResourceWrites);
|
||||
|
||||
@ -8,6 +8,8 @@ export * from "./dto/shared/access.dto";
|
||||
export * from "./dto/shared/company.dto";
|
||||
export * from "./dto/shared/people.dto";
|
||||
export * from "./folder-mappings";
|
||||
export * from "./intervention-templates";
|
||||
export * from "./interventions";
|
||||
export * from "./mail-configurations";
|
||||
export * from "./mailboxes";
|
||||
export * from "./printer-configurations";
|
||||
|
||||
41
modules/companies/src/common/intervention-templates.ts
Normal file
41
modules/companies/src/common/intervention-templates.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { InterventionTemplateInputSchema } from "../api/domain/intervention-template-values";
|
||||
|
||||
export type {
|
||||
InterventionStepInput,
|
||||
InterventionTemplateInput,
|
||||
} from "../api/domain/intervention-template-values";
|
||||
export {
|
||||
InterventionStepInputSchema,
|
||||
InterventionTemplateInputSchema,
|
||||
} from "../api/domain/intervention-template-values";
|
||||
export const SaveInterventionTemplateSchema = z.strictObject({
|
||||
values: InterventionTemplateInputSchema,
|
||||
requestKey: z.uuid(),
|
||||
expectedRevisionId: z.uuid().nullable(),
|
||||
});
|
||||
export const InterventionTemplateSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid().nullable(),
|
||||
values: InterventionTemplateInputSchema,
|
||||
history: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: InterventionTemplateInputSchema,
|
||||
actorId: z.uuid().nullable(),
|
||||
actorName: z.string().min(1),
|
||||
recordedAt: z.iso.datetime(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
export const InterventionTemplatesSchema = z.strictObject({
|
||||
templates: z.array(InterventionTemplateSchema),
|
||||
});
|
||||
export const InterventionTemplateResponseSchema = z.strictObject({
|
||||
template: InterventionTemplateSchema,
|
||||
});
|
||||
export type InterventionTemplateDTO = z.infer<typeof InterventionTemplateSchema>;
|
||||
export type SaveInterventionTemplateInput = z.infer<typeof SaveInterventionTemplateSchema>;
|
||||
67
modules/companies/src/common/interventions.ts
Normal file
67
modules/companies/src/common/interventions.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { InterventionTemplateInputSchema } from "../api/domain/intervention-template-values";
|
||||
import {
|
||||
InterventionInputSchema,
|
||||
InterventionModalitySchema,
|
||||
InterventionStateSchema,
|
||||
InterventionStepStateSchema,
|
||||
} from "../api/domain/intervention-values";
|
||||
|
||||
import { InterventionTemplateSchema } from "./intervention-templates";
|
||||
import { TicketListSchema, TicketLookupsSchema, TicketSchema } from "./tickets";
|
||||
|
||||
export type { InterventionCheckInput, InterventionInput } from "../api/domain/intervention-values";
|
||||
|
||||
export {
|
||||
InterventionInputSchema,
|
||||
InterventionModalitySchema,
|
||||
InterventionStateSchema,
|
||||
InterventionStepStateSchema,
|
||||
};
|
||||
export const InterventionProcedureSchema = z.strictObject({
|
||||
templateId: z.uuid(),
|
||||
revisionId: z.uuid(),
|
||||
values: InterventionTemplateInputSchema,
|
||||
});
|
||||
export const SaveInterventionSchema = z.strictObject({
|
||||
values: InterventionInputSchema,
|
||||
requestKey: z.uuid(),
|
||||
expectedRevisionId: z.uuid().nullable(),
|
||||
template: z.strictObject({ id: z.uuid(), revisionId: z.uuid() }).nullable(),
|
||||
});
|
||||
export const InterventionSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
companyName: z.string(),
|
||||
reference: z.string(),
|
||||
values: InterventionInputSchema,
|
||||
assigneeName: z.string().nullable(),
|
||||
ticketReference: z.string().nullable(),
|
||||
procedure: InterventionProcedureSchema.nullable(),
|
||||
history: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: InterventionInputSchema,
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string(),
|
||||
recordedAt: z.iso.datetime(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
export const InterventionListSchema = z.strictObject({
|
||||
interventions: z.array(InterventionSchema),
|
||||
companies: TicketListSchema.shape.companies,
|
||||
technicians: TicketListSchema.shape.technicians,
|
||||
});
|
||||
export const InterventionLookupsSchema = TicketLookupsSchema.extend({
|
||||
tickets: z.array(TicketSchema),
|
||||
templates: z.array(InterventionTemplateSchema),
|
||||
});
|
||||
export const InterventionResponseSchema = z.strictObject({ intervention: InterventionSchema });
|
||||
export type InterventionDTO = z.infer<typeof InterventionSchema>;
|
||||
export type InterventionListDTO = z.infer<typeof InterventionListSchema>;
|
||||
export type InterventionLookupsDTO = z.infer<typeof InterventionLookupsSchema>;
|
||||
export type SaveInterventionInput = z.infer<typeof SaveInterventionSchema>;
|
||||
@ -9,6 +9,8 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AccessWorkspacePage } from "../access/access-workspace";
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
import { InterventionTemplatesPage } from "../intervention-templates/templates-page";
|
||||
import { InterventionsPage } from "../interventions/interventions-page";
|
||||
import { TicketsPage } from "../tickets/tickets-page";
|
||||
|
||||
import { CompaniesPage } from "./companies-page";
|
||||
@ -18,6 +20,7 @@ import { CompanyFeedback } from "./company-feedback";
|
||||
type WorkspaceLocation =
|
||||
| { area: "company"; id: string }
|
||||
| { area: "tickets" }
|
||||
| { area: "interventions" }
|
||||
| { area: "directory" }
|
||||
| { area: "catalogs" };
|
||||
|
||||
@ -35,7 +38,9 @@ export function CompaniesWorkspace({
|
||||
const { t: accessTranslation } = useTranslation("access");
|
||||
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
|
||||
const [location, setLocation] = useState<WorkspaceLocation>();
|
||||
const [catalog, setCatalog] = useState<"access" | "assets" | "resources">("access");
|
||||
const [catalog, setCatalog] = useState<
|
||||
"access" | "assets" | "resources" | "intervention-templates"
|
||||
>("access");
|
||||
const companies = query.data?.companies ?? [];
|
||||
const current =
|
||||
location ?? (companies[0] ? { area: "company", id: companies[0].id } : { area: "directory" });
|
||||
@ -74,6 +79,16 @@ export function CompaniesWorkspace({
|
||||
>
|
||||
T
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("interventions.global")}
|
||||
aria-pressed={current.area === "interventions"}
|
||||
className="desk-space-button"
|
||||
onClick={() => setLocation({ area: "interventions" })}
|
||||
title={t("interventions.global")}
|
||||
variant="ghost"
|
||||
>
|
||||
I
|
||||
</Button>
|
||||
<div className="desk-rail-separator" />
|
||||
{companies.map((company) => (
|
||||
<Button
|
||||
@ -147,6 +162,13 @@ export function CompaniesWorkspace({
|
||||
>
|
||||
{assetTranslation("title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "intervention-templates"}
|
||||
onClick={() => setCatalog("intervention-templates")}
|
||||
variant="ghost"
|
||||
>
|
||||
{t("interventionTemplates.title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "resources"}
|
||||
onClick={() => setCatalog("resources")}
|
||||
@ -157,7 +179,7 @@ export function CompaniesWorkspace({
|
||||
</>
|
||||
) : (
|
||||
<Button aria-pressed variant="ghost">
|
||||
{t(current.area === "tickets" ? "tickets.global" : "title")}
|
||||
{t(workspaceTitle(current.area))}
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
@ -181,10 +203,12 @@ function GlobalCatalogContent({
|
||||
catalog,
|
||||
csrfToken,
|
||||
}: {
|
||||
catalog: "access" | "assets" | "resources";
|
||||
catalog: "access" | "assets" | "resources" | "intervention-templates";
|
||||
csrfToken: string;
|
||||
}) {
|
||||
switch (catalog) {
|
||||
case "intervention-templates":
|
||||
return <InterventionTemplatesPage csrfToken={csrfToken} />;
|
||||
case "access":
|
||||
return <AccessWorkspacePage csrfToken={csrfToken} scope="global" />;
|
||||
case "assets":
|
||||
@ -198,6 +222,8 @@ function workspaceTitle(area: WorkspaceLocation["area"]) {
|
||||
switch (area) {
|
||||
case "catalogs":
|
||||
return "globalCatalogs";
|
||||
case "interventions":
|
||||
return "interventions.global";
|
||||
case "tickets":
|
||||
return "tickets.global";
|
||||
default:
|
||||
@ -211,11 +237,13 @@ function WorkspaceContent({
|
||||
onSelectCompany,
|
||||
}: {
|
||||
area: WorkspaceLocation["area"];
|
||||
catalog: "access" | "assets" | "resources";
|
||||
catalog: "access" | "assets" | "resources" | "intervention-templates";
|
||||
csrfToken: string;
|
||||
onSelectCompany: (id: string) => void;
|
||||
}) {
|
||||
switch (area) {
|
||||
case "interventions":
|
||||
return <InterventionsPage csrfToken={csrfToken} />;
|
||||
case "tickets":
|
||||
return <TicketsPage csrfToken={csrfToken} />;
|
||||
case "directory":
|
||||
|
||||
@ -24,6 +24,8 @@ import { companyApi, companyKeys } from "../api/company-api";
|
||||
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
|
||||
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
|
||||
import { FolderMappingsPage } from "../folders/folder-mappings-page";
|
||||
import { InterventionTemplatesPage } from "../intervention-templates/templates-page";
|
||||
import { InterventionsPage } from "../interventions/interventions-page";
|
||||
import { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||
import { MailboxRelations } from "../mail/mailbox-relations";
|
||||
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||
@ -114,6 +116,8 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
||||
);
|
||||
}
|
||||
type CompanyTab =
|
||||
| "interventions"
|
||||
| "intervention-templates"
|
||||
| "tickets"
|
||||
| "processes"
|
||||
| "remote"
|
||||
@ -227,6 +231,8 @@ export function CompanyDetail({
|
||||
["telework", t("telework.title"), KeyRoundIcon],
|
||||
["remote", t("remote.title"), KeyRoundIcon],
|
||||
["processes", t("processes.title"), LayoutTemplateIcon],
|
||||
["intervention-templates", t("interventionTemplates.title"), LayoutTemplateIcon],
|
||||
["interventions", t("interventions.title"), LayoutTemplateIcon],
|
||||
["tickets", t("tickets.title"), LayoutTemplateIcon],
|
||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||
[
|
||||
@ -277,6 +283,10 @@ function CompanySection({
|
||||
tab: CompanyTab;
|
||||
}) {
|
||||
switch (tab) {
|
||||
case "intervention-templates":
|
||||
return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />;
|
||||
case "interventions":
|
||||
return <InterventionsPage companyId={company.id} csrfToken={csrfToken} />;
|
||||
case "tickets":
|
||||
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
|
||||
case "processes":
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { applicationsEnglish, applicationsSpanish } from "./applications/application-i18n";
|
||||
import { certificatesEnglish, certificatesSpanish } from "./certificates/certificate-i18n";
|
||||
import { foldersEnglish, foldersSpanish } from "./folders/folder-i18n";
|
||||
import {
|
||||
interventionTemplatesEnglish,
|
||||
interventionTemplatesSpanish,
|
||||
} from "./intervention-templates/template-i18n";
|
||||
import { interventionsEnglish, interventionsSpanish } from "./interventions/intervention-i18n";
|
||||
import { mailEnglish, mailSpanish } from "./mail/mail-i18n";
|
||||
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
||||
import { printersEnglish, printersSpanish } from "./printers/printer-i18n";
|
||||
@ -12,6 +17,8 @@ import { teleworkEnglish, teleworkSpanish } from "./telework/telework-i18n";
|
||||
import { ticketsEnglish, ticketsSpanish } from "./tickets/ticket-i18n";
|
||||
|
||||
export const companiesSpanish = {
|
||||
interventionTemplates: interventionTemplatesSpanish,
|
||||
interventions: interventionsSpanish,
|
||||
tickets: ticketsSpanish,
|
||||
processes: processesSpanish,
|
||||
remote: remoteSpanish,
|
||||
@ -138,6 +145,8 @@ export const companiesSpanish = {
|
||||
},
|
||||
};
|
||||
export const companiesEnglish = {
|
||||
interventionTemplates: interventionTemplatesEnglish,
|
||||
interventions: interventionsEnglish,
|
||||
tickets: ticketsEnglish,
|
||||
processes: processesEnglish,
|
||||
remote: remoteEnglish,
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
InterventionTemplateResponseSchema,
|
||||
InterventionTemplatesSchema,
|
||||
type SaveInterventionTemplateInput,
|
||||
} from "../../common/intervention-templates";
|
||||
export const interventionTemplateKeys = {
|
||||
list: (scope: string) => ["intervention-templates", scope],
|
||||
};
|
||||
export class InterventionTemplateApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
async function request(
|
||||
scope: string,
|
||||
id?: string,
|
||||
body?: SaveInterventionTemplateInput,
|
||||
csrfToken?: string
|
||||
) {
|
||||
const response = await fetch(`/api/intervention-templates/${scope}${id ? `/${id}/update` : ""}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
credentials: "same-origin",
|
||||
headers:
|
||||
body === undefined
|
||||
? {}
|
||||
: { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const data: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const parsed = z.object({ code: z.string() }).safeParse(data);
|
||||
throw new InterventionTemplateApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
export const interventionTemplateApi = {
|
||||
async list(scope: string) {
|
||||
return InterventionTemplatesSchema.parse(await request(scope));
|
||||
},
|
||||
async save(
|
||||
scope: string,
|
||||
id: string | undefined,
|
||||
input: SaveInterventionTemplateInput,
|
||||
csrfToken: string
|
||||
) {
|
||||
return InterventionTemplateResponseSchema.parse(await request(scope, id, input, csrfToken));
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type {
|
||||
InterventionTemplateDTO,
|
||||
InterventionTemplateInput,
|
||||
} from "../../common/intervention-templates";
|
||||
export function TemplateSteps({ values }: { values: InterventionTemplateInput }) {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<>
|
||||
<p className="whitespace-pre-wrap text-sm">{values.description}</p>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-sm">
|
||||
{values.steps.map((step) => (
|
||||
<li key={step.id}>
|
||||
<span className="font-medium">{step.title}</span>
|
||||
{step.required && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
· {t("interventionTemplates.required")}
|
||||
</span>
|
||||
)}
|
||||
{step.instructions && (
|
||||
<p className="whitespace-pre-wrap text-muted-foreground">{step.instructions}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function TemplateHistory({ template }: { template: InterventionTemplateDTO }) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
return (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">{t("history")}</summary>
|
||||
<ol className="space-y-3 mt-3">
|
||||
{[...template.history].reverse().map((revision) => (
|
||||
<li className="rounded border p-3 space-y-2" key={revision.id}>
|
||||
<p className="text-sm">
|
||||
{new Date(revision.recordedAt).toLocaleString(i18n.language)} · {revision.actorName}
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
<TemplateSteps values={revision.values} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
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 { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
type InterventionTemplateDTO,
|
||||
type InterventionTemplateInput,
|
||||
InterventionTemplateInputSchema,
|
||||
} from "../../common/intervention-templates";
|
||||
|
||||
import { InterventionTemplateApiError, interventionTemplateApi } from "./template-api";
|
||||
import { InterventionTemplateFeedback, InterventionTemplateHelp } from "./template-help";
|
||||
import { TemplateStepFields } from "./template-step-fields";
|
||||
|
||||
export function newTemplateStep() {
|
||||
return {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: "",
|
||||
instructions: "",
|
||||
required: false,
|
||||
};
|
||||
}
|
||||
export function copyTemplateValues(
|
||||
template: InterventionTemplateDTO,
|
||||
name: string
|
||||
): InterventionTemplateInput {
|
||||
return {
|
||||
...template.values,
|
||||
name,
|
||||
active: true,
|
||||
steps: template.values.steps.map((step) => ({
|
||||
...step,
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
export function InterventionTemplateForm({
|
||||
scope,
|
||||
template,
|
||||
initialValues,
|
||||
csrfToken,
|
||||
onSaved,
|
||||
}: {
|
||||
scope: string;
|
||||
template?: InterventionTemplateDTO | undefined;
|
||||
initialValues?: InterventionTemplateInput | undefined;
|
||||
csrfToken: string;
|
||||
onSaved: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive());
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const form = useForm<InterventionTemplateInput>({
|
||||
resolver: zodResolver(InterventionTemplateInputSchema),
|
||||
defaultValues: template?.values ??
|
||||
initialValues ?? { name: "", description: "", active: true, steps: [newTemplateStep()] },
|
||||
});
|
||||
const steps = useFieldArray({ control: form.control, name: "steps", keyName: "fieldKey" });
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: InterventionTemplateInput) =>
|
||||
interventionTemplateApi.save(
|
||||
scope,
|
||||
template?.id,
|
||||
{ values, requestKey, expectedRevisionId: template?.history.at(-1)?.id ?? null },
|
||||
csrfToken
|
||||
),
|
||||
onError: async (error) => {
|
||||
if (error instanceof InterventionTemplateApiError && error.code === "TEMPLATE_CHANGED") {
|
||||
await client.invalidateQueries({ queryKey: ["intervention-templates"] });
|
||||
}
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
await client.invalidateQueries({ queryKey: ["intervention-templates"] });
|
||||
if (mounted.current) {
|
||||
onSaved(response.template.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||
<Input id={`${prefix}-name`} maxLength={160} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-description`}>{t("interventionTemplates.description")}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-description`}
|
||||
maxLength={4000}
|
||||
rows={3}
|
||||
{...form.register("description")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={form.watch("active")}
|
||||
id={`${prefix}-active`}
|
||||
onCheckedChange={(value) =>
|
||||
form.setValue("active", value === true, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${prefix}-active`}>{t("active")}</Label>
|
||||
</div>
|
||||
<h3 className="font-medium">{t("interventionTemplates.steps")}</h3>
|
||||
{steps.fields.map((step, index) => (
|
||||
<TemplateStepFields
|
||||
count={steps.fields.length}
|
||||
form={form}
|
||||
index={index}
|
||||
key={step.fieldKey}
|
||||
onMove={steps.move}
|
||||
onRemove={steps.remove}
|
||||
prefix={prefix}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
disabled={steps.fields.length >= 100}
|
||||
onClick={() => steps.append(newTemplateStep())}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventionTemplates.addStep")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
<InterventionTemplateFeedback
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<InterventionTemplateHelp />
|
||||
<FormActions>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { InterventionTemplateApiError } from "./template-api";
|
||||
export function InterventionTemplateHelp() {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<ContextHelp
|
||||
description={t("interventionTemplates.helpDescription")}
|
||||
examples={t("interventionTemplates.examples")}
|
||||
examplesLabel={t("mail.examplesLabel")}
|
||||
guidance={t("interventionTemplates.guidance")}
|
||||
scopeNote={t("interventionTemplates.helpScope")}
|
||||
title={t("interventionTemplates.help")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export function InterventionTemplateFeedback({
|
||||
error,
|
||||
invalid = false,
|
||||
}: {
|
||||
error: unknown;
|
||||
invalid?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const code = invalid
|
||||
? "INVALID_REQUEST"
|
||||
: error instanceof InterventionTemplateApiError
|
||||
? error.code
|
||||
: "INTERNAL_ERROR";
|
||||
return error || invalid ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t(`interventionTemplates.errors.${code}`, {
|
||||
defaultValue: t("interventionTemplates.errors.INTERNAL_ERROR"),
|
||||
})}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
export const interventionTemplatesSpanish = {
|
||||
title: "Plantillas de intervención",
|
||||
new: "Nueva plantilla de intervención",
|
||||
edit: "Editar plantilla de intervención",
|
||||
description: "Descripción (opcional)",
|
||||
steps: "Pasos",
|
||||
addStep: "Añadir paso",
|
||||
removeStep: "Quitar paso",
|
||||
stepTitle: "Nombre del paso",
|
||||
instructions: "Instrucciones (opcional)",
|
||||
required: "Comprobación obligatoria antes de finalizar",
|
||||
up: "Subir paso",
|
||||
down: "Bajar paso",
|
||||
global: "Global",
|
||||
company: "De esta compañía",
|
||||
copy: "Copiar plantilla",
|
||||
copyOf: "Copia de",
|
||||
filter: "Buscar plantilla",
|
||||
showInactive: "Mostrar inactivas",
|
||||
empty: "No hay plantillas que coincidan.",
|
||||
scopeGlobal: "Estas plantillas son comunes a todas las compañías.",
|
||||
scopeCompany:
|
||||
"Consulta las plantillas globales y crea variantes propias de esta compañía. Las globales se editan desde Catálogos globales.",
|
||||
help: "¿Para qué sirve una plantilla de intervención?",
|
||||
helpDescription:
|
||||
"Guarda un procedimiento repetible como una lista ordenada de pasos. Una intervención sencilla podrá registrarse sin plantilla.",
|
||||
helpScope:
|
||||
"La plantilla es un procedimiento: no contiene personas, cuentas ni datos concretos de una compañía.",
|
||||
examples: "Sustitución de equipo, configuración de correo, baja de persona o revisión periódica.",
|
||||
guidance:
|
||||
"Añade instrucciones solo cuando ayuden al técnico. Marca como obligatorias únicamente las comprobaciones esenciales. Desactivar conserva el historial.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Revisa el nombre y los pasos. Debe haber entre 1 y 100 pasos con nombre.",
|
||||
TEMPLATE_CHANGED:
|
||||
"Otra persona ha modificado la plantilla. Cierra y vuelve a abrir el formulario para cargar su última versión.",
|
||||
TEMPLATE_NAME_EXISTS: "Ya existe una plantilla con ese nombre en este ámbito.",
|
||||
REQUEST_KEY_REUSED:
|
||||
"La petición ya se utilizó con otros datos. Cierra y vuelve a abrir el formulario.",
|
||||
TEMPLATE_NOT_FOUND: "La plantilla no está disponible en este ámbito.",
|
||||
COMPANY_NOT_FOUND: "La compañía no está disponible.",
|
||||
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
|
||||
FORBIDDEN: "Recarga la página para autorizar la operación.",
|
||||
UNAUTHENTICATED: "La sesión ha caducado. Vuelve a entrar.",
|
||||
},
|
||||
};
|
||||
export const interventionTemplatesEnglish = {
|
||||
title: "Intervention templates",
|
||||
new: "New intervention template",
|
||||
edit: "Edit intervention template",
|
||||
description: "Description (optional)",
|
||||
steps: "Steps",
|
||||
addStep: "Add step",
|
||||
removeStep: "Remove step",
|
||||
stepTitle: "Step name",
|
||||
instructions: "Instructions (optional)",
|
||||
required: "Required check before completion",
|
||||
up: "Move step up",
|
||||
down: "Move step down",
|
||||
global: "Global",
|
||||
company: "This company",
|
||||
copy: "Copy template",
|
||||
copyOf: "Copy of",
|
||||
filter: "Find template",
|
||||
showInactive: "Show inactive",
|
||||
empty: "No matching templates.",
|
||||
scopeGlobal: "These templates are shared by all companies.",
|
||||
scopeCompany:
|
||||
"Browse global templates and create company variants. Edit global templates in Global catalogs.",
|
||||
help: "What is an intervention template for?",
|
||||
helpDescription:
|
||||
"Save a repeatable procedure as an ordered list of steps. Simple interventions can be recorded without a template.",
|
||||
helpScope: "A template is a procedure: it contains no people, accounts or specific company data.",
|
||||
examples: "Device replacement, email setup, employee departure or periodic checks.",
|
||||
guidance:
|
||||
"Add instructions when useful. Make only essential checks required. Deactivation retains history.",
|
||||
errors: {
|
||||
INVALID_REQUEST: "Review the name and steps. Include between 1 and 100 named steps.",
|
||||
TEMPLATE_CHANGED:
|
||||
"Someone else changed this template. Close and reopen the form to load the latest version.",
|
||||
TEMPLATE_NAME_EXISTS: "A template with this name already exists in this scope.",
|
||||
REQUEST_KEY_REUSED: "This request was already used with other data. Close and reopen the form.",
|
||||
TEMPLATE_NOT_FOUND: "This template is unavailable in this scope.",
|
||||
COMPANY_NOT_FOUND: "The company is unavailable.",
|
||||
INTERNAL_ERROR: "The operation failed. Try again.",
|
||||
FORBIDDEN: "Reload the page to authorize the operation.",
|
||||
UNAUTHENTICATED: "Your session expired. Sign in again.",
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,88 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { InterventionTemplateInput } from "../../common/intervention-templates";
|
||||
export function TemplateStepFields({
|
||||
form,
|
||||
index,
|
||||
count,
|
||||
prefix,
|
||||
onMove,
|
||||
onRemove,
|
||||
}: {
|
||||
form: UseFormReturn<InterventionTemplateInput>;
|
||||
index: number;
|
||||
count: number;
|
||||
prefix: string;
|
||||
onMove: (from: number, to: number) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const id = `${prefix}-step-${index}`;
|
||||
return (
|
||||
<section
|
||||
aria-label={`${t("interventionTemplates.steps")} ${index + 1}`}
|
||||
className="space-y-3 rounded-lg border p-4"
|
||||
>
|
||||
<header className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{index + 1}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
disabled={index === 0}
|
||||
onClick={() => onMove(index, index - 1)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventionTemplates.up")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={index === count - 1}
|
||||
onClick={() => onMove(index, index + 1)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventionTemplates.down")}
|
||||
</Button>
|
||||
<Button onClick={() => onRemove(index)} size="sm" type="button" variant="outline">
|
||||
{t("interventionTemplates.removeStep")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-title`}>{t("interventionTemplates.stepTitle")}</Label>
|
||||
<Input
|
||||
id={`${id}-title`}
|
||||
maxLength={200}
|
||||
required
|
||||
{...form.register(`steps.${index}.title`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-instructions`}>{t("interventionTemplates.instructions")}</Label>
|
||||
<Textarea
|
||||
id={`${id}-instructions`}
|
||||
maxLength={4000}
|
||||
rows={2}
|
||||
{...form.register(`steps.${index}.instructions`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Checkbox
|
||||
checked={form.watch(`steps.${index}.required`)}
|
||||
id={`${id}-required`}
|
||||
onCheckedChange={(value) =>
|
||||
form.setValue(`steps.${index}.required`, value === true, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${id}-required`}>{t("interventionTemplates.required")}</Label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,160 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { interventionTemplateApi, interventionTemplateKeys } from "./template-api";
|
||||
import { TemplateHistory, TemplateSteps } from "./template-detail";
|
||||
import { InterventionTemplateForm, copyTemplateValues } from "./template-form";
|
||||
import { InterventionTemplateFeedback } from "./template-help";
|
||||
export function InterventionTemplatesPage({
|
||||
scope = "global",
|
||||
csrfToken,
|
||||
}: {
|
||||
scope?: string;
|
||||
csrfToken: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const query = useQuery({
|
||||
queryKey: interventionTemplateKeys.list(scope),
|
||||
queryFn: () => interventionTemplateApi.list(scope),
|
||||
});
|
||||
const [editor, setEditor] = useState<{ id?: string; copyId?: string }>();
|
||||
const [search, setSearch] = useState("");
|
||||
const [showInactive, setShowInactive] = useState(true);
|
||||
const templates = query.data?.templates ?? [];
|
||||
const selected = templates.find((item) => item.id === editor?.id);
|
||||
const copied = templates.find((item) => item.id === editor?.copyId);
|
||||
const visible = templates.filter(
|
||||
(item) =>
|
||||
(showInactive || item.values.active) &&
|
||||
item.values.name.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">{t("interventionTemplates.title")}</h2>
|
||||
<Button disabled={!query.data || query.isError} onClick={() => setEditor({})}>
|
||||
{t("interventionTemplates.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
scope === "global"
|
||||
? "interventionTemplates.scopeGlobal"
|
||||
: "interventionTemplates.scopeCompany"
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div>
|
||||
<Label className="sr-only" htmlFor={`${prefix}-search`}>
|
||||
{t("interventionTemplates.filter")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${prefix}-search`}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("interventionTemplates.filter")}
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Checkbox
|
||||
checked={showInactive}
|
||||
id={`${prefix}-inactive`}
|
||||
onCheckedChange={(value) => setShowInactive(value === true)}
|
||||
/>
|
||||
<Label htmlFor={`${prefix}-inactive`}>{t("interventionTemplates.showInactive")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<InterventionTemplateFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
query.refetch().catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{query.data && !query.isError && (
|
||||
<>
|
||||
{!visible.length && <p>{t("interventionTemplates.empty")}</p>}
|
||||
{visible.map((template) => (
|
||||
<section className="rounded-xl border p-5 space-y-4" key={template.id}>
|
||||
<header className="flex flex-wrap justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-medium">{template.values.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
template.companyId
|
||||
? "interventionTemplates.company"
|
||||
: "interventionTemplates.global"
|
||||
)}{" "}
|
||||
· {t(template.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{template.companyId === (scope === "global" ? null : scope) && (
|
||||
<Button
|
||||
onClick={() => setEditor({ id: template.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setEditor({ copyId: template.id })}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventionTemplates.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TemplateSteps values={template.values} />
|
||||
<TemplateHistory template={template} />
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditor(undefined);
|
||||
}
|
||||
}}
|
||||
open={editor !== undefined}
|
||||
title={t(selected ? "interventionTemplates.edit" : "interventionTemplates.new")}
|
||||
wide
|
||||
>
|
||||
{editor && (
|
||||
<InterventionTemplateForm
|
||||
csrfToken={csrfToken}
|
||||
initialValues={
|
||||
copied
|
||||
? copyTemplateValues(
|
||||
copied,
|
||||
`${t("interventionTemplates.copyOf")} ${copied.values.name}`
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
key={editor.id ?? editor.copyId ?? "new"}
|
||||
onSaved={() => setEditor(undefined)}
|
||||
scope={scope}
|
||||
template={selected}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
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 { InterventionTemplateDTO } from "../../common/intervention-templates";
|
||||
import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||
|
||||
import { interventionTemplateKeys } from "./template-api";
|
||||
import { InterventionTemplateForm, copyTemplateValues } from "./template-form";
|
||||
import { InterventionTemplatesPage } from "./templates-page";
|
||||
|
||||
const companyId = UniqueID.generateNewID().toPrimitive();
|
||||
function template(): InterventionTemplateDTO {
|
||||
const values = {
|
||||
name: "Revisión",
|
||||
description: "",
|
||||
active: true,
|
||||
steps: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: "Comprobar",
|
||||
instructions: "",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId: null,
|
||||
values,
|
||||
history: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
values,
|
||||
actorId: null,
|
||||
actorName: "Metis Desk",
|
||||
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();
|
||||
const global = template();
|
||||
client.setQueryData(interventionTemplateKeys.list("global"), { templates: [global] });
|
||||
client.setQueryData(interventionTemplateKeys.list(companyId), {
|
||||
templates: [global, { ...template(), companyId }],
|
||||
});
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("plantillas de intervención: listado sin formulario, es/en, globales no editables desde compañía", async () => {
|
||||
const global = await render(<InterventionTemplatesPage csrfToken="test" />);
|
||||
assert.ok(global.includes("Plantillas de intervención"));
|
||||
assert.equal(global.includes("<form"), false);
|
||||
const scoped = await render(<InterventionTemplatesPage csrfToken="test" scope={companyId} />);
|
||||
assert.equal((scoped.match(/>Editar</g) ?? []).length, 1);
|
||||
const english = await render(<InterventionTemplatesPage csrfToken="test" />, "en");
|
||||
assert.ok(english.includes("Intervention templates"));
|
||||
});
|
||||
test("plantillas de intervención: ayuda antes del pie y controles de pasos", async () => {
|
||||
const html = await render(
|
||||
<InterventionTemplateForm csrfToken="test" onSaved={() => undefined} scope="global" />
|
||||
);
|
||||
assert.ok(html.includes("Añadir paso"));
|
||||
assert.ok(html.includes("Subir paso"));
|
||||
assert.ok(html.includes("Comprobación obligatoria"));
|
||||
assert.ok(
|
||||
html.indexOf("¿Para qué sirve una plantilla de intervención?") < html.indexOf("Guardar")
|
||||
);
|
||||
});
|
||||
test("plantillas de intervención: copia activa y pasos con identidades independientes", () => {
|
||||
const source = template();
|
||||
const copy = copyTemplateValues(
|
||||
{ ...source, values: { ...source.values, active: false } },
|
||||
"Copia"
|
||||
);
|
||||
assert.equal(copy.active, true);
|
||||
assert.equal(copy.name, "Copia");
|
||||
assert.notEqual(copy.steps[0]?.id, source.values.steps[0]?.id);
|
||||
assert.equal(copy.steps[0]?.title, source.values.steps[0]?.title);
|
||||
});
|
||||
44
modules/companies/src/web/interventions/intervention-api.ts
Normal file
44
modules/companies/src/web/interventions/intervention-api.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
InterventionListSchema,
|
||||
InterventionLookupsSchema,
|
||||
InterventionResponseSchema,
|
||||
type SaveInterventionInput,
|
||||
} from "../../common/interventions";
|
||||
export const interventionKeys = {
|
||||
list: (companyId?: string) => ["interventions", companyId ?? "global"],
|
||||
lookups: (companyId: string) => ["intervention-lookups", companyId],
|
||||
};
|
||||
export class InterventionApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
async function request(path: string, input?: SaveInterventionInput, token?: string) {
|
||||
const response = await fetch(`/api/interventions${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 InterventionApiError(error.success ? error.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
export const interventionApi = {
|
||||
async list(companyId?: string) {
|
||||
return InterventionListSchema.parse(await request(companyId ? `/${companyId}` : ""));
|
||||
},
|
||||
async lookups(companyId: string) {
|
||||
return InterventionLookupsSchema.parse(await request(`/${companyId}/lookups`));
|
||||
},
|
||||
async save(companyId: string, input: SaveInterventionInput, token: string, id?: string) {
|
||||
return InterventionResponseSchema.parse(
|
||||
await request(`/${companyId}${id ? `/${id}/update` : ""}`, input, token)
|
||||
);
|
||||
},
|
||||
};
|
||||
186
modules/companies/src/web/interventions/intervention-detail.tsx
Normal file
186
modules/companies/src/web/interventions/intervention-detail.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type {
|
||||
InterventionDTO,
|
||||
InterventionInput,
|
||||
InterventionListDTO,
|
||||
} from "../../common/interventions";
|
||||
import type { TicketLookupsDTO } from "../../common/tickets";
|
||||
import { ticketTargetOptions } from "../tickets/ticket-targets";
|
||||
|
||||
import { interventionApi, interventionKeys } from "./intervention-api";
|
||||
|
||||
export function stepCompletion(intervention: InterventionDTO, stepId: string) {
|
||||
let completed: InterventionDTO["history"][number] | undefined;
|
||||
let previousState: string | undefined;
|
||||
for (const revision of intervention.history) {
|
||||
const step = revision.values.steps.find((item) => item.id === stepId);
|
||||
if (step?.state === "done" && previousState !== "done") {
|
||||
completed = revision;
|
||||
}
|
||||
if (step?.state !== "done") {
|
||||
completed = undefined;
|
||||
}
|
||||
previousState = step?.state;
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
function InterventionSnapshot({
|
||||
values,
|
||||
data,
|
||||
lookups,
|
||||
}: {
|
||||
values: InterventionInput;
|
||||
data: InterventionListDTO;
|
||||
lookups: TicketLookupsDTO | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const date = (value: string | null) =>
|
||||
value ? new Date(value).toLocaleString(i18n.language) : "—";
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium">
|
||||
{values.subject} · {t(`interventions.states.${values.state}`)}
|
||||
</p>
|
||||
<dl className="grid sm:grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<dt>{t("interventions.assignee")}</dt>
|
||||
<dd>
|
||||
{data.technicians.find((item) => item.id === values.assigneeId)?.name ??
|
||||
t("interventions.unassigned")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("interventions.modality")}</dt>
|
||||
<dd>{t(`interventions.modalities.${values.modality}`)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("interventions.plannedDate")}</dt>
|
||||
<dd>{values.plannedDate ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("interventions.minutes")}</dt>
|
||||
<dd>{values.minutes ?? t("interventions.unknownTime")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("interventions.startedAt")}</dt>
|
||||
<dd>{date(values.startedAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("interventions.endedAt")}</dt>
|
||||
<dd>{date(values.endedAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{(["diagnosis", "workPerformed", "resolution"] as const).map(
|
||||
(field) =>
|
||||
values[field] && (
|
||||
<div key={field}>
|
||||
<h4 className="font-medium text-sm">{t(`interventions.${field}`)}</h4>
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{values[field]}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{values.affected.length > 0 && (
|
||||
<ul className="text-sm">
|
||||
{values.affected.map((target) => (
|
||||
<li key={target.kind + target.targetId}>
|
||||
{t(`tickets.${target.kind}`)} ·{" "}
|
||||
{lookups
|
||||
? (ticketTargetOptions(lookups, target.kind).find(
|
||||
(item) => item.id === target.targetId
|
||||
)?.name ?? target.targetId)
|
||||
: target.targetId}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{values.steps.length > 0 && (
|
||||
<ol className="list-decimal pl-5 space-y-2 text-sm">
|
||||
{values.steps.map((step) => (
|
||||
<li key={step.id}>
|
||||
<p>
|
||||
{step.title} · {t(`interventions.stepStates.${step.state}`)}
|
||||
{step.required ? ` · ${t("interventionTemplates.required")}` : ""}
|
||||
</p>
|
||||
{step.instructions && <p className="whitespace-pre-wrap">{step.instructions}</p>}
|
||||
{step.notes && <p className="whitespace-pre-wrap">{step.notes}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function InterventionDetail({
|
||||
intervention,
|
||||
data,
|
||||
}: {
|
||||
intervention: InterventionDTO;
|
||||
data: InterventionListDTO;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const lookups = useQuery({
|
||||
queryKey: interventionKeys.lookups(intervention.companyId),
|
||||
queryFn: () => interventionApi.lookups(intervention.companyId),
|
||||
});
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{intervention.reference} · {intervention.companyName} ·{" "}
|
||||
{intervention.ticketReference ?? t("interventions.internal")}
|
||||
</p>
|
||||
<InterventionSnapshot data={data} lookups={lookups.data} values={intervention.values} />
|
||||
{intervention.values.steps.map((step) => {
|
||||
const completed = stepCompletion(intervention, step.id);
|
||||
return completed ? (
|
||||
<p className="text-xs text-muted-foreground" key={step.id}>
|
||||
{step.title}:{" "}
|
||||
{t("interventions.completed", {
|
||||
actor: completed.actorName,
|
||||
date: new Date(completed.recordedAt).toLocaleString(i18n.language),
|
||||
})}
|
||||
</p>
|
||||
) : null;
|
||||
})}
|
||||
{intervention.procedure && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
{t("interventions.source")}: {intervention.procedure.values.name}
|
||||
</summary>
|
||||
<p className="text-sm whitespace-pre-wrap">{intervention.procedure.values.description}</p>
|
||||
<ol className="list-decimal pl-5 text-sm">
|
||||
{intervention.procedure.values.steps.map((step) => (
|
||||
<li key={step.id}>
|
||||
{step.title}
|
||||
{step.instructions && <p className="whitespace-pre-wrap">{step.instructions}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
<details>
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{t("interventions.history")} ({intervention.history.length})
|
||||
</summary>
|
||||
<ol className="space-y-3 mt-3">
|
||||
{[...intervention.history].reverse().map((revision) => (
|
||||
<li className="rounded-md border p-3 space-y-2" key={revision.id}>
|
||||
<p className="text-sm">
|
||||
{new Date(revision.recordedAt).toLocaleString(i18n.language)} · {revision.actorName}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("interventions.ticket")}:{" "}
|
||||
{lookups.data?.tickets.find((item) => item.id === revision.values.ticketId)
|
||||
?.reference ??
|
||||
revision.values.ticketId ??
|
||||
t("interventions.internal")}
|
||||
</p>
|
||||
<InterventionSnapshot data={data} lookups={lookups.data} values={revision.values} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
modules/companies/src/web/interventions/intervention-fields.tsx
Normal file
122
modules/companies/src/web/interventions/intervention-fields.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
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 type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
type InterventionInput,
|
||||
InterventionModalitySchema,
|
||||
InterventionStateSchema,
|
||||
} from "../../common/interventions";
|
||||
export function InterventionWorkFields({
|
||||
form,
|
||||
prefix,
|
||||
}: {
|
||||
form: UseFormReturn<InterventionInput>;
|
||||
prefix: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-state`}>{t("interventions.state")}</Label>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-state`}
|
||||
label={t("interventions.state")}
|
||||
onValueChange={(value) => {
|
||||
const parsed = InterventionStateSchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("state", parsed.data);
|
||||
}
|
||||
}}
|
||||
options={InterventionStateSchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`interventions.states.${value}`),
|
||||
}))}
|
||||
placeholder={t("tickets.choose")}
|
||||
value={form.watch("state")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-modality`}>{t("interventions.modality")}</Label>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-modality`}
|
||||
label={t("interventions.modality")}
|
||||
onValueChange={(value) => {
|
||||
const parsed = InterventionModalitySchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue("modality", parsed.data);
|
||||
}
|
||||
}}
|
||||
options={InterventionModalitySchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`interventions.modalities.${value}`),
|
||||
}))}
|
||||
placeholder={t("tickets.choose")}
|
||||
value={form.watch("modality")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-plannedDate`}>{t("interventions.plannedDate")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-plannedDate`}
|
||||
type="date"
|
||||
{...form.register("plannedDate", { setValueAs: (value: string) => value || null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-minutes`}>{t("interventions.minutes")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-minutes`}
|
||||
max={1000000}
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
{...form.register("minutes", {
|
||||
setValueAs: (value: string) =>
|
||||
value === "" || value === null ? null : Number(value),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
{(["startedAt", "endedAt"] as const).map((field) => (
|
||||
<div className="space-y-2" key={field}>
|
||||
<Label htmlFor={`${prefix}-${field}`}>{t(`interventions.${field}`)}</Label>
|
||||
<Input
|
||||
id={`${prefix}-${field}`}
|
||||
onChange={(event) =>
|
||||
form.setValue(
|
||||
field,
|
||||
event.target.value ? new Date(event.target.value).toISOString() : null
|
||||
)
|
||||
}
|
||||
type="datetime-local"
|
||||
value={toLocalDateTime(form.watch(field))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("interventions.timeHelp")}</p>
|
||||
{(["diagnosis", "workPerformed", "resolution"] as const).map((field) => (
|
||||
<div className="space-y-2" key={field}>
|
||||
<Label htmlFor={`${prefix}-${field}`}>{t(`interventions.${field}`)}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-${field}`}
|
||||
maxLength={10000}
|
||||
rows={3}
|
||||
{...form.register(field)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function toLocalDateTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(value);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
397
modules/companies/src/web/interventions/intervention-form.tsx
Normal file
397
modules/companies/src/web/interventions/intervention-form.tsx
Normal file
@ -0,0 +1,397 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
type InterventionDTO,
|
||||
type InterventionInput,
|
||||
InterventionInputSchema,
|
||||
type InterventionListDTO,
|
||||
type SaveInterventionInput,
|
||||
} from "../../common/interventions";
|
||||
import type { TicketDTO } from "../../common/tickets";
|
||||
import { InterventionTemplateForm } from "../intervention-templates/template-form";
|
||||
import { TicketForm } from "../tickets/ticket-form";
|
||||
import { TicketQuickForms } from "../tickets/ticket-quick-forms";
|
||||
import { TicketTargets } from "../tickets/ticket-targets";
|
||||
|
||||
import { InterventionApiError, interventionApi, interventionKeys } from "./intervention-api";
|
||||
import { InterventionWorkFields } from "./intervention-fields";
|
||||
import { InterventionFeedback, InterventionHelp } from "./intervention-help";
|
||||
import { InterventionProcedureFields } from "./intervention-procedure-fields";
|
||||
import { InterventionSteps } from "./intervention-steps";
|
||||
|
||||
export function emptyIntervention(ticket?: TicketDTO): InterventionInput {
|
||||
return {
|
||||
subject: ticket?.values.subject ?? "",
|
||||
ticketId: ticket?.id ?? null,
|
||||
assigneeId: ticket?.values.assigneeId ?? null,
|
||||
state: "pending",
|
||||
modality: "remote",
|
||||
plannedDate: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
minutes: null,
|
||||
diagnosis: "",
|
||||
workPerformed: "",
|
||||
resolution: "",
|
||||
affected: ticket?.values.affected.map((item) => ({ ...item })) ?? [],
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
export function InterventionForm({
|
||||
scope,
|
||||
intervention,
|
||||
initialTicket,
|
||||
data,
|
||||
csrfToken,
|
||||
onSaved,
|
||||
}: {
|
||||
scope?: string;
|
||||
intervention?: InterventionDTO;
|
||||
initialTicket?: TicketDTO;
|
||||
data: InterventionListDTO;
|
||||
csrfToken: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const [companyId, setCompanyId] = useState(
|
||||
intervention?.companyId ?? initialTicket?.companyId ?? scope ?? ""
|
||||
);
|
||||
const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive());
|
||||
const [quick, setQuick] = useState<"company" | "ticket" | "template">();
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("");
|
||||
const [applied, setApplied] = useState<SaveInterventionInput["template"]>(null);
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
const form = useForm<InterventionInput>({
|
||||
resolver: zodResolver(InterventionInputSchema),
|
||||
defaultValues: intervention?.values ?? emptyIntervention(initialTicket),
|
||||
});
|
||||
const values = form.watch();
|
||||
const lookups = useQuery({
|
||||
queryKey: interventionKeys.lookups(companyId),
|
||||
queryFn: () => interventionApi.lookups(companyId),
|
||||
enabled: Boolean(companyId),
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: InterventionInput) =>
|
||||
interventionApi.save(
|
||||
companyId,
|
||||
{
|
||||
values: input,
|
||||
requestKey,
|
||||
expectedRevisionId: intervention?.history.at(-1)?.id ?? null,
|
||||
template: intervention ? null : applied,
|
||||
},
|
||||
csrfToken,
|
||||
intervention?.id
|
||||
),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: ["interventions"] });
|
||||
if (mounted.current) {
|
||||
onSaved();
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
if (error instanceof InterventionApiError && error.code === "INTERVENTION_CHANGED") {
|
||||
await client.invalidateQueries({ queryKey: ["interventions"] });
|
||||
}
|
||||
if (error instanceof InterventionApiError && error.code === "TEMPLATE_CHANGED") {
|
||||
await client.invalidateQueries({ queryKey: interventionKeys.lookups(companyId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
const changeCompany = (id: string) => {
|
||||
setCompanyId(id);
|
||||
form.setValue("ticketId", null);
|
||||
form.setValue("affected", []);
|
||||
form.setValue("steps", []);
|
||||
setApplied(null);
|
||||
setSelectedTemplate("");
|
||||
};
|
||||
const applyTemplate = () => {
|
||||
const template = lookups.data?.templates.find(
|
||||
(item) => item.id === selectedTemplate && item.values.active
|
||||
);
|
||||
if (!template) {
|
||||
setApplied(null);
|
||||
form.setValue("steps", []);
|
||||
return;
|
||||
}
|
||||
setApplied({ id: template.id, revisionId: template.history.at(-1)?.id ?? "" });
|
||||
form.setValue(
|
||||
"steps",
|
||||
template.values.steps.map((step) => ({
|
||||
...step,
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
state: "pending",
|
||||
notes: "",
|
||||
}))
|
||||
);
|
||||
};
|
||||
const selectTicket = (id: string) => {
|
||||
form.setValue("ticketId", id || null);
|
||||
const ticket = lookups.data?.tickets.find((item) => item.id === id);
|
||||
if (!ticket) {
|
||||
return;
|
||||
}
|
||||
if (!form.getValues("subject").trim()) {
|
||||
form.setValue("subject", ticket.values.subject);
|
||||
}
|
||||
if (!form.getValues("affected").length) {
|
||||
form.setValue("affected", ticket.values.affected);
|
||||
}
|
||||
if (
|
||||
!form.getValues("assigneeId") &&
|
||||
data.technicians.some((item) => item.id === ticket.values.assigneeId && item.active)
|
||||
) {
|
||||
form.setValue("assigneeId", ticket.values.assigneeId);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
|
||||
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||
<InterventionCompanyField
|
||||
companies={data.companies}
|
||||
companyId={companyId}
|
||||
fixed={isCompanyFixed(scope, intervention, initialTicket)}
|
||||
onChange={changeCompany}
|
||||
onNew={() => setQuick("company")}
|
||||
prefix={prefix}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-subject`}>{t("interventions.subject")}</Label>
|
||||
<Input
|
||||
id={`${prefix}-subject`}
|
||||
maxLength={200}
|
||||
required
|
||||
{...form.register("subject")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-ticket`}>{t("interventions.ticket")}</Label>
|
||||
<SelectWithAction
|
||||
action={{
|
||||
label: t("tickets.new"),
|
||||
onClick: () => setQuick("ticket"),
|
||||
disabled: !(companyId && lookups.data),
|
||||
}}
|
||||
disabled={!lookups.data}
|
||||
id={`${prefix}-ticket`}
|
||||
label={t("interventions.ticket")}
|
||||
onValueChange={selectTicket}
|
||||
options={
|
||||
lookups.data?.tickets.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${item.reference} · ${item.values.subject}`,
|
||||
})) ?? []
|
||||
}
|
||||
placeholder={t("interventions.internal")}
|
||||
value={values.ticketId ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-assignee`}>{t("interventions.assignee")}</Label>
|
||||
<SelectWithAction
|
||||
id={`${prefix}-assignee`}
|
||||
label={t("interventions.assignee")}
|
||||
onValueChange={(id) => form.setValue("assigneeId", id || null)}
|
||||
options={data.technicians
|
||||
.filter((item) => item.active || item.id === intervention?.values.assigneeId)
|
||||
.map((item) => ({ value: item.id, label: item.name }))}
|
||||
placeholder={t("interventions.unassigned")}
|
||||
value={values.assigneeId ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<InterventionWorkFields form={form} prefix={prefix} />
|
||||
{companyId && lookups.isPending && <p role="status">{t("loading")}</p>}
|
||||
<InterventionFeedback error={lookups.error} />
|
||||
{lookups.isError && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
lookups.refetch().catch(() => undefined);
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{lookups.data && (
|
||||
<TicketTargets
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
lookupKey={interventionKeys.lookups(companyId)}
|
||||
lookups={lookups.data}
|
||||
onChange={(affected) => form.setValue("affected", affected)}
|
||||
values={values.affected}
|
||||
/>
|
||||
)}
|
||||
<InterventionProcedureFields
|
||||
applied={applied}
|
||||
intervention={intervention}
|
||||
lookups={lookups.data}
|
||||
onApply={applyTemplate}
|
||||
onNewTemplate={() => setQuick("template")}
|
||||
onSelect={setSelectedTemplate}
|
||||
prefix={prefix}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
<InterventionSteps form={form} prefix={prefix} />
|
||||
</fieldset>
|
||||
<InterventionFeedback
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<InterventionHelp />
|
||||
<FormActions>
|
||||
<Button
|
||||
disabled={mutation.isPending || !companyId || !lookups.data || lookups.isError}
|
||||
type="submit"
|
||||
>
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
<TicketQuickForms
|
||||
companyId={companyId}
|
||||
csrfToken={csrfToken}
|
||||
kind={quick === "company" ? "company" : undefined}
|
||||
onClose={() => setQuick(undefined)}
|
||||
onCompany={(id) => {
|
||||
client
|
||||
.invalidateQueries({ queryKey: ["interventions"] })
|
||||
.then(() => {
|
||||
changeCompany(id);
|
||||
setQuick(undefined);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}}
|
||||
onPerson={() => undefined}
|
||||
/>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
open={quick === "ticket"}
|
||||
title={t("tickets.new")}
|
||||
wide
|
||||
>
|
||||
{quick === "ticket" && (
|
||||
<TicketForm
|
||||
csrfToken={csrfToken}
|
||||
data={{
|
||||
companies: data.companies,
|
||||
technicians: data.technicians,
|
||||
tickets: lookups.data?.tickets ?? [],
|
||||
}}
|
||||
onSaved={(id) => {
|
||||
client
|
||||
.invalidateQueries({ queryKey: interventionKeys.lookups(companyId) })
|
||||
.then(() => {
|
||||
form.setValue("ticketId", id);
|
||||
setQuick(undefined);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}}
|
||||
scope={companyId}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setQuick(undefined);
|
||||
}
|
||||
}}
|
||||
open={quick === "template"}
|
||||
title={t("interventions.newTemplate")}
|
||||
wide
|
||||
>
|
||||
{quick === "template" && (
|
||||
<InterventionTemplateForm
|
||||
csrfToken={csrfToken}
|
||||
onSaved={(id) => {
|
||||
client
|
||||
.invalidateQueries({ queryKey: interventionKeys.lookups(companyId) })
|
||||
.then(() => {
|
||||
setSelectedTemplate(id);
|
||||
setQuick(undefined);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}}
|
||||
scope={companyId}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function isCompanyFixed(
|
||||
scope: string | undefined,
|
||||
intervention: InterventionDTO | undefined,
|
||||
ticket: TicketDTO | undefined
|
||||
) {
|
||||
return Boolean(scope || intervention || ticket);
|
||||
}
|
||||
function InterventionCompanyField({
|
||||
prefix,
|
||||
companyId,
|
||||
companies,
|
||||
fixed,
|
||||
onChange,
|
||||
onNew,
|
||||
}: {
|
||||
prefix: string;
|
||||
companyId: string;
|
||||
companies: InterventionListDTO["companies"];
|
||||
fixed: boolean;
|
||||
onChange: (id: string) => void;
|
||||
onNew: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={prefix}>{t("tickets.company")}</Label>
|
||||
{fixed ? (
|
||||
<p>{companies.find((item) => item.id === companyId)?.name ?? companyId}</p>
|
||||
) : (
|
||||
<SelectWithAction
|
||||
action={{ label: t("newCompany"), onClick: onNew }}
|
||||
id={prefix}
|
||||
label={t("tickets.company")}
|
||||
onValueChange={onChange}
|
||||
options={companies.map((item) => ({ value: item.id, label: item.name }))}
|
||||
placeholder={t("tickets.choose")}
|
||||
value={companyId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { InterventionApiError } from "./intervention-api";
|
||||
export function InterventionHelp() {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<ContextHelp
|
||||
description={t("interventions.helpDescription")}
|
||||
examples={t("interventions.examples")}
|
||||
examplesLabel={t("mail.examplesLabel")}
|
||||
guidance={t("interventions.guidance")}
|
||||
scopeNote={t("interventions.scope")}
|
||||
title={t("interventions.help")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export function InterventionFeedback({
|
||||
error,
|
||||
invalid = false,
|
||||
}: {
|
||||
error: unknown;
|
||||
invalid?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const code = invalid
|
||||
? "INVALID_REQUEST"
|
||||
: error instanceof InterventionApiError
|
||||
? error.code
|
||||
: "INTERNAL_ERROR";
|
||||
return error || invalid ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t(`interventions.errors.${code}`, {
|
||||
defaultValue: t("interventions.errors.INTERNAL_ERROR"),
|
||||
})}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
171
modules/companies/src/web/interventions/intervention-i18n.ts
Normal file
171
modules/companies/src/web/interventions/intervention-i18n.ts
Normal file
@ -0,0 +1,171 @@
|
||||
export const interventionsSpanish = {
|
||||
resetFilters: "Limpiar filtros",
|
||||
title: "Intervenciones",
|
||||
global: "Todas las intervenciones",
|
||||
new: "Nueva intervención",
|
||||
edit: "Editar intervención",
|
||||
details: "Ficha de intervención",
|
||||
subject: "Asunto",
|
||||
state: "Estado",
|
||||
ticket: "Ticket (opcional)",
|
||||
internal: "Intervención interna, sin ticket",
|
||||
fromTicket: "Crear intervención",
|
||||
assignee: "Técnico responsable",
|
||||
unassigned: "Sin asignar",
|
||||
modality: "Modalidad",
|
||||
states: {
|
||||
pending: "Pendiente",
|
||||
in_progress: "En curso",
|
||||
finished: "Finalizada",
|
||||
cancelled: "Cancelada",
|
||||
},
|
||||
modalities: { remote: "Remota", onsite: "Presencial", other: "Otra" },
|
||||
plannedDate: "Fecha prevista (opcional)",
|
||||
startedAt: "Inicio real (opcional)",
|
||||
endedAt: "Fin real (opcional)",
|
||||
minutes: "Tiempo empleado (minutos, opcional)",
|
||||
unknownTime: "Sin registrar",
|
||||
timeHelp: "El tiempo empleado es manual y puede diferir del intervalo entre inicio y fin.",
|
||||
diagnosis: "Diagnóstico (opcional)",
|
||||
workPerformed: "Trabajo realizado",
|
||||
resolution: "Resultado / resolución (opcional)",
|
||||
template: "Plantilla (opcional)",
|
||||
noTemplate: "Sin plantilla",
|
||||
apply: "Aplicar plantilla",
|
||||
newTemplate: "Nueva plantilla",
|
||||
templateNotice:
|
||||
"Aplicar sustituye los pasos actuales. La copia se puede adaptar y conserva la versión original de la plantilla.",
|
||||
source: "Plantilla de origen",
|
||||
steps: "Pasos",
|
||||
progress: "{{done}} de {{total}} pasos hechos",
|
||||
addStep: "Añadir paso",
|
||||
removeStep: "Quitar paso",
|
||||
notes: "Observaciones / motivo de no aplicación",
|
||||
stepStates: {
|
||||
pending: "Pendiente",
|
||||
in_progress: "En curso",
|
||||
done: "Hecho",
|
||||
not_applicable: "No aplica",
|
||||
},
|
||||
search: "Buscar intervención",
|
||||
allCompanies: "Todas las compañías",
|
||||
allStates: "Todos los estados",
|
||||
allAssignees: "Todos los técnicos",
|
||||
dateFrom: "Prevista desde",
|
||||
dateTo: "Prevista hasta",
|
||||
empty: "No hay intervenciones que coincidan.",
|
||||
loading: "Cargando intervenciones…",
|
||||
history: "Historial de la intervención",
|
||||
completed: "Completado por {{actor}} · {{date}}",
|
||||
help: "¿Para qué sirve una intervención?",
|
||||
helpDescription:
|
||||
"Registra el trabajo técnico realizado o previsto para una compañía, con ticket o por iniciativa interna.",
|
||||
scope:
|
||||
"Puede ser una actuación sencilla sin pasos. Las plantillas copian un procedimiento; no configuran equipos ni retiran accesos automáticamente.",
|
||||
examples: "Reconfigurar un correo, resolver un atasco de impresora o sustituir un portátil.",
|
||||
guidance:
|
||||
"Para finalizar, indica técnico y trabajo realizado. Los pasos obligatorios deben estar hechos o no aplicar con un motivo. Finalizar no cierra el ticket.",
|
||||
errors: {
|
||||
INVALID_REQUEST:
|
||||
"Revisa los datos. En curso o finalizada requiere técnico. Para finalizar indica el trabajo y completa los pasos obligatorios (o justifica que no aplican). Las fechas deben estar ordenadas y los minutos ser positivos o cero.",
|
||||
INTERVENTION_NOT_FOUND: "La intervención no está disponible en esta compañía.",
|
||||
INTERVENTION_CHANGED:
|
||||
"Otra persona ha modificado la intervención. Cierra y vuelve a abrir el formulario.",
|
||||
TICKET_NOT_FOUND: "El ticket no pertenece a esta compañía.",
|
||||
TEMPLATE_UNAVAILABLE: "La plantilla no está activa o no está disponible para esta compañía.",
|
||||
TEMPLATE_CHANGED: "La plantilla cambió. Recarga sus opciones y vuelve a aplicarla.",
|
||||
TARGET_UNAVAILABLE: "Algún elemento afectado no pertenece a la compañía o está inactivo.",
|
||||
ASSIGNEE_UNAVAILABLE: "El técnico no está disponible para una nueva asignación.",
|
||||
REQUEST_KEY_REUSED:
|
||||
"Esta petición ya se utilizó con otros datos. Cierra y vuelve a abrir el formulario.",
|
||||
COMPANY_NOT_FOUND: "La compañía no está disponible.",
|
||||
UNAUTHENTICATED: "La sesión ha caducado. Vuelve a entrar.",
|
||||
FORBIDDEN: "Recarga la página para autorizar la operación.",
|
||||
INTERNAL_ERROR: "No se ha podido completar la operación. Inténtalo de nuevo.",
|
||||
},
|
||||
};
|
||||
export const interventionsEnglish = {
|
||||
resetFilters: "Clear filters",
|
||||
title: "Interventions",
|
||||
global: "All interventions",
|
||||
new: "New intervention",
|
||||
edit: "Edit intervention",
|
||||
details: "Intervention details",
|
||||
subject: "Subject",
|
||||
state: "State",
|
||||
ticket: "Ticket (optional)",
|
||||
internal: "Internal intervention, no ticket",
|
||||
fromTicket: "Create intervention",
|
||||
assignee: "Responsible technician",
|
||||
unassigned: "Unassigned",
|
||||
modality: "Modality",
|
||||
states: {
|
||||
pending: "Pending",
|
||||
in_progress: "In progress",
|
||||
finished: "Finished",
|
||||
cancelled: "Cancelled",
|
||||
},
|
||||
modalities: { remote: "Remote", onsite: "On site", other: "Other" },
|
||||
plannedDate: "Planned date (optional)",
|
||||
startedAt: "Actual start (optional)",
|
||||
endedAt: "Actual end (optional)",
|
||||
minutes: "Time spent (minutes, optional)",
|
||||
unknownTime: "Not recorded",
|
||||
timeHelp: "Time spent is recorded manually and may differ from elapsed time.",
|
||||
diagnosis: "Diagnosis (optional)",
|
||||
workPerformed: "Work performed",
|
||||
resolution: "Result / resolution (optional)",
|
||||
template: "Template (optional)",
|
||||
noTemplate: "No template",
|
||||
apply: "Apply template",
|
||||
newTemplate: "New template",
|
||||
templateNotice:
|
||||
"Applying replaces current steps. The copy can be adapted and retains the original template version.",
|
||||
source: "Source template",
|
||||
steps: "Steps",
|
||||
progress: "{{done}} of {{total}} steps done",
|
||||
addStep: "Add step",
|
||||
removeStep: "Remove step",
|
||||
notes: "Notes / reason for not applying",
|
||||
stepStates: {
|
||||
pending: "Pending",
|
||||
in_progress: "In progress",
|
||||
done: "Done",
|
||||
not_applicable: "Not applicable",
|
||||
},
|
||||
search: "Find intervention",
|
||||
allCompanies: "All companies",
|
||||
allStates: "All states",
|
||||
allAssignees: "All technicians",
|
||||
dateFrom: "Planned from",
|
||||
dateTo: "Planned until",
|
||||
empty: "No matching interventions.",
|
||||
loading: "Loading interventions…",
|
||||
history: "Intervention history",
|
||||
completed: "Completed by {{actor}} · {{date}}",
|
||||
help: "What is an intervention for?",
|
||||
helpDescription:
|
||||
"Record technical work planned or performed for a company, linked to a ticket or initiated internally.",
|
||||
scope:
|
||||
"Simple work can have no steps. Templates copy a procedure; they do not configure devices or remove access automatically.",
|
||||
examples: "Reconfigure email, clear a printer jam or replace a laptop.",
|
||||
guidance:
|
||||
"To finish, enter a technician and work performed. Required steps must be done or marked not applicable with a reason. Finishing does not close the ticket.",
|
||||
errors: {
|
||||
INVALID_REQUEST:
|
||||
"Review the data. In progress or finished requires a technician. To finish, describe the work and complete required checks (or explain why they do not apply). Dates must be ordered and minutes nonnegative integers.",
|
||||
INTERVENTION_NOT_FOUND: "The intervention is unavailable in this company.",
|
||||
INTERVENTION_CHANGED: "Someone changed this intervention. Close and reopen the form.",
|
||||
TICKET_NOT_FOUND: "The ticket does not belong to this company.",
|
||||
TEMPLATE_UNAVAILABLE: "The template is inactive or unavailable for this company.",
|
||||
TEMPLATE_CHANGED: "The template changed. Refresh the options and apply it again.",
|
||||
TARGET_UNAVAILABLE: "An affected item is inactive or does not belong to the company.",
|
||||
ASSIGNEE_UNAVAILABLE: "The technician is unavailable for a new assignment.",
|
||||
REQUEST_KEY_REUSED:
|
||||
"This request was already used with different data. Close and reopen the form.",
|
||||
COMPANY_NOT_FOUND: "The company is unavailable.",
|
||||
UNAUTHENTICATED: "Your session expired. Sign in again.",
|
||||
FORBIDDEN: "Reload the page to authorize the operation.",
|
||||
INTERNAL_ERROR: "The operation failed. Try again.",
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,74 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type {
|
||||
InterventionDTO,
|
||||
InterventionLookupsDTO,
|
||||
SaveInterventionInput,
|
||||
} from "../../common/interventions";
|
||||
export function InterventionProcedureFields({
|
||||
prefix,
|
||||
intervention,
|
||||
lookups,
|
||||
selectedTemplate,
|
||||
onSelect,
|
||||
onApply,
|
||||
onNewTemplate,
|
||||
applied,
|
||||
}: {
|
||||
prefix: string;
|
||||
intervention: InterventionDTO | undefined;
|
||||
lookups: InterventionLookupsDTO | undefined;
|
||||
selectedTemplate: string;
|
||||
onSelect: (value: string) => void;
|
||||
onApply: () => void;
|
||||
onNewTemplate: () => void;
|
||||
applied: SaveInterventionInput["template"];
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
{!intervention && (
|
||||
<section className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-template`}>{t("interventions.template")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<SelectWithAction
|
||||
action={{
|
||||
label: t("interventions.newTemplate"),
|
||||
onClick: onNewTemplate,
|
||||
disabled: !lookups,
|
||||
}}
|
||||
disabled={!lookups}
|
||||
id={`${prefix}-template`}
|
||||
label={t("interventions.template")}
|
||||
onValueChange={onSelect}
|
||||
options={
|
||||
lookups?.templates
|
||||
.filter((item) => item.values.active)
|
||||
.map((item) => ({ value: item.id, label: item.values.name })) ?? []
|
||||
}
|
||||
placeholder={t("interventions.noTemplate")}
|
||||
value={selectedTemplate}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={!lookups} onClick={onApply} type="button" variant="outline">
|
||||
{t("interventions.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("interventions.templateNotice")}</p>
|
||||
</section>
|
||||
)}
|
||||
{(intervention?.procedure || applied) && (
|
||||
<p className="text-sm">
|
||||
{t("interventions.source")}:{" "}
|
||||
{intervention?.procedure?.values.name ??
|
||||
lookups?.templates.find((item) => item.id === applied?.id)?.values.name}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
modules/companies/src/web/interventions/intervention-steps.tsx
Normal file
145
modules/companies/src/web/interventions/intervention-steps.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
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 { type UseFormReturn, useFieldArray } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { type InterventionInput, InterventionStepStateSchema } from "../../common/interventions";
|
||||
export function InterventionSteps({
|
||||
form,
|
||||
prefix,
|
||||
}: {
|
||||
form: UseFormReturn<InterventionInput>;
|
||||
prefix: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const array = useFieldArray({ control: form.control, name: "steps", keyName: "fieldKey" });
|
||||
const values = form.watch("steps");
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<header className="flex justify-between items-center">
|
||||
<h3 className="font-medium">{t("interventions.steps")}</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("interventions.progress", {
|
||||
done: values.filter((item) => item.state === "done").length,
|
||||
total: values.length,
|
||||
})}
|
||||
</span>
|
||||
</header>
|
||||
{array.fields.map((field, index) => {
|
||||
const id = `${prefix}-step-${index}`;
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border p-4" key={field.fieldKey}>
|
||||
<header className="flex flex-wrap justify-between gap-2">
|
||||
<span>{index + 1}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
disabled={index === 0}
|
||||
onClick={() => array.move(index, index - 1)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventionTemplates.up")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={index === array.fields.length - 1}
|
||||
onClick={() => array.move(index, index + 1)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventionTemplates.down")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => array.remove(index)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventions.removeStep")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-title`}>{t("interventionTemplates.stepTitle")}</Label>
|
||||
<Input
|
||||
id={`${id}-title`}
|
||||
maxLength={200}
|
||||
required
|
||||
{...form.register(`steps.${index}.title`)}
|
||||
/>
|
||||
</div>
|
||||
<details>
|
||||
<summary className="text-sm cursor-pointer">
|
||||
{t("interventionTemplates.instructions")}
|
||||
</summary>
|
||||
<Textarea
|
||||
aria-label={t("interventionTemplates.instructions")}
|
||||
maxLength={4000}
|
||||
rows={2}
|
||||
{...form.register(`steps.${index}.instructions`)}
|
||||
/>
|
||||
</details>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Checkbox
|
||||
checked={form.watch(`steps.${index}.required`)}
|
||||
id={`${id}-required`}
|
||||
onCheckedChange={(value) =>
|
||||
form.setValue(`steps.${index}.required`, value === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`${id}-required`}>{t("interventionTemplates.required")}</Label>
|
||||
</div>
|
||||
<SelectWithAction
|
||||
id={`${id}-state`}
|
||||
label={t("interventions.state")}
|
||||
onValueChange={(value) => {
|
||||
const parsed = InterventionStepStateSchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
form.setValue(`steps.${index}.state`, parsed.data);
|
||||
}
|
||||
}}
|
||||
options={InterventionStepStateSchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`interventions.stepStates.${value}`),
|
||||
}))}
|
||||
placeholder={t("tickets.choose")}
|
||||
value={form.watch(`steps.${index}.state`)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-notes`}>{t("interventions.notes")}</Label>
|
||||
<Textarea
|
||||
id={`${id}-notes`}
|
||||
maxLength={4000}
|
||||
rows={2}
|
||||
{...form.register(`steps.${index}.notes`)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
disabled={array.fields.length >= 100}
|
||||
onClick={() =>
|
||||
array.append({
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
title: "",
|
||||
instructions: "",
|
||||
required: false,
|
||||
state: "pending",
|
||||
notes: "",
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventions.addStep")}
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
275
modules/companies/src/web/interventions/interventions-page.tsx
Normal file
275
modules/companies/src/web/interventions/interventions-page.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
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 InterventionDTO, InterventionStateSchema } from "../../common/interventions";
|
||||
|
||||
import { interventionApi, interventionKeys } from "./intervention-api";
|
||||
import { InterventionDetail } from "./intervention-detail";
|
||||
import { InterventionForm } from "./intervention-form";
|
||||
import { InterventionFeedback } from "./intervention-help";
|
||||
export type InterventionFilters = {
|
||||
search: string;
|
||||
company: string;
|
||||
state: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
assignee: string;
|
||||
};
|
||||
export function filterInterventions(
|
||||
items: readonly InterventionDTO[],
|
||||
filters: InterventionFilters
|
||||
) {
|
||||
const search = filters.search.trim().toLocaleLowerCase();
|
||||
return items
|
||||
.filter(
|
||||
(item) =>
|
||||
(!filters.company || item.companyId === filters.company) &&
|
||||
(!filters.state || item.values.state === filters.state) &&
|
||||
matchesPlannedDate(item.values.plannedDate, filters.dateFrom, filters.dateTo) &&
|
||||
(!filters.assignee ||
|
||||
(filters.assignee === "unassigned"
|
||||
? item.values.assigneeId === null
|
||||
: item.values.assigneeId === filters.assignee)) &&
|
||||
(!search ||
|
||||
[item.reference, item.values.subject, item.companyName, item.assigneeName ?? ""].some(
|
||||
(text) => text.toLocaleLowerCase().includes(search)
|
||||
))
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(b.history.at(-1)?.recordedAt ?? "").localeCompare(a.history.at(-1)?.recordedAt ?? "")
|
||||
);
|
||||
}
|
||||
export function InterventionsPage({
|
||||
companyId,
|
||||
csrfToken,
|
||||
}: {
|
||||
companyId?: string;
|
||||
csrfToken: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const query = useQuery({
|
||||
queryKey: interventionKeys.list(companyId),
|
||||
queryFn: () => interventionApi.list(companyId),
|
||||
});
|
||||
const [filters, setFilters] = useState<InterventionFilters>({
|
||||
search: "",
|
||||
company: "",
|
||||
state: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
assignee: "",
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
const [edit, setEdit] = useState<InterventionDTO | undefined>();
|
||||
const [view, setView] = useState<InterventionDTO | undefined>();
|
||||
const items = filterInterventions(query.data?.interventions ?? [], 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 ? "interventions.title" : "interventions.global")}
|
||||
</h2>
|
||||
<Button
|
||||
disabled={!query.data || query.isError}
|
||||
onClick={() => {
|
||||
setEdit(undefined);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("interventions.new")}
|
||||
</Button>
|
||||
</header>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Input
|
||||
aria-label={t("interventions.search")}
|
||||
onChange={(event) => setFilters({ ...filters, search: event.target.value })}
|
||||
placeholder={t("interventions.search")}
|
||||
value={filters.search}
|
||||
/>
|
||||
{!companyId && (
|
||||
<SelectWithAction
|
||||
id="interventions-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("interventions.allCompanies")}
|
||||
value={filters.company}
|
||||
/>
|
||||
)}
|
||||
<SelectWithAction
|
||||
id="interventions-state-filter"
|
||||
label={t("interventions.state")}
|
||||
onValueChange={(state) => setFilters({ ...filters, state })}
|
||||
options={InterventionStateSchema.options.map((value) => ({
|
||||
value,
|
||||
label: t(`interventions.states.${value}`),
|
||||
}))}
|
||||
placeholder={t("interventions.allStates")}
|
||||
value={filters.state}
|
||||
/>
|
||||
<SelectWithAction
|
||||
id="interventions-assignee-filter"
|
||||
label={t("interventions.assignee")}
|
||||
onValueChange={(assignee) => setFilters({ ...filters, assignee })}
|
||||
options={[
|
||||
{ value: "unassigned", label: t("interventions.unassigned") },
|
||||
...(query.data?.technicians.map((item) => ({ value: item.id, label: item.name })) ??
|
||||
[]),
|
||||
]}
|
||||
placeholder={t("interventions.allAssignees")}
|
||||
value={filters.assignee}
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("interventions.dateFrom")}
|
||||
onChange={(event) => setFilters({ ...filters, dateFrom: event.target.value })}
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("interventions.dateTo")}
|
||||
onChange={(event) => setFilters({ ...filters, dateTo: event.target.value })}
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
search: "",
|
||||
company: "",
|
||||
state: "",
|
||||
assignee: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("interventions.resetFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
|
||||
{query.isError && (
|
||||
<div className="space-y-3">
|
||||
<InterventionFeedback 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("interventions.empty")}</p>
|
||||
)}
|
||||
<ul className="divide-y">
|
||||
{items.map((intervention) => (
|
||||
<li className="space-y-2 py-4" key={intervention.id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{intervention.reference} · {intervention.companyName}
|
||||
</p>
|
||||
<h3 className="font-semibold">{intervention.values.subject}</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setView(intervention)} size="sm" variant="outline">
|
||||
{t("interventions.details")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEdit(intervention);
|
||||
setOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{t(`interventions.states.${intervention.values.state}`)} ·{" "}
|
||||
{intervention.assigneeName ?? t("interventions.unassigned")} ·{" "}
|
||||
{intervention.values.minutes === null
|
||||
? t("interventions.unknownTime")
|
||||
: `${intervention.values.minutes} min`}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{intervention.ticketReference ?? t("interventions.internal")}
|
||||
{intervention.values.plannedDate ? ` · ${intervention.values.plannedDate}` : ""}
|
||||
</p>
|
||||
{intervention.values.steps.length > 0 && (
|
||||
<p className="text-sm">
|
||||
{t("interventions.progress", {
|
||||
done: intervention.values.steps.filter((step) => step.state === "done").length,
|
||||
total: intervention.values.steps.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("tickets.updated")}: {date(intervention.history.at(-1)?.recordedAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setOpen}
|
||||
open={open}
|
||||
title={t(edit ? "interventions.edit" : "interventions.new")}
|
||||
wide
|
||||
>
|
||||
{query.data && open && (
|
||||
<InterventionForm
|
||||
key={edit?.id ?? "new"}
|
||||
{...(companyId ? { scope: companyId } : {})}
|
||||
{...(edit ? { intervention: 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("interventions.details")}
|
||||
wide
|
||||
>
|
||||
{view && query.data && <InterventionDetail data={query.data} intervention={view} />}
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function matchesPlannedDate(date: string | null, from: string, to: string) {
|
||||
if (!(from || to)) {
|
||||
return true;
|
||||
}
|
||||
if (!date) {
|
||||
return false;
|
||||
}
|
||||
return (!from || date >= from) && (!to || date <= to);
|
||||
}
|
||||
150
modules/companies/src/web/interventions/interventions.test.tsx
Normal file
150
modules/companies/src/web/interventions/interventions.test.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
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 { InterventionDTO, InterventionListDTO } from "../../common/interventions";
|
||||
import { companiesEnglish, companiesSpanish } from "../i18n";
|
||||
|
||||
import { interventionKeys } from "./intervention-api";
|
||||
import { stepCompletion } from "./intervention-detail";
|
||||
import { InterventionForm, emptyIntervention } from "./intervention-form";
|
||||
import { InterventionsPage, filterInterventions } from "./interventions-page";
|
||||
|
||||
const companyId = UniqueID.generateNewID().toPrimitive();
|
||||
const data: InterventionListDTO = {
|
||||
interventions: [],
|
||||
companies: [{ id: companyId, name: "Empresa test", active: true }],
|
||||
technicians: [],
|
||||
};
|
||||
function item(): InterventionDTO {
|
||||
const values = { ...emptyIntervention(), subject: "Atasco", plannedDate: "2026-10-07" };
|
||||
return {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId,
|
||||
companyName: "Empresa test",
|
||||
reference: "I-000001",
|
||||
values,
|
||||
assigneeName: null,
|
||||
ticketReference: null,
|
||||
procedure: null,
|
||||
history: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
values,
|
||||
actorId: companyId,
|
||||
actorName: "Técnico",
|
||||
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(interventionKeys.list(), data);
|
||||
client.setQueryData(interventionKeys.list(companyId), data);
|
||||
client.setQueryData(interventionKeys.lookups(companyId), {
|
||||
people: [],
|
||||
assets: [],
|
||||
resources: [],
|
||||
workstations: [],
|
||||
tickets: [],
|
||||
templates: [],
|
||||
});
|
||||
try {
|
||||
return renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
test("intervenciones: filtros por compañía, técnico, estado y fecha prevista", () => {
|
||||
const filters = { search: "", company: "", state: "", assignee: "", dateFrom: "", dateTo: "" };
|
||||
const intervention = item();
|
||||
assert.equal(filterInterventions([intervention], filters).length, 1);
|
||||
assert.equal(filterInterventions([intervention], { ...filters, state: "finished" }).length, 0);
|
||||
assert.equal(
|
||||
filterInterventions([intervention], { ...filters, assignee: "unassigned" }).length,
|
||||
1
|
||||
);
|
||||
assert.equal(
|
||||
filterInterventions([intervention], { ...filters, dateFrom: "2026-10-08" }).length,
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
filterInterventions([intervention], {
|
||||
...filters,
|
||||
dateFrom: "2026-10-07",
|
||||
dateTo: "2026-10-07",
|
||||
}).length,
|
||||
1
|
||||
);
|
||||
assert.equal(filterInterventions([intervention], { ...filters, company: "other" }).length, 0);
|
||||
});
|
||||
test("intervenciones: vistas es/en sin formularios junto al listado", async () => {
|
||||
const es = await render(<InterventionsPage csrfToken="test" />);
|
||||
assert.ok(es.includes("Todas las intervenciones"));
|
||||
assert.equal(es.includes("<form"), false);
|
||||
const en = await render(<InterventionsPage csrfToken="test" />, "en");
|
||||
assert.ok(en.includes("All interventions"));
|
||||
});
|
||||
test("intervenciones: modal sencillo, pasos opcionales, altas rápidas y ayuda inferior", async () => {
|
||||
const html = await render(
|
||||
<InterventionForm csrfToken="test" data={data} onSaved={() => undefined} scope={companyId} />
|
||||
);
|
||||
assert.ok(html.includes("Empresa test"));
|
||||
assert.ok(html.includes("Nueva plantilla"));
|
||||
assert.ok(html.includes("Nuevo ticket"));
|
||||
assert.ok(html.includes("Añadir paso"));
|
||||
assert.ok(html.indexOf("¿Para qué sirve una intervención?") < html.indexOf("Guardar"));
|
||||
assert.equal(html.includes("Nombre del paso"), false);
|
||||
});
|
||||
test("intervenciones: autor de completar un paso se conserva al editar notas", () => {
|
||||
const source = item();
|
||||
const id = UniqueID.generateNewID().toPrimitive();
|
||||
const step = {
|
||||
id,
|
||||
title: "Comprobar",
|
||||
instructions: "",
|
||||
required: false,
|
||||
state: "done" as const,
|
||||
notes: "",
|
||||
};
|
||||
const first = {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
actorId: companyId,
|
||||
actorName: "Ana",
|
||||
recordedAt: "2026-10-07T10:00:00.000Z",
|
||||
values: { ...source.values, steps: [step] },
|
||||
};
|
||||
const second = {
|
||||
...first,
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
actorName: "David",
|
||||
recordedAt: "2026-10-07T11:00:00.000Z",
|
||||
values: { ...first.values, steps: [{ ...step, notes: "Nota" }] },
|
||||
};
|
||||
assert.equal(stepCompletion({ ...source, history: [first, second] }, id)?.actorName, "Ana");
|
||||
const reopened = {
|
||||
...second,
|
||||
values: { ...second.values, steps: [{ ...step, state: "pending" as const }] },
|
||||
};
|
||||
assert.equal(stepCompletion({ ...source, history: [first, reopened] }, id), undefined);
|
||||
assert.equal(
|
||||
stepCompletion({ ...source, history: [first, reopened, second] }, id)?.actorName,
|
||||
"David"
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { TicketDTO } from "../../common/tickets";
|
||||
|
||||
import { interventionApi, interventionKeys } from "./intervention-api";
|
||||
import { InterventionForm } from "./intervention-form";
|
||||
import { InterventionFeedback } from "./intervention-help";
|
||||
export function TicketInterventionForm({
|
||||
ticket,
|
||||
csrfToken,
|
||||
onSaved,
|
||||
}: {
|
||||
ticket: TicketDTO;
|
||||
csrfToken: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const query = useQuery({
|
||||
queryKey: interventionKeys.list(ticket.companyId),
|
||||
queryFn: () => interventionApi.list(ticket.companyId),
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
|
||||
<InterventionFeedback error={query.error} />
|
||||
{query.isError && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
query.refetch().catch(() => undefined);
|
||||
}}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{query.data && !query.isError && (
|
||||
<InterventionForm
|
||||
csrfToken={csrfToken}
|
||||
data={query.data}
|
||||
initialTicket={ticket}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export function TicketInterventions({
|
||||
companyId,
|
||||
ticketId,
|
||||
}: {
|
||||
companyId: string;
|
||||
ticketId: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const query = useQuery({
|
||||
queryKey: interventionKeys.list(companyId),
|
||||
queryFn: () => interventionApi.list(companyId),
|
||||
});
|
||||
const items = query.data?.interventions.filter((item) => item.values.ticketId === ticketId) ?? [];
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h4 className="font-medium">{t("interventions.title")}</h4>
|
||||
{query.isPending && <p role="status">{t("interventions.loading")}</p>}
|
||||
<InterventionFeedback error={query.error} />
|
||||
{query.data && !query.isError && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.reference} · {item.values.subject} ·{" "}
|
||||
{t(`interventions.states.${item.values.state}`)} ·{" "}
|
||||
{item.assigneeName ?? t("interventions.unassigned")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { TicketDTO, TicketListDTO } from "../../common/tickets";
|
||||
import { TicketInterventions } from "../interventions/ticket-interventions";
|
||||
|
||||
import { ticketApi, ticketKeys } from "./ticket-api";
|
||||
import { ticketTargetOptions } from "./ticket-targets";
|
||||
@ -50,6 +51,7 @@ export function TicketDetail({ ticket, data }: { ticket: TicketDTO; data: Ticket
|
||||
{ticket.values.description && (
|
||||
<p className="whitespace-pre-wrap break-words">{ticket.values.description}</p>
|
||||
)}
|
||||
<TicketInterventions companyId={ticket.companyId} ticketId={ticket.id} />
|
||||
<h4 className="font-medium">
|
||||
{t("tickets.history")} ({ticket.history.length})
|
||||
</h4>
|
||||
|
||||
@ -35,7 +35,7 @@ export function TicketForm({
|
||||
ticket?: TicketDTO;
|
||||
data: TicketListDTO;
|
||||
csrfToken: string;
|
||||
onSaved: () => void;
|
||||
onSaved: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
@ -70,9 +70,9 @@ export function TicketForm({
|
||||
csrfToken,
|
||||
ticket?.id
|
||||
),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (response) => {
|
||||
await client.invalidateQueries({ queryKey: ["tickets"] });
|
||||
onSaved();
|
||||
onSaved(response.ticket.id);
|
||||
},
|
||||
});
|
||||
const changeCompany = (id: string) => {
|
||||
|
||||
@ -28,12 +28,14 @@ export function TicketTargets({
|
||||
values,
|
||||
lookups,
|
||||
onChange,
|
||||
lookupKey,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
values: TicketTargetInput[];
|
||||
lookups: TicketLookupsDTO;
|
||||
onChange: (values: TicketTargetInput[]) => void;
|
||||
lookupKey?: readonly unknown[];
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
@ -141,7 +143,7 @@ export function TicketTargets({
|
||||
kind={quick}
|
||||
onSaved={(id) => {
|
||||
client
|
||||
.invalidateQueries({ queryKey: ticketKeys.lookups(companyId) })
|
||||
.invalidateQueries({ queryKey: lookupKey ?? ticketKeys.lookups(companyId) })
|
||||
.then(() => {
|
||||
add(id, quick);
|
||||
setQuick(undefined);
|
||||
|
||||
@ -7,6 +7,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { type TicketDTO, TicketPrioritySchema, TicketStateSchema } from "../../common/tickets";
|
||||
import { TicketInterventionForm } from "../interventions/ticket-interventions";
|
||||
|
||||
import { ticketApi, ticketKeys } from "./ticket-api";
|
||||
import { TicketDetail } from "./ticket-detail";
|
||||
@ -56,6 +57,7 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
||||
const [open, setOpen] = useState(false);
|
||||
const [edit, setEdit] = useState<TicketDTO | undefined>();
|
||||
const [view, setView] = useState<TicketDTO | undefined>();
|
||||
const [interventionTicket, setInterventionTicket] = useState<TicketDTO>();
|
||||
const items = filterTickets(query.data?.tickets ?? [], filters);
|
||||
const date = (value: string | undefined) =>
|
||||
value
|
||||
@ -159,6 +161,9 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
||||
<h3 className="font-semibold">{ticket.values.subject}</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setInterventionTicket(ticket)} size="sm" variant="outline">
|
||||
{t("interventions.fromTicket")}
|
||||
</Button>
|
||||
<Button onClick={() => setView(ticket)} size="sm" variant="outline">
|
||||
{t("tickets.details")}
|
||||
</Button>
|
||||
@ -205,6 +210,26 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={(value) => {
|
||||
if (!value) {
|
||||
setInterventionTicket(undefined);
|
||||
}
|
||||
}}
|
||||
open={Boolean(interventionTicket)}
|
||||
title={t("interventions.new")}
|
||||
wide
|
||||
>
|
||||
{interventionTicket && (
|
||||
<TicketInterventionForm
|
||||
csrfToken={csrfToken}
|
||||
onSaved={() => setInterventionTicket(undefined)}
|
||||
ticket={interventionTicket}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
onOpenChange={(value) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user