Poder desactivar sedes

This commit is contained in:
David Arranz 2026-10-16 11:09:56 +02:00
parent 2149ecb17b
commit 77cb8b0b97
50 changed files with 993 additions and 74 deletions

View File

@ -6,7 +6,7 @@
"scripts": {
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
"typecheck": "tsc -p tsconfig.json",
"test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test src/asset-inventory.integration.test.ts src/identity.test.ts",
"test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts",
"build": "tsup --config tsup.config.ts",
"start": "node --env-file=../../.env dist/index.js"
},

View File

@ -49,6 +49,7 @@ import { verifyRelationships } from "./relationships.integration";
import { verifyRemoteConnections } from "./remote.integration";
import { verifyResourceMigration } from "./resource-migration.integration";
import { verifyResources } from "./resources.integration";
import { verifySites } from "./sites.integration";
import { verifySoftwareInstallations } from "./software-installations.integration";
import { verifySupport } from "./support.integration";
import { verifyTelework } from "./telework.integration";
@ -524,6 +525,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
);
const noAddressSite = await operator.request(`/companies/${customer.id}/sites`, {
name: "Almacén",
active: true,
});
assert.equal(noAddressSite.status, 201);
@ -534,7 +536,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
const siteAddress = "Calle Mayor, 10\n28001 Madrid";
const changedSite = await operator.request(
`/companies/${customer.id}/sites/${emptySite.id}/update`,
{ name: "Sede central", address: siteAddress }
{ name: "Sede central", active: true, address: siteAddress }
);
assert.equal(changedSite.status, 200);
@ -544,7 +546,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
);
const crossSite = await operator.request(
`/companies/${secondCompany.id}/sites/${emptySite.id}/update`,
{ name: "Ajena", address: null }
{ name: "Ajena", active: true, address: null }
);
assert.equal(crossSite.status, 404);
@ -554,6 +556,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
(
await operator.request(`/companies/${customer.id}/sites`, {
name: "Oficina",
active: true,
address: largeAddress,
})
).status,
@ -563,6 +566,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
(
await operator.request(`/companies/${customer.id}/sites`, {
name: "Demasiado",
active: true,
address: "a".repeat(10001),
})
).status,
@ -592,7 +596,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
);
const clearedSite = await operator.request(
`/companies/${customer.id}/sites/${emptySite.id}/update`,
{ name: "Sede central", address: null }
{ name: "Sede central", active: true, address: null }
);
assert.equal(clearedSite.status, 200);
@ -922,6 +926,7 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
secondCompany.id,
database
);
await verifySites((...args) => operator.request(...args), customer.id, secondCompany.id);
await verifyPrinters(
(...args) => operator.request(...args),
customer.id,
@ -1075,6 +1080,11 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
);
await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED");
await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED");
await expectError(
await operator.request(`/companies/${customer.id}/sites/${emptySite.id}/delete`, {}),
401,
"UNAUTHENTICATED"
);
await assert.rejects(
database.query("UPDATE users SET active = 0 WHERE id = :id", {
replacements: { id: initial.id },

View File

@ -57,6 +57,7 @@ export async function verifyInventory(
async function site(company: string) {
const response = await request(`/companies/${company}/sites`, {
name: "Sede inventario",
active: true,
address: null,
});

View File

@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { test } from "node:test";
import { DataTypes, Op } from "sequelize";
import { CompanyUseCases } from "../../../modules/companies/src/api/application/company.use-cases";
import { CompanyInputMapper } from "../../../modules/companies/src/api/application/company-input.mapper";
import {
CompanyModel,
SiteModel,
initializeCompanyModels,
} from "../../../modules/companies/src/api/infrastructure/persistence/company.model";
import { CompanyPersistenceMapper } from "../../../modules/companies/src/api/infrastructure/persistence/company-persistence.mapper";
import { migrateCompanies } from "../../../modules/companies/src/api/infrastructure/persistence/migrate-companies";
import { migratePeople } from "../../../modules/companies/src/api/infrastructure/persistence/migrate-people";
import { initializePeopleModels } from "../../../modules/companies/src/api/infrastructure/persistence/people.model";
import { SequelizeCompanyRepository } from "../../../modules/companies/src/api/infrastructure/persistence/sequelize-company.repository";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
test("MariaDB: archivo reversible y borrado de sedes solo sin referencias", async () => {
const environment = readDatabaseEnvironment(process.env);
assert.equal(environment.NODE_ENV, "test");
assert.equal(environment.DB_NAME, "metis_desk_test");
const database = createDatabase(environment);
const companyIds: string[] = [];
const linksTable = `site_lifecycle_test_${randomUUID().replaceAll("-", "")}`;
let createdLinks = false;
try {
await migrateCompanies(database);
await migratePeople(database);
initializeCompanyModels(database);
initializePeopleModels(database);
const useCases = new CompanyUseCases(
new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()),
new CompanyInputMapper()
);
const created = await useCases.create({ name: "Prueba de ciclo de sedes", active: true });
assert.ok(created.isSuccess);
const companyId = created.data.id.toPrimitive();
companyIds.push(companyId);
const other = await useCases.create({ name: "Otra compañía de prueba de sedes", active: true });
assert.ok(other.isSuccess);
companyIds.push(other.data.id.toPrimitive());
const added = await useCases.addSite(companyId, { name: "Sede", active: true });
assert.ok(added.isSuccess);
const site = added.data.sites[0];
assert.ok(site);
const siteId = site.id.toPrimitive();
const foreign = await useCases.removeSite(other.data.id.toPrimitive(), siteId);
assert.ok(!foreign.isSuccess);
assert.equal(foreign.error.code, "SITE_NOT_FOUND");
for (const active of [false, false, true]) {
const changed = await useCases.updateSite(companyId, siteId, { name: "Sede", active });
assert.ok(changed.isSuccess);
assert.equal(changed.data.sites[0]?.active, active);
const reloaded = await useCases.get(companyId);
assert.ok(reloaded.isSuccess);
assert.equal(reloaded.data.sites[0]?.active, active);
}
await database.getQueryInterface().createTable(linksTable, {
id: { type: DataTypes.UUID, primaryKey: true },
siteId: {
type: DataTypes.UUID,
allowNull: false,
references: { model: "company_sites", key: "id" },
onDelete: "RESTRICT",
},
});
createdLinks = true;
await database.getQueryInterface().bulkInsert(linksTable, [{ id: randomUUID(), siteId }]);
const blocked = await useCases.removeSite(companyId, siteId);
assert.ok(!blocked.isSuccess);
assert.equal(blocked.error.code, "SITE_IN_USE");
const archived = await useCases.updateSite(companyId, siteId, { name: "Sede", active: false });
assert.ok(archived.isSuccess);
await migrateCompanies(database);
const reloaded = await useCases.get(companyId);
assert.ok(reloaded.isSuccess);
assert.equal(reloaded.data.sites[0]?.active, false);
const stillBlocked = await useCases.removeSite(companyId, siteId);
assert.ok(!stillBlocked.isSuccess);
assert.equal(stillBlocked.error.code, "SITE_IN_USE");
await database.getQueryInterface().bulkDelete(linksTable, { siteId });
const removed = await useCases.removeSite(companyId, siteId);
assert.ok(removed.isSuccess);
assert.equal(removed.data.sites.length, 0);
const missing = await useCases.removeSite(companyId, siteId);
assert.ok(!missing.isSuccess);
assert.equal(missing.error.code, "SITE_NOT_FOUND");
} finally {
try {
if (createdLinks) {
await database.getQueryInterface().dropTable(linksTable);
}
if (companyIds.length) {
await SiteModel.destroy({ where: { companyId: { [Op.in]: companyIds } } });
await CompanyModel.destroy({ where: { id: { [Op.in]: companyIds } } });
}
} finally {
await database.close();
}
}
});

View File

@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import {
AssetResponseSchema,
CatalogResponseSchema,
WorkstationResponseSchema,
} from "@metis/assets/common";
import { CompanyErrorResponseSchema, CompanyResponseSchema } from "@metis/companies/common";
import { z } from "zod/v4";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
export async function verifySites(request: Request, companyId: string, otherId: string) {
const path = `/companies/${companyId}/sites`;
const createSite = async (name: string) => {
const response = await request(path, { name, active: true });
assert.equal(response.status, 201);
const site = CompanyResponseSchema.parse(await response.json()).company.sites.find(
(item) => item.name === name
);
assert.ok(site);
return site;
};
const expectError = async (response: Response, status: number, code: string) => {
assert.equal(response.status, status);
assert.equal(CompanyErrorResponseSchema.parse(await response.json()).code, code);
};
const empty = await createSite("Sede vacía para borrar");
await expectError(
await request(`${path}/${empty.id}/update`, { name: empty.name, active: "false" }),
400,
"INVALID_REQUEST"
);
await expectError(await request(`${path}/invalid/delete`, {}), 400, "INVALID_REQUEST");
await expectError(
await request(`${path}/${empty.id}/delete`, { cascade: true }),
400,
"INVALID_REQUEST"
);
await expectError(
await request(`/companies/${otherId}/sites/${empty.id}/delete`, {}),
404,
"SITE_NOT_FOUND"
);
for (const active of [false, false, true, false]) {
const response = await request(`${path}/${empty.id}/update`, { name: empty.name, active });
assert.equal(response.status, 200);
assert.equal(
CompanyResponseSchema.parse(await response.json()).company.sites.find(
(site) => site.id === empty.id
)?.active,
active
);
}
const reloaded = await request(`/companies/${companyId}`);
assert.equal(
CompanyResponseSchema.parse(await reloaded.json()).company.sites.find(
(site) => site.id === empty.id
)?.active,
false
);
const removed = await request(`${path}/${empty.id}/delete`, {});
assert.equal(removed.status, 200);
assert.equal(
CompanyResponseSchema.parse(await removed.json()).company.sites.some(
(site) => site.id === empty.id
),
false
);
await expectError(await request(`${path}/${empty.id}/delete`, {}), 404, "SITE_NOT_FOUND");
const used = await createSite("Sede con historial de activo");
const catalog = CatalogResponseSchema.parse(
await (await request(`/asset-catalog/${companyId}`)).json()
);
const template = catalog.items.find(
(item) =>
item.values.kind === "template" && item.values.name === "Monitor" && item.effectiveActive
);
assert.ok(template);
const assetInput = {
name: "Activo para sede",
templateId: template.id,
siteId: used.id,
status: "operational",
notes: "",
characteristics: {},
};
const created = await request(`/assets/${companyId}`, { values: assetInput });
assert.equal(created.status, 201);
const asset = AssetResponseSchema.parse(await created.json()).asset;
await expectError(await request(`${path}/${used.id}/delete`, {}), 409, "SITE_IN_USE");
assert.equal(
(await request(`${path}/${used.id}/update`, { name: used.name, active: false })).status,
200
);
const rejected = await request(`/assets/${companyId}`, { values: assetInput });
assert.equal(rejected.status, 404);
assert.equal(z.object({ code: z.string() }).parse(await rejected.json()).code, "SITE_NOT_FOUND");
assert.equal(
(
await request(`/assets/${companyId}/${asset.id}/update`, {
values: { ...assetInput, notes: "Actualización en sede archivada" },
})
).status,
200
);
assert.equal(
(
await request(`/assets/${companyId}/${asset.id}/update`, {
values: { ...assetInput, siteId: null },
})
).status,
200
);
await expectError(await request(`${path}/${used.id}/delete`, {}), 409, "SITE_IN_USE");
const workstationSite = await createSite("Sede con historial de puesto");
const stationInput = {
name: "Puesto de sede",
siteId: workstationSite.id,
personId: null,
active: true,
assetIds: [],
computerAssetId: null,
};
const stationResponse = await request(`/assets/${companyId}/workstations`, {
values: stationInput,
});
assert.equal(stationResponse.status, 201);
const station = WorkstationResponseSchema.parse(await stationResponse.json()).workstation;
assert.equal(
(
await request(`${path}/${workstationSite.id}/update`, {
name: workstationSite.name,
active: false,
})
).status,
200
);
assert.equal(
(await request(`/assets/${companyId}/workstations`, { values: stationInput })).status,
404
);
assert.equal(
(
await request(`/assets/${companyId}/workstations/${station.id}/update`, {
values: stationInput,
})
).status,
200
);
assert.equal(
(
await request(`/assets/${companyId}/workstations/${station.id}/update`, {
values: { ...stationInput, siteId: null },
})
).status,
200
);
await expectError(await request(`${path}/${workstationSite.id}/delete`, {}), 409, "SITE_IN_USE");
const remoteSite = await createSite("Sede de conexión remota");
const company = CompanyResponseSchema.parse(
await (await request(`/companies/${companyId}`)).json()
).company;
const person = company.people[0];
assert.ok(person);
const remoteInput = {
personId: person.id,
sourceKind: "personal_computer",
sourceAssetId: null,
targetKind: "network",
targetId: null,
siteId: remoteSite.id,
method: "VPN",
requiresVpn: false,
state: "pending",
notes: null,
};
const remote = await request(`/remote-connections/${companyId}`, { values: remoteInput });
assert.equal(remote.status, 201);
await expectError(await request(`${path}/${remoteSite.id}/delete`, {}), 409, "SITE_IN_USE");
assert.equal(
(await request(`${path}/${remoteSite.id}/update`, { name: remoteSite.name, active: false }))
.status,
200
);
const archivedRemote = await request(`/remote-connections/${companyId}`, {
values: { ...remoteInput, method: "Otra VPN" },
});
assert.equal(archivedRemote.status, 404);
assert.equal(
z.object({ code: z.string() }).parse(await archivedRemote.json()).code,
"REMOTE_REFERENCE_NOT_FOUND"
);
}

View File

@ -23,6 +23,7 @@ export const workspaceCompany: CompanyDTO = {
id: "0199b200-0000-7000-8000-000000000003",
companyId: "0199b200-0000-7000-8000-000000000001",
name: "Sede central",
active: true,
address: null,
phone: null,
},

View File

@ -637,3 +637,12 @@ mantienen como metadatos de consulta.
Los listados de activos y recursos lo utilizan para sus familias; el picker de activos lo hereda mediante `AssetList`. La barra ocupa únicamente el ancho disponible, mantiene una sola fila y no comprime los nombres. Permite desplazamiento horizontal nativo con tacto o trackpad y muestra botones laterales solo cuando hay desbordamiento. Los botones se deshabilitan al llegar a cada extremo. La barra de desplazamiento nativa permanece oculta, evitando controles verticales accidentales; los botones conservan nombre accesible y tooltip.
Conserva la semántica y el teclado de Base UI. Al cambiar de familia, enfocar una pestaña o redimensionar el contenedor, revela la pestaña correspondiente moviendo solo el eje horizontal. Un `ResizeObserver` actualiza los extremos al abrir o cerrar paneles, redimensionar la ventana o cambiar las etiquetas; se retiran el observer y los listeners al desmontar. La raíz `Tabs` y los paneles consumidores mantienen `min-w-0` para permitir que la barra se reduzca dentro de flex y grid.
## Archivo y eliminación de sedes
`SiteSection` conserva el listado común, añade el estado y un filtro para consultar las
archivadas. Su controller coordina la selección y el cambio de estado; `SiteForm`
reutiliza `SwitchField`. `SiteDeleteDialog` compone `ConfirmDeleteDialog`, presenta el
nombre de la sede y conserva el error de negocio dentro del modal. La caché de detalle
de compañía se actualiza tras completar cada operación. Los selectores conservan la
sede anterior archivada y excluyen las demás archivadas. Véase [Sedes](sedes.md).

47
docs/sedes.md Normal file
View File

@ -0,0 +1,47 @@
# Sedes de compañía
Cada sede pertenece a una compañía y conserva su UUID, nombre, dirección y teléfono.
El estado `active` distingue sedes disponibles y archivadas. La migración explícita
`migrateCompanies` añade la columna relacional no nullable y mantiene activas las sedes
existentes; puede volver a ejecutarse sin reinicializar los estados.
## Archivo y reactivación
El listado muestra las sedes activas por defecto. «Mostrar también las sedes archivadas»
permite consultar y reactivar las anteriores. El menú de fila ofrece Archivar/Reactivar,
Editar y Eliminar sede; el formulario de edición también permite cambiar el estado.
Archivar no borra ni desvincula datos. La sede sigue resolviéndose en fichas e historiales.
Los selectores de activos, puestos y destinos de red ofrecen solo sedes activas y, al
editar, la sede anterior aunque esté archivada. El servidor aplica el mismo contrato:
permite conservar la referencia anterior y rechaza nuevas asociaciones a sedes archivadas.
En accesos remotos el destino sigue siendo inmutable.
## Borrado definitivo
`POST /api/companies/:companyId/sites/:id/delete` recibe un objeto vacío y devuelve la
compañía actualizada. Requiere la misma sesión y protección CSRF que las demás escrituras
sobre compañías; valida UUID, pertenencia, cuerpo y ausencia de query adicional.
La operación bloquea la compañía dentro de una transacción y elimina exclusivamente
la sede solicitada. Las claves foráneas `RESTRICT` impiden borrar sedes referenciadas,
incluidas revisiones históricas de activos, puestos y accesos remotos. No se propagan
borrados ni se limpian referencias para permitir la eliminación. Una referencia produce
`409 SITE_IN_USE` y el diálogo propone archivar; una sede ausente o de otra compañía
produce `404 SITE_NOT_FOUND`. Repetir un borrado no genera otros efectos.
Los fallos de infraestructura distintos de una restricción referencial se propagan al
manejador HTTP común. La interfaz mantiene el diálogo abierto si el borrado falla y
actualiza la caché de la compañía únicamente tras confirmar la operación.
## Validación
La prueba aislada `apps/server/src/sites.integration.test.ts` usa MariaDB y comprueba
persistencia, repetición de migración, referencias con FK y borrado tras liberar las
asociaciones. Solo crea y limpia sus propios registros en la base de pruebas.
Las pruebas de dominio cubren archivo, reactivación, identidad y repetición del estado.
Las pruebas web comprueban textos es/en, el filtro inicial y el control de estado.
`apps/server/src/sites.integration.ts`, invocado por la suite HTTP/MariaDB, comprueba
validación, pertenencia, borrado de una sede vacía, conservación de referencias,
restricciones históricas y rechazo de nuevas asociaciones a sedes archivadas.

View File

@ -15,7 +15,7 @@ export type InventoryContext = Readonly<{
brands: readonly Brand[];
assets: readonly Asset[];
relationships: readonly AssetRelationship[];
sites: readonly UniqueID[];
sites: readonly { id: UniqueID; active: boolean }[];
people: readonly { id: UniqueID; active: boolean }[];
actorNames: ReadonlyMap<string, string>;
}>;
@ -67,10 +67,21 @@ export interface IAssetRepository {
>;
}
export function validateAssetReferences(context: InventoryContext, values: AssetValues) {
export function validateAssetReferences(
context: InventoryContext,
values: AssetValues,
previous?: Asset
) {
const next = values.toPrimitive();
if (next.siteId && !context.sites.some((site) => site.toPrimitive() === next.siteId)) {
if (
next.siteId &&
!context.sites.some(
(site) =>
site.id.toPrimitive() === next.siteId &&
(site.active || previous?.values.toPrimitive().siteId === next.siteId)
)
) {
return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
}

View File

@ -55,7 +55,7 @@ export class AssetUseCases {
if (!mapped.isSuccess) {
return Result.fail(mapped.error);
}
const references = validateAssetReferences(context, mapped.data.values);
const references = validateAssetReferences(context, mapped.data.values, previous);
if (!references.isSuccess) {
return Result.fail(references.error);

View File

@ -20,7 +20,14 @@ export function mapWorkstationInput(
}
const values = mapped.data.toPrimitive();
if (values.siteId && !context.sites.some((id) => id.toPrimitive() === values.siteId)) {
if (
values.siteId &&
!context.sites.some(
(site) =>
site.id.toPrimitive() === values.siteId &&
(site.active || previous?.values.toPrimitive().siteId === values.siteId)
)
) {
return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
}
const person = context.people.find((item) => item.id.toPrimitive() === values.personId);

View File

@ -3,10 +3,17 @@ import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import type { InventoryContext } from "../application/asset.repository";
import { validateAssetReferences } from "../application/asset.repository";
import { mapWorkstationInput } from "../application/workstation-input.mapper";
import { Asset } from "./asset.aggregate";
import { AssetCatalog } from "./asset-catalog.aggregate";
import { assetDisplayStatus, assetTechnicalStatus, canAssignAsset } from "./asset-state";
import { type AssetInput, AssetInputSchema } from "./asset-values";
import { AssetSpecification, AssetValues } from "./value-objects/asset-values.vo";
import { WorkstationValues } from "./value-objects/workstation-values.vo";
import { Workstation } from "./workstation.aggregate";
const company = UniqueID.generateNewID();
const actor = UniqueID.generateNewID();
@ -217,3 +224,64 @@ test("Fecha de alta opcional: calendario real, independencia de compra, revision
assert.equal(cleared.data.history.length, 3);
assert.equal(cleared.data.history[1]?.values.toPrimitive().registeredOn, "2024-02-29");
});
test("sedes archivadas: nuevas referencias rechazadas y referencias anteriores conservadas", () => {
const siteId = UniqueID.generateNewID();
const context: InventoryContext = {
catalog: AssetCatalog.create(),
brands: [],
workstations: [],
assets: [],
relationships: [],
sites: [{ id: siteId, active: false }],
people: [],
actorNames: new Map(),
};
const spec = specification();
const atSite = AssetValues.create({ ...input(), siteId: siteId.toPrimitive() }, spec);
assert.ok(atSite.isSuccess);
const created = Asset.create(company, spec, atSite.data, actor, "2026-10-16T10:00:00.000Z");
assert.ok(created.isSuccess);
const rejected = validateAssetReferences(context, atSite.data);
assert.ok(!rejected.isSuccess);
assert.equal(rejected.error.code, "SITE_NOT_FOUND");
assert.ok(validateAssetReferences(context, atSite.data, created.data).isSuccess);
assert.ok(
validateAssetReferences({ ...context, sites: [{ id: siteId, active: true }] }, atSite.data)
.isSuccess
);
assert.ok(
!validateAssetReferences({ ...context, sites: [] }, atSite.data, created.data).isSuccess
);
const stationInput = {
name: "Puesto",
active: true,
siteId: siteId.toPrimitive(),
personId: null,
assetIds: [],
};
const stationValues = WorkstationValues.create(stationInput);
assert.ok(stationValues.isSuccess);
const station = Workstation.create(
company,
stationValues.data,
actor,
"2026-10-16T10:00:00.000Z"
);
assert.ok(station.isSuccess);
const stationRejected = mapWorkstationInput(stationInput, context);
assert.ok(!stationRejected.isSuccess);
assert.equal(stationRejected.error.code, "SITE_NOT_FOUND");
assert.ok(mapWorkstationInput(stationInput, context, station.data).isSuccess);
assert.ok(mapWorkstationInput({ ...stationInput, siteId: null }, context).isSuccess);
assert.ok(
mapWorkstationInput(stationInput, { ...context, sites: [{ id: siteId, active: true }] })
.isSuccess
);
});

View File

@ -71,8 +71,8 @@ export class SequelizeAssetRepository implements IAssetRepository {
companyId.toPrimitive(),
transaction
);
const sites = await this.database.query<{ id: string }>(
"SELECT id FROM company_sites WHERE companyId=:id",
const sites = await this.database.query<{ id: string; active: boolean }>(
"SELECT id,active FROM company_sites WHERE companyId=:id",
{ replacements, type: QueryTypes.SELECT, transaction }
);
const people = await this.database.query<{ id: string; active: boolean }>(
@ -141,7 +141,10 @@ export class SequelizeAssetRepository implements IAssetRepository {
relationships,
catalog,
assets,
sites: sites.map((site) => inventoryIdentity(site.id)),
sites: sites.map((site) => ({
id: inventoryIdentity(site.id),
active: Boolean(site.active),
})),
people: people.map((person) => ({
id: inventoryIdentity(person.id),
active: Boolean(person.active),

View File

@ -65,7 +65,7 @@ async function render(language: string, node: ReactNode) {
const listProps = {
assets,
catalog: [],
sites: [{ id: id(4), name: "Sede Central" }],
sites: [{ id: id(4), name: "Sede Central", active: true }],
templateId: null,
onTemplateChange: () => undefined,
search: "",

View File

@ -364,7 +364,7 @@ test("alta de puesto: sede única automática, responsable opcional y cuatro pas
language,
<WorkstationForm
companyId={companyId}
lookups={{ ...lookups, sites: [{ id: siteId, name: "Sede única test" }] }}
lookups={{ ...lookups, sites: [{ id: siteId, name: "Sede única test", active: true }] }}
onSaved={() => undefined}
/>
);
@ -451,7 +451,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
companyId={companyId}
lookups={{
...lookups,
sites: [{ id: siteId, name: "Fábrica" }],
sites: [{ id: siteId, name: "Fábrica", active: true }],
}}
onClose={() => undefined}
onDelete={() => undefined}

View File

@ -5,6 +5,7 @@ export type InventoryLookups = {
sites: readonly {
id: string;
name: string;
active: boolean;
}[];
people: readonly {
id: string;

View File

@ -139,10 +139,12 @@ export function AssetIdentityFields({
id={`${prefix}-site`}
label={t("site")}
onValueChange={(id) => form.setValue("siteId", id || null)}
options={lookups.sites.map((site) => ({
value: site.id,
label: site.name,
}))}
options={lookups.sites
.filter((site) => site.active || site.id === asset?.values.siteId)
.map((site) => ({
value: site.id,
label: site.name,
}))}
value={values.siteId ?? ""}
/>
</div>

View File

@ -27,7 +27,8 @@ export function useWorkstationFormController({
const mounted = useRef(true);
const isWizard = !workstation;
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
const initialSite = lookups.sites.length === 1 ? lookups.sites[0] : undefined;
const activeSites = lookups.sites.filter((site) => site.active);
const initialSite = activeSites.length === 1 ? activeSites[0] : undefined;
const form = useForm<WorkstationInput>({
resolver: zodResolver(WorkstationInputSchema),
defaultValues: workstation?.values ?? {

View File

@ -40,7 +40,9 @@ export function WorkstationAssociationFields({
id={`${prefix}-site`}
label={t("site")}
onValueChange={(id) => onSelect("siteId", id)}
options={lookups.sites.map((item) => ({ value: item.id, label: item.name }))}
options={lookups.sites
.filter((item) => item.active || item.id === workstation?.values.siteId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
required={required}
value={values.siteId ?? ""}

View File

@ -99,6 +99,11 @@ export class CompanyInputMapper {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return Result.ok({ name: name.data, address: address.data, phone: phone.data });
return Result.ok({
name: name.data,
active: dto.active,
address: address.data,
phone: phone.data,
});
}
}

View File

@ -59,6 +59,7 @@ export function buildCompanyResponse(company: Company): CompanyDTO {
id: site.id.toPrimitive(),
companyId: site.companyId.toPrimitive(),
name: site.name.toPrimitive(),
active: site.active,
address: site.address.getOrUndefined()?.toPrimitive() ?? null,
phone: site.phone.getOrUndefined()?.toPrimitive() ?? null,
})),

View File

@ -12,6 +12,7 @@ export interface ICompanyRepository {
id: UniqueID,
change: (company: Company) => Result<Company, CompanyError>
): Promise<Result<Company, CompanyError>>;
removeSite(id: UniqueID, siteId: UniqueID): Promise<Result<Company, CompanyError>>;
removePerson(id: UniqueID, personId: UniqueID): Promise<Result<Company, CompanyError>>;
removeAssignment(
id: UniqueID,

View File

@ -209,6 +209,17 @@ export class CompanyUseCases {
return this.companies.removeAssignment(parent.data, person.data, assignment.data);
}
async removeSite(companyId: string, id: string) {
const parent = this.input.id(companyId);
const child = this.input.id(id);
if (!(parent.isSuccess && child.isSuccess)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));
}
return this.companies.removeSite(parent.data, child.data);
}
async addSite(id: string, dto: SiteRequestDTO) {
const parsed = this.input.id(id);
const props = this.input.site(dto);

View File

@ -26,6 +26,13 @@ export function mapRemoteInput(
if (!remoteReferences(values, context)) {
return Result.fail(new RemoteConnectionError("REMOTE_REFERENCE_NOT_FOUND"));
}
if (
!previous &&
input.siteId &&
!context.sites.some((site) => site.id.toPrimitive() === input.siteId && site.active)
) {
return Result.fail(new RemoteConnectionError("REMOTE_REFERENCE_NOT_FOUND"));
}
// Se documentan conexiones existentes aunque su autorización ya no sea vigente; se señalan para revisión.
if (
!values.closed &&

View File

@ -9,7 +9,7 @@ export function remoteReferences(values: RemoteConnectionValues, context: Remote
return (
context.people.some((p) => p.id.equals(values.personId)) &&
(!v.sourceAssetId || context.assets.some((a) => a.id.toPrimitive() === v.sourceAssetId)) &&
(!v.siteId || context.sites.some((s) => s.toPrimitive() === v.siteId)) &&
(!v.siteId || context.sites.some((s) => s.id.toPrimitive() === v.siteId)) &&
(v.targetKind === "network" ||
(v.targetKind === "asset"
? context.assets.some((a) => a.id.toPrimitive() === v.targetId)

View File

@ -9,7 +9,7 @@ export type RemoteContext = Readonly<{
connections: readonly RemoteConnection[];
authorizations: readonly TeleworkAuthorization[];
people: readonly { id: UniqueID; active: boolean }[];
sites: readonly UniqueID[];
sites: readonly { id: UniqueID; active: boolean }[];
assets: readonly { id: UniqueID; notRetired: boolean }[];
resources: readonly { id: UniqueID; active: boolean }[];
actors: ReadonlyMap<string, string>;

View File

@ -3,6 +3,7 @@ export type CompanyErrorCode =
| "COMPANY_NOT_FOUND"
| "CONTACT_NOT_FOUND"
| "SITE_NOT_FOUND"
| "SITE_IN_USE"
| "PERSON_NOT_FOUND"
| "PERSON_IN_USE"
| "DEPARTMENT_NOT_FOUND"

View File

@ -56,7 +56,7 @@ test("compañía, contacto y sede tienen UUID v7 propios y se conserva la identi
test("la pertenencia impide asociar o editar entidades de otra compañía", () => {
const company = companyFixture();
const other = companyFixture("Otra");
const props = input.site({ name: "Central", address: null });
const props = input.site({ active: true, name: "Central", address: null });
assert.ok(props.isSuccess);
const site = Site.create({ ...props.data, companyId: other.id });
@ -90,8 +90,9 @@ function inputContact() {
test("la dirección y el teléfono son optativos y se pueden borrar", () => {
const company = companyFixture();
const empty = input.site({ name: "Central", address: null, phone: null });
const empty = input.site({ active: true, name: "Central", address: null, phone: null });
const full = input.site({
active: true,
name: "Central",
address: "Calle Mayor, 10\n28001 Madrid",
phone: "+34 900 123 456",
@ -130,7 +131,57 @@ test("nombres, email, teléfono y longitud de dirección se validan en los Value
}).isSuccess,
false
);
assert.equal(input.site({ name: "Central", address: "x".repeat(10001) }).isSuccess, false);
assert.equal(input.site({ name: "Central", address: "x".repeat(10000) }).isSuccess, true);
assert.equal(input.site({ name: "Central", address: null, phone: "abc" }).isSuccess, false);
assert.equal(
input.site({ active: true, name: "Central", address: "x".repeat(10001) }).isSuccess,
false
);
assert.equal(
input.site({ active: true, name: "Central", address: "x".repeat(10000) }).isSuccess,
true
);
assert.equal(
input.site({ active: true, name: "Central", address: null, phone: "abc" }).isSuccess,
false
);
});
test("sedes: archivar y reactivar conserva identidad y datos; repetir el estado es idempotente", () => {
const company = companyFixture();
const props = input.site({
name: "Central",
active: true,
address: "Calle Mayor",
phone: "900123456",
});
assert.ok(props.isSuccess);
const site = Site.create({ ...props.data, companyId: company.id });
assert.ok(site.isSuccess);
const added = company.addSite(site.data);
assert.ok(added.isSuccess);
const archived = added.data.updateSite(site.data.id, { ...props.data, active: false });
assert.ok(archived.isSuccess);
assert.equal(archived.data.sites[0]?.active, false);
assert.ok(archived.data.sites[0]?.id.equals(site.data.id));
assert.equal(archived.data.sites[0]?.address.getOrUndefined()?.toPrimitive(), "Calle Mayor");
assert.equal(added.data.sites[0]?.active, true);
const repeated = archived.data.updateSite(site.data.id, { ...props.data, active: false });
assert.ok(repeated.isSuccess);
assert.equal(repeated.data, archived.data);
const restored = archived.data.updateSite(site.data.id, props.data);
assert.ok(restored.isSuccess);
assert.equal(restored.data.sites[0]?.active, true);
const removed = restored.data.removeSite(site.data.id);
assert.ok(removed.isSuccess);
assert.equal(removed.data.sites.length, 0);
const missing = removed.data.removeSite(site.data.id);
assert.ok(!missing.isSuccess);
assert.equal(missing.error.code, "SITE_NOT_FOUND");
});

View File

@ -333,6 +333,19 @@ export class Company extends AggregateRoot<CompanyProps> {
);
}
removeSite(id: UniqueID): Result<Company, CompanyError> {
if (!this.sites.some((site) => site.id.equals(id))) {
return Result.fail(new CompanyError("SITE_NOT_FOUND"));
}
return Result.ok(
new Company(
{ ...this.props, sites: this.sites.filter((site) => !site.id.equals(id)) },
this.id
)
);
}
addSite(site: Site): Result<Company, CompanyError> {
if (!site.companyId.equals(this.id)) {
return Result.fail(new CompanyError("INVALID_REQUEST"));

View File

@ -39,7 +39,7 @@ const context: RemoteContext = {
connections: [],
authorizations: [],
people: [{ id: person, active: true }],
sites: [site],
sites: [{ id: site, active: true }],
assets: [
{ id: source, notRetired: true },
{ id: target, notRetired: true },
@ -289,3 +289,16 @@ test("remotos: persistencia conserva auditoría y detecta referencias, claves e
)
);
});
test("remotos: una sede archivada conserva conexiones existentes pero rechaza nuevas", () => {
const inactive: RemoteContext = { ...context, sites: [{ id: site, active: false }] };
const atSite = { ...input, siteId: site.toPrimitive() };
const rejected = mapRemoteInput(atSite, inactive);
assert.ok(!rejected.isSuccess);
assert.equal(rejected.error.code, "REMOTE_REFERENCE_NOT_FOUND");
const previous = create(atSite);
assert.ok(mapRemoteInput({ ...atSite, notes: "Conservada" }, inactive, previous).isSuccess);
assert.ok(mapRemoteInput(atSite, context).isSuccess);
});

View File

@ -10,6 +10,7 @@ import type { SiteAddress } from "./value-objects/site-address.vo";
export type SiteProps = Readonly<{
companyId: UniqueID;
name: Name;
active: boolean;
address: Maybe<SiteAddress>;
phone: Maybe<PhoneNumber>;
}>;
@ -35,6 +36,10 @@ export class Site extends DomainEntity<SiteProps> {
return this.props.name;
}
get active() {
return this.props.active;
}
get address() {
return this.props.address;
}
@ -54,7 +59,10 @@ export class Site extends DomainEntity<SiteProps> {
const nextPhone = values.phone.getOrUndefined();
const samePhone = currentPhone ? currentPhone.equals(nextPhone) : nextPhone === undefined;
return this.name.equals(values.name) && sameAddress && samePhone
return this.name.equals(values.name) &&
this.active === values.active &&
sameAddress &&
samePhone
? Result.ok(this)
: Result.ok(new Site({ ...values, companyId: this.companyId }, this.id));
}

View File

@ -127,6 +127,25 @@ export function registerCompanyRoutes(
post("/:companyId/sites/:id/update", SiteRequestSchema, ChildParamsSchema, (dto, params) =>
useCases.updateSite(params.companyId, params.id, dto)
);
router.post("/:companyId/sites/:id/delete", (request, response, next) => {
const params = ChildParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
sendError(response, "INVALID_REQUEST", 400);
return;
}
useCases
.removeSite(params.data.companyId, params.data.id)
.then((result) => sendResult(response, result))
.catch(next);
});
post(
"/:companyId/people",
PersonRequestSchema,

View File

@ -173,6 +173,7 @@ export class CompanyPersistenceMapper {
id: site.id.toPrimitive(),
companyId: site.companyId.toPrimitive(),
name: site.name.toPrimitive(),
active: site.active,
address: site.address.getOrUndefined()?.toPrimitive() ?? null,
phone: site.phone.getOrUndefined()?.toPrimitive() ?? null,
})),

View File

@ -38,6 +38,7 @@ export class SiteModel extends Model<
declare id: string;
declare companyId: string;
declare name: string;
declare active: boolean;
declare address: string | null;
declare phone: string | null;
}
@ -73,6 +74,7 @@ export function initializeCompanyModels(database: Sequelize): void {
SiteModel.init(
{
...childFields,
active: { type: DataTypes.BOOLEAN, allowNull: false },
address: { type: DataTypes.TEXT, allowNull: true },
phone: { type: DataTypes.STRING(50), allowNull: true },
},

View File

@ -39,6 +39,13 @@ export async function migrateCompanies(database: Sequelize): Promise<void> {
if (tables.includes("company_sites")) {
const columns = await query.describeTable("company_sites");
if (!columns.active) {
await query.addColumn("company_sites", "active", {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
});
}
if (!columns.phone) {
await query.addColumn("company_sites", "phone", {
type: DataTypes.STRING(50),
@ -48,6 +55,7 @@ export async function migrateCompanies(database: Sequelize): Promise<void> {
} else {
await query.createTable("company_sites", {
...childFields,
active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
address: { type: DataTypes.TEXT, allowNull: true },
phone: { type: DataTypes.STRING(50), allowNull: true },
});

View File

@ -2,7 +2,7 @@ import type { UniqueID } from "@repo/rdx-ddd";
import type { Result as DomainResult } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
import { Op } from "sequelize";
import { ForeignKeyConstraintError, Op } from "sequelize";
import type { ICompanyRepository } from "../../application/company.repository";
import type { Company } from "../../domain/company.aggregate";
@ -116,6 +116,35 @@ export class SequelizeCompanyRepository implements ICompanyRepository {
return company;
}
async removeSite(id: UniqueID, siteId: UniqueID): Promise<DomainResult<Company, CompanyError>> {
return this.database.transaction(async (transaction) => {
const current = (await this.load(id, transaction, true)).getOrUndefined();
if (!current) {
return Result.fail(new CompanyError("COMPANY_NOT_FOUND"));
}
const changed = current.removeSite(siteId);
if (!changed.isSuccess) {
return changed;
}
try {
// Las FK RESTRICT incluyen referencias históricas y evitan carreras con nuevas asociaciones.
await SiteModel.destroy({
where: { id: siteId.toPrimitive(), companyId: id.toPrimitive() },
transaction,
});
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return Result.fail(new CompanyError("SITE_IN_USE"));
}
throw error;
}
return changed;
});
}
async removePerson(
id: UniqueID,
personId: UniqueID
@ -200,7 +229,7 @@ export class SequelizeCompanyRepository implements ICompanyRepository {
if (record.sites.length) {
await SiteModel.bulkCreate(record.sites, {
transaction,
updateOnDuplicate: ["name", "address", "phone"],
updateOnDuplicate: ["name", "active", "address", "phone"],
});
}
if (record.departments.length) {

View File

@ -57,8 +57,8 @@ export class SequelizeRemoteConnectionRepository implements IRemoteConnectionRep
"SELECT id,active FROM company_contacts WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const sites = await this.database.query<{ id: string }>(
"SELECT id FROM company_sites WHERE companyId=:companyId",
const sites = await this.database.query<{ id: string; active: boolean }>(
"SELECT id,active FROM company_sites WHERE companyId=:companyId",
{ replacements: { companyId }, transaction, type: QueryTypes.SELECT }
);
const authorizations = await this.database.query<TeleworkRow>(
@ -87,7 +87,7 @@ export class SequelizeRemoteConnectionRepository implements IRemoteConnectionRep
)
),
people: people.map((p) => ({ id: teleworkIdentity(p.id), active: Boolean(p.active) })),
sites: sites.map((s) => teleworkIdentity(s.id)),
sites: sites.map((s) => ({ id: teleworkIdentity(s.id), active: Boolean(s.active) })),
assets: await this.inventory.list(company),
resources: await this.resources.list(company),
actors: new Map(actors.map((a) => [a.id, a.name])),

View File

@ -15,6 +15,7 @@ export const CompanyErrorResponseSchema = z.strictObject({
"COMPANY_NOT_FOUND",
"CONTACT_NOT_FOUND",
"SITE_NOT_FOUND",
"SITE_IN_USE",
"PERSON_NOT_FOUND",
"DEPARTMENT_NOT_FOUND",
"DEPARTMENT_NAME_EXISTS",

View File

@ -23,6 +23,7 @@ export const CompanyFieldsSchema = z.strictObject({
export const ContactFieldsSchema = PersonFieldsSchema.omit({ isContact: true });
export const SiteFieldsSchema = z.strictObject({
active: z.boolean(),
name: RequiredTextSchema,
address: z.string().max(10000).nullable(),
phone: z.string().max(50).nullable(),

View File

@ -690,7 +690,8 @@
"INTERNAL_ERROR": "The operation could not be completed. Try again.",
"INVALID_LOGO": "Invalid image. Use a non-animated PNG, JPEG or WebP.",
"LOGO_TOO_LARGE": "Logo exceeds the limit: maximum 2 MB and 4096 × 4096 px.",
"LOGO_NOT_FOUND": "The logo is no longer available."
"LOGO_NOT_FOUND": "The logo is no longer available.",
"SITE_IN_USE": "The site has associated records or historical references and cannot be deleted. You can archive it."
},
"shell": {
"catalogs": "Catalogs"
@ -767,7 +768,17 @@
"licenseHelp": "Informational reference to a license belonging to this company. It does not reserve seats or confirm coverage for this product or device. Leave it empty for free software or when the license is not yet known.",
"licenseUnavailable": "Unavailable"
},
"companyLabel": "Company"
"companyLabel": "Company",
"siteLifecycle": {
"active": "Active site",
"help": "Archiving a site preserves its associations and history. It is no longer available for new assignments and can be restored.",
"archive": "Archive",
"restore": "Restore",
"archived": "Archived",
"showArchived": "Also show archived sites",
"delete": "Delete site",
"deleteDescription": "“{{name}}” will be permanently deleted. This is only allowed if it has no associated records, including historical references. If it is in use, you can archive it."
}
},
"access": {
"helpTitle": "What is it and what is it for?",

View File

@ -690,7 +690,8 @@
"INTERNAL_ERROR": "No se ha podido completar la operación. Inténtalo de nuevo.",
"INVALID_LOGO": "La imagen no es válida. Usa PNG, JPEG o WebP sin animación.",
"LOGO_TOO_LARGE": "El logotipo supera el límite: máximo 2 MB y 4096 × 4096 px.",
"LOGO_NOT_FOUND": "El logotipo ya no está disponible."
"LOGO_NOT_FOUND": "El logotipo ya no está disponible.",
"SITE_IN_USE": "La sede tiene registros asociados o referencias históricas y no puede eliminarse. Puedes archivarla."
},
"shell": {
"catalogs": "Catálogos"
@ -767,7 +768,17 @@
"licenseHelp": "Referencia informativa a una licencia de esta compañía. No reserva plazas ni confirma que la licencia cubra este producto o equipo. Puedes dejarla vacía para software gratuito o si aún no se conoce.",
"licenseUnavailable": "No disponible"
},
"companyLabel": "Compañía"
"companyLabel": "Compañía",
"siteLifecycle": {
"active": "Sede activa",
"help": "Al archivar una sede se conservan sus asociaciones e historial. Deja de estar disponible para nuevas asignaciones y puede reactivarse.",
"archive": "Archivar",
"restore": "Reactivar",
"archived": "Archivada",
"showArchived": "Mostrar también las sedes archivadas",
"delete": "Eliminar sede",
"deleteDescription": "Se eliminará «{{name}}» de forma permanente. Solo se permite si no tiene ningún registro asociado, incluidas referencias históricas. Si está en uso, puedes archivarla."
}
},
"access": {
"helpTitle": "¿Qué es y para qué sirve?",

View File

@ -14,6 +14,7 @@ import { DashboardPage, dashboardKeys } from "../../dashboard";
import { PersonForm } from "../../people";
import { companiesEnglish, companiesSpanish } from "../../shared";
import { SiteForm } from "../../sites";
import { SiteSection } from "../../sites/list";
import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview";
import { CompanyDetail } from "../detail";
import { CompanyOverview } from "../detail/ui/company-overview";
@ -341,3 +342,63 @@ test("el menú de compañía ordena trabajo y personas por grupos sin repetir la
assert.equal(html.includes("Mi contraseña"), false);
assert.ok(html.includes('data-slot="avatar-badge"'));
});
test("sedes: listado traducido con filtro de archivadas y solo sedes activas por defecto", async () => {
const company = {
id: companyId,
name: "Compañía",
active: true,
contacts: [],
people: [],
departments: [],
assignments: [],
sites: [
{
id: companyId,
companyId,
name: "Central activa",
active: true,
address: null,
phone: null,
},
{
id: "0199b200-0000-7000-8000-000000000002",
companyId,
name: "Sede archivada test",
active: false,
address: null,
phone: null,
},
],
};
const archived = company.sites.find((site) => !site.active);
assert.ok(archived);
for (const language of ["es", "en"]) {
const markup = await renderPage(language, <SiteSection company={company} />, () => undefined);
assert.ok(markup.includes("Central activa"));
assert.equal(markup.includes("Sede archivada test"), false);
assert.ok(
markup.includes(
language === "es" ? "Mostrar también las sedes archivadas" : "Also show archived sites"
)
);
const form = await renderPage(
language,
<SiteForm companyId={companyId} site={archived} />,
() => undefined
);
assert.ok(form.includes(language === "es" ? "Sede activa" : "Active site"));
assert.ok(
form.includes(
language === "es"
? "se conservan sus asociaciones e historial"
: "preserves its associations and history"
)
);
assert.ok(form.includes('aria-checked="false"'));
}
});

View File

@ -39,7 +39,9 @@ export function RemoteTargetField({
const targetOptions = {
network: [
{ value: "", label: t("remote.allCompany") },
...company.sites.map((s) => ({ value: s.id, label: s.name })),
...company.sites
.filter((site) => site.active || (fixed && site.id === v.siteId))
.map((s) => ({ value: s.id, label: s.name })),
],
asset: assetOptions.filter((a) => a.value !== v.sourceAssetId),
resource: resourceOptions,

View File

@ -101,12 +101,17 @@ test("los adapters mantienen la ausencia semántica sin modificar los campos del
assert.equal(request.phone, null);
assert.equal(request.active, false);
assert.equal(person.email, " ana@example.test ");
assert.deepEqual(buildSiteRequest({ name: "Office", address: " ", phone: " " }), {
name: "Office",
address: null,
phone: null,
});
assert.deepEqual(
buildSiteRequest({ active: true, name: "Office", address: " ", phone: " " }),
{
active: true,
name: "Office",
address: null,
phone: null,
}
);
const site = buildSiteRequest({
active: true,
name: "Office",
address: " Street\n Floor ",
phone: " +34 900 123 456 ",

View File

@ -0,0 +1,5 @@
import { CompanyResponseSchema } from "../../../../common";
import { companyRequest } from "../../../companies/shared/api/company-request";
export const deleteSite = (companyId: string, id: string) =>
companyRequest(`/${companyId}/sites/${id}/delete`, CompanyResponseSchema, {});

View File

@ -0,0 +1,37 @@
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { SiteDTO } from "../../../../common";
import { CompanyApiError } from "../../../companies/shared/api";
import { companyKeys } from "../../../companies/shared/queries";
import { deleteSite } from "../api/delete-site.api";
export function SiteDeleteDialog({ site, onClose }: { site: SiteDTO; onClose: () => void }) {
const { t } = useTranslation("companies");
const client = useQueryClient();
const mutation = useMutation({
mutationFn: () => deleteSite(site.companyId, site.id),
onSuccess: (response) => client.setQueryData(companyKeys.detail(site.companyId), response),
});
return (
<ConfirmDeleteDialog
cancelLabel={t("cancel")}
confirmLabel={t("siteLifecycle.delete")}
description={t("siteLifecycle.deleteDescription", { name: site.name })}
errorLabel={t(
`errors.${mutation.error instanceof CompanyApiError ? mutation.error.code : "INTERNAL_ERROR"}`
)}
onConfirm={() => mutation.mutateAsync()}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
pendingLabel={t("deleteRecord.pending")}
title={t("siteLifecycle.delete")}
/>
);
}

View File

@ -0,0 +1,37 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import type { CompanyDTO, SiteDTO } from "../../../../common";
import { companyKeys } from "../../../companies/shared/queries";
import { saveSite } from "../../shared/api";
export function useSiteSectionController(company: CompanyDTO) {
const client = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string>();
const [deletingId, setDeletingId] = useState<string>();
const [showArchived, setShowArchived] = useState(false);
const mutation = useMutation({
mutationFn: (site: SiteDTO) =>
saveSite(company.id, site.id, {
name: site.name,
address: site.address,
phone: site.phone,
active: !site.active,
}),
onSuccess: (response) => client.setQueryData(companyKeys.detail(company.id), response),
});
return {
createOpen,
setCreateOpen,
setSelectedId,
setDeletingId,
showArchived,
setShowArchived,
mutation,
selected: company.sites.find((site) => site.id === selectedId),
deleting: company.sites.find((site) => site.id === deletingId),
sites: company.sites.filter((site) => showArchived || site.active),
};
}

View File

@ -2,20 +2,36 @@ import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { CompanyFeedback } from "../../../companies/shared";
import { SiteDeleteDialog } from "../../delete/ui/site-delete-dialog";
import { SiteEditorDialog } from "../../shared";
import { useSiteSectionController } from "../controllers/use-site-section-controller";
export function SiteSection({ company }: { company: CompanyDTO }) {
const { t, i18n } = useTranslation("companies");
const [createOpen, setCreateOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | undefined>();
const selected = company.sites.find((site) => site.id === selectedId);
const filterId = useId();
const {
createOpen,
setCreateOpen,
setSelectedId,
deleting,
setDeletingId,
showArchived,
setShowArchived,
mutation,
selected,
sites,
} = useSiteSectionController(company);
return (
<div className="space-y-6">
@ -24,6 +40,7 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
locale={i18n.language}
title={t("sites")}
/>
<CompanyFeedback error={mutation.error} />
<EntityTable
columns={[
{
@ -33,6 +50,16 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
hideable: false,
render: (site) => <span className="font-medium">{site.name}</span>,
},
{
id: "state",
label: t("status"),
value: (site) => t(site.active ? "active" : "siteLifecycle.archived"),
render: (site) => (
<EntityStatus active={site.active}>
{t(site.active ? "active" : "siteLifecycle.archived")}
</EntityStatus>
),
},
{
id: "address",
label: t("addressOptional"),
@ -52,16 +79,45 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
hideable: false,
render: (site) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setSelectedId(site.id)}>{t("edit")}</EntityAction>
<EntityAction disabled={mutation.isPending} onClick={() => setSelectedId(site.id)}>
{t("edit")}
</EntityAction>
<EntityAction disabled={mutation.isPending} onClick={() => mutation.mutate(site)}>
{t(site.active ? "siteLifecycle.archive" : "siteLifecycle.restore")}
</EntityAction>
<EntityAction
className="text-destructive"
disabled={mutation.isPending}
onClick={() => setDeletingId(site.id)}
>
{t("siteLifecycle.delete")}
</EntityAction>
</EntityRowActions>
),
},
]}
emptyMessage={t("noSites")}
items={company.sites}
filters={
<FormLabel className="flex items-center gap-2" htmlFor={filterId}>
<Checkbox
checked={showArchived}
id={filterId}
onCheckedChange={(checked) => setShowArchived(checked === true)}
/>
{t("siteLifecycle.showArchived")}
</FormLabel>
}
items={sites}
locale={i18n.language}
title={t("sites")}
/>
{deleting && (
<SiteDeleteDialog
key={deleting.id}
onClose={() => setDeletingId(undefined)}
site={deleting}
/>
)}
<SiteEditorDialog
companyId={company.id}
onOpenChange={setCreateOpen}

View File

@ -15,6 +15,7 @@ export function useSiteFormController({ site, companyId, onSaved }: SiteFormProp
resolver: zodResolver(SiteRequestSchema),
defaultValues: {
name: site?.name ?? "",
active: site?.active ?? true,
address: site?.address ?? "",
phone: site?.phone ?? "",
},
@ -35,7 +36,11 @@ export function useSiteFormController({ site, companyId, onSaved }: SiteFormProp
}
},
});
const submit = form.handleSubmit((values) => mutation.mutate(values));
const submit = form.handleSubmit((values) => {
if (!mutation.isPending) {
mutation.mutate(values);
}
});
return { form, mutation, submit };
}

View File

@ -1,5 +1,6 @@
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
@ -21,27 +22,43 @@ export function SiteForm(props: SiteFormProps) {
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
<FormBody>
{showTitle && <h3 className="text-lg font-semibold">{t(site ? "editSite" : "newSite")}</h3>}
<div className="space-y-2">
<FormLabel htmlFor={`${id}-name`} required>
{t("name")}
</FormLabel>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${id}-address`}>{t("addressOptional")}</FormLabel>
<Textarea
className="min-h-40"
id={`${id}-address`}
maxLength={10000}
rows={6}
{...form.register("address")}
/>
<p className="text-xs text-muted-foreground">{t("addressHelp")}</p>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${id}-phone`}>{t("phoneOptional")}</FormLabel>
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
</div>
<fieldset className="space-y-5" disabled={mutation.isPending}>
<div className="space-y-2">
<FormLabel htmlFor={`${id}-name`} required>
{t("name")}
</FormLabel>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${id}-address`}>{t("addressOptional")}</FormLabel>
<Textarea
className="min-h-40"
id={`${id}-address`}
maxLength={10000}
rows={6}
{...form.register("address")}
/>
<p className="text-xs text-muted-foreground">{t("addressHelp")}</p>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${id}-phone`}>{t("phoneOptional")}</FormLabel>
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
</div>
{site && (
<>
<SwitchField
checked={form.watch("active")}
disabled={mutation.isPending}
id={`${id}-active`}
label={t("siteLifecycle.active")}
onCheckedChange={(checked) =>
form.setValue("active", checked, { shouldDirty: true })
}
/>
<p className="text-sm text-muted-foreground">{t("siteLifecycle.help")}</p>
</>
)}
</fieldset>
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}