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 { QueryTypes } from "sequelize";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
import { migrateInterventionTemplates } from "../../../modules/companies/src/api/infrastructure/persistence/intervention-template-migration";
|
||||||
|
|
||||||
import { verifyAccess } from "./access.integration";
|
import { verifyAccess } from "./access.integration";
|
||||||
import { createApp } from "./app";
|
import { createApp } from "./app";
|
||||||
import { verifyApplications } from "./applications.integration";
|
import { verifyApplications } from "./applications.integration";
|
||||||
@ -26,6 +28,8 @@ import { verifyAssets } from "./assets.integration";
|
|||||||
import { verifyCertificates } from "./certificates.integration";
|
import { verifyCertificates } from "./certificates.integration";
|
||||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||||
|
import { verifyInterventionTemplates } from "./intervention-templates.integration";
|
||||||
|
import { verifyInterventions } from "./interventions.integration";
|
||||||
import { verifyInventory } from "./inventory.integration";
|
import { verifyInventory } from "./inventory.integration";
|
||||||
import { verifyMail } from "./mail.integration";
|
import { verifyMail } from "./mail.integration";
|
||||||
import { verifyPrinters } from "./printers.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 once(server, "listening");
|
||||||
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1");
|
||||||
await database.query("DELETE FROM http_sessions");
|
await database.query("DELETE FROM http_sessions");
|
||||||
|
await database.query("DELETE FROM 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 ticket_revisions");
|
||||||
await database.query("DELETE FROM tickets");
|
await database.query("DELETE FROM tickets");
|
||||||
await database.query("UPDATE ticket_counter SET nextNumber=1 WHERE id=1");
|
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
|
database
|
||||||
);
|
);
|
||||||
await verifySupport((...args) => operator.request(...args), customer.id, secondCompany.id);
|
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 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í.
|
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
|
||||||
const migratedRuntime = await createIdentityRuntime(database, environment);
|
const migratedRuntime = await createIdentityRuntime(database, environment);
|
||||||
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
|
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
|
||||||
@ -919,6 +940,12 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
|||||||
401,
|
401,
|
||||||
"UNAUTHENTICATED"
|
"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("/tickets"), 401, "UNAUTHENTICATED");
|
||||||
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
|
||||||
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
|
||||||
|
|||||||
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
|
## 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).
|
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
|
## Siguientes entregas: orden actualizado
|
||||||
|
|
||||||
1. Intervenciones: trabajo realizado, técnico, tiempos y elementos afectados; múltiples intervenciones por ticket e intervenciones internas sin ticket.
|
1. Vinculación explícita entre un acceso remoto y su VPN previa, si resulta necesaria.
|
||||||
2. Vinculación explícita entre un acceso remoto y su VPN previa, si resulta necesaria.
|
|
||||||
|
|
||||||
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
|
Este orden se concreta antes de cada entrega. Las licencias y su relación con cuentas se explorarán posteriormente.
|
||||||
|
|
||||||
@ -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).
|
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
|
## 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
|
## 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
|
## 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.
|
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": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote/remote.test.tsx src/web/processes/processes.test.tsx 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": {
|
"dependencies": {
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@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 { CompanyUseCases } from "./application/company.use-cases";
|
||||||
import { CompanyInputMapper } from "./application/company-input.mapper";
|
import { CompanyInputMapper } from "./application/company-input.mapper";
|
||||||
import { FolderMappingUseCases } from "./application/folder-mapping.use-cases";
|
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 type { IMailInventoryService } from "./application/mail-configuration.repository";
|
||||||
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
import { MailConfigurationUseCases } from "./application/mail-configuration.use-cases";
|
||||||
import { MailboxUseCases } from "./application/mailbox.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 { registerCertificateConfigurationRoutes } from "./infrastructure/http/certificate-configuration.routes";
|
||||||
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
|
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
|
||||||
import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.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 { registerMailRoutes } from "./infrastructure/http/mail-configuration.routes";
|
||||||
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
|
import { registerMailboxRoutes } from "./infrastructure/http/mailbox.routes";
|
||||||
import { registerPrinterConfigurationRoutes } from "./infrastructure/http/printer-configuration.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 { initializeCompanyModels } from "./infrastructure/persistence/company.model";
|
||||||
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
|
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
|
||||||
import { migrateFolderMappings } from "./infrastructure/persistence/folder-mapping-migration";
|
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 { migrateMailConfigurations } from "./infrastructure/persistence/mail-configuration-migration";
|
||||||
import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration";
|
import { migrateMailboxes } from "./infrastructure/persistence/mailbox-migration";
|
||||||
import { migrateCompanies } from "./infrastructure/persistence/migrate-companies";
|
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 { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository";
|
||||||
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
||||||
import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.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 { SequelizeMailConfigurationRepository } from "./infrastructure/persistence/sequelize-mail-configuration.repository";
|
||||||
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
|
import { SequelizeMailboxRepository } from "./infrastructure/persistence/sequelize-mailbox.repository";
|
||||||
import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository";
|
import { SequelizePrinterConfigurationRepository } from "./infrastructure/persistence/sequelize-printer-configuration.repository";
|
||||||
@ -74,6 +82,9 @@ export async function createCompanyModule(
|
|||||||
) {
|
) {
|
||||||
await migrateCompanies(database);
|
await migrateCompanies(database);
|
||||||
await migratePeople(database);
|
await migratePeople(database);
|
||||||
|
await migrateInterventionTemplates(database);
|
||||||
|
const interventionTemplateRepository = new SequelizeInterventionTemplateRepository(database);
|
||||||
|
const interventionTemplates = new InterventionTemplateUseCases(interventionTemplateRepository);
|
||||||
initializeCompanyModels(database);
|
initializeCompanyModels(database);
|
||||||
initializePeopleModels(database);
|
initializePeopleModels(database);
|
||||||
const repository = new SequelizeCompanyRepository(database, new CompanyPersistenceMapper());
|
const repository = new SequelizeCompanyRepository(database, new CompanyPersistenceMapper());
|
||||||
@ -94,8 +105,15 @@ export async function createCompanyModule(
|
|||||||
resources: IResourceInventoryService
|
resources: IResourceInventoryService
|
||||||
) => {
|
) => {
|
||||||
await migrateTickets(database);
|
await migrateTickets(database);
|
||||||
const tickets = new TicketUseCases(
|
const ticketRepository = new SequelizeTicketRepository(database, inventory, resources);
|
||||||
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);
|
await migrateProcesses(database);
|
||||||
const processes = new ProcessUseCases(
|
const processes = new ProcessUseCases(
|
||||||
@ -127,6 +145,7 @@ export async function createCompanyModule(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
register: (app: Parameters<typeof registerWorkProfileRoutes>[0]) => {
|
||||||
|
registerInterventionRoutes(app, interventions, authenticate);
|
||||||
registerTicketRoutes(app, tickets, authenticate);
|
registerTicketRoutes(app, tickets, authenticate);
|
||||||
registerProcessRoutes(app, processes, authenticate);
|
registerProcessRoutes(app, processes, authenticate);
|
||||||
registerRemoteConnectionRoutes(app, remote, authenticate);
|
registerRemoteConnectionRoutes(app, remote, authenticate);
|
||||||
@ -155,6 +174,7 @@ export async function createCompanyModule(
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
|
||||||
|
registerInterventionTemplateRoutes(app, interventionTemplates, authenticate);
|
||||||
registerCompanyRoutes(app, useCases, authenticate);
|
registerCompanyRoutes(app, useCases, authenticate);
|
||||||
registerTeleworkAuthorizationRoutes(app, telework, authenticate);
|
registerTeleworkAuthorizationRoutes(app, telework, authenticate);
|
||||||
registerAccessRoutes(app, access, authenticate, legacyResourceWrites);
|
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/company.dto";
|
||||||
export * from "./dto/shared/people.dto";
|
export * from "./dto/shared/people.dto";
|
||||||
export * from "./folder-mappings";
|
export * from "./folder-mappings";
|
||||||
|
export * from "./intervention-templates";
|
||||||
|
export * from "./interventions";
|
||||||
export * from "./mail-configurations";
|
export * from "./mail-configurations";
|
||||||
export * from "./mailboxes";
|
export * from "./mailboxes";
|
||||||
export * from "./printer-configurations";
|
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 { AccessWorkspacePage } from "../access/access-workspace";
|
||||||
import { companyApi, companyKeys } from "../api/company-api";
|
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 { TicketsPage } from "../tickets/tickets-page";
|
||||||
|
|
||||||
import { CompaniesPage } from "./companies-page";
|
import { CompaniesPage } from "./companies-page";
|
||||||
@ -18,6 +20,7 @@ import { CompanyFeedback } from "./company-feedback";
|
|||||||
type WorkspaceLocation =
|
type WorkspaceLocation =
|
||||||
| { area: "company"; id: string }
|
| { area: "company"; id: string }
|
||||||
| { area: "tickets" }
|
| { area: "tickets" }
|
||||||
|
| { area: "interventions" }
|
||||||
| { area: "directory" }
|
| { area: "directory" }
|
||||||
| { area: "catalogs" };
|
| { area: "catalogs" };
|
||||||
|
|
||||||
@ -35,7 +38,9 @@ export function CompaniesWorkspace({
|
|||||||
const { t: accessTranslation } = useTranslation("access");
|
const { t: accessTranslation } = useTranslation("access");
|
||||||
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
|
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
|
||||||
const [location, setLocation] = useState<WorkspaceLocation>();
|
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 companies = query.data?.companies ?? [];
|
||||||
const current =
|
const current =
|
||||||
location ?? (companies[0] ? { area: "company", id: companies[0].id } : { area: "directory" });
|
location ?? (companies[0] ? { area: "company", id: companies[0].id } : { area: "directory" });
|
||||||
@ -74,6 +79,16 @@ export function CompaniesWorkspace({
|
|||||||
>
|
>
|
||||||
T
|
T
|
||||||
</Button>
|
</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" />
|
<div className="desk-rail-separator" />
|
||||||
{companies.map((company) => (
|
{companies.map((company) => (
|
||||||
<Button
|
<Button
|
||||||
@ -147,6 +162,13 @@ export function CompaniesWorkspace({
|
|||||||
>
|
>
|
||||||
{assetTranslation("title")}
|
{assetTranslation("title")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-pressed={catalog === "intervention-templates"}
|
||||||
|
onClick={() => setCatalog("intervention-templates")}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{t("interventionTemplates.title")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-pressed={catalog === "resources"}
|
aria-pressed={catalog === "resources"}
|
||||||
onClick={() => setCatalog("resources")}
|
onClick={() => setCatalog("resources")}
|
||||||
@ -157,7 +179,7 @@ export function CompaniesWorkspace({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button aria-pressed variant="ghost">
|
<Button aria-pressed variant="ghost">
|
||||||
{t(current.area === "tickets" ? "tickets.global" : "title")}
|
{t(workspaceTitle(current.area))}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@ -181,10 +203,12 @@ function GlobalCatalogContent({
|
|||||||
catalog,
|
catalog,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
}: {
|
}: {
|
||||||
catalog: "access" | "assets" | "resources";
|
catalog: "access" | "assets" | "resources" | "intervention-templates";
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
}) {
|
}) {
|
||||||
switch (catalog) {
|
switch (catalog) {
|
||||||
|
case "intervention-templates":
|
||||||
|
return <InterventionTemplatesPage csrfToken={csrfToken} />;
|
||||||
case "access":
|
case "access":
|
||||||
return <AccessWorkspacePage csrfToken={csrfToken} scope="global" />;
|
return <AccessWorkspacePage csrfToken={csrfToken} scope="global" />;
|
||||||
case "assets":
|
case "assets":
|
||||||
@ -198,6 +222,8 @@ function workspaceTitle(area: WorkspaceLocation["area"]) {
|
|||||||
switch (area) {
|
switch (area) {
|
||||||
case "catalogs":
|
case "catalogs":
|
||||||
return "globalCatalogs";
|
return "globalCatalogs";
|
||||||
|
case "interventions":
|
||||||
|
return "interventions.global";
|
||||||
case "tickets":
|
case "tickets":
|
||||||
return "tickets.global";
|
return "tickets.global";
|
||||||
default:
|
default:
|
||||||
@ -211,11 +237,13 @@ function WorkspaceContent({
|
|||||||
onSelectCompany,
|
onSelectCompany,
|
||||||
}: {
|
}: {
|
||||||
area: WorkspaceLocation["area"];
|
area: WorkspaceLocation["area"];
|
||||||
catalog: "access" | "assets" | "resources";
|
catalog: "access" | "assets" | "resources" | "intervention-templates";
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
onSelectCompany: (id: string) => void;
|
onSelectCompany: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
switch (area) {
|
switch (area) {
|
||||||
|
case "interventions":
|
||||||
|
return <InterventionsPage csrfToken={csrfToken} />;
|
||||||
case "tickets":
|
case "tickets":
|
||||||
return <TicketsPage csrfToken={csrfToken} />;
|
return <TicketsPage csrfToken={csrfToken} />;
|
||||||
case "directory":
|
case "directory":
|
||||||
|
|||||||
@ -24,6 +24,8 @@ import { companyApi, companyKeys } from "../api/company-api";
|
|||||||
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
|
import { ApplicationConfigurationsPage } from "../applications/application-configurations-page";
|
||||||
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
|
import { CertificateConfigurationsPage } from "../certificates/certificate-configurations-page";
|
||||||
import { FolderMappingsPage } from "../folders/folder-mappings-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 { MailConfigurationsPage } from "../mail/mail-configurations-page";
|
||||||
import { MailboxRelations } from "../mail/mailbox-relations";
|
import { MailboxRelations } from "../mail/mailbox-relations";
|
||||||
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
import { PrinterConfigurationsPage } from "../printers/printer-configurations-page";
|
||||||
@ -114,6 +116,8 @@ function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
type CompanyTab =
|
type CompanyTab =
|
||||||
|
| "interventions"
|
||||||
|
| "intervention-templates"
|
||||||
| "tickets"
|
| "tickets"
|
||||||
| "processes"
|
| "processes"
|
||||||
| "remote"
|
| "remote"
|
||||||
@ -227,6 +231,8 @@ export function CompanyDetail({
|
|||||||
["telework", t("telework.title"), KeyRoundIcon],
|
["telework", t("telework.title"), KeyRoundIcon],
|
||||||
["remote", t("remote.title"), KeyRoundIcon],
|
["remote", t("remote.title"), KeyRoundIcon],
|
||||||
["processes", t("processes.title"), LayoutTemplateIcon],
|
["processes", t("processes.title"), LayoutTemplateIcon],
|
||||||
|
["intervention-templates", t("interventionTemplates.title"), LayoutTemplateIcon],
|
||||||
|
["interventions", t("interventions.title"), LayoutTemplateIcon],
|
||||||
["tickets", t("tickets.title"), LayoutTemplateIcon],
|
["tickets", t("tickets.title"), LayoutTemplateIcon],
|
||||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||||
[
|
[
|
||||||
@ -277,6 +283,10 @@ function CompanySection({
|
|||||||
tab: CompanyTab;
|
tab: CompanyTab;
|
||||||
}) {
|
}) {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
|
case "intervention-templates":
|
||||||
|
return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />;
|
||||||
|
case "interventions":
|
||||||
|
return <InterventionsPage companyId={company.id} csrfToken={csrfToken} />;
|
||||||
case "tickets":
|
case "tickets":
|
||||||
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
|
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
|
||||||
case "processes":
|
case "processes":
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { applicationsEnglish, applicationsSpanish } from "./applications/application-i18n";
|
import { applicationsEnglish, applicationsSpanish } from "./applications/application-i18n";
|
||||||
import { certificatesEnglish, certificatesSpanish } from "./certificates/certificate-i18n";
|
import { certificatesEnglish, certificatesSpanish } from "./certificates/certificate-i18n";
|
||||||
import { foldersEnglish, foldersSpanish } from "./folders/folder-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 { mailEnglish, mailSpanish } from "./mail/mail-i18n";
|
||||||
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
import { mailboxEnglish, mailboxSpanish } from "./mail/mailbox-i18n";
|
||||||
import { printersEnglish, printersSpanish } from "./printers/printer-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";
|
import { ticketsEnglish, ticketsSpanish } from "./tickets/ticket-i18n";
|
||||||
|
|
||||||
export const companiesSpanish = {
|
export const companiesSpanish = {
|
||||||
|
interventionTemplates: interventionTemplatesSpanish,
|
||||||
|
interventions: interventionsSpanish,
|
||||||
tickets: ticketsSpanish,
|
tickets: ticketsSpanish,
|
||||||
processes: processesSpanish,
|
processes: processesSpanish,
|
||||||
remote: remoteSpanish,
|
remote: remoteSpanish,
|
||||||
@ -138,6 +145,8 @@ export const companiesSpanish = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const companiesEnglish = {
|
export const companiesEnglish = {
|
||||||
|
interventionTemplates: interventionTemplatesEnglish,
|
||||||
|
interventions: interventionsEnglish,
|
||||||
tickets: ticketsEnglish,
|
tickets: ticketsEnglish,
|
||||||
processes: processesEnglish,
|
processes: processesEnglish,
|
||||||
remote: remoteEnglish,
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { TicketDTO, TicketListDTO } from "../../common/tickets";
|
import type { TicketDTO, TicketListDTO } from "../../common/tickets";
|
||||||
|
import { TicketInterventions } from "../interventions/ticket-interventions";
|
||||||
|
|
||||||
import { ticketApi, ticketKeys } from "./ticket-api";
|
import { ticketApi, ticketKeys } from "./ticket-api";
|
||||||
import { ticketTargetOptions } from "./ticket-targets";
|
import { ticketTargetOptions } from "./ticket-targets";
|
||||||
@ -50,6 +51,7 @@ export function TicketDetail({ ticket, data }: { ticket: TicketDTO; data: Ticket
|
|||||||
{ticket.values.description && (
|
{ticket.values.description && (
|
||||||
<p className="whitespace-pre-wrap break-words">{ticket.values.description}</p>
|
<p className="whitespace-pre-wrap break-words">{ticket.values.description}</p>
|
||||||
)}
|
)}
|
||||||
|
<TicketInterventions companyId={ticket.companyId} ticketId={ticket.id} />
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
{t("tickets.history")} ({ticket.history.length})
|
{t("tickets.history")} ({ticket.history.length})
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export function TicketForm({
|
|||||||
ticket?: TicketDTO;
|
ticket?: TicketDTO;
|
||||||
data: TicketListDTO;
|
data: TicketListDTO;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
onSaved: () => void;
|
onSaved: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
const prefix = useId();
|
const prefix = useId();
|
||||||
@ -70,9 +70,9 @@ export function TicketForm({
|
|||||||
csrfToken,
|
csrfToken,
|
||||||
ticket?.id
|
ticket?.id
|
||||||
),
|
),
|
||||||
onSuccess: async () => {
|
onSuccess: async (response) => {
|
||||||
await client.invalidateQueries({ queryKey: ["tickets"] });
|
await client.invalidateQueries({ queryKey: ["tickets"] });
|
||||||
onSaved();
|
onSaved(response.ticket.id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const changeCompany = (id: string) => {
|
const changeCompany = (id: string) => {
|
||||||
|
|||||||
@ -28,12 +28,14 @@ export function TicketTargets({
|
|||||||
values,
|
values,
|
||||||
lookups,
|
lookups,
|
||||||
onChange,
|
onChange,
|
||||||
|
lookupKey,
|
||||||
}: {
|
}: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
values: TicketTargetInput[];
|
values: TicketTargetInput[];
|
||||||
lookups: TicketLookupsDTO;
|
lookups: TicketLookupsDTO;
|
||||||
onChange: (values: TicketTargetInput[]) => void;
|
onChange: (values: TicketTargetInput[]) => void;
|
||||||
|
lookupKey?: readonly unknown[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
const prefix = useId();
|
const prefix = useId();
|
||||||
@ -141,7 +143,7 @@ export function TicketTargets({
|
|||||||
kind={quick}
|
kind={quick}
|
||||||
onSaved={(id) => {
|
onSaved={(id) => {
|
||||||
client
|
client
|
||||||
.invalidateQueries({ queryKey: ticketKeys.lookups(companyId) })
|
.invalidateQueries({ queryKey: lookupKey ?? ticketKeys.lookups(companyId) })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
add(id, quick);
|
add(id, quick);
|
||||||
setQuick(undefined);
|
setQuick(undefined);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { type TicketDTO, TicketPrioritySchema, TicketStateSchema } from "../../common/tickets";
|
import { type TicketDTO, TicketPrioritySchema, TicketStateSchema } from "../../common/tickets";
|
||||||
|
import { TicketInterventionForm } from "../interventions/ticket-interventions";
|
||||||
|
|
||||||
import { ticketApi, ticketKeys } from "./ticket-api";
|
import { ticketApi, ticketKeys } from "./ticket-api";
|
||||||
import { TicketDetail } from "./ticket-detail";
|
import { TicketDetail } from "./ticket-detail";
|
||||||
@ -56,6 +57,7 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [edit, setEdit] = useState<TicketDTO | undefined>();
|
const [edit, setEdit] = useState<TicketDTO | undefined>();
|
||||||
const [view, setView] = useState<TicketDTO | undefined>();
|
const [view, setView] = useState<TicketDTO | undefined>();
|
||||||
|
const [interventionTicket, setInterventionTicket] = useState<TicketDTO>();
|
||||||
const items = filterTickets(query.data?.tickets ?? [], filters);
|
const items = filterTickets(query.data?.tickets ?? [], filters);
|
||||||
const date = (value: string | undefined) =>
|
const date = (value: string | undefined) =>
|
||||||
value
|
value
|
||||||
@ -159,6 +161,9 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
|||||||
<h3 className="font-semibold">{ticket.values.subject}</h3>
|
<h3 className="font-semibold">{ticket.values.subject}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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">
|
<Button onClick={() => setView(ticket)} size="sm" variant="outline">
|
||||||
{t("tickets.details")}
|
{t("tickets.details")}
|
||||||
</Button>
|
</Button>
|
||||||
@ -205,6 +210,26 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormDialog>
|
</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
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
onOpenChange={(value) => {
|
onOpenChange={(value) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user