feat(assets): implement asset picker dialog and selection components

- Added `useAssetPickerController` for managing asset selection logic.
- Created `AssetSelectProps` type for defining asset selection properties.
- Implemented `AssetPickerDialog` for displaying the asset selection interface.
- Developed `AssetPickerList` for rendering the list of selectable assets.
- Introduced `AssetSelect` component for integrating the asset picker into forms.
- Created `AssetList` component for shared asset listing functionality.
- Added tests for workstation creation logic to ensure proper validation.
- Implemented workstation asset fields and association fields for managing workstation assets.
- Developed workstation lifecycle dialog for managing work profile states.
- Added form progress component for visualizing multi-step forms.
This commit is contained in:
David Arranz 2026-10-14 19:42:59 +02:00
parent 2728692b98
commit b45925f3e2
104 changed files with 8171 additions and 910 deletions

16
TODO.md
View File

@ -1,3 +1,15 @@
- En el listado de puestos de trabajo de una compañía, cuando se hace click en nombre de un puesto, nos iríamos a una pantalla que hay que crear que es la ficha completa de ese puesto de trabajo. En esa ficha aparecerían de forma resumida la información sobre el activo, los perfiles de usuario que tiene, personas relacionadas, etc. Visualmente esquemático y bonito.
- En el listado de recursos, quiero poder filtrar por pestañas por cada plantilla de recurso existente que haya pero manteniendo una pestaña "Todo" para poder visualizar todos los recusos juntos. En el listado de activos ya está hecho como te estoy indicando.
- Analiza la posibildad de que los listados se autorefresquen para deterctar cambios hechos por otros usuarios, sabiendo que utilizamos @tanstack/react-query
- En los cuadros de diálogo en los que hay un desplegable para seleccionar un activo, cámbialo para que aparezca otro cuadro de diálogo con el listado de activos para seleccionar uno o más activos si fuera el caso. Ese nuevo cuadro de diálogo para seleccionar activos debe tener la misma disposición que la pantalla de listado de activos. Necesito que tenga el cuadro de búsqueda rapida y las pestañas por cada plantilla de activo. Acuerdate de buscar siempre patrones visuales de componentes para poder reutilizarlos en futuras peticiones.
- Permitir "eliminar" un perfil de usuario y "desactivar" un perfil de usaurio. Comprobar en qué condiciones se peude realizar cada acción. Primero habría que "desactivar" luego se puede proceder a "eliminar".
- En la pantalla de un puesto de trabajo, permitir editar ciertos campos mediante editores inline. Por ejemplo, El nombre del puesto de trabajo se puede editr a traves de un icono con un lapiz. ¿Me explico? Auditar qué información de esa pantalla sería posible de editar de esa manera.
- En la ficha de un perfil de trabajo, ordenar los grupos de los recursos de forma más lógica: primero las carpetas compartidas, luegos los correos electrónicos, luego las aplicaciones. El resto, como tú quieras.
- Elimina la plantilla de recurso "impresoras del perfil" de forma global.
- Cuando se modifica una plantilla de activos para añadir un campo nuevo, una sección nueva, cambiar orden de campos, etc, pensar cómo se debería reflejar este cambio en los activos que dependen de esta plantilla en todas las compañías.
- En la ficha de perfil de usuario quiero cambiar la forma de añadir un recurso.

View File

@ -56,12 +56,11 @@ export async function verifyAssetStates(request: Request, companyId: string, dat
};
const stationValues = {
name: "Uso compartido prueba",
name: "Asignación prueba",
active: true,
siteId: null,
personId: null,
assetIds: [] as string[],
sharedAssetIds: [original.id],
assetIds: [original.id],
};
const station = async (name: string) => {
@ -74,8 +73,7 @@ export async function verifyAssetStates(request: Request, companyId: string, dat
assert.equal(original.displayStatus, "available");
assert.equal(original.assigned, false);
const first = await station("Compartido A");
const second = await station("Compartido B");
const first = await station("Puesto de prueba");
const assigned = await current();
assert.equal(assigned.values.status, "operational");
@ -154,8 +152,7 @@ export async function verifyAssetStates(request: Request, companyId: string, dat
assert.equal((await current()).values.status, "broken");
await state("operational");
assert.equal((await request(`${path}/workstations/${first.id}/decommission`, {})).status, 200);
assert.equal((await current()).displayStatus, "in_use");
assert.equal((await request(`${path}/workstations/${second.id}/delete`, {})).status, 200);
assert.equal((await request(`${path}/workstations/${first.id}/delete`, {})).status, 200);
assert.equal((await current()).displayStatus, "available");
assert.equal((await current()).assigned, false);
const retired = await state("retired");

View File

@ -51,7 +51,6 @@ export async function verifyPrinters(
personId: null,
computerAssetId: host.id,
assetIds: [host.id],
sharedAssetIds: [],
};
const mainResponse = await request(
`/assets/${companyId}/workstations${station ? `/${station.id}/update` : ""}`,

View File

@ -3,9 +3,31 @@ import { randomUUID } from "node:crypto";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { z } from "zod/v4";
type Request = (path: string, body?: unknown) => Promise<Response>;
async function verifyProfileDeletionBlocked(
request: Request,
row: { companyId: string; profileId?: string },
table: string
) {
if (!row.profileId) {
return;
}
const path = `/work-profiles/${row.companyId}/${row.profileId}`;
assert.equal((await request(`${path}/deactivate`, {})).status, 200);
const blocked = await request(`${path}/delete`, {});
assert.equal(blocked.status, 409);
assert.equal(
z.object({ code: z.string() }).parse(await blocked.json()).code,
"PROFILE_IN_USE",
`La referencia de ${table} debe impedir eliminar el perfil`
);
}
export async function verifyRecordDeletion(request: Request, database: Sequelize) {
const counts = async () => {
const result: number[] = [];
@ -106,10 +128,12 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
] as const;
for (const [table, history, foreignKey, endpoint] of specs) {
const rows = await database.query<{ id: string; companyId: string; assetId?: string }>(
`SELECT * FROM ${table}`,
{ type: QueryTypes.SELECT }
);
const rows = await database.query<{
id: string;
companyId: string;
assetId?: string;
profileId?: string;
}>(`SELECT * FROM ${table}`, { type: QueryTypes.SELECT });
assert.ok(rows.length > 0, `No existen datos para probar ${table}`);
// Todos los estados, incluidos los cerrados, admiten corregir un alta equivocada.
@ -122,6 +146,8 @@ export async function verifyRecordDeletion(request: Request, database: Sequelize
: row.id;
const path = `/${endpoint}/${row.companyId}/${suffix}/delete`;
await verifyProfileDeletionBlocked(request, row, table);
assert.equal(
(await request(`/${endpoint}/${randomUUID()}/${suffix}/delete`, {})).status,
404

View File

@ -14,9 +14,7 @@ export async function unlinkAssetFromWorkstations(
);
for (const station of inventory.workstations.filter(
(item) =>
item.values.active &&
[...item.values.assetIds, ...item.values.sharedAssetIds].includes(assetId)
(item) => item.values.active && item.values.assetIds.includes(assetId)
)) {
const response = await request(`/assets/${companyId}/workstations/${station.id}/update`, {
values: {
@ -24,7 +22,6 @@ export async function unlinkAssetFromWorkstations(
computerAssetId:
station.values.computerAssetId === assetId ? null : station.values.computerAssetId,
assetIds: station.values.assetIds.filter((id) => id !== assetId),
sharedAssetIds: station.values.sharedAssetIds.filter((id) => id !== assetId),
},
});

View File

@ -15,12 +15,7 @@ export async function prepareProfileHosts(
);
for (const station of current.workstations) {
if (
station.values.active &&
assetIds.some((id) =>
[...station.values.assetIds, ...station.values.sharedAssetIds].includes(id)
)
) {
if (station.values.active && assetIds.some((id) => station.values.assetIds.includes(id))) {
const closed = await request(`/assets/${companyId}/workstations/${station.id}/update`, {
values: { ...station.values, active: false },
});
@ -39,7 +34,6 @@ export async function prepareProfileHosts(
personId: null,
computerAssetId: assetId,
assetIds: [assetId],
sharedAssetIds: [],
},
});

View File

@ -241,4 +241,60 @@ export async function verifyWorkProfiles(
)?.needsReview,
true
);
const lifecyclePath = `${path}/${another.id}`;
const activeDeletion = await request(`${lifecyclePath}/delete`, {});
assert.equal(activeDeletion.status, 409);
assert.equal(
z.object({ code: z.string() }).parse(await activeDeletion.json()).code,
"PROFILE_ACTIVE"
);
for (const action of ["deactivate", "delete"]) {
assert.equal((await request(`${lifecyclePath}/${action}`, { actorId: person.id })).status, 400);
assert.equal((await request(`${lifecyclePath}/${action}?unknown=1`, {})).status, 400);
assert.equal(
(await request(`${lifecyclePath}/${action}`, {}, "https://other.example")).status,
403
);
assert.equal(
(await request(`${lifecyclePath}/${action}`, {}, undefined, "a".repeat(64))).status,
403
);
assert.equal(
(await request(`/work-profiles/${otherId}/${another.id}/${action}`, {})).status,
404
);
}
const deactivate = await request(`${lifecyclePath}/deactivate`, {});
assert.equal(deactivate.status, 200);
const inactive = WorkProfileResponseSchema.parse(await deactivate.json()).profile;
assert.equal(inactive.values.active, false);
assert.equal(inactive.history.length, another.history.length + 1);
assert.deepEqual(
WorkProfileResponseSchema.parse(await (await request(`${lifecyclePath}/deactivate`, {})).json())
.profile,
inactive
);
const deleted = await request(`${lifecyclePath}/delete`, {});
assert.equal(deleted.status, 200);
assert.deepEqual(await deleted.json(), { deleted: true });
assert.equal((await request(`${lifecyclePath}/delete`, {})).status, 404);
assert.equal((await request(`${lifecyclePath}/deactivate`, {})).status, 404);
assert.equal(
WorkProfilesResponseSchema.parse(await (await request(path)).json()).profiles.some(
(item) => item.id === another.id
),
false
);
const revisions = await database.query(
"SELECT id FROM work_profile_revisions WHERE profileId=:id",
{
replacements: { id: another.id },
}
);
assert.equal(revisions[0].length, 0);
}

View File

@ -32,7 +32,7 @@ export async function verifyWorkstationLifecycle(
assert.ok(source);
const assets = [];
for (const name of ["Equipo de baja", "Impresora compartida de baja"]) {
for (const name of ["Equipo de baja", "Impresora de baja"]) {
const response = await request(`/assets/${companyId}`, {
values: { ...source.values, name, status: "operational" },
});
@ -62,8 +62,7 @@ export async function verifyWorkstationLifecycle(
siteId: source.values.siteId,
personId: person.id,
computerAssetId: computer.id,
assetIds: [computer.id],
sharedAssetIds: [shared.id],
assetIds: [computer.id, shared.id],
};
const create = async (data = values) => {
@ -105,19 +104,17 @@ export async function verifyWorkstationLifecycle(
assert.equal(retired.values.personId, null);
assert.equal(retired.values.computerAssetId ?? null, null);
assert.deepEqual(retired.values.assetIds, []);
assert.deepEqual(retired.values.sharedAssetIds, []);
assert.equal(retired.history.length, 2);
assert.deepEqual(retired.history[0]?.values.assetIds, [computer.id]);
assert.deepEqual(retired.history[0]?.values.assetIds, [computer.id, shared.id]);
assert.deepEqual(
WorkstationResponseSchema.parse(await (await request(retirePath, {})).json()).workstation,
retired
);
// Ambas clases de composición pueden reservarse de nuevo tras la baja.
// Los activos de la composición pueden reservarse de nuevo tras la baja.
const replacement = await create({
...values,
name: "Puesto reutiliza activos",
assetIds: [computer.id, shared.id],
sharedAssetIds: [],
});
const protectedResponse = await request(`/tickets/${companyId}`, {
values: {
@ -180,18 +177,17 @@ export async function verifyWorkstationLifecycle(
assert.equal(Number(remaining[0]?.total), 0);
}
// La eliminación directa de un puesto activo libera su reserva sin borrar activos.
const direct = await create({ ...values, name: "Eliminar puesto activo", sharedAssetIds: [] });
const direct = await create({ ...values, name: "Eliminar puesto activo" });
assert.equal((await request(`${path}/${direct.id}/delete`, {})).status, 200);
const reusable = await create({
...values,
name: "Reserva después de eliminación",
sharedAssetIds: [],
});
assert.equal((await request(`${path}/${reusable.id}/delete`, {})).status, 200);
const concurrent = await create({ ...values, name: "Puesto concurrente", sharedAssetIds: [] });
const candidate = { ...values, name: "Nueva reserva concurrente", sharedAssetIds: [] };
const concurrent = await create({ ...values, name: "Puesto concurrente" });
const candidate = { ...values, name: "Nueva reserva concurrente" };
const [retirement, reservation] = await Promise.all([
request(`${path}/${concurrent.id}/decommission`, {}),
request(path, { values: candidate }),

View File

@ -5,6 +5,7 @@ import { CompanyResponseSchema } from "@metis/companies/common";
import type { Sequelize } from "sequelize";
import { z } from "zod/v4";
import { removeSharedWorkstationAssets } from "../../../modules/assets/src/api/infrastructure/persistence/remove-shared-workstation-assets";
import { initializeWorkstationMembers } from "../../../modules/assets/src/api/infrastructure/persistence/workstation-relational";
type Request = (path: string, body?: unknown, origin?: string, token?: string) => Promise<Response>;
@ -70,7 +71,6 @@ export async function verifyWorkstations(
siteId: own.sites[0]?.id ?? null,
personId: person.id,
assetIds: [first.id, second.id],
sharedAssetIds: [],
};
const save = async (values: unknown, id?: string) => {
@ -188,29 +188,11 @@ export async function verifyWorkstations(
InventoryResponseSchema.parse(await (await request(`/assets/${companyId}`)).json()),
beforeInitialization
);
await error(
{ ...input, personId: null, assetIds: [], sharedAssetIds: [first.id] },
"ASSET_IN_WORKSTATION"
);
await error(
{ ...input, personId: null, assetIds: [], sharedAssetIds: [foreign.id] },
"ASSET_NOT_FOUND",
undefined,
404
);
await error({ ...input, sharedAssetIds: [first.id] }, "INVALID_MEMBERS");
await error({ ...input, sharedAssetIds: [] }, "INVALID_REQUEST", undefined, 400);
await save({ ...restored.values, active: false }, restored.id);
const sharedInput = { ...input, personId: null, assetIds: [], sharedAssetIds: [first.id] };
const sharedOne = await save({ ...sharedInput, name: "Compartido uno" });
const sharedTwo = await save({ ...sharedInput, name: "Compartido dos" });
const exclusive = await save({ ...input, personId: null, assetIds: [first.id] });
await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION");
await save({ ...sharedOne.values, sharedAssetIds: [] }, sharedOne.id);
await error({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] }, "ASSET_IN_WORKSTATION");
await save({ ...sharedTwo.values, active: false }, sharedTwo.id);
const exclusive = await save({ ...sharedInput, assetIds: [first.id], sharedAssetIds: [] });
await error({ ...sharedTwo.values, active: true }, "ASSET_IN_WORKSTATION", sharedTwo.id);
await error({ ...exclusive.values }, "ASSET_IN_WORKSTATION");
await save({ ...exclusive.values, active: false }, exclusive.id);
const designated = await save(
{ ...exclusive.values, active: false, computerAssetId: first.id },
@ -230,17 +212,58 @@ export async function verifyWorkstations(
"INVALID_MEMBERS",
designated.id
);
// Simula las asociaciones compartidas del esquema anterior, también en su última revisión.
const latest = designated.history.at(-1);
assert.ok(latest);
await database.transaction(async (transaction) => {
const replacements = {
id: designated.id,
revision: latest.id,
asset: second.id,
company: companyId,
};
await database.query(
"INSERT INTO workstation_members (workstationId,assetId,kind,position) VALUES (:id,:asset,'shared',1)",
{ replacements, transaction }
);
await database.query(
"INSERT INTO workstation_revision_members (revisionId,assetId,kind,position) VALUES (:revision,:asset,'shared',1)",
{ replacements, transaction }
);
await database.query(
"INSERT INTO workstation_assets (workstationId,companyId,assetId,activeKey) VALUES (:id,:company,:asset,NULL)",
{ replacements, transaction }
);
});
await removeSharedWorkstationAssets(database);
await removeSharedWorkstationAssets(database);
const migrated = InventoryResponseSchema.parse(
await (await request(`/assets/${companyId}`)).json()
);
assert.deepEqual(
migrated.workstations.find((item) => item.id === designated.id),
designated
);
assert.ok(migrated.assets.some((asset) => asset.id === second.id));
const race = await Promise.all([
request(path, {
values: {
...sharedInput,
...input,
personId: null,
name: "Reserva concurrente",
assetIds: [second.id],
sharedAssetIds: [],
},
}),
request(path, {
values: { ...sharedInput, name: "Uso compartido concurrente", sharedAssetIds: [second.id] },
values: {
...input,
personId: null,
name: "Segunda reserva concurrente",
assetIds: [second.id],
},
}),
]);

View File

@ -49,6 +49,11 @@ El [inventario de activos](inventario-de-activos.md) utiliza estas plantillas. E
Las plantillas permiten organizar campos en secciones ordenadas, globales o propias de compañía. Consultar [Secciones de campos en plantillas](secciones-de-campos-en-plantillas.md) para las reglas, presentación y conservación de datos existentes.
La [propuesta de evolución de plantillas de activos](evolucion-de-plantillas-de-activos.md)
define cómo propagar cambios a los activos existentes de todas las compañías,
conservando valores e historial. Está pendiente de implementación; no modifica el
contrato actual de instantáneas.
## Iconos de plantilla
Cada plantilla global o propia de compañía admite un icono opcional de la biblioteca Lucide existente. El formulario incluye un selector visual con nombres traducidos, selección marcada y opción «Sin icono». Las copias conservan el icono; las plantillas personalizadas permiten cambiarlo y retirarlo.

View File

@ -153,7 +153,7 @@ EntityTable recibe datos y columnas tipadas; utiliza TanStack Table para búsque
La paginación es local sobre los conjuntos que las API actuales ya devuelven. No simula paginación de servidor. La selección del activo para el panel sigue perteneciendo a Inventario, fuera de la tabla compartida.
El inventario de activos compone las primitivas Tabs de shadcn/ui con EntityTable para filtrar por plantilla. La barra admite desplazamiento horizontal y navegación por teclado; incluye «Todo» y las plantillas del catálogo de la compañía. Un único panel conserva la tabla, su ordenación y sus columnas al cambiar de pestaña. `filterKey` reinicia la página; `filtersApplied` y `onClearFilters` integran el filtro con los estados vacíos y la limpieza común del listado.
Los inventarios de activos y recursos componen las primitivas Tabs de shadcn/ui con EntityTable para filtrar por plantilla. La barra admite desplazamiento horizontal y navegación por teclado; incluye «Todo» y las plantillas del catálogo de la compañía. Un único panel conserva la tabla, su ordenación y sus columnas al cambiar de pestaña. `filterKey` reinicia la página; `filtersApplied` y `onClearFilters` integran el filtro con los estados vacíos y la limpieza común del listado.
EntityTable separa internamente la configuración de columnas/ordenación, el contenido semántico y la paginación; useEntityTable concentra el estado de representación. entityLabel permite contar «plantillas», «campos» o «tickets» aunque el título del espacio sea más amplio. Los tamaños disponibles son 10, 25, 50 y 100; una opción vacía nunca cambia el tamaño a cero.
@ -383,9 +383,9 @@ El panel lateral de activos muestra plantilla, estado y puestos activos inmediat
## Ficha de puesto de trabajo
La ruta `/companies/:companyId/workstations/:id` muestra una ficha completa al pulsar el nombre en el listado. La cabecera concentra nombre, estado, sede, persona asignada y edición modal. El contenido adapta sus dos columnas a una en pantallas pequeñas: activos exclusivos/compartidos, perfiles de usuario y personas relacionadas con avatar. Los enlaces abren fichas reales de activos, personas y perfiles. El historial sigue disponible desde las acciones del listado.
La ruta `/companies/:companyId/workstations/:id` muestra una ficha completa al pulsar el nombre en el listado. La cabecera concentra nombre, estado, sede, persona asignada y edición modal. El contenido adapta sus dos columnas a una en pantallas pequeñas: activos del puesto, perfiles de usuario y personas relacionadas con avatar. Los enlaces abren fichas reales de activos, personas y perfiles. El historial sigue disponible desde las acciones del listado.
Los perfiles se deducen por los activos del puesto, incluidos los compartidos; no se introduce una relación de propiedad entre perfil y puesto. Los inactivos se identifican explícitamente. Las personas se reúnen sin duplicados a partir de la asignación al puesto y sus perfiles. Los fallos parciales al cargar perfiles no impiden consultar los activos; se ofrece reintento. No se confunde un error o carga pendiente con ausencia de perfiles.
Los perfiles se deducen por el ordenador principal del puesto; no se introduce una relación de propiedad entre perfil y puesto. Los inactivos se identifican explícitamente. Las personas se reúnen sin duplicados a partir de la asignación al puesto y sus perfiles. Los fallos parciales al cargar perfiles no impiden consultar los activos; se ofrece reintento. No se confunde un error o carga pendiente con ausencia de perfiles.
## Gestión de usuarios
@ -422,3 +422,19 @@ entre controles. Las columnas responden al ancho del contenedor; nombre y estado
comparten fila cuando hay espacio. Se conservan los márgenes del anillo de foco de
`FormBody`, la ayuda de campo, el desplazamiento del cuerpo y las acciones fijas.
Se emplean los tokens semánticos del tema, sin estilos ni cambios en vendor.
## Progreso de formularios y alta de puestos
`FormProgress` de `@repo/rdx-ui/components/form-progress` presenta pasos numerados, marca el actual con `aria-current="step"`, identifica los completados y lleva el foco al anuncio traducido de progreso. Recibe etiquetas, descripción y textos accesibles del consumidor; no conoce entidades ni consultas. Lo usan los asistentes de recursos y puestos.
El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y resumen. Los controles de asociaciones, composición, ordenador y resumen tienen responsabilidades propias y se reutilizan en la edición cuando corresponde. Las acciones Anterior/Siguiente conservan valores; Guardar aparece en el resumen. La sede única y el activo único tienen selección automática. Véase [Puestos de trabajo](puestos-de-trabajo.md#alta-en-cuatro-pasos).
## Selección de activos en diálogo
`AssetSelect`, contrato público de `@metis/assets/client`, sustituye los desplegables de activos de formularios. Su disparador muestra el valor y conserva la acción de alta rápida. El modo único recibe un UUID y devuelve un UUID; el múltiple recibe una colección, devuelve una colección confirmada y declara `maxSelection`. Los consumidores aportan opciones permitidas, bloqueos, textos y sedes. Las reglas de negocio siguen en cada formulario.
`AssetPickerDialog` carga inventario y catálogo mediante sus queries existentes. Conserva una selección provisional hasta confirmar; Cancelar, Escape y cerrar no modifican el formulario padre. No invoca endpoints de escritura. El diálogo muestra estados de carga/error y reintento, contador de seleccionados, controles accesibles y confirmación deshabilitada si hay selecciones inválidas o se supera el límite. Las selecciones sobreviven a búsqueda, pestañas y paginación. No navega ni cambia filtros URL de la pantalla subyacente.
`AssetList`, en assets/shared/ui, concentra las pestañas por UUID de plantilla (incluidas vacías/inactivas y especificaciones históricas), búsqueda rápida por nombre/plantilla/estado/sede y las columnas Nombre, Plantilla, Estado y Sede. Tanto inventario como selector lo componen con `EntityTable`. El inventario aporta enlaces, acciones y resaltado; el selector aporta checkboxes o radios de shadcn/ui en la columna fija de acciones, accesible también al desplazar horizontalmente la tabla. Se reutilizan ordenación, columnas configurables, rangos y paginación existentes. Este componente conoce activos y permanece en su módulo; `EntityTable`, `FormDialog`, `FormBody`, `FormActions` y `ButtonGroup` siguen siendo los patrones genéricos independientes del dominio.
El selector se aplica a activos del puesto (adición múltiple), ordenador principal (único entre miembros), extremo de relaciones, impresora por perfil, origen/destino de conexiones remotas y activos afectados de tickets (adición múltiple). Cada caso conserva exclusiones, referencias fijas y altas rápidas. Añadir varios activos a un puesto es una sola actualización de composición; solo se propone automáticamente el ordenador cuando la composición resultante contiene un único activo.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,11 @@ Ficha de consulta disponible desde «Entorno actual» en cada perfil de usuario.
## Equipo y puesto
Muestra las características del equipo que tienen valor, conservando valores como No o cero. Consulta los puestos activos que contienen ese dispositivo y presenta sus activos exclusivos y compartidos sin duplicarlos. No infiere asignaciones ni titularidades entre persona, perfil y puesto. Las relaciones físicas y el mapa visual permanecen en sus consultas actuales; no se incorpora un mapa nuevo.
Muestra las características del equipo que tienen valor, conservando valores como No o cero. Consulta los puestos activos que contienen ese dispositivo y presenta sus activos sin duplicarlos. No infiere asignaciones ni titularidades entre persona, perfil y puesto. Las relaciones físicas y el mapa visual permanecen en sus consultas actuales; no se incorpora un mapa nuevo.
## Configuraciones
Los cinco bloques son correos, carpetas compartidas, impresoras, aplicaciones y certificados. Cada fila conserva método, ruta, permisos documentados, conexión, modalidad, versión o ubicación según corresponda; también estado, notas y aviso de revisión.
Las configuraciones aparecen después de los datos del equipo, en este orden: carpetas compartidas, correos electrónicos, aplicaciones, impresoras y certificados. Los bloques de teletrabajo, accesos remotos y asistencia técnica remota aparecen después. Cada fila conserva método, ruta, permisos documentados, conexión, modalidad, versión o ubicación según corresponda; también estado, notas y aviso de revisión.
Una persona puede tener varios correos y varios perfiles. La ficha muestra solamente las configuraciones del perfil seleccionado; permite cambiar a otros perfiles vigentes de esa persona. Las configuraciones retiradas o nunca configuradas quedan en las consultas de gestión e historial. Los correos antiguos sin perfil requieren vinculación explícita: se avisa de los registros del mismo dispositivo y persona, sin asignarlos automáticamente.

View File

@ -0,0 +1,102 @@
# Evolución de plantillas de activos
## Estado y objetivo
Propuesta pendiente de implementación. Define cómo deberían reflejarse las ediciones
de una plantilla en los activos existentes de todas las compañías que la utilizan.
No cambia el comportamiento actual ni las reglas del catálogo de recursos.
Actualmente `mapAssetInput` captura la especificación al crear el activo y reutiliza
la anterior al editarlo. El formulario utiliza esa misma instantánea. Por tanto,
añadir campos, secciones o cambiar el orden solo afecta a las nuevas altas.
El icono ya se consulta desde la plantilla actual.
Cada `AssetRevision` conserva valores, autor y fecha, pero no una especificación
propia. Sustituir la especificación del activo sin evolucionar este contrato haría
que las revisiones antiguas se interpretasen con campos y grupos posteriores.
Véanse [campos y plantillas](campos-y-plantillas-de-activos.md) y
[secciones de campos](secciones-de-campos-en-plantillas.md) para el contrato vigente.
## Comportamiento propuesto
La ficha y la edición del activo deben seguir la plantilla actual, conservando los
valores ya registrados. El historial debe mostrar la estructura y las reglas
vigentes cuando se registró cada revisión.
| Cambio en la plantilla | Efecto en los activos existentes |
| --- | --- |
| Añadir un campo opcional | Aparece vacío en la ficha y disponible en la edición; no se inventa un valor. |
| Añadir un campo obligatorio o marcarlo obligatorio | Aparece pendiente de completar si falta un valor. El activo continúa siendo consultable. |
| Añadir, renombrar o reordenar secciones | Se aplica a la presentación actual; una sección vacía no se muestra en la ficha. |
| Mover o reordenar campos | Cambia su presentación, manteniendo identidad y valor. |
| Eliminar una sección | Sus campos pasan a «Sin sección», conservando los valores. |
| Retirar un campo | Deja de solicitarse; los valores existentes siguen consultables como características retiradas, separados de los campos actuales. |
| Reincorporar un campo | Recupera su valor anterior por UUID, si existe. |
| Cambiar nombre, descripción o unidad | Actualiza la presentación actual; no convierte ni modifica los valores. |
| Añadir opciones a un campo de lista | Las opciones nuevas quedan disponibles sin alterar la selección guardada. |
| Desactivar plantilla o campo | Conserva activos, asociaciones, valores e historial; mantiene las reglas de disponibilidad existentes. |
La evolución no cambia el tipo de un campo ni elimina opciones existentes: ambas
operaciones continúan prohibidas por el catálogo. Un campo nuevo con el mismo nombre
que otro retirado tiene otra identidad y no hereda sus valores.
Los nuevos activos cumplen desde el alta todas las reglas actuales. En los
existentes, la edición de características exige completar los campos obligatorios
actuales. Las operaciones independientes sobre estado, ubicación o notas no deben
quedar bloqueadas por un cambio posterior de plantilla; esto requiere distinguir
su validación de la edición de características. Nunca se rellenan valores por
defecto para disimular información pendiente. Un cambio a opcional elimina el
pendiente correspondiente sin borrar información.
## Alcance entre compañías
La dependencia se identifica por UUID de plantilla, nunca por nombre. Una edición
global afecta a los activos vinculados a esa plantilla en todas las compañías,
incluidos los retirados y los de compañías donde se haya desactivado su disponibilidad.
Una plantilla local solo afecta a sus propios activos. Una copia tiene identidad
propia y permanece independiente de la plantilla original.
La propagación respeta la compañía propietaria de cada activo y la autorización del
servidor. El navegador no elige qué compañías pueden ser modificadas. La edición
de una plantilla no reasigna activos ni altera relaciones, puestos o perfiles.
## Implementación prevista
1. Introducir versiones inmutables de la especificación de plantilla, con identidad
UUID v7 y persistencia relacional. Una modificación semántica crea una versión;
un guardado equivalente conserva la existente.
2. Asociar cada revisión del activo a la especificación con la que se registró.
Una migración explícita conserva la instantánea existente para todas sus
revisiones anteriores: no puede reconstruirse qué plantilla histórica existía
si esa información nunca se almacenó.
3. Resolver la especificación actual desde la plantilla al consultar y editar el
activo, combinándola con sus valores por UUID de campo. Así el cambio se refleja
en todas las compañías sin reescribir masivamente sus activos. Conservar los
valores retirados y mantener la especificación histórica para el historial.
4. Hacer que el dominio y los mappers distingan validación de características,
cambios independientes y rehidratación histórica. Guardar una edición real
contra la versión actual en una transacción. Si la plantilla cambió mientras el
formulario estaba abierto, devolver un conflicto recuperable que permita
revisar los cambios conservando la entrada del usuario.
5. Mostrar en el editor de plantilla el alcance del cambio y, en cada activo, los
campos obligatorios pendientes y los valores retirados. Invalidar las consultas
afectadas del catálogo y del inventario en el cliente que realiza el cambio;
otros clientes reciben la estructura vigente al volver a consultar.
Guardar una plantilla no debe generar revisiones técnicas ficticias por activo.
La versión de plantilla conserva autor y fecha para explicar el cambio estructural.
Una consulta tampoco escribe datos ni crea historial. Repetir una operación
equivalente no duplica versiones ni revisiones. La comparación es semántica.
## Validación necesaria
Probar propagación global en dos compañías y aislamiento local y de copias;
altas y activos anteriores; campos opcionales y obligatorios; valores `0` y `false`;
reordenación y eliminación de secciones; retirada y reincorporación por identidad;
historial sin cambios; disponibilidad desactivada; activos retirados; guardados
equivalentes; conflictos entre edición de plantilla y activo; autorización;
migración relacional repetible y caché del frontend.
Esta evolución sustituirá, cuando esté implementada, la regla actual que congela
la estructura del activo. Hasta entonces las instantáneas siguen siendo inmutables,
como documenta el contrato vigente de secciones.

View File

@ -8,7 +8,7 @@ El listado ofrece una pestaña «Todo» y una por cada plantilla global o propia
Un activo representa un elemento concreto, identificado mediante UUID v7. Su nombre sirve para reconocerlo (por ejemplo PORT-ANA-01), pero no sustituye al UUID y puede repetirse. La compañía y la plantilla son obligatorias. Sede, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla. La persona se relaciona mediante un puesto de trabajo o un perfil de usuario.
Estados técnicos manuales: Operativo, Averiado, En mantenimiento y Retirado. Un activo operativo se muestra Disponible si ningún puesto activo lo utiliza, o En uso si figura como exclusivo o compartido en uno o varios puestos activos. La asignación se calcula desde la composición, independientemente del responsable. Los puestos inactivos y las revisiones históricas no cuentan como uso vigente. Para retirarlo se exige desvincularlo primero de todos los puestos activos, conservando sede e historial. La eliminación definitiva existente sigue protegida por referencias; no hay transferencia entre compañías.
Estados técnicos manuales: Operativo, Averiado, En mantenimiento y Retirado. Un activo operativo se muestra Disponible si ningún puesto activo lo utiliza, o En uso si figura en un puesto activo. La asignación se calcula desde la composición, independientemente del responsable. Los puestos inactivos y las revisiones históricas no cuentan como uso vigente. Para retirarlo se exige desvincularlo primero de todos los puestos activos, conservando sede e historial. La eliminación definitiva existente sigue protegida por referencias; no hay transferencia entre compañías.
La sede y la persona pertenecen a la compañía. Solo se permiten nuevas asignaciones a personas activas. Una persona que se desactiva conserva su asignación anterior, para facilitar la revisión y retirada; puede desasignarse. Las personas no reciben acceso a Metis Desk.
@ -63,12 +63,12 @@ Los puestos de trabajo y la asistencia técnica se almacenan también de forma r
`values.status` en la API representa el estado técnico. `assigned` y `displayStatus` son datos calculados al construir las respuestas de alta, edición y consulta; no se persisten como columnas. Listados, búsqueda, ordenación y fichas muestran `displayStatus`. El formulario solo ofrece estados técnicos y explica la distinción.
`assetTechnicalStatus`, `assetDisplayStatus` y `canAssignAsset` son funciones puras pequeñas del dominio. La selección de activos del puesto reutiliza la regla de asignación y muestra por qué un elemento está bloqueado. Una nueva asociación a un puesto activo, o su reactivación, requiere un activo Operativo. Las reservas exclusivas y compartidas mantienen sus restricciones y bloqueos transaccionales. Una asociación existente puede conservarse al editar un puesto si su activo se avería o entra en mantenimiento; cambiarlo de exclusivo a compartido o viceversa se considera una nueva asociación.
`assetTechnicalStatus`, `assetDisplayStatus` y `canAssignAsset` son funciones puras pequeñas del dominio. La selección de activos del puesto reutiliza la regla de asignación y muestra por qué un elemento está bloqueado. Una nueva asociación a un puesto activo, o su reactivación, requiere un activo Operativo. Las reservas mantienen sus restricciones y bloqueos transaccionales. Una asociación existente puede conservarse al editar un puesto si su activo se avería o entra en mantenimiento.
Una avería o mantenimiento no borra asignaciones, perfiles ni software, y prevalece visualmente sobre En uso. Se conserva la información del puesto para organizar la reparación. Al recuperar Operativo vuelve a mostrarse En uso si sigue asignado. Al dar de baja/eliminar un puesto o retirar un miembro se recalcula el uso; un activo compartido permanece En uso mientras otro puesto activo lo utilice. Un activo averiado o en mantenimiento conserva su estado al liberarse.
Una avería o mantenimiento no borra asignaciones, perfiles ni software, y prevalece visualmente sobre En uso. Se conserva la información del puesto para organizar la reparación. Al recuperar Operativo vuelve a mostrarse En uso si sigue asignado. Al desactivar/eliminar un puesto o retirar un miembro se recalcula el uso; el activo queda Disponible cuando deja de pertenecer a un puesto activo. Un activo averiado o en mantenimiento conserva su estado al liberarse.
El servicio de inventario exporta `notRetired`, en sustitución del booleano genérico `available`. Perfiles, software, correo, impresoras, certificados, accesos remotos, procesos y consultas de soporte utilizan ese contrato explícito. Se conserva su criterio para registrar configuraciones: no retirado no significa libre ni operativo. La creación/activación de un perfil mantiene además los requisitos de persona y ordenador principal de un puesto activo. Las configuraciones existentes y sus avisos siguen documentando preparación/retirada manual, sin simular operaciones sobre equipos.
La persistencia conserva los códigos originales Disponible/En uso en los registros existentes y sus revisiones. Se interpretan como estado técnico Operativo al consultar la situación actual; no se inventan asignaciones ni se reescribe el historial. Las nuevas entradas HTTP solo aceptan los cuatro estados técnicos. Una consulta y un guardado semánticamente equivalente no añaden revisiones de normalización. El mapper sigue comprobando que el estado original de raíz y última revisión coincida. La restricción SQL se amplía idempotentemente para admitir los nuevos estados, preservando la lectura de los registros originales; no se añade JSON ni nuevas columnas de estados calculados.
Pruebas de reglas puras, elegibilidad exclusiva/compartida, historial e idempotencia, uso compartido por varios puestos, avería/reparación conservando asignaciones, bloqueo de nuevas asociaciones, retirada con referencias activas, liberación por baja/eliminación y respuestas HTTP/MariaDB. Los escenarios de retirada en las pruebas desvinculan primero los puestos, como exige el nuevo comportamiento.
Pruebas de reglas puras, elegibilidad de activos, historial e idempotencia, avería/reparación conservando asignaciones, bloqueo de nuevas asociaciones, retirada con referencias activas, liberación por baja/eliminación y respuestas HTTP/MariaDB. Los escenarios de retirada en las pruebas desvinculan primero los puestos, como exige el nuevo comportamiento.

View File

@ -62,7 +62,7 @@ La aplicación navega por espacios y secciones mediante rutas URL independientes
| Usuarios internos | [UserManagement](../modules/identity/src/web/users/list/ui/user-management.tsx) | Sí |
| Activos | [AssetInventoryPage](../modules/assets/src/web/assets/list/ui/inventory-page.tsx) | Sí; conserva panel lateral |
| Puestos de trabajo | [WorkstationsPage](../modules/assets/src/web/workstations/list/ui/workstations-page.tsx) | Sí |
| Recursos, incluidas aplicaciones y licencias | [ResourcesPage](../modules/resources/src/web/resources/list/ui/resources-page.tsx) | Sí |
| Recursos, incluidas aplicaciones y licencias | [ResourcesPage](../modules/resources/src/web/resources/list/ui/resources-page.tsx) | Sí; pestañas por plantilla y «Todo» |
| Campos y plantillas de recursos, global/compañía | [ResourceCatalogPage](../modules/resources/src/web/catalog/list/ui/catalog-page.tsx) | Sí; dos secciones |
| Campos, plantillas y relaciones de activos, global/compañía | [AssetCatalogPage](../modules/assets/src/web/catalog/list/ui/catalog-page.tsx) | Sí; tres secciones |
| Cuentas y accesos / catálogos informáticos | [AccessWorkspacePage](../modules/companies/src/web/access/list/ui/access-workspace.tsx) | Sí; cuenta, titularidad, grupo, pertenencia, autorización, necesidad, cobertura, entorno, sistema, tipo, permiso y plantilla |
@ -125,3 +125,7 @@ La primera columna tiene anchura mínima para evitar nombres ilegibles. El despl
Disparador solo con icono de tres puntos, etiqueta accesible y título conservados. Columna actions siempre al final, compacta, alineada y anclada a la derecha; no admite ocultación, búsqueda u ordenación. El fondo opaco respeta hover, menú abierto y selección del inventario.
Verificación: 204 pruebas correctas, tipos y compilación correctos; los cinco archivos de código/estilos de este ajuste pasan Biome. Las comprobaciones globales conservan los errores anteriores. En navegador se han comprobado apertura/cancelación de edición, botón sin texto, posición de cabecera y celdas antes y después del desplazamiento horizontal a anchura móvil, y color del activo seleccionado. No se modificaron registros de negocio durante la revisión.
### Selector de activos
Los formularios que seleccionan activos usan un diálogo con el mismo `AssetList` del inventario. Reutiliza `EntityTable`, búsqueda, pestañas por plantilla, columnas y paginación. La selección provisional no cambia el formulario hasta confirmar, ni altera la URL o filtros del listado padre. Cada consumidor restringe los candidatos y declara selección única o múltiple con límite. Véase [componentes de interfaz](componentes-de-interfaz.md#selección-de-activos-en-diálogo).

View File

@ -16,7 +16,7 @@ Un puesto agrupa activos; el perfil pertenece a una persona y a un dispositivo.
El responsable del puesto y las personas con perfiles tienen significados distintos. No se propagan cambios automáticamente entre ellas. Ninguna de estas referencias demuestra una autorización ni que una configuración esté lista.
Los puestos distinguen activos exclusivos y recursos compartidos. Consultar [Puestos de trabajo](puestos-de-trabajo.md).
Los puestos agrupan un único conjunto de activos. Consultar [Puestos de trabajo](puestos-de-trabajo.md).
## Revisión y altas rápidas
@ -30,7 +30,7 @@ La ayuda aparece inmediatamente antes del pie con Guardar y Cancelar. Se mantien
WorkProfile es un AggregateRoot con UniqueID; WorkProfileRevision es una DomainEntity con su propio UUID. WorkProfileValues es un Value Object inmutable con Name, UniqueID y Maybe para los opcionales. Crear genera UUID v7; actualizar conserva identidad, compara valores semánticamente y añade una revisión solo si cambian. Rehidratar conserva UUID e historial.
work_profiles conserva raíz, referencias y clave única nullable de la pareja vigente. work_profile_revisions conserva instantáneas ordenadas, cuenta histórica, actor autenticado y fecha UTC, con UUID propio y claves foráneas. Las migraciones explícitas son idempotentes. No hay borrado de perfiles ni de sus revisiones.
work_profiles conserva raíz, referencias y clave única nullable de la pareja vigente. work_profile_revisions conserva instantáneas ordenadas, cuenta histórica, actor autenticado y fecha UTC, con UUID propio y claves foráneas. Las migraciones explícitas son idempotentes. El borrado permitido elimina la raíz y sus revisiones en una única transacción.
El repositorio bloquea la compañía antes de validar y guardar. Personas y cuentas usan ese mismo bloqueo, y el inventario se consulta mediante un servicio público de assets, sin importar sus modelos ORM. Dos altas concurrentes de la misma pareja no pueden producir dos perfiles vigentes. El mapper comprueba identidad, referencias, cuenta histórica, orden y fechas, última revisión y clave vigente. Una inconsistencia falla explícitamente.
@ -44,7 +44,7 @@ La base del perfil incorpora ahora [buzones y configuraciones de correo](correos
El perfil incorpora también carpetas compartidas e [impresoras por perfil](impresoras-del-perfil.md), con acciones para abrir cada consulta filtrada. Las [aplicaciones por perfil](aplicaciones-del-perfil.md) están implementadas sobre recursos. Los [certificados por perfil](certificados-del-perfil.md) también están implementados. La ficha [Entorno actual](entorno-actual.md) reúne estas configuraciones. El mapa visual queda aplazado.
La comprobación en navegador verifica la nueva sección, los desplegables de persona y dispositivo, la apertura de las tres altas rápidas y la conservación del formulario al cancelarlas. En puestos se comprueba la carga de una composición anterior, el selector compartido, su incorporación visual y la cancelación sin persistir cambios.
La comprobación en navegador verifica la nueva sección, los desplegables de persona y dispositivo, la apertura de las tres altas rápidas y la conservación del formulario al cancelarlas. En puestos se comprueba la carga de una composición anterior, el selector de activos, su incorporación visual y la cancelación sin persistir cambios.
## Alta desde el puesto
@ -53,4 +53,30 @@ El formulario de alta pide puesto y persona. La persona no se toma automáticame
Se conserva la identidad persona/dispositivo y sus validaciones, permisos e historial. La relación con el puesto sigue consultándose por su ordenador principal; no se duplica una asignación independiente en el perfil. Cambiar responsable no modifica perfiles.
El backend comprueba también esta condición al crear o reactivar un perfil vigente y al cambiar su cuenta. Los periféricos y activos compartidos no alojan perfiles del puesto. Cambiar el ordenador principal o desactivar el puesto conserva los perfiles anteriores y su historial, pero señala los vigentes para revisión; no los transfiere al equipo nuevo. Se permite completar sus notas y archivarlos. Si el mismo equipo se incorpora como ordenador principal de otro puesto activo, la consulta refleja su ubicación actual.
El backend comprueba también esta condición al crear o reactivar un perfil vigente y al cambiar su cuenta. Los periféricos no alojan perfiles del puesto. Cambiar el ordenador principal o desactivar el puesto conserva los perfiles anteriores y su historial, pero señala los vigentes para revisión; no los transfiere al equipo nuevo. Se permite completar sus notas y archivarlos. Si el mismo equipo se incorpora como ordenador principal de otro puesto activo, la consulta refleja su ubicación actual.
## Desactivar y eliminar
Las acciones de fila permiten desactivar y eliminar mediante un modal de confirmación.
Desactivar conserva valores, configuraciones e historial; solo cambia `active` y añade
una revisión si el perfil estaba vigente. Se permite aunque la persona, el ordenador
o la cuenta ya no estén disponibles. No retira permisos ni configuraciones externas.
Repetir la desactivación no genera revisiones adicionales. Los perfiles desactivados
se consultan mediante el filtro de archivados y pueden reactivarse desde el editor
con las comprobaciones habituales de disponibilidad y pareja vigente única.
Eliminar exige que el perfil esté desactivado y que no existan referencias en correo,
carpetas, impresoras, aplicaciones o certificados, tanto en sus registros actuales
como en sus revisiones. Los estados retirado o nunca configurado también bloquean
el borrado mientras exista el registro vinculado. Primero deben eliminarse esos
registros mediante sus acciones existentes. No se borran configuraciones en cascada.
La eliminación definitiva incluye todo el historial del propio perfil.
`POST /api/work-profiles/:companyId/:id/deactivate` y
`POST /api/work-profiles/:companyId/:id/delete` reciben un objeto vacío, requieren
sesión, origen válido y CSRF, y verifican pertenencia a la compañía. La desactivación
obtiene el actor de la sesión. El borrado devuelve `PROFILE_ACTIVE` o `PROFILE_IN_USE`
(409) cuando no cumple las condiciones; un perfil inexistente o de otra compañía
devuelve `PROFILE_NOT_FOUND` (404). Repetir un borrado devuelve 404 sin nuevos efectos.
El bloqueo de compañía serializa estas operaciones con altas y cambios de
configuraciones; las claves foráneas conservan la protección de referencias.

View File

@ -2,7 +2,7 @@
## Uso
La sección Puestos de trabajo de cada compañía agrupa los activos utilizados habitualmente como un conjunto. Un puesto tiene UUID v7, nombre, estado activo/inactivo, sede opcional, persona opcional y hasta 200 activos exclusivos y 200 recursos compartidos. No se admite duplicar un activo ni incluirlo en ambas listas. Se puede crear vacío y completar posteriormente. Una persona puede tener varios puestos. Los puestos no contienen otros puestos.
La sección Puestos de trabajo de cada compañía agrupa los activos utilizados habitualmente como un conjunto. Un puesto tiene UUID v7, nombre, estado activo/inactivo, sede, responsable opcional y hasta 200 activos sin duplicados. El alta exige al menos un activo y un ordenador principal. Los puestos antiguos o desactivados pueden conservar una composición vacía hasta completarlos. Una persona puede tener varios puestos. Los puestos no contienen otros puestos.
Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. Al crear, elegir responsable y sede propone el nombre «Puesto de {persona} en {sede}»; el técnico puede modificarlo o borrarlo antes de guardar y esa edición manual no se sobrescribe. El formulario ofrece selectores con «+» para dar de alta sedes, personas y activos sin perder sus datos. Guardar un elemento relacionado lo selecciona o incorpora al puesto; cancelarlo mantiene el formulario principal. Los elementos guardados sobreviven si posteriormente se cancela el puesto. El alta de activo desde un puesto usa plantillas y sedes existentes para evitar un tercer modal.
@ -10,7 +10,7 @@ La ayuda aparece inmediatamente antes de Guardar y Cancelar. El listado permite
## Reglas
Un activo exclusivo solo puede pertenecer a un puesto activo. Un recurso compartido puede utilizarse en varios puestos activos, por ejemplo una impresora de red. Un activo reservado como exclusivo no puede figurar como compartido en otro puesto activo; un recurso compartido debe retirarse de todos los otros puestos vigentes antes de reservarlo como exclusivo. El formulario marca como ocupados los activos de otros puestos y no permite seleccionarlos en una composición incompatible. Las reservas exclusivas y usos compartidos se validan bajo el mismo bloqueo de compañía, también frente a escrituras concurrentes. El servidor vuelve a comprobarlo dentro de una transacción y la base de datos protege la reserva con una clave única. Dos altas concurrentes no pueden reservar el mismo activo como exclusivo, ni mezclar un uso exclusivo con un uso compartido en otro puesto vigente.
Un activo solo puede pertenecer a un puesto activo. El formulario marca como ocupados los activos reservados por otros puestos. El servidor valida la reserva bajo bloqueo de compañía en una transacción y la base de datos la protege con una clave única. Dos altas concurrentes no pueden reservar el mismo activo.
Desactivar un puesto conserva su composición y libera sus reservas. Un activo puede figurar en composiciones de puestos inactivos. Reactivar comprueba de nuevo la disponibilidad: si un activo está ocupado, hay que retirarlo de uno de los puestos antes de guardar. Retirar un activo del puesto no elimina el activo ni sus relaciones.
@ -24,7 +24,7 @@ La eliminación definitiva se realiza mediante una operación explícita con con
Workstation es un agregado; WorkstationRevision es una entidad interna. WorkstationValues es un Value Object inmutable con nombres Name, referencias UniqueID y opcionales Maybe. Infrastructure reconstruye y valida los valores, la última revisión, la auditoría y el ámbito.
Las tablas workstations y workstation_revisions mantienen raíces y revisiones con claves foráneas a compañía, sede, persona y actor. workstation_assets es una proyección técnica de las referencias utilizadas en todo el historial y de sus reservas vigentes; no representa otra entidad de negocio. Conserva claves foráneas a activos históricos de ambas listas; solo la composición exclusiva genera la reserva vigente. Conserva claves compuestas para la pertenencia a compañía y una clave única nullable para la reserva. Las migraciones son idempotentes.
Las tablas workstations y workstation_revisions mantienen raíces y revisiones con claves foráneas a compañía, sede, persona y actor. workstation_assets es una proyección técnica de las referencias utilizadas en todo el historial y de sus reservas vigentes; no representa otra entidad de negocio. Conserva claves foráneas a activos históricos; la composición activa genera la reserva vigente. Conserva claves compuestas para la pertenencia a compañía y una clave única nullable para la reserva. Las migraciones son idempotentes.
El repositorio del inventario carga los puestos y serializa sus escrituras con el bloqueo de catálogo y compañía ya utilizado por activos y relaciones. Conserva revisiones existentes y solo inserta las nuevas.
@ -40,27 +40,38 @@ Un puesto agrupa equipos y recursos; el [perfil de usuario](perfiles-de-trabajo.
La relación opcional `personId` identifica al responsable del puesto, no a todos sus usuarios. No se propaga a los perfiles. Estos identifican a cada persona que utiliza el ordenador, de modo que un puesto compartido puede tener varios perfiles.
`computerAssetId` identifica el único ordenador principal entre los activos exclusivos del puesto. No se deduce por nombre de plantilla ni por orden de selección. El resto de activos se registra como periféricos. Dos ordenadores se organizan en dos puestos. El ordenador principal se persiste como columna con clave foránea en el puesto y sus revisiones; un cambio conserva historial. Los puestos existentes mantienen su composición y necesitan designar el ordenador antes de crear nuevos perfiles. Retirarlo de la composición limpia la selección del ordenador principal.
`computerAssetId` identifica el único ordenador principal entre los activos del puesto. No se deduce por nombre de plantilla ni por orden de selección. El resto de activos se registra como periféricos. Dos ordenadores se organizan en dos puestos. El ordenador principal se persiste como columna con clave foránea en el puesto y sus revisiones; un cambio conserva historial. Los puestos existentes mantienen su composición y necesitan designar el ordenador antes de crear nuevos perfiles. Retirarlo de la composición limpia la selección del ordenador principal.
La ficha reúne únicamente perfiles del ordenador principal, incluidos los archivados cuando corresponda. Los periféricos y activos compartidos no aportan perfiles. El entorno actual y la consulta del perfil utilizan el mismo criterio. Cambiar el ordenador principal no reasigna perfiles automáticamente: los perfiles vigentes que dejan de tener un puesto activo requieren revisión.
La ficha reúne únicamente perfiles del ordenador principal, incluidos los archivados cuando corresponda. Los periféricos no aportan perfiles. El entorno actual y la consulta del perfil utilizan el mismo criterio. Cambiar el ordenador principal no reasigna perfiles automáticamente: los perfiles vigentes que dejan de tener un puesto activo requieren revisión.
La ficha consulta también el [software del ordenador](software-del-ordenador.md), incluido el sistema operativo actual, por `computerAssetId`. Las instalaciones permanecen vinculadas al activo al cambiar el ordenador principal.
## Baja y eliminación
## Desactivación y eliminación
El listado y la ficha completa ofrecen **Dar de baja** y **Eliminar definitivamente**, mediante un mismo diálogo reutilizable con confirmación y resultado de servidor.
El listado y la ficha completa ofrecen **Desactivar** y **Eliminar definitivamente**, mediante un mismo diálogo reutilizable con confirmación y resultado de servidor.
Dar de baja deja el puesto inactivo, vacía sus activos exclusivos y compartidos y retira el ordenador principal y responsable. Conserva nombre, sede, UUID e historial. Las asociaciones anteriores permanecen en las revisiones, pero no reservan activos ni representan usos vigentes. Repetir la baja no añade revisiones. También se puede aplicar a un puesto ya inactivo que conserve composición. Se puede editar posteriormente para reconstruirlo y reactivarlo, comprobando las reservas actuales.
Desactivar deja el puesto inactivo, vacía sus activos y retira el ordenador principal y responsable. Conserva nombre, sede, UUID e historial. Las asociaciones anteriores permanecen en las revisiones, pero no reservan activos ni representan usos vigentes. Repetir la desactivación no añade revisiones. También se puede aplicar a un puesto ya inactivo que conserve composición. Se puede editar posteriormente para reconstruirlo y reactivarlo, comprobando las reservas actuales.
Eliminar definitivamente borra el puesto, revisiones y asociaciones técnicas en una transacción y libera sus reservas, incluso si seguía activo. Conserva los activos, personas y sedes. Las referencias externas, incluidos tickets e intervenciones actuales o históricos, impiden la eliminación con `WORKSTATION_IN_USE`; la alternativa es dar de baja. La comprobación utiliza las claves foráneas declaradas para proteger también futuras referencias. Un error no elimina parcialmente la composición ni sus reservas.
Eliminar definitivamente borra el puesto, revisiones y asociaciones técnicas en una transacción y libera sus reservas, incluso si seguía activo. Conserva los activos, personas y sedes. Las referencias externas, incluidos tickets e intervenciones actuales o históricos, impiden la eliminación con `WORKSTATION_IN_USE`; la alternativa es desactivar. La comprobación utiliza las claves foráneas declaradas para proteger también futuras referencias. Un error no elimina parcialmente la composición ni sus reservas.
En este modelo los recursos compartidos del puesto son activos compartidos (por ejemplo impresoras). Los perfiles de usuario, correo, permisos, certificados y software pertenecen a personas, cuentas o al ordenador, y no son asignaciones directas del puesto. La baja libera la composición del puesto; conserva esos registros y no simula desinstalaciones ni retirada de accesos externos. Los perfiles sin un puesto vigente permanecen para revisión. La ficha actualiza los datos después de confirmar la operación; tras eliminar vuelve al listado.
El puesto mantiene un único conjunto de activos. Los perfiles de usuario, correo, permisos, certificados y software pertenecen a personas, cuentas o al ordenador, y no son asignaciones directas del puesto. La desactivación libera la composición del puesto; conserva esos registros y no simula desinstalaciones ni retirada de accesos externos. Los perfiles sin un puesto vigente permanecen para revisión. La ficha actualiza los datos después de confirmar la operación; tras eliminar vuelve al listado.
`POST /api/assets/:companyId/workstations/:id/decommission` y `POST /api/assets/:companyId/workstations/:id/delete` reciben cuerpo vacío `{}`. Requieren sesión, origen y CSRF; compañía y puesto se validan en servidor. La baja registra actor de sesión y fecha en la revisión. Ambas operaciones comparten los bloqueos de catálogo y compañía con las reservas de activos. La baja conserva las referencias históricas; la eliminación elimina las asociaciones internas mediante las claves foráneas y las revisiones, sin desactivar restricciones ni persistir JSON.
`POST /api/assets/:companyId/workstations/:id/decommission` y `POST /api/assets/:companyId/workstations/:id/delete` reciben cuerpo vacío `{}`. Requieren sesión, origen y CSRF; compañía y puesto se validan en servidor. La desactivación registra actor de sesión y fecha en la revisión. Ambas operaciones comparten los bloqueos de catálogo y compañía con las reservas de activos. La desactivación conserva las referencias históricas; la eliminación elimina las asociaciones internas mediante las claves foráneas y las revisiones, sin desactivar restricciones ni persistir JSON.
Las pruebas cubren baja activa/inactiva, liberación de reservas exclusivas y compartidas, ordenador y responsable, historial e idempotencia, eliminación activa/inactiva, referencias externas, rollback lógico, pertenencia a compañía, cuerpo estricto y protección CSRF/origen.
Las pruebas cubren desactivación activa/inactiva, liberación de reservas, ordenador y responsable, historial e idempotencia, eliminación activa/inactiva, referencias externas, rollback lógico, pertenencia a compañía, cuerpo estricto y protección CSRF/origen.
El estado mostrado del activo se calcula desde su estado técnico y los puestos activos, según [Inventario de activos](inventario-de-activos.md). La baja libera composición y reservas sin cambiar el estado técnico de sus activos.
El estado mostrado del activo se calcula desde su estado técnico y los puestos activos, según [Inventario de activos](inventario-de-activos.md). La desactivación libera composición y reservas sin cambiar el estado técnico de sus activos.
## Alta en cuatro pasos
1. Sede y responsable. Si existe una única sede se selecciona automáticamente. El responsable es opcional; las altas relacionadas conservan el formulario.
2. Activos. Un diálogo con listado, búsqueda rápida y pestañas por plantilla permite añadir varios a la vez. Es obligatorio añadir al menos uno; se impiden duplicados, activos ocupados y nuevas asociaciones no operativas.
3. Ordenador principal. Se elige en un diálogo de selección única entre los activos seleccionados. Si solo queda uno se asigna automáticamente; al retirar el ordenador se limpia la selección o se asigna el único restante.
4. Resumen. Muestra sede, responsable, composición y ordenador principal. El nombre propuesto por sede/responsable se puede editar antes de Guardar.
Anterior conserva valores y Siguiente valida los requisitos del paso. Guardar solo aparece en el resumen y no se envía el alta desde pasos intermedios. La edición mantiene un formulario único y permite conservar/completar puestos anteriores o desactivados. Cancelar permanece en el pie del modal y la ayuda inmediatamente encima.
Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan.

View File

@ -103,7 +103,7 @@ La consulta conserva los filtros que ya están conectados a la URL:
- Tickets: `companyId`, `pendingOnly`, `q`, `state`, `priority` y `assignee`.
- Intervenciones: `companyId`, `pendingOnly`, `q`, `state`, `assignee`, `dateFrom` y `dateTo`.
- Activos: `q` y `template` (UUID de plantilla), tanto en el listado como en la ficha seleccionada.
- Recursos: `q`.
- Recursos: `q` y `template` (UUID de plantilla), tanto en el listado como en la ficha seleccionada.
- Perfiles y procesos: `q`, con `archived` o `closed`, respectivamente.
- Catálogos de activos y recursos: `q` y `tab`; `relations` solo en activos.
- Cuentas y accesos: `q` y `kind`, respetando los tipos disponibles en cada ámbito.

View File

@ -28,6 +28,10 @@ La biblioteca visual conserva sus textos en su propio paquete, sin depender de m
No duplicar textos de componentes genéricos en los módulos. Los textos específicos de una entidad pertenecen al módulo consumidor.
## Terminología de desactivación y eliminación
**Desactivar** (`Deactivate` en inglés) indica una baja lógica: el registro se conserva y queda inactivo. **Eliminar** (`Delete` en inglés) indica el borrado definitivo, sujeto a las restricciones de cada entidad. Las etiquetas, confirmaciones y errores utilizan estos términos de forma coherente; no se utiliza «Dar de baja» como etiqueta de desactivación.
## Incorporar un idioma
Crear un JSON junto a los existentes en cada módulo y en la biblioteca visual, conservando las claves y parámetros. Exportar las traducciones desde los adaptadores del módulo, registrarlas en `apps/web/src/i18n.ts` y ampliar `supportedLngs`, el selector de `apps/web/src/app.tsx` y los nombres de idioma de Core. Ampliar también la selección de idioma de `entityListLabels` y las pruebas de paridad. La carga sigue siendo explícita, sin nuevas dependencias.

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/assets/select/asset-selection.test.tsx"
},
"dependencies": {
"@hookform/resolvers": "5.2.2",

View File

@ -23,9 +23,7 @@ export function mapAssetInput(
context.workstations.some(
(station) =>
station.values.active &&
[...station.values.assetIds, ...station.values.sharedAssetIds].some(
(id) => id.toPrimitive() === previous?.id.toPrimitive()
)
station.values.assetIds.some((id) => id.toPrimitive() === previous?.id.toPrimitive())
)
) {
return Result.fail(new AssetCatalogError("ASSET_ASSIGNED"));

View File

@ -9,11 +9,7 @@ export function buildAssetResponse(
workstations: readonly Workstation[] = []
): AssetDTO {
const assigned = workstations.some(
(station) =>
station.values.active &&
[...station.values.assetIds, ...station.values.sharedAssetIds].some((id) =>
id.equals(asset.id)
)
(station) => station.values.active && station.values.assetIds.some((id) => id.equals(asset.id))
);
return {

View File

@ -31,7 +31,7 @@ export function mapWorkstationInput(
) {
return Result.fail(new AssetCatalogError("PERSON_UNAVAILABLE"));
}
for (const id of [...values.assetIds, ...values.sharedAssetIds]) {
for (const id of values.assetIds) {
const error = memberError(id, values, context, previous);
if (error) {
@ -56,17 +56,11 @@ function memberError(
if (!values.active) {
return undefined;
}
const exclusive = values.assetIds.includes(id);
const previousMembers = exclusive ? previous?.values.assetIds : previous?.values.sharedAssetIds;
const previousMembers = previous?.values.assetIds;
const retained =
previous?.values.active && previousMembers?.some((item) => item.toPrimitive() === id);
if (
!(
retained ||
canAssignAsset(asset.values.technicalStatus, exclusive ? "exclusive" : "shared", false, false)
)
) {
if (!(retained || canAssignAsset(asset.values.technicalStatus, false))) {
return "ASSET_NOT_OPERATIONAL";
}
const others = context.workstations.filter(
@ -75,11 +69,8 @@ function memberError(
const exclusiveElsewhere = others.some((item) =>
item.values.assetIds.some((assetId) => assetId.toPrimitive() === id)
);
const sharedElsewhere = others.some((item) =>
item.values.sharedAssetIds.some((assetId) => assetId.toPrimitive() === id)
);
if (exclusiveElsewhere || (exclusive && sharedElsewhere)) {
if (exclusiveElsewhere) {
return "ASSET_IN_WORKSTATION";
}

View File

@ -8,11 +8,6 @@ export function assetDisplayStatus(status: AssetInput["status"], assigned: boole
return status === "operational" ? (assigned ? "in_use" : "available") : status;
}
export function canAssignAsset(
status: AssetInput["status"],
kind: "exclusive" | "shared",
exclusiveElsewhere: boolean,
sharedElsewhere: boolean
) {
return status === "operational" && !exclusiveElsewhere && (kind === "shared" || !sharedElsewhere);
export function canAssignAsset(status: AssetInput["status"], assignedElsewhere: boolean) {
return status === "operational" && !assignedElsewhere;
}

View File

@ -149,15 +149,12 @@ test("estado técnico y uso: asignaciones calculadas, elegibilidad exclusiva/com
assert.equal(assetTechnicalStatus("in_use"), "operational");
assert.equal(assetDisplayStatus("operational", false), "available");
assert.equal(assetDisplayStatus("operational", true), "in_use");
assert.ok(canAssignAsset("operational", "exclusive", false, false));
assert.ok(!canAssignAsset("operational", "exclusive", false, true));
assert.ok(!canAssignAsset("operational", "shared", true, false));
assert.ok(canAssignAsset("operational", "shared", false, true));
assert.ok(canAssignAsset("operational", false));
assert.ok(!canAssignAsset("operational", true));
for (const state of ["broken", "maintenance", "retired"] as const) {
assert.equal(assetDisplayStatus(state, false), state);
assert.equal(assetDisplayStatus(state, true), state);
assert.ok(!canAssignAsset(state, "exclusive", false, false));
assert.ok(!canAssignAsset(state, "shared", false, false));
assert.ok(!canAssignAsset(state, false));
}
assert.equal(AssetInputSchema.safeParse({ ...input(), status: "in_use" }).success, false);
assert.equal(AssetInputSchema.safeParse({ ...input(), status: "available" }).success, false);

View File

@ -12,8 +12,7 @@ export class WorkstationValues {
public readonly active: boolean,
public readonly siteId: Maybe<UniqueID>,
public readonly personId: Maybe<UniqueID>,
public readonly assetIds: readonly UniqueID[],
public readonly sharedAssetIds: readonly UniqueID[]
public readonly assetIds: readonly UniqueID[]
) {
Object.freeze(this);
}
@ -37,7 +36,6 @@ export class WorkstationValues {
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
}
const ids: UniqueID[] = [];
const shared: UniqueID[] = [];
for (const value of parsed.data.assetIds) {
const id = UniqueID.create(value);
@ -47,14 +45,6 @@ export class WorkstationValues {
}
ids.push(id.data);
}
for (const value of parsed.data.sharedAssetIds) {
const id = UniqueID.create(value);
if (!id.isSuccess || [...ids, ...shared].some((previous) => previous.equals(id.data))) {
return Result.fail(new AssetCatalogError("INVALID_MEMBERS"));
}
shared.push(id.data);
}
if (input.computerAssetId && !input.assetIds.includes(input.computerAssetId)) {
return Result.fail(new AssetCatalogError("INVALID_MEMBERS"));
@ -67,8 +57,7 @@ export class WorkstationValues {
input.active,
site.data,
person.data,
Object.freeze(ids),
Object.freeze(shared)
Object.freeze(ids)
)
);
}
@ -83,7 +72,6 @@ export class WorkstationValues {
siteId: this.siteId.getOrUndefined()?.toPrimitive() ?? null,
personId: this.personId.getOrUndefined()?.toPrimitive() ?? null,
assetIds: this.assetIds.map((id) => id.toPrimitive()),
sharedAssetIds: this.sharedAssetIds.map((id) => id.toPrimitive()),
};
}
@ -98,9 +86,7 @@ export class WorkstationValues {
a.siteId === b.siteId &&
a.personId === b.personId &&
a.assetIds.length === b.assetIds.length &&
a.assetIds.every((id) => b.assetIds.includes(id)) &&
a.sharedAssetIds.length === b.sharedAssetIds.length &&
a.sharedAssetIds.every((id) => b.sharedAssetIds.includes(id))
a.assetIds.every((id) => b.assetIds.includes(id))
);
}
}

View File

@ -7,7 +7,6 @@ export const WorkstationInputSchema = z.strictObject({
siteId: z.uuid().nullable(),
personId: z.uuid().nullable(),
assetIds: z.array(z.uuid()).max(200),
sharedAssetIds: z.array(z.uuid()).max(200),
});
export type WorkstationInput = z.infer<typeof WorkstationInputSchema>;

View File

@ -60,7 +60,6 @@ export class Workstation extends AggregateRoot<WorkstationProps> {
computerAssetId: null,
personId: null,
assetIds: [],
sharedAssetIds: [],
});
return values.isSuccess ? this.update(values.data, actorId, recordedAt) : values;

View File

@ -15,7 +15,6 @@ function input(): WorkstationInput {
active: true,
siteId: null,
personId: null,
sharedAssetIds: [],
assetIds,
};
}
@ -88,36 +87,6 @@ test("Puestos: UUID estable e historial de composición, persona y estado; guard
);
});
test("Puestos: recursos compartidos separados, sin duplicados y cambios con historial", () => {
const sharedId = UniqueID.generateNewID().toPrimitive();
const original = values({ ...input(), sharedAssetIds: [sharedId] });
assert.ok(Object.isFrozen(original.sharedAssetIds));
assert.ok(
!WorkstationValues.create({ ...input(), sharedAssetIds: [assetIds[0] ?? ""] }).isSuccess
);
assert.ok(
!WorkstationValues.create({ ...input(), sharedAssetIds: [sharedId, sharedId] }).isSuccess
);
const created = Workstation.create(
UniqueID.generateNewID(),
original,
UniqueID.generateNewID(),
"2026-10-04T10:00:00.000Z"
);
assert.ok(created.isSuccess);
const changed = created.data.update(
values(),
UniqueID.generateNewID(),
"2026-10-04T11:00:00.000Z"
);
assert.ok(changed.isSuccess);
assert.deepEqual(changed.data.history[0]?.values.toPrimitive().sharedAssetIds, [sharedId]);
assert.equal(changed.data.history.length, 2);
});
test("Puestos: ordenador principal exclusivo, único e independiente del responsable", () => {
const computerId = assetIds[0] ?? "";
const first = values({ ...input(), computerAssetId: computerId });
@ -135,7 +104,6 @@ test("Puestos: ordenador principal exclusivo, único e independiente del respons
...input(),
computerAssetId: computerId,
assetIds: [],
sharedAssetIds: [computerId],
}).isSuccess
);
assert.ok(!first.equals(values({ ...input(), computerAssetId: assetIds[1] })));
@ -148,14 +116,12 @@ test("Puestos: ordenador principal exclusivo, único e independiente del respons
test("baja del puesto: libera composición y responsable, conserva historial e identidad y es idempotente", () => {
const actor = UniqueID.generateNewID();
const shared = UniqueID.generateNewID().toPrimitive();
const created = Workstation.create(
UniqueID.generateNewID(),
values({
...input(),
computerAssetId: assetIds[0] ?? null,
personId: actor.toPrimitive(),
sharedAssetIds: [shared],
}),
actor,
"2026-10-13T10:00:00.000Z"
@ -171,9 +137,7 @@ test("baja del puesto: libera composición y responsable, conserva historial e i
assert.equal(retired.data.values.toPrimitive().personId, null);
assert.equal(retired.data.values.computerAssetId.getOrUndefined(), undefined);
assert.deepEqual(retired.data.values.toPrimitive().assetIds, []);
assert.deepEqual(retired.data.values.toPrimitive().sharedAssetIds, []);
assert.deepEqual(retired.data.history[0]?.values.toPrimitive().assetIds, assetIds);
assert.deepEqual(retired.data.history[0]?.values.toPrimitive().sharedAssetIds, [shared]);
assert.ok(retired.data.history[1]?.actorId.equals(actor));
const repeated = retired.data.decommission(actor, "2026-10-13T12:00:00.000Z");

View File

@ -42,9 +42,7 @@ export async function loadWorkstations(
if (
(values.siteId && !sites.some((site) => site.id === values.siteId)) ||
(values.personId && !people.some((person) => person.id === values.personId)) ||
[...values.assetIds, ...values.sharedAssetIds].some(
(id) => !assets.some((asset) => asset.id.toPrimitive() === id)
)
values.assetIds.some((id) => !assets.some((asset) => asset.id.toPrimitive() === id))
) {
throw new Error("Puesto de trabajo fuera de su compañía.");
}

View File

@ -0,0 +1,20 @@
import type { Sequelize } from "sequelize";
/** Retira el vínculo compartido de instantáneas y proyecciones, sin borrar activos ni revisiones. */
export async function removeSharedWorkstationAssets(database: Sequelize) {
await database.transaction(async (transaction) => {
await database.query("DELETE FROM workstation_members WHERE kind='shared'", { transaction });
await database.query("DELETE FROM workstation_revision_members WHERE kind='shared'", {
transaction,
});
await database.query(
`DELETE reference FROM workstation_assets AS reference
WHERE NOT EXISTS (
SELECT 1 FROM workstation_revision_members AS member
INNER JOIN workstation_revisions AS revision ON revision.id=member.revisionId
WHERE revision.workstationId=reference.workstationId AND member.assetId=reference.assetId
)`,
{ transaction }
);
});
}

View File

@ -53,12 +53,7 @@ export class WorkstationPersistenceMapper {
if (!(history.length && history.at(-1)?.values.equals(values))) {
throw new Error("Última revisión de puesto inconsistente.");
}
const expected = new Set(
history.flatMap((item) => [
...item.values.toPrimitive().assetIds,
...item.values.toPrimitive().sharedAssetIds,
])
);
const expected = new Set(history.flatMap((item) => item.values.toPrimitive().assetIds));
if (
references.length !== expected.size ||
@ -99,12 +94,7 @@ export class WorkstationPersistenceMapper {
personId: revision.values.toPrimitive().personId,
})),
references: [
...new Set(
item.history.flatMap((revision) => [
...revision.values.toPrimitive().assetIds,
...revision.values.toPrimitive().sharedAssetIds,
])
),
...new Set(item.history.flatMap((revision) => revision.values.toPrimitive().assetIds)),
].map((assetId) => ({
workstationId: id,
companyId,

View File

@ -47,7 +47,7 @@ function reconstruct(row: StoredRow, members: readonly Member[]): Values {
.filter((member) => member.ownerId === row.id)
.sort((a, b) => a.position - b.position);
if (ordered.some((member) => !["exclusive", "shared"].includes(member.kind))) {
if (ordered.some((member) => member.kind !== "exclusive")) {
throw new Error("Tipo de asignación de activo inconsistente.");
}
@ -60,9 +60,6 @@ function reconstruct(row: StoredRow, members: readonly Member[]): Values {
assetIds: ordered
.filter((member) => member.kind === "exclusive")
.map((member) => member.assetId),
sharedAssetIds: ordered
.filter((member) => member.kind === "shared")
.map((member) => member.assetId),
});
}
@ -139,13 +136,13 @@ async function writeMembers(
replacements: { id },
transaction,
});
for (const [kind, ids] of [
["exclusive", values.assetIds],
["shared", values.sharedAssetIds],
] as const) {
for (const [position, assetId] of ids.entries()) {
await writeAssetRow(database, table, { [owner]: id, assetId, kind, position }, transaction);
}
for (const [position, assetId] of values.assetIds.entries()) {
await writeAssetRow(
database,
table,
{ [owner]: id, assetId, kind: "exclusive", position },
transaction
);
}
}

View File

@ -1,6 +1,7 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
import { removeSharedWorkstationAssets } from "./remove-shared-workstation-assets";
import { initializeWorkstationMembers } from "./workstation-relational";
/** Proyección técnica de referencias históricas y reserva vigente; no representa otra entidad de negocio. */
@ -104,4 +105,5 @@ export async function migrateWorkstations(database: Sequelize) {
timestamps: false,
});
await initializeWorkstationMembers(database);
await removeSharedWorkstationAssets(database);
}

View File

@ -120,7 +120,7 @@ function inventoryAssignments(context: InventoryContext) {
if (!(values.active && values.personId)) {
continue;
}
for (const assetId of [...values.assetIds, ...values.sharedAssetIds]) {
for (const assetId of values.assetIds) {
const people = peopleByAsset.get(assetId) ?? new Set<string>();
people.add(values.personId);

View File

@ -147,10 +147,6 @@
},
"workstations": "Workstations",
"newWorkstation": "New workstation",
"addSharedWorkstationAsset": "Add a shared resource",
"exclusiveWorkstationAssets": "Exclusive assets",
"sharedWorkstationAssets": "Shared resources",
"noSharedWorkstationAssets": "No shared resources.",
"editWorkstation": "Edit workstation",
"workstationPerson": "Workstation custodian",
"workstationSuggestedName": "{{person}}'s workstation at {{site}}",
@ -158,7 +154,7 @@
"workstationAssets": "Workstation assets",
"noWorkstations": "This company has no workstations yet.",
"noWorkstationAssets": "This workstation has no assets yet.",
"addWorkstationAsset": "Add an exclusive asset",
"addWorkstationAsset": "Add assets",
"workstationOccupied": "{{name}} · used in another workstation",
"removeWorkstationAsset": "Remove {{name}} from workstation",
"workstationCount": "{{count}} assets",
@ -166,7 +162,7 @@
"workstationCount_other": "{{count}} assets",
"workstationDescription": "A workstation groups a computer and its peripherals. Its optional custodian is independent of the people with profiles on it.",
"workstationExamples": "Ana's workstation: laptop, two monitors and a printer. One person can have several workstations.",
"workstationGuidance": "Exclusive assets can belong to one active workstation. Shared resources can be used in several workstations unless they are reserved as exclusive. Deactivation preserves composition and releases its assets; reactivation checks availability. Workstation assignment does not change individual asset assignments.",
"workstationGuidance": "Select the site, add at least one asset and designate the main computer. Each asset can belong to only one active workstation. Deactivation keeps its composition and releases its assets; reactivation checks availability.",
"workstationScope": "All elements must belong to this company. Person, site and composition changes retain history.",
"workstationInAsset": "Workstation",
"noWorkstation": "No active workstation",
@ -249,6 +245,7 @@
"selectField": "Add a field to the template",
"choose": "Select a field",
"addField": "New field",
"clear": "Clear selection",
"required": "Required",
"chooseType": "Select a type",
"view": "View",
@ -301,6 +298,7 @@
"moveDown": "Move down",
"remove": "Remove section",
"removeField": "Remove field",
"clear": "Clear selection",
"required": "Required",
"assign": "Section",
"help": "Organize fields without changing their values or requirements. Removing a section moves its fields to Unsectioned.",
@ -354,7 +352,7 @@
"notFound": "This workstation does not exist in this company.",
"assets": "Workstation assets",
"profiles": "User profiles",
"profilesHint": "Profiles associated with the exclusive and shared assets of this workstation.",
"profilesHint": "Profiles associated with the assets of this workstation.",
"profilesError": "Unable to load user profiles.",
"noProfiles": "No profiles are linked to this workstation’s assets.",
"people": "Related people",
@ -365,24 +363,54 @@
"missingAsset": "Asset unavailable."
},
"workstationComputer": "Main computer",
"workstationComputerHelp": "Select the computer among this workstation’s exclusive assets. Profiles will reside on it; the remaining assets are peripherals. Use two workstations for two computers.",
"workstationComputerHelp": "Select the computer from the workstation assets. Profiles will be created on it; the other assets are peripherals.",
"workstationLifecycle": {
"decommission": {
"title": "Decommission",
"description": "Decommission “{{name}}” and unlink its responsible person, computer, exclusive and shared assets. History is retained. Assets, profiles and computer software retain their records.",
"pending": "Decommissioning…",
"error": "Could not decommission the workstation. Please try again."
"title": "Deactivate",
"description": "Deactivate “{{name}}” and unlink its responsible person, computer, assets. History is retained. Assets, profiles and computer software retain their records.",
"pending": "Deactivating…",
"error": "Could not deactivate the workstation. Please try again."
},
"delete": {
"title": "Delete permanently",
"description": "Delete “{{name}}”, its history and its associations. Assets, people, profiles and software are retained. This cannot be undone. If tickets or interventions refer to it, use Decommission.",
"description": "Delete “{{name}}”, its history and its associations. Assets, people, profiles and software are retained. This cannot be undone. If tickets or interventions refer to it, use Deactivate.",
"pending": "Deleting…",
"error": "Could not delete the workstation. Please try again."
},
"inUse": "Other records, such as tickets or interventions, reference this workstation. Decommission it to retain those references."
"inUse": "Other records, such as tickets or interventions, reference this workstation. Deactivate it to retain those references."
},
"operational": "Operational",
"broken": "Broken",
"technicalStatus": "Technical state",
"technicalStatusHelp": "Available and In use are calculated from active workstations. Breakdowns and maintenance retain existing assignments."
"technicalStatusHelp": "Available and In use are calculated from active workstations. Breakdowns and maintenance retain existing assignments.",
"workstationPersonOptional": "Workstation owner (optional)",
"workstationNameBySite": "Workstation at {{site}}",
"workstationSteps": {
"label": "New workstation steps",
"progress": "Step {{step}} of {{total}}",
"completed": "Completed",
"location": "Site and owner",
"assets": "Assets",
"computer": "Main computer",
"summary": "Summary",
"locationDescription": "Select a site. You can leave the owner unassigned.",
"assetsDescription": "Add at least one asset to the workstation.",
"computerDescription": "Identify the asset that will host user profiles.",
"summaryDescription": "Review the details and workstation name before saving.",
"singleComputer": "The only selected asset is automatically assigned as the main computer.",
"previous": "Previous",
"next": "Next"
},
"assetPicker": {
"single": "Select asset",
"multiple": "Select assets",
"select": "Selection",
"selectAsset": "Select {{name}}",
"selected": "{{count}} assets selected",
"selected_one": "{{count}} asset selected",
"selected_other": "{{count}} assets selected",
"confirm": "Select",
"clear": "Clear selection",
"required": "Required"
}
}

View File

@ -147,10 +147,6 @@
},
"workstations": "Puestos de trabajo",
"newWorkstation": "Nuevo puesto",
"addSharedWorkstationAsset": "Añadir un recurso compartido",
"exclusiveWorkstationAssets": "Activos exclusivos",
"sharedWorkstationAssets": "Recursos compartidos",
"noSharedWorkstationAssets": "Sin recursos compartidos.",
"editWorkstation": "Editar puesto",
"workstationPerson": "Responsable del puesto",
"workstationSuggestedName": "Puesto de {{person}} en {{site}}",
@ -158,7 +154,7 @@
"workstationAssets": "Activos del puesto",
"noWorkstations": "Esta compañía todavía no tiene puestos de trabajo.",
"noWorkstationAssets": "Este puesto todavía no tiene activos.",
"addWorkstationAsset": "Añadir un activo exclusivo",
"addWorkstationAsset": "Añadir activos",
"workstationOccupied": "{{name}} · ocupado en otro puesto",
"removeWorkstationAsset": "Retirar {{name}} del puesto",
"workstationCount": "{{count}} activos",
@ -166,7 +162,7 @@
"workstationCount_other": "{{count}} activos",
"workstationDescription": "Un puesto reúne un ordenador y sus periféricos. Su responsable es opcional e independiente de las personas que tienen perfiles en él.",
"workstationExamples": "Puesto de Ana: portátil, dos monitores y una impresora. Una persona puede tener varios puestos.",
"workstationGuidance": "Los activos exclusivos solo pueden estar en un puesto activo. Los recursos compartidos pueden utilizarse en varios puestos, siempre que no estén reservados como exclusivos. Desactivar conserva la composición y libera sus activos; reactivar comprueba su disponibilidad. El puesto no cambia las asignaciones individuales de los activos.",
"workstationGuidance": "Selecciona la sede, añade al menos un activo y designa el ordenador principal. Cada activo solo puede pertenecer a un puesto activo. Desactivar conserva la composición y libera sus activos; reactivar comprueba su disponibilidad.",
"workstationScope": "Todos los elementos deben pertenecer a esta compañía. Los cambios de persona, sede y composición conservan historial.",
"workstationInAsset": "Puesto de trabajo",
"noWorkstation": "Sin puesto activo",
@ -249,6 +245,7 @@
"selectField": "Añadir un campo a la plantilla",
"choose": "Selecciona un campo",
"addField": "Nuevo campo",
"clear": "Quitar selección",
"required": "Obligatorio",
"chooseType": "Selecciona un tipo",
"view": "Ver",
@ -301,6 +298,7 @@
"moveDown": "Mover hacia abajo",
"remove": "Eliminar sección",
"removeField": "Quitar campo",
"clear": "Quitar selección",
"required": "Obligatorio",
"assign": "Sección",
"help": "Organiza los campos sin cambiar sus valores ni su obligatoriedad. Al eliminar una sección, sus campos pasan a «Sin sección».",
@ -354,7 +352,7 @@
"notFound": "Este puesto de trabajo no existe en esta compañía.",
"assets": "Activos del puesto",
"profiles": "Perfiles de usuario",
"profilesHint": "Perfiles asociados a los activos exclusivos y compartidos de este puesto.",
"profilesHint": "Perfiles asociados a los activos de este puesto.",
"profilesError": "No se han podido cargar los perfiles de usuario.",
"noProfiles": "No hay perfiles vinculados a los activos de este puesto.",
"people": "Personas relacionadas",
@ -365,24 +363,54 @@
"missingAsset": "Activo no disponible."
},
"workstationComputer": "Ordenador principal",
"workstationComputerHelp": "Selecciona el ordenador entre los activos exclusivos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos. Si hay dos ordenadores, crea dos puestos.",
"workstationComputerHelp": "Selecciona el ordenador entre los activos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos.",
"workstationLifecycle": {
"decommission": {
"title": "Dar de baja",
"description": "Se dará de baja «{{name}}» y se desvincularán su responsable, ordenador y activos exclusivos y compartidos. Se conservará el historial. Los activos, perfiles y software del ordenador conservarán sus registros.",
"pending": "Dando de baja…",
"error": "No se pudo dar de baja el puesto. Inténtalo de nuevo."
"title": "Desactivar",
"description": "Se desactivará «{{name}}» y se desvincularán su responsable, ordenador y activos. Se conservará el historial. Los activos, perfiles y software del ordenador conservarán sus registros.",
"pending": "Desactivando…",
"error": "No se pudo desactivar el puesto. Inténtalo de nuevo."
},
"delete": {
"title": "Eliminar definitivamente",
"description": "Se eliminarán «{{name}}», su historial y sus asociaciones. Los activos, personas, perfiles y software se conservarán. Esta acción no se puede deshacer. Si hay tickets o intervenciones vinculados, utiliza Dar de baja.",
"description": "Se eliminarán «{{name}}», su historial y sus asociaciones. Los activos, personas, perfiles y software se conservarán. Esta acción no se puede deshacer. Si hay tickets o intervenciones vinculados, utiliza Desactivar.",
"pending": "Eliminando…",
"error": "No se pudo eliminar el puesto. Inténtalo de nuevo."
},
"inUse": "El puesto está referenciado por otros registros, por ejemplo tickets o intervenciones. Da de baja el puesto para conservar esas referencias."
"inUse": "El puesto está referenciado por otros registros, por ejemplo tickets o intervenciones. Desactiva el puesto para conservar esas referencias."
},
"operational": "Operativo",
"broken": "Averiado",
"technicalStatus": "Estado técnico",
"technicalStatusHelp": "Disponible y En uso se calculan según los puestos activos. Una avería o mantenimiento conserva las asignaciones existentes."
"technicalStatusHelp": "Disponible y En uso se calculan según los puestos activos. Una avería o mantenimiento conserva las asignaciones existentes.",
"workstationPersonOptional": "Responsable del puesto (opcional)",
"workstationNameBySite": "Puesto en {{site}}",
"workstationSteps": {
"label": "Pasos del nuevo puesto",
"progress": "Paso {{step}} de {{total}}",
"completed": "Completado",
"location": "Sede y responsable",
"assets": "Activos",
"computer": "Ordenador principal",
"summary": "Resumen",
"locationDescription": "Selecciona una sede. Puedes dejar el responsable sin asignar.",
"assetsDescription": "Añade al menos un activo al puesto.",
"computerDescription": "Identifica el activo que alojará los perfiles de usuario.",
"summaryDescription": "Revisa los datos y el nombre del puesto antes de guardar.",
"singleComputer": "El único activo seleccionado se asigna automáticamente como ordenador principal.",
"previous": "Anterior",
"next": "Siguiente"
},
"assetPicker": {
"single": "Seleccionar activo",
"multiple": "Seleccionar activos",
"select": "Selección",
"selectAsset": "Seleccionar {{name}}",
"selected": "{{count}} activos seleccionados",
"selected_one": "{{count}} activo seleccionado",
"selected_other": "{{count}} activos seleccionados",
"confirm": "Seleccionar",
"clear": "Quitar selección",
"required": "Obligatorio"
}
}

View File

@ -1,5 +1,6 @@
export type AssetRelationshipsProps = {
compact?: boolean;
companyId: string;
sites?: readonly { id: string; name: string }[] | undefined;
assetId: string;
};

View File

@ -95,6 +95,7 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
companyId={companyId}
onOpenChange={setAdding}
onSaved={() => setAdding(false)}
sites={props.sites}
/>
)}
{ending && (

View File

@ -1,5 +1,6 @@
export type RelationshipFormProps = {
companyId: string;
sites?: readonly { id: string; name: string }[] | undefined;
assetId: string;
onSaved: () => void;
};

View File

@ -6,10 +6,12 @@ import { RelationshipForm } from "./relationship-form";
export function RelationshipEditorDialog({
companyId,
assetId,
sites,
onSaved,
onOpenChange,
}: {
companyId: string;
sites?: readonly { id: string; name: string }[] | undefined;
assetId: string;
onSaved: () => void;
onOpenChange: (open: boolean) => void;
@ -24,7 +26,7 @@ export function RelationshipEditorDialog({
open
title={t("linkAsset")}
>
<RelationshipForm assetId={assetId} companyId={companyId} onSaved={onSaved} />
<RelationshipForm assetId={assetId} companyId={companyId} onSaved={onSaved} sites={sites} />
</FormDialog>
);
}

View File

@ -7,13 +7,14 @@ import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import { AssetSelect } from "../../../assets/select/ui/asset-select";
import { CatalogRelationCreateDialog } from "../../../catalog-relations";
import { InventoryFeedback } from "../../../shared";
import { useRelationshipFormController } from "../controllers";
import type { RelationshipFormProps } from "../types";
export function RelationshipForm(props: RelationshipFormProps) {
const { companyId } = props;
const { companyId, sites } = props;
const {
typeId,
setTypeId,
@ -56,7 +57,8 @@ export function RelationshipForm(props: RelationshipFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-target`}>{t("targetAsset")}</Label>
<SelectWithAction
<AssetSelect
companyId={companyId}
disabled={mutation.isPending || inventory.isPending}
id={`${id}-target`}
label={t("targetAsset")}
@ -64,6 +66,7 @@ export function RelationshipForm(props: RelationshipFormProps) {
options={targetAssets.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("chooseAsset")}
required
sites={sites}
value={targetId}
/>
</div>

View File

@ -10,9 +10,7 @@ export function useAssetDetailController({ asset }: AssetDetailProps) {
queryFn: () => getAssetInventory(asset.companyId),
});
const workstations = inventory.data?.workstations.filter(
(item) =>
item.values.active &&
(item.values.assetIds.includes(asset.id) || item.values.sharedAssetIds.includes(asset.id))
(item) => item.values.active && item.values.assetIds.includes(asset.id)
);
return { workstations };

View File

@ -33,7 +33,7 @@ export function AssetDetailDialog({
wide
>
<AssetDetail asset={asset} lookups={lookups}>
<AssetRelationships assetId={asset.id} companyId={companyId} />
<AssetRelationships assetId={asset.id} companyId={companyId} sites={lookups.sites} />
<AssetSupport
assetId={asset.id}
companyId={companyId}

View File

@ -80,7 +80,12 @@ export function AssetSelectionPanel({
</div>
<AssetDetail asset={asset} compact lookups={lookups}>
<DetailSection title={t("relations")}>
<AssetRelationships assetId={asset.id} compact companyId={companyId} />
<AssetRelationships
assetId={asset.id}
compact
companyId={companyId}
sites={lookups.sites}
/>
</DetailSection>
<DetailSection title={t("support.title")}>
<AssetSupport

View File

@ -1,7 +1,6 @@
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryKeys } from "../../../shared/queries";
@ -13,11 +12,9 @@ import { useCompactInventory } from "./use-compact-inventory";
export function useAssetInventoryPageController({
companyId,
lookups,
initialId,
initialCreate = false,
}: AssetInventoryPageProps) {
const { t } = useTranslation("assets");
const client = useQueryClient();
const query = useListQuery({
queryKey: inventoryKeys.company(companyId),
@ -84,19 +81,6 @@ export function useAssetInventoryPageController({
const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId);
const deleted = assets.find((asset) => asset.id === deleteId);
const filtered = assets.filter((asset) => {
if (templateId && asset.values.templateId !== templateId) {
return false;
}
const site = lookups.sites.find((site) => site.id === asset.values.siteId);
return [
asset.values.name,
asset.specification.templateName,
site?.name ?? "",
t(asset.displayStatus),
].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
});
return {
query,
@ -122,6 +106,5 @@ export function useAssetInventoryPageController({
selected,
edited,
viewed,
filtered,
};
}

View File

@ -3,18 +3,15 @@ import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { Button } from "@repo/shadcn-ui/components/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs";
import { useTranslation } from "react-i18next";
import { CatalogError } from "../../../catalog";
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
import { InventoryFeedback } from "../../../shared";
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
import { AssetList } from "../../shared/ui/asset-list";
import { useAssetInventoryPageController } from "../controllers";
import type { AssetInventoryPageProps } from "../types";
@ -29,7 +26,6 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
const {
query,
catalog,
templates,
templateId,
setTemplateId,
clearFilters,
@ -48,7 +44,6 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
selected,
edited,
viewed,
filtered,
missingView,
entityHref,
} = useAssetInventoryPageController(props);
@ -103,127 +98,56 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
</Button>
)}
{query.data && (
<Tabs onValueChange={setTemplateId} value={templateId ?? "all"}>
<div className="min-w-0 overflow-x-auto">
<TabsList aria-label={t("filterByTemplate")} variant="line">
<TabsTrigger value="all">{t("allAssets")}</TabsTrigger>
{templates.map((template) => (
<TabsTrigger key={template.id} value={template.id}>
{template.values.name}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent keepMounted value={templateId ?? "all"}>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (asset) => asset.values.name,
hideable: false,
render: (asset) => (
<BrowserLink
aria-pressed={selected?.id === asset.id && detailOpen}
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] app-link text-primary hover:text-primary focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
href={entityHref(asset.id)}
onClick={() => setDetailOpen(true)}
<AssetList
assets={query.data.assets}
catalog={catalog.data?.items ?? []}
extraColumns={[
{
id: "actions",
label: t("actions"),
hideable: false,
render: (asset) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction
className="inline-flex inventory:hidden"
onClick={() => setViewId(asset.id)}
>
{asset.values.name}
</BrowserLink>
),
},
{
id: "template",
label: t("template"),
value: (asset) => asset.specification.templateName,
render: (asset) => {
const template = catalog.data?.items.find(
(item) => item.id === asset.values.templateId
);
return (
<span className="inline-flex items-center gap-2">
<AssetTemplateIcon
icon={
template?.values.kind === "template"
? template.values.icon
: undefined
}
/>
{asset.specification.templateName}
</span>
);
},
},
{
id: "state",
label: t("status"),
value: (asset) => t(asset.displayStatus),
render: (asset) => (
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
),
},
{
id: "site",
label: t("site"),
value: (asset) =>
lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
t("noAssignment"),
render: (asset) => (
<>
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
t("noAssignment")}
</>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (asset) => (
<EntityRowActions label={t("actions")}>
{" "}
<div className="contents">
<EntityAction
className="inline-flex inventory:hidden"
onClick={() => setViewId(asset.id)}
>
{t("viewAsset")}
</EntityAction>
<EntityAction
disabled={!catalog.data}
onClick={() => setEditor(asset.id)}
>
{t("edit")}
</EntityAction>
<EntityAction
className="text-destructive data-highlighted:text-destructive"
onClick={() => setDeleteId(asset.id)}
>
{t("deleteAsset")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
emptyMessage={t("noAssets")}
filterKey={templateId ?? "all"}
filtersApplied={templateId !== null}
items={filtered}
locale={i18n.language}
onClearFilters={clearFilters}
onSearchChange={setSearch}
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
search={search}
searchPlaceholder={t("search")}
title={t("inventory")}
/>
</TabsContent>
</Tabs>
{t("viewAsset")}
</EntityAction>
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
{t("edit")}
</EntityAction>
<EntityAction
className="text-destructive data-highlighted:text-destructive"
onClick={() => setDeleteId(asset.id)}
>
{t("deleteAsset")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
onClearFilters={clearFilters}
onSearchChange={setSearch}
onTemplateChange={setTemplateId}
renderName={(asset) => (
<BrowserLink
aria-pressed={selected?.id === asset.id && detailOpen}
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] app-link text-primary hover:text-primary focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
href={entityHref(asset.id)}
onClick={() => setDetailOpen(true)}
>
{asset.values.name}
</BrowserLink>
)}
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
search={search}
sites={lookups.sites}
templateId={templateId}
/>
)}
</div>
<AssetSelectionPanel

View File

@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { AssetSchema } from "../../../common/inventory";
import { assetsEnglish, assetsSpanish } from "../../shared";
import { AssetList } from "../shared/ui/asset-list";
import { AssetPickerList } from "./ui/asset-picker-list";
import { AssetSelect } from "./ui/asset-select";
const id = (n: number) => `0199b200-0000-7000-8000-${String(n).padStart(12, "0")}`;
const assets = [2, 3].map((n) => {
const values = {
name: `Equipo ${n}`,
templateId: id(n + 10),
siteId: id(4),
status: "operational",
notes: "",
characteristics: {},
};
return AssetSchema.parse({
id: id(n),
companyId: id(1),
values,
displayStatus: "available",
assigned: false,
specification: {
templateId: values.templateId,
templateName: n === 2 ? "Portátiles" : "Monitores",
fields: [
{ fieldId: id(5), name: "Serie", type: "text", unit: "", choices: [], required: false },
],
},
history: [
{
id: id(n + 20),
values,
actorId: id(6),
actorName: "Técnico",
recordedAt: "2026-10-14T10:00:00.000Z",
},
],
});
});
async function render(language: string, node: ReactNode) {
const i18n = createInstance();
await i18n.init({
lng: language,
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
});
return renderToStaticMarkup(<I18nextProvider i18n={i18n}>{node}</I18nextProvider>);
}
const listProps = {
assets,
catalog: [],
sites: [{ id: id(4), name: "Sede Central" }],
templateId: null,
onTemplateChange: () => undefined,
search: "",
onSearchChange: () => undefined,
onClearFilters: () => undefined,
};
test("selector: comparte búsqueda, pestañas por identidad y columnas del inventario en es/en", async () => {
for (const language of ["es", "en"]) {
const html = await render(
language,
<AssetPickerList
{...listProps}
disabled={(value) => value === id(3)}
mode="multiple"
onToggle={() => undefined}
selectedIds={[id(2)]}
unavailableLabels={new Map([[id(3), "Ocupado"]])}
/>
);
assert.equal((html.match(/role="tab"/g) ?? []).length, 3);
assert.ok(html.includes("Portátiles"));
assert.ok(html.includes("Monitores"));
assert.ok(html.includes("Sede Central"));
assert.ok(html.includes(language === "es" ? "Seleccionar Equipo 2" : "Select Equipo 2"));
assert.ok(html.includes('role="checkbox"'));
assert.ok(html.includes('aria-checked="true"'));
assert.ok(html.includes("Ocupado"));
assert.ok(html.includes(language === "es" ? "Buscar" : "Search"));
}
});
test("selector: filtra conjuntamente por plantilla, nombre, sede y estado; distingue vacío", async () => {
for (const [search, templateId, expected] of [
["equipo 2", null, ["Equipo 2"]],
["SEDE CENTRAL", id(13), ["Equipo 3"]],
["disponible", id(12), ["Equipo 2"]],
["no existe", null, []],
] satisfies [string, string | null, string[]][]) {
const html = await render(
"es",
<AssetList {...listProps} search={search} templateId={templateId} />
);
const table = html.match(/<table[\s\S]*?<\/table>/)?.[0];
assert.ok(table);
for (const asset of assets) {
assert.equal(table.includes(asset.values.name), expected.includes(asset.values.name));
}
if (!expected.length) {
assert.ok(table.includes("No hay resultados para estos criterios."));
}
}
});
test("selector único: controles radio y disparador de diálogo; conserva el alta rápida y el bloqueo", async () => {
const table = await render(
"es",
<AssetPickerList
{...listProps}
disabled={() => false}
mode="single"
onToggle={() => undefined}
selectedIds={[id(2)]}
unavailableLabels={new Map()}
/>
);
assert.ok(table.includes('role="radio"'));
assert.equal(table.includes('role="checkbox"'), false);
const trigger = await render(
"es",
<AssetSelect
action={{ label: "Nuevo activo", onClick: () => undefined }}
companyId={id(1)}
disabled
id="asset"
label="Ordenador principal"
onValueChange={() => undefined}
options={assets.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder="Elegir"
value={id(2)}
/>
);
assert.ok(trigger.includes('aria-haspopup="dialog"'));
assert.ok(trigger.includes("Equipo 2"));
assert.ok(trigger.includes('aria-label="Nuevo activo"'));
assert.ok(trigger.includes("disabled"));
assert.equal(trigger.includes('role="combobox"'), false);
});

View File

@ -0,0 +1,89 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryKeys } from "../../../shared/queries";
import { getAssetInventory } from "../../list/api";
import type { AssetSelectProps } from "../types/asset-select-props";
export function useAssetPickerController(props: AssetSelectProps, onClose: () => void) {
const [selectedIds, setSelectedIds] = useState<readonly string[]>(
props.mode === "multiple" ? props.value : props.value ? [props.value] : []
);
const [search, setSearch] = useState("");
const [templateId, setTemplateId] = useState<string | null>(null);
const inventory = useQuery({
queryKey: inventoryKeys.company(props.companyId),
queryFn: () => getAssetInventory(props.companyId),
});
const catalog = useQuery({
queryKey: catalogKeys.scope(props.companyId),
queryFn: () => catalogApi.get(props.companyId),
});
const options = new Map(props.options.map((option) => [option.value, option]));
const assets = (inventory.data?.assets ?? []).filter(
(asset) => asset.companyId === props.companyId && options.has(asset.id)
);
const availableIds = new Set(
assets.filter((asset) => !options.get(asset.id)?.disabled).map((asset) => asset.id)
);
const withinLimit = props.mode !== "multiple" || selectedIds.length <= props.maxSelection;
const validSelection =
withinLimit && selectedIds.length > 0 && selectedIds.every((id) => availableIds.has(id));
const ready = Boolean(inventory.data && catalog.data && !inventory.isError && !catalog.isError);
const disabled = (id: string) =>
!availableIds.has(id) ||
(props.mode === "multiple" &&
selectedIds.length >= props.maxSelection &&
!selectedIds.includes(id));
const toggle = (id: string) => {
if (disabled(id)) {
return;
}
setSelectedIds((previous) =>
props.mode === "multiple"
? previous.includes(id)
? previous.filter((value) => value !== id)
: [...previous, id]
: [id]
);
};
const confirm = () => {
if (!(ready && validSelection)) {
return;
}
if (props.mode === "multiple") {
props.onValueChange([...selectedIds]);
} else {
const id = selectedIds[0];
if (!id) {
return;
}
props.onValueChange(id);
}
onClose();
};
const retry = () => Promise.all([inventory.refetch(), catalog.refetch()]);
return {
inventory,
catalog,
assets,
options,
selectedIds,
search,
setSearch,
templateId,
setTemplateId,
disabled,
toggle,
confirm,
retry,
canConfirm: ready && validSelection,
};
}

View File

@ -0,0 +1,26 @@
import type { SelectAction, SelectOption } from "@repo/rdx-ui/components/select-with-action";
type AssetSelectBaseProps = {
companyId: string;
id: string;
label: string;
placeholder: string;
options: readonly SelectOption[];
sites?: readonly { id: string; name: string }[] | undefined;
disabled?: boolean | undefined;
action?: SelectAction | undefined;
required?: boolean | undefined;
invalid?: boolean | undefined;
describedBy?: string | undefined;
};
export type AssetSelectProps = AssetSelectBaseProps &
(
| { mode?: "single"; value: string; onValueChange: (id: string) => void }
| {
mode: "multiple";
value: readonly string[];
onValueChange: (ids: string[]) => void;
maxSelection: number;
}
);

View File

@ -0,0 +1,86 @@
import { FormActions, FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import { InventoryFeedback } from "../../../shared";
import { useAssetPickerController } from "../controllers/use-asset-picker-controller";
import type { AssetSelectProps } from "../types/asset-select-props";
import { AssetPickerList } from "./asset-picker-list";
export function AssetPickerDialog({
selection,
onClose,
}: {
selection: AssetSelectProps;
onClose: () => void;
}) {
const { t } = useTranslation("assets");
const c = useAssetPickerController(selection, onClose);
return (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t(selection.mode === "multiple" ? "assetPicker.multiple" : "assetPicker.single")}
wide
>
<FormBody>
<p className="text-sm text-muted-foreground">{selection.label}</p>
{(c.inventory.isPending || c.catalog.isPending) && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={c.inventory.error ?? c.catalog.error} />
{(c.inventory.isError || c.catalog.isError) && (
<Button
onClick={() => {
c.retry().catch(() => undefined);
}}
type="button"
variant="outline"
>
{t("retry")}
</Button>
)}
{c.inventory.data && (
<AssetPickerList
assets={c.assets}
catalog={c.catalog.data?.items ?? []}
disabled={c.disabled}
mode={selection.mode ?? "single"}
onClearFilters={() => {
c.setSearch("");
c.setTemplateId(null);
}}
onSearchChange={c.setSearch}
onTemplateChange={(id) => c.setTemplateId(id === "all" ? null : id)}
onToggle={c.toggle}
search={c.search}
selectedIds={c.selectedIds}
sites={selection.sites}
templateId={c.templateId}
unavailableLabels={
new Map(
selection.options
.filter((option) => option.disabled)
.map((option) => [option.value, option.label])
)
}
/>
)}
<p aria-live="polite" className="text-sm text-muted-foreground">
{t("assetPicker.selected", { count: c.selectedIds.length })}
</p>
</FormBody>
<FormActions>
<Button disabled={!c.canConfirm} onClick={c.confirm} type="button">
{t("assetPicker.confirm")}
</Button>
</FormActions>
</FormDialog>
);
}

View File

@ -0,0 +1,72 @@
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components/radio-group";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { AssetList } from "../../shared/ui/asset-list";
export function AssetPickerList({
mode,
selectedIds,
disabled,
onToggle,
unavailableLabels,
...listProps
}: ComponentProps<typeof AssetList> & {
mode: "single" | "multiple";
selectedIds: readonly string[];
disabled: (id: string) => boolean;
onToggle: (id: string) => void;
unavailableLabels: ReadonlyMap<string, string>;
}) {
const { t } = useTranslation("assets");
const list = (
<AssetList
{...listProps}
extraColumns={[
{
id: "actions",
label: t("assetPicker.select"),
hideable: false,
render: (asset) => (
<div className="space-y-1">
{mode === "multiple" ? (
<Checkbox
aria-label={t("assetPicker.selectAsset", { name: asset.values.name })}
checked={selectedIds.includes(asset.id)}
disabled={disabled(asset.id)}
onCheckedChange={() => onToggle(asset.id)}
/>
) : (
<RadioGroupItem
aria-label={t("assetPicker.selectAsset", { name: asset.values.name })}
disabled={disabled(asset.id)}
value={asset.id}
/>
)}
{unavailableLabels.has(asset.id) && (
<p className="text-xs text-muted-foreground">{unavailableLabels.get(asset.id)}</p>
)}
</div>
),
},
]}
/>
);
return mode === "multiple" ? (
list
) : (
<RadioGroup
aria-label={t("assetPicker.single")}
onValueChange={(id) => {
if (typeof id === "string") {
onToggle(id);
}
}}
value={selectedIds[0] ?? ""}
>
{list}
</RadioGroup>
);
}

View File

@ -0,0 +1,70 @@
import { ButtonGroup } from "@repo/rdx-ui/components/button-group";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { AssetSelectProps } from "../types/asset-select-props";
import { AssetPickerDialog } from "./asset-picker-dialog";
/** La elección se confirma en un diálogo; cancelar conserva el valor del formulario padre. */
export function AssetSelect(props: AssetSelectProps) {
const { id, label, placeholder, options, disabled, action, invalid, describedBy, required } =
props;
const { t } = useTranslation("assets");
const [open, setOpen] = useState(false);
const displayValue =
props.mode === "multiple"
? props.value.length
? t("assetPicker.selected", { count: props.value.length })
: placeholder
: (options.find((option) => option.value === props.value)?.label ??
(props.value || placeholder));
return (
<>
<ButtonGroup aria-label={label} className="w-full min-w-0">
<Button
aria-describedby={describedBy}
aria-haspopup="dialog"
aria-invalid={invalid || undefined}
aria-label={label}
className="min-w-0 flex-1 justify-start whitespace-normal text-left"
disabled={disabled}
id={id}
onClick={() => setOpen(true)}
type="button"
variant="outline"
>
{displayValue}
{required && <span className="sr-only">{t("assetPicker.required")}</span>}
</Button>
{props.mode !== "multiple" && !required && props.value && (
<Button
aria-label={t("assetPicker.clear")}
disabled={disabled}
onClick={() => props.onValueChange("")}
size="icon"
type="button"
variant="outline"
>
<span aria-hidden="true">×</span>
</Button>
)}
{action && (
<Button
aria-label={action.label}
disabled={disabled || action.disabled}
onClick={action.onClick}
size="icon"
type="button"
variant="outline"
>
<span aria-hidden="true">+</span>
</Button>
)}
</ButtonGroup>
{open && <AssetPickerDialog onClose={() => setOpen(false)} selection={props} />}
</>
);
}

View File

@ -313,10 +313,13 @@ test("Puestos de trabajo: altas rápidas y ayuda inferior; un activo anidado no
<WorkstationForm companyId={companyId} lookups={lookups} onSaved={() => undefined} />
);
for (const label of ["Nueva sede", "Nueva persona", "Nuevo activo"]) {
for (const label of ["Nueva sede", "Nueva persona"]) {
assert.ok(html.includes(`aria-label="${label}"`));
}
assert.ok(html.indexOf('name="active"') < html.indexOf("<aside"));
assert.ok(html.includes("Paso 1 de 4"));
assert.ok(html.includes("Siguiente"));
assert.equal(html.includes(">Guardar<"), false);
assert.equal(html.includes('aria-label="Nuevo activo"'), false);
assert.ok(html.indexOf("<aside") < html.indexOf('type="submit"'));
const nested = await render(
"es",
@ -334,6 +337,35 @@ test("Puestos de trabajo: altas rápidas y ayuda inferior; un activo anidado no
}
});
test("alta de puesto: sede única automática, responsable opcional y cuatro pasos traducidos", async () => {
const siteId = UniqueID.generateNewID().toPrimitive();
for (const language of ["es", "en"]) {
const html = await render(
language,
<WorkstationForm
companyId={companyId}
lookups={{ ...lookups, sites: [{ id: siteId, name: "Sede única test" }] }}
onSaved={() => undefined}
/>
);
assert.ok(html.includes("Sede única test"));
assert.ok(
html.includes(
language === "es" ? "Responsable del puesto (opcional)" : "Workstation owner (optional)"
)
);
assert.ok(html.includes(language === "es" ? "Paso 1 de 4" : "Step 1 of 4"));
assert.ok(html.includes('aria-current="step"'));
assert.equal(html.includes(language === "es" ? ">Guardar<" : ">Save<"), false);
assert.equal(
html.includes(language === "es" ? "Recursos compartidos" : "Shared assets"),
false
);
}
});
test("la ficha lateral agrupa información y ofrece acciones superiores y enlaces reales", async () => {
const fieldId = UniqueID.generateNewID().toPrimitive();
const personId = UniqueID.generateNewID().toPrimitive();
@ -401,8 +433,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
active: true,
siteId,
personId,
assetIds: [],
sharedAssetIds: [asset.id],
assetIds: [asset.id],
},
history: [
{
@ -415,8 +446,7 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
active: true,
siteId,
personId,
assetIds: [],
sharedAssetIds: [asset.id],
assetIds: [asset.id],
},
},
],

View File

@ -0,0 +1,138 @@
import type { EntityColumn } from "@repo/rdx-ui/components/entity-list";
import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory";
import { AssetTemplateIcon } from "../../../catalog-templates/shared/ui/asset-template-icon";
/** Inventario y selectores comparten columnas, búsqueda, pestañas y paginación. */
export function AssetList({
assets,
catalog,
sites = [],
templateId,
onTemplateChange,
search,
onSearchChange,
onClearFilters,
renderName,
extraColumns = [],
rowClassName = () => "",
}: {
assets: readonly AssetDTO[];
catalog: readonly CatalogItemDTO[];
sites?: readonly { id: string; name: string }[] | undefined;
templateId: string | null;
onTemplateChange: (id: string) => void;
search: string;
onSearchChange: (search: string) => void;
onClearFilters: () => void;
renderName?: ((asset: AssetDTO) => ReactNode) | undefined;
extraColumns?: readonly EntityColumn<AssetDTO>[] | undefined;
rowClassName?: ((asset: AssetDTO) => string) | undefined;
}) {
const { t, i18n } = useTranslation("assets");
const templates = new Map(
catalog
.filter((item) => item.values.kind === "template")
.map((item) => [item.id, item.values.name])
);
for (const asset of assets) {
if (!templates.has(asset.values.templateId)) {
templates.set(asset.values.templateId, asset.specification.templateName);
}
}
const siteName = (asset: AssetDTO) =>
sites.find((site) => site.id === asset.values.siteId)?.name ??
(asset.values.siteId ? t("noValue") : t("noAssignment"));
const term = search.trim().toLocaleLowerCase();
const items = assets.filter(
(asset) =>
(!templateId || asset.values.templateId === templateId) &&
[
asset.values.name,
asset.specification.templateName,
siteName(asset),
t(asset.displayStatus),
].some((value) => value.toLocaleLowerCase().includes(term))
);
return (
<Tabs onValueChange={onTemplateChange} value={templateId ?? "all"}>
<div className="min-w-0 overflow-x-auto">
<TabsList aria-label={t("filterByTemplate")} variant="line">
<TabsTrigger value="all">{t("allAssets")}</TabsTrigger>
{[...templates].map(([id, name]) => (
<TabsTrigger key={id} value={id}>
{name}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent keepMounted value={templateId ?? "all"}>
<EntityTable
columns={[
{
id: "name",
label: t("name"),
value: (asset) => asset.values.name,
hideable: false,
render: (asset) =>
renderName ? (
renderName(asset)
) : (
<span className="font-medium">{asset.values.name}</span>
),
},
{
id: "template",
label: t("template"),
value: (asset) => asset.specification.templateName,
render: (asset) => {
const template = catalog.find((item) => item.id === asset.values.templateId);
return (
<span className="inline-flex items-center gap-2">
<AssetTemplateIcon
icon={template?.values.kind === "template" ? template.values.icon : undefined}
/>
{asset.specification.templateName}
</span>
);
},
},
{
id: "state",
label: t("status"),
value: (asset) => t(asset.displayStatus),
render: (asset) => (
<EntityStatus active={asset.values.status === "operational"}>
{t(asset.displayStatus)}
</EntityStatus>
),
},
{ id: "site", label: t("site"), value: siteName, render: siteName },
...extraColumns,
]}
emptyMessage={t("noAssets")}
filterKey={templateId ?? "all"}
filtersApplied={templateId !== null}
items={items}
locale={i18n.language}
onClearFilters={onClearFilters}
onSearchChange={onSearchChange}
rowClassName={rowClassName}
search={search}
searchPlaceholder={t("search")}
title={t("inventory")}
/>
</TabsContent>
</Tabs>
);
}

View File

@ -1,4 +1,5 @@
export { AssetForm, AssetInventoryPage } from "./assets";
export { AssetSelect } from "./assets/select/ui/asset-select";
export { BrandCatalogPage } from "./brands";
export { AssetCatalogPage } from "./catalog";
export { catalogApi, catalogKeys } from "./catalog/shared/api";

View File

@ -47,11 +47,7 @@ export function WorkstationHistoryDialog({
{siteName(revision.values.siteId)} · {personName(revision.values.personId)}
</p>
<p>
{t("exclusiveWorkstationAssets")}: {members(revision.values.assetIds)}
</p>
<p>
{t("sharedWorkstationAssets")}:{" "}
{members(revision.values.sharedAssetIds, "noSharedWorkstationAssets")}
{t("workstationAssets")}: {members(revision.values.assetIds)}
</p>
</li>
))}

View File

@ -106,24 +106,17 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
},
{
id: "exclusive",
label: t("exclusiveWorkstationAssets"),
label: t("workstationAssets"),
render: (item) => <>{members(item.values.assetIds)}</>,
},
{
id: "shared",
label: t("sharedWorkstationAssets"),
render: (item) => (
<>{members(item.values.sharedAssetIds, "noSharedWorkstationAssets")}</>
),
},
{
id: "count",
label: t("characteristics"),
value: (item) => item.values.assetIds.length + item.values.sharedAssetIds.length,
value: (item) => item.values.assetIds.length,
render: (item) => (
<>
{t("workstationCount", {
count: item.values.assetIds.length + item.values.sharedAssetIds.length,
count: item.values.assetIds.length,
})}
</>
),
@ -142,7 +135,6 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
{(item.values.active ||
item.values.assetIds.length > 0 ||
item.values.sharedAssetIds.length > 0 ||
item.values.personId) && (
<EntityAction
onClick={() => setOperation({ id: item.id, action: "decommission" })}

View File

@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { FormEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -11,6 +12,7 @@ import { getAssetInventory } from "../../../assets/list/api";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryKeys } from "../../../shared/queries";
import { saveWorkstation } from "../api";
import { workstationCreationReady, workstationMainComputer } from "../helpers/workstation-creation";
import type { WorkstationFormProps } from "../types";
import { suggestedWorkstationName } from "../workstation-name";
@ -23,6 +25,22 @@ export function useWorkstationFormController({
const { t } = useTranslation("assets");
const client = useQueryClient();
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 form = useForm<WorkstationInput>({
resolver: zodResolver(WorkstationInputSchema),
defaultValues: workstation?.values ?? {
name: initialSite ? t("workstationNameBySite", { site: initialSite.name }) : "",
active: true,
siteId: initialSite?.id ?? null,
personId: null,
assetIds: [],
computerAssetId: null,
},
});
const values = form.watch();
const [quick, setQuick] = useState(false);
useEffect(() => {
mounted.current = true;
@ -31,20 +49,6 @@ export function useWorkstationFormController({
mounted.current = false;
};
}, []);
const form = useForm<WorkstationInput>({
resolver: zodResolver(WorkstationInputSchema),
defaultValues: workstation?.values ?? {
name: "",
active: true,
siteId: null,
personId: null,
assetIds: [],
sharedAssetIds: [],
},
});
const values = form.watch();
const [quick, setQuick] = useState(false);
const [quickField, setQuickField] = useState<"assetIds" | "sharedAssetIds">("assetIds");
const inventory = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
@ -54,61 +58,57 @@ export function useWorkstationFormController({
queryFn: () => catalogApi.get(companyId),
});
const assets = inventory.data?.assets ?? [];
const stations = inventory.data?.workstations ?? [];
const occupied = new Set(
stations
(inventory.data?.workstations ?? [])
.filter((item) => item.values.active && item.id !== workstation?.id)
.flatMap((item) => item.values.assetIds)
);
const sharedUsed = new Set(
stations
.filter((item) => item.values.active && item.id !== workstation?.id)
.flatMap((item) => item.values.sharedAssetIds)
);
const unavailableReason = (field: "assetIds" | "sharedAssetIds", id: string) => {
const unavailableReason = (id: string) => {
if (!values.active) {
return undefined;
}
const retained = workstation?.values.active && workstation.values[field].includes(id);
const asset = assets.find((item) => item.id === id);
if (!asset) {
return "ASSET_NOT_FOUND";
}
if (
!(
retained ||
canAssignAsset(
asset.values.status,
field === "assetIds" ? "exclusive" : "shared",
false,
false
)
)
) {
const retained = workstation?.values.active && workstation.values.assetIds.includes(id);
if (!(retained || canAssignAsset(asset.values.status, false))) {
return "ASSET_NOT_OPERATIONAL";
}
if (occupied.has(id) || (field === "assetIds" && sharedUsed.has(id))) {
if (occupied.has(id)) {
return "ASSET_IN_WORKSTATION";
}
return undefined;
};
const unavailable = (field: "assetIds" | "sharedAssetIds", id: string) =>
Boolean(unavailableReason(field, id));
const changeAssets = (ids: string[]) => {
form.setValue("assetIds", ids, { shouldDirty: true });
form.setValue(
"computerAssetId",
workstationMainComputer(ids, form.getValues("computerAssetId")),
{ shouldDirty: true }
);
};
const add = (field: "assetIds" | "sharedAssetIds", id: string) => {
if (
id &&
mounted.current &&
![...form.getValues("assetIds"), ...form.getValues("sharedAssetIds")].includes(id)
) {
form.setValue(field, [...form.getValues(field), id]);
const addMany = (ids: readonly string[]) => {
const current = form.getValues("assetIds");
const additions = ids.filter((id) => id && !current.includes(id));
if (mounted.current && current.length + additions.length <= 200) {
changeAssets([...new Set([...current, ...additions])]);
}
};
const add = (id: string) => addMany([id]);
const remove = (id: string) => {
changeAssets(form.getValues("assetIds").filter((item) => item !== id));
};
const selectAssociation = (field: "siteId" | "personId", id: string) => {
const selectedId = id || null;
@ -118,15 +118,14 @@ export function useWorkstationFormController({
}
const siteId = field === "siteId" ? selectedId : form.getValues("siteId");
const personId = field === "personId" ? selectedId : form.getValues("personId");
const siteName = lookups.sites.find((item) => item.id === siteId)?.name;
const name = suggestedWorkstationName(
lookups.people.find((item) => item.id === personId)?.name,
lookups.sites.find((item) => item.id === siteId)?.name,
siteName,
(person, site) => t("workstationSuggestedName", { person, site })
);
if (name) {
form.setValue("name", name, { shouldDirty: false });
}
form.setValue("name", name ?? (siteName ? t("workstationNameBySite", { site: siteName }) : ""));
};
const mutation = useMutation({
@ -138,7 +137,34 @@ export function useWorkstationFormController({
}
},
});
const submit = form.handleSubmit((input) => mutation.mutate(input));
const assetsReady = !!inventory.data && values.assetIds.every((id) => !unavailableReason(id));
const canContinue = assetsReady && workstationCreationReady(step, values);
const save = form.handleSubmit((input) => {
if (assetsReady && (!isWizard || workstationCreationReady(4, input))) {
mutation.mutate(input);
}
});
const submit = async (event: FormEvent<HTMLFormElement>) => {
if (mutation.isPending) {
event.preventDefault();
return;
}
if (isWizard && step !== 4) {
event.preventDefault();
if (canContinue) {
setStep(NEXT_STEPS[step]);
}
return;
}
await save(event);
};
const previousStep = () => {
setStep(PREVIOUS_STEPS[step]);
};
const siteCreated = (id: string) => {
if (mounted.current) {
@ -153,18 +179,22 @@ export function useWorkstationFormController({
};
return {
isWizard,
step,
previousStep,
canContinue,
assetsReady,
form,
values,
quick,
setQuick,
quickField,
setQuickField,
inventory,
catalog,
assets,
unavailable,
unavailableReason,
add,
addMany,
remove,
selectAssociation,
mutation,
submit,
@ -172,3 +202,6 @@ export function useWorkstationFormController({
personCreated,
};
}
const NEXT_STEPS = { 1: 2, 2: 3, 3: 4, 4: 4 } as const;
const PREVIOUS_STEPS = { 1: 1, 2: 1, 3: 2, 4: 3 } as const;

View File

@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { WorkstationInput } from "../../../../common";
import { workstationCreationReady, workstationMainComputer } from "./workstation-creation";
const values: WorkstationInput = {
name: "Puesto",
active: true,
siteId: "0199b200-0000-7000-8000-000000000001",
personId: null,
assetIds: ["0199b200-0000-7000-8000-000000000002"],
computerAssetId: "0199b200-0000-7000-8000-000000000002",
};
test("alta: sede necesaria, responsable opcional, composición mínima y ordenador perteneciente al puesto", () => {
assert.equal(workstationCreationReady(1, { ...values, siteId: null }), false);
assert.equal(workstationCreationReady(1, { ...values, assetIds: [] }), true);
assert.equal(workstationCreationReady(2, { ...values, assetIds: [] }), false);
assert.equal(workstationCreationReady(2, { ...values, computerAssetId: null }), true);
assert.equal(workstationCreationReady(3, { ...values, computerAssetId: null }), false);
assert.equal(workstationCreationReady(3, { ...values, computerAssetId: values.siteId }), false);
assert.equal(workstationCreationReady(4, { ...values, name: " " }), false);
assert.equal(workstationCreationReady(4, values), true);
});
test("composición: asigna el activo único, conserva una elección válida y limpia el ordenador retirado", () => {
assert.equal(workstationMainComputer(["a"], null), "a");
assert.equal(workstationMainComputer(["a"], "b"), "a");
assert.equal(workstationMainComputer(["a", "b"], "a"), "a");
assert.equal(workstationMainComputer(["a", "b"], "c"), null);
assert.equal(workstationMainComputer(["a", "b"], null), null);
assert.equal(workstationMainComputer([], "a"), null);
});

View File

@ -0,0 +1,29 @@
import type { WorkstationInput } from "../../../../common";
/** Una composición unitaria identifica el ordenador sin pedir una segunda selección. */
export function workstationMainComputer(
ids: readonly string[],
current: string | null | undefined
) {
return ids.length === 1 ? (ids[0] ?? null) : current && ids.includes(current) ? current : null;
}
export function workstationCreationReady(step: 1 | 2 | 3 | 4, values: WorkstationInput) {
if (!values.siteId) {
return false;
}
if (step === 1) {
return true;
}
if (!values.assetIds.length) {
return false;
}
if (step === 2) {
return true;
}
if (!(values.computerAssetId && values.assetIds.includes(values.computerAssetId))) {
return false;
}
return step === 3 || values.name.trim().length > 0;
}

View File

@ -22,7 +22,6 @@ export function WorkstationActions({
const canDecommission =
workstation.values.active ||
workstation.values.assetIds.length > 0 ||
workstation.values.sharedAssetIds.length > 0 ||
Boolean(workstation.values.personId);
return (

View File

@ -0,0 +1,102 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Label } from "@repo/shadcn-ui/components/label";
import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../../../../common/inventory";
import { AssetSelect } from "../../../assets/select/ui/asset-select";
export function WorkstationAssetFields({
assets,
companyId,
sites,
selectedIds,
prefix,
disabled,
canCreate,
unavailableReason,
onAdd,
onRemove,
onCreate,
}: {
assets: readonly AssetDTO[];
companyId: string;
sites: readonly { id: string; name: string }[];
selectedIds: readonly string[];
prefix: string;
disabled: boolean;
canCreate: boolean;
unavailableReason: (
id: string
) => "ASSET_NOT_FOUND" | "ASSET_NOT_OPERATIONAL" | "ASSET_IN_WORKSTATION" | undefined;
onAdd: (ids: string[]) => void;
onRemove: (id: string) => void;
onCreate: () => void;
}) {
const { t } = useTranslation("assets");
return (
<div className="space-y-2">
<Label htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</Label>
<AssetSelect
action={{ label: t("newAsset"), onClick: onCreate, disabled: !canCreate }}
companyId={companyId}
disabled={disabled || selectedIds.length >= 200}
id={`${prefix}-assets`}
label={t("addWorkstationAsset")}
maxSelection={200 - selectedIds.length}
mode="multiple"
onValueChange={onAdd}
options={assets
.filter((asset) => !selectedIds.includes(asset.id))
.map((asset) => {
const reason = unavailableReason(asset.id);
return {
value: asset.id,
label:
reason === "ASSET_NOT_OPERATIONAL"
? `${asset.values.name} · ${t(asset.values.status)}`
: reason
? t("workstationOccupied", { name: asset.values.name })
: asset.values.name,
disabled: !!reason,
};
})}
placeholder={t("assetPicker.multiple")}
sites={sites}
value={[]}
/>
{selectedIds.length ? (
<ul className="space-y-2">
{selectedIds.map((id) => {
const name = assets.find((asset) => asset.id === id)?.values.name ?? id;
const reason = unavailableReason(id);
return (
<li
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
key={id}
>
<div>
<p>{name}</p>
{reason && <p className="text-destructive">{t(`inventoryErrors.${reason}`)}</p>}
</div>
<Button
aria-label={t("removeWorkstationAsset", { name })}
onClick={() => onRemove(id)}
size="sm"
type="button"
variant="outline"
>
×
</Button>
</li>
);
})}
</ul>
) : (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)}
</div>
);
}

View File

@ -0,0 +1,66 @@
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Label } from "@repo/shadcn-ui/components/label";
import { useTranslation } from "react-i18next";
import type { WorkstationInput } from "../../../../common";
import type { WorkstationFormProps } from "../types";
export function WorkstationAssociationFields({
lookups,
workstation,
values,
prefix,
required,
onSelect,
onSiteCreated,
onPersonCreated,
}: {
lookups: WorkstationFormProps["lookups"];
workstation: WorkstationFormProps["workstation"];
values: WorkstationInput;
prefix: string;
required: boolean;
onSelect: (field: "siteId" | "personId", id: string) => void;
onSiteCreated: (id: string) => void;
onPersonCreated: (id: string) => void;
}) {
const { t } = useTranslation("assets");
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
<SelectWithAction
action={{
label: t("newSite"),
onClick: () => lookups.onCreateSite(onSiteCreated),
}}
id={`${prefix}-site`}
label={t("site")}
onValueChange={(id) => onSelect("siteId", id)}
options={lookups.sites.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
required={required}
value={values.siteId ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-person`}>{t("workstationPersonOptional")}</Label>
<SelectWithAction
action={{
label: t("newPerson"),
onClick: () => lookups.onCreatePerson(onPersonCreated),
}}
id={`${prefix}-person`}
label={t("workstationPersonOptional")}
onValueChange={(id) => onSelect("personId", id)}
options={lookups.people
.filter((item) => item.active || item.id === workstation?.values.personId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
value={values.personId ?? ""}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
import { Label } from "@repo/shadcn-ui/components/label";
import { useTranslation } from "react-i18next";
import type { WorkstationInput } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory";
import { AssetSelect } from "../../../assets/select/ui/asset-select";
export function WorkstationComputerField({
values,
assets,
companyId,
sites,
prefix,
singleComputer,
required,
onChange,
}: {
values: WorkstationInput;
assets: readonly AssetDTO[];
companyId: string;
sites: readonly { id: string; name: string }[];
prefix: string;
singleComputer: boolean;
required: boolean;
onChange: (id: string | null) => void;
}) {
const { t } = useTranslation("assets");
return (
<div className="space-y-2">
<Label htmlFor={`${prefix}-computer`}>{t("workstationComputer")}</Label>
<AssetSelect
companyId={companyId}
disabled={singleComputer}
id={`${prefix}-computer`}
label={t("workstationComputer")}
onValueChange={(id) => onChange(id || null)}
options={assets
.filter((asset) => values.assetIds.includes(asset.id))
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("noAssignment")}
required={required}
sites={sites}
value={values.computerAssetId ?? ""}
/>
<p className="text-xs text-muted-foreground">
{t(singleComputer ? "workstationSteps.singleComputer" : "workstationComputerHelp")}
</p>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
export function WorkstationFormActions({
step,
pending,
canSubmit,
onPrevious,
}: {
step: 1 | 2 | 3 | 4 | undefined;
pending: boolean;
canSubmit: boolean;
onPrevious: () => void;
}) {
const { t } = useTranslation("assets");
const action = step && step < 4 ? "workstationSteps.next" : "save";
return (
<FormActions>
{step && step > 1 && (
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
{t("workstationSteps.previous")}
</Button>
)}
<Button disabled={pending || !canSubmit} type="submit">
{t(pending ? "saving" : action)}
</Button>
</FormActions>
);
}

View File

@ -1,8 +1,7 @@
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormProgress } from "@repo/rdx-ui/components/form-progress";
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 { Label } from "@repo/shadcn-ui/components/label";
import { useId } from "react";
@ -13,21 +12,31 @@ import { InventoryFeedback } from "../../../shared";
import { useWorkstationFormController } from "../controllers";
import type { WorkstationFormProps } from "../types";
import { WorkstationAssetFields } from "./workstation-asset-fields";
import { WorkstationAssociationFields } from "./workstation-association-fields";
import { WorkstationComputerField } from "./workstation-computer-field";
import { WorkstationFormActions } from "./workstation-form-actions";
import { WorkstationSummary } from "./workstation-summary";
export function WorkstationForm(props: WorkstationFormProps) {
const { companyId, lookups, workstation } = props;
const {
isWizard,
step,
previousStep,
canContinue,
assetsReady,
form,
values,
quick,
setQuick,
quickField,
setQuickField,
inventory,
catalog,
assets,
unavailable,
unavailableReason,
add,
addMany,
remove,
selectAssociation,
mutation,
submit,
@ -36,153 +45,82 @@ export function WorkstationForm(props: WorkstationFormProps) {
} = useWorkstationFormController(props);
const { t } = useTranslation("assets");
const prefix = useId();
const stepKeys = ["location", "assets", "computer", "summary"] as const;
const layout = isWizard ? STEP_LAYOUTS[step] : FULL_LAYOUT;
const singleComputer = isWizard && values.assetIds.length === 1;
return (
<>
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
<FormBody>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={prefix}>{t("name")}</Label>
<Input id={prefix} maxLength={100} required {...form.register("name")} />
</div>
<SwitchField
checked={values.active}
id={`${prefix}-active`}
label={t("active")}
onCheckedChange={(active) => form.setValue("active", active)}
{isWizard && (
<FormProgress
completedLabel={t("workstationSteps.completed")}
description={t(`workstationSteps.${stepKeys[step - 1]}Description`)}
label={t("workstationSteps.label")}
labels={stepKeys.map((key) => t(`workstationSteps.${key}`))}
progress={t("workstationSteps.progress", { step, total: 4 })}
step={step}
/>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
<SelectWithAction
action={{
label: t("newSite"),
onClick: () => lookups.onCreateSite(siteCreated),
}}
id={`${prefix}-site`}
label={t("site")}
onValueChange={(id) => selectAssociation("siteId", id)}
options={lookups.sites.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
value={values.siteId ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-person`}>{t("workstationPerson")}</Label>
<SelectWithAction
action={{
label: t("newPerson"),
onClick: () => lookups.onCreatePerson(personCreated),
}}
id={`${prefix}-person`}
label={t("workstationPerson")}
onValueChange={(id) => selectAssociation("personId", id)}
options={lookups.people
.filter((item) => item.active || item.id === workstation?.values.personId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
value={values.personId ?? ""}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-computer`}>{t("workstationComputer")}</Label>
<SelectWithAction
id={`${prefix}-computer`}
label={t("workstationComputer")}
onValueChange={(id) => form.setValue("computerAssetId", id || null)}
options={assets
.filter((asset) => values.assetIds.includes(asset.id))
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("noAssignment")}
value={values.computerAssetId ?? ""}
)}
<fieldset className="space-y-4" disabled={mutation.isPending}>
{layout.location && (
<WorkstationAssociationFields
lookups={lookups}
onPersonCreated={personCreated}
onSelect={selectAssociation}
onSiteCreated={siteCreated}
prefix={prefix}
required={isWizard}
values={values}
workstation={workstation}
/>
<p className="text-xs text-muted-foreground">{t("workstationComputerHelp")}</p>
</div>
{(["assetIds", "sharedAssetIds"] as const).map((field) => (
<div className="space-y-2" key={field}>
<Label htmlFor={`${prefix}-${field}`}>
{t(field === "assetIds" ? "addWorkstationAsset" : "addSharedWorkstationAsset")}
</Label>
<SelectWithAction
action={{
label: t("newAsset"),
onClick: () => {
setQuickField(field);
setQuick(true);
},
disabled: !catalog.data,
}}
disabled={inventory.isPending || values[field].length >= 200}
id={`${prefix}-${field}`}
label={t(
field === "assetIds" ? "addWorkstationAsset" : "addSharedWorkstationAsset"
)}
onValueChange={(id) => add(field, id)}
options={assets
.filter(
(item) => ![...values.assetIds, ...values.sharedAssetIds].includes(item.id)
)
.map((item) => ({
value: item.id,
label:
unavailableReason(field, item.id) === "ASSET_NOT_OPERATIONAL"
? `${item.values.name} · ${t(item.values.status)}`
: unavailable(field, item.id)
? t("workstationOccupied", { name: item.values.name })
: item.values.name,
disabled: unavailable(field, item.id),
}))}
placeholder={t("chooseAsset")}
value=""
/>
{values[field].length ? (
<ul className="space-y-2">
{values[field].map((id) => (
<li
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
key={id}
>
<div>
<p>{assets.find((item) => item.id === id)?.values.name ?? id}</p>
{unavailable(field, id) && (
<p className="text-destructive">
{t(`inventoryErrors.${unavailableReason(field, id)}`)}
</p>
)}
</div>
<Button
aria-label={t("removeWorkstationAsset", {
name: assets.find((item) => item.id === id)?.values.name ?? id,
})}
onClick={() => {
form.setValue(
field,
values[field].filter((item) => item !== id)
);
if (values.computerAssetId === id) {
form.setValue("computerAssetId", null);
}
}}
size="sm"
type="button"
variant="outline"
>
×
</Button>
</li>
))}
</ul>
)}
{layout.assets && (
<WorkstationAssetFields
assets={assets}
canCreate={!!catalog.data}
companyId={companyId}
disabled={inventory.isPending}
onAdd={addMany}
onCreate={() => setQuick(true)}
onRemove={remove}
prefix={prefix}
selectedIds={values.assetIds}
sites={lookups.sites}
unavailableReason={unavailableReason}
/>
)}
{layout.computer && (
<WorkstationComputerField
assets={assets}
companyId={companyId}
onChange={(id) => form.setValue("computerAssetId", id)}
prefix={prefix}
required={isWizard}
singleComputer={singleComputer}
sites={lookups.sites}
values={values}
/>
)}
{layout.summary && (
<>
<div className="space-y-2">
<Label htmlFor={prefix}>{t("name")}</Label>
<Input id={prefix} maxLength={100} required {...form.register("name")} />
</div>
{isWizard ? (
<WorkstationSummary assets={assets} lookups={lookups} values={values} />
) : (
<p className="text-sm text-muted-foreground">
{t(field === "assetIds" ? "noWorkstationAssets" : "noSharedWorkstationAssets")}
</p>
<SwitchField
checked={values.active}
id={`${prefix}-active`}
label={t("active")}
onCheckedChange={(active) => form.setValue("active", active)}
/>
)}
</div>
))}
</>
)}
</fieldset>
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert">
@ -197,21 +135,15 @@ export function WorkstationForm(props: WorkstationFormProps) {
guidance={t("workstationGuidance")}
scopeNote={t("workstationScope")}
title={t("helpTitle")}
variant="plain"
/>
</FormBody>
<FormActions>
<Button
disabled={
mutation.isPending ||
!inventory.data ||
values.assetIds.some((id) => unavailable("assetIds", id)) ||
values.sharedAssetIds.some((id) => unavailable("sharedAssetIds", id))
}
type="submit"
>
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
<WorkstationFormActions
canSubmit={isWizard ? canContinue : assetsReady}
onPrevious={previousStep}
pending={mutation.isPending}
step={isWizard ? step : undefined}
/>
</form>
{quick && catalog.data && (
<WorkstationAssetCreateDialog
@ -220,7 +152,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
lookups={lookups}
onOpenChange={setQuick}
onSaved={(id) => {
add(quickField, id);
add(id);
setQuick(false);
}}
/>
@ -228,3 +160,11 @@ export function WorkstationForm(props: WorkstationFormProps) {
</>
);
}
const FULL_LAYOUT = { location: true, assets: true, computer: true, summary: true };
const STEP_LAYOUTS = {
1: { location: true, assets: false, computer: false, summary: false },
2: { location: false, assets: true, computer: false, summary: false },
3: { location: false, assets: false, computer: true, summary: false },
4: { location: false, assets: false, computer: false, summary: true },
} as const;

View File

@ -0,0 +1,52 @@
import { useTranslation } from "react-i18next";
import type { WorkstationInput } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory";
import type { WorkstationFormProps } from "../types";
export function WorkstationSummary({
values,
assets,
lookups,
}: {
values: WorkstationInput;
assets: readonly AssetDTO[];
lookups: WorkstationFormProps["lookups"];
}) {
const { t } = useTranslation("assets");
return (
<dl className="space-y-4 text-sm">
<div>
<dt className="text-muted-foreground">{t("site")}</dt>
<dd>
{lookups.sites.find((site) => site.id === values.siteId)?.name ?? t("noAssignment")}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("workstationPerson")}</dt>
<dd>
{lookups.people.find((person) => person.id === values.personId)?.name ??
t("noAssignment")}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("workstationAssets")}</dt>
<dd>
<ul className="list-inside list-disc">
{values.assetIds.map((id) => (
<li key={id}>{assets.find((asset) => asset.id === id)?.values.name ?? id}</li>
))}
</ul>
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("workstationComputer")}</dt>
<dd>
{assets.find((asset) => asset.id === values.computerAssetId)?.values.name ??
t("noAssignment")}
</dd>
</div>
</dl>
);
}

View File

@ -22,6 +22,7 @@ export type WorkProfileContext = Readonly<{
export interface IWorkProfileRepository {
get(companyId: UniqueID): Promise<Result<WorkProfileContext, WorkProfileError>>;
remove(companyId: UniqueID, id: UniqueID): Promise<Result<void, WorkProfileError>>;
save(
companyId: UniqueID,
action: (context: WorkProfileContext) => Result<WorkProfile, WorkProfileError>

View File

@ -19,6 +19,35 @@ export class WorkProfileUseCases {
: Promise.resolve(Result.fail(new WorkProfileError("INVALID_REQUEST")));
}
remove(companyId: string, id: string) {
const company = UniqueID.create(companyId);
const identity = UniqueID.create(id);
if (!(company.isSuccess && identity.isSuccess)) {
return Promise.resolve(Result.fail(new WorkProfileError("INVALID_REQUEST")));
}
return this.repository.remove(company.data, identity.data);
}
deactivate(companyId: string, id: string, actorId: string) {
const company = UniqueID.create(companyId);
const identity = UniqueID.create(id);
const actor = UniqueID.create(actorId);
if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) {
return Promise.resolve(Result.fail(new WorkProfileError("INVALID_REQUEST")));
}
return this.repository.save(company.data, (context) => {
const previous = context.profiles.find((item) => item.id.equals(identity.data));
return previous
? previous.deactivate(actor.data, new Date().toISOString())
: Result.fail(new WorkProfileError("PROFILE_NOT_FOUND"));
});
}
save(companyId: string, id: string | undefined, input: WorkProfileInput, actorId: string) {
const company = UniqueID.create(companyId);
const actor = UniqueID.create(actorId);

View File

@ -3,7 +3,7 @@ import { AggregateRoot } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import type { WorkProfileValues } from "./value-objects/work-profile-values.vo";
import { WorkProfileValues } from "./value-objects/work-profile-values.vo";
import { WorkProfileRevision } from "./work-profile-revision.entity";
import { WorkProfileError } from "./work-profile-values";
@ -53,6 +53,27 @@ export class WorkProfile extends AggregateRoot<WorkProfileProps> {
return this.props.history;
}
deactivate(actorId: UniqueID, recordedAt: string): Result<WorkProfile, WorkProfileError> {
const values = WorkProfileValues.create({ ...this.values.toPrimitive(), active: false });
return values.isSuccess ? this.update(values.data, actorId, recordedAt) : values;
}
checkRemoval({
hasConfigurations,
}: {
hasConfigurations: boolean;
}): Result<void, WorkProfileError> {
if (this.values.active) {
return Result.fail(new WorkProfileError("PROFILE_ACTIVE"));
}
if (hasConfigurations) {
return Result.fail(new WorkProfileError("PROFILE_IN_USE"));
}
return Result.ok(undefined);
}
update(
values: WorkProfileValues,
actorId: UniqueID,

View File

@ -93,6 +93,32 @@ test("Perfil: cuenta opcional, UUID, historial, identidad fija e idempotencia",
assert.ok(!WorkProfileValues.create({ ...input(), name: " " }).isSuccess);
assert.ok(!WorkProfileValues.create({ ...input(), assetId: "invalid" }).isSuccess);
});
test("Perfil: desactivar conserva valores e historial, es idempotente y precede al borrado", () => {
const item = profile();
const blocked = item.checkRemoval({ hasConfigurations: false });
assert.ok(!blocked.isSuccess);
assert.equal(blocked.error.code, "PROFILE_ACTIVE");
const result = item.deactivate(actor, now);
assert.ok(result.isSuccess);
const inactive = result.data;
assert.deepEqual(inactive.values.toPrimitive(), { ...item.values.toPrimitive(), active: false });
assert.ok(inactive.id.equals(item.id));
assert.equal(inactive.history.length, 2);
assert.equal(inactive.history[0], item.history[0]);
const repeated = inactive.deactivate(actor, now);
assert.ok(repeated.isSuccess);
assert.equal(repeated.data, inactive);
assert.ok(inactive.checkRemoval({ hasConfigurations: false }).isSuccess);
const linked = inactive.checkRemoval({ hasConfigurations: true });
assert.ok(!linked.isSuccess);
assert.equal(linked.error.code, "PROFILE_IN_USE");
});
test("Perfil: misma persona/dispositivo único vigente, múltiples dispositivos/personas y revisión sin pérdida de datos", () => {
const previous = profile();
const ctx = { ...context(), profiles: [previous] };

View File

@ -85,5 +85,63 @@ export function registerWorkProfileRoutes(
.catch(next);
});
}
router.post("/:companyId/:id/deactivate", (request, response, next) => {
const params = ProfileItemParamsSchema.safeParse(request.params);
const actor = z
.object({ userId: z.uuid() })
.safeParse("session" in request ? request.session : undefined);
if (
!(
params.success &&
actor.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.deactivate(params.data.companyId, params.data.id, actor.data.userId)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({
profile: buildWorkProfileResponse(result.data.profile, result.data.context),
});
})
.catch(next);
});
router.post("/:companyId/:id/delete", (request, response, next) => {
const params = ProfileItemParamsSchema.safeParse(request.params);
if (
!(
params.success &&
z.strictObject({}).safeParse(request.body).success &&
z.strictObject({}).safeParse(request.query).success
)
) {
fail(response, "INVALID_REQUEST");
return;
}
useCases
.remove(params.data.companyId, params.data.id)
.then((result) => {
if (!result.isSuccess) {
fail(response, result.error.code);
return;
}
response.json({ deleted: true });
})
.catch(next);
});
app.use("/api/work-profiles", authenticate, router);
}

View File

@ -102,6 +102,53 @@ export class SequelizeWorkProfileRepository implements IWorkProfileRepository {
return this.database.transaction((transaction) => this.load(companyId, transaction, false));
}
remove(companyId: UniqueID, id: UniqueID): Promise<Result<void, WorkProfileError>> {
return this.database.transaction(async (transaction) => {
const context = await this.load(companyId, transaction, true);
if (!context.isSuccess) {
return Result.fail(context.error);
}
const profile = context.data.profiles.find((item) => item.id.equals(id));
if (!profile) {
return Result.fail(new WorkProfileError("PROFILE_NOT_FOUND"));
}
// También las instantáneas históricas impiden borrar una referencia utilizada.
const tables = [
"mail_configurations",
"mail_configuration_revisions",
"folder_mappings",
"folder_mapping_revisions",
"printer_configurations",
"printer_configuration_revisions",
"application_configurations",
"application_configuration_revisions",
"certificate_configurations",
"certificate_configuration_revisions",
];
const references = await this.database.query<{ profileId: string }>(
`${tables.map((table) => `SELECT profileId FROM ${table} WHERE profileId=:id`).join(" UNION ALL ")} LIMIT 1`,
{ replacements: { id: id.toPrimitive() }, transaction, type: QueryTypes.SELECT }
);
const allowed = profile.checkRemoval({ hasConfigurations: references.length > 0 });
if (!allowed.isSuccess) {
return allowed;
}
await this.database.query("DELETE FROM work_profile_revisions WHERE profileId=:id", {
replacements: { id: id.toPrimitive() },
transaction,
});
await this.database.query("DELETE FROM work_profiles WHERE id=:id AND companyId=:companyId", {
replacements: { id: id.toPrimitive(), companyId: companyId.toPrimitive() },
transaction,
});
return Result.ok(undefined);
});
}
save(companyId: UniqueID, action: Parameters<IWorkProfileRepository["save"]>[1]) {
return this.database.transaction(async (transaction) => {
const context = await this.load(companyId, transaction, true);

View File

@ -453,8 +453,6 @@
"authorization": "Authorization",
"expiry": "Expiry",
"validity": "Validity",
"shared": "Shared within workstation",
"exclusive": "Exclusive within workstation",
"retiredAsset": "Retired asset",
"profileNotFound": "This profile is unavailable in this company.",
"loadError": "Could not load the complete environment. Retry to view all configurations.",
@ -735,10 +733,18 @@
"PROFILE_REFERENCE_UNAVAILABLE": "The person or workstation main computer is unavailable, or the account has no current authorization for this person.",
"PROFILE_ALREADY_ACTIVE": "A current profile already exists for this person on this device.",
"PROFILE_TARGET_IMMUTABLE": "A profile's person and device cannot change.",
"PROFILE_ACTIVE": "Deactivate the profile before deleting it.",
"PROFILE_IN_USE": "The profile has linked mail, folder, printer, application or certificate configurations, including revisions. Delete those records before deleting the profile.",
"PROFILE_NOT_FOUND": "This profile is unavailable.",
"COMPANY_NOT_FOUND": "This company is unavailable.",
"INTERNAL_ERROR": "The operation could not be completed. Please try again."
},
"deactivate": "Deactivate profile",
"delete": "Delete profile",
"deactivateDescription": "Deactivate “{{name}}”? Its data, history and configurations are preserved. This action does not remove permissions or configurations from the device.",
"deleteDescription": "Permanently delete “{{name}}” and its history? It must be inactive and have no linked current or historical mail, folder, printer, application or certificate configurations.",
"lifecyclePending": "Processing…",
"lifecycleHelp": "To delete a profile, deactivate it first and delete its linked configuration records. Deactivating preserves history and does not revoke external access.",
"workstation": "Workstation",
"chooseWorkstation": "Select a workstation",
"workstationNeedsComputer": "Edit this workstation and identify an available main computer before creating the profile."

View File

@ -453,8 +453,6 @@
"authorization": "Autorización",
"expiry": "Caducidad",
"validity": "Vigencia",
"shared": "Compartido en el puesto",
"exclusive": "Uso exclusivo en el puesto",
"retiredAsset": "Activo retirado",
"profileNotFound": "El perfil no está disponible en esta compañía.",
"loadError": "No se ha podido cargar el entorno completo. Reintenta para consultar todas sus configuraciones.",
@ -735,10 +733,18 @@
"PROFILE_REFERENCE_UNAVAILABLE": "La persona o el ordenador principal del puesto no están disponibles, o la cuenta no tiene una autorización vigente para esta persona.",
"PROFILE_ALREADY_ACTIVE": "Ya existe un perfil vigente de esta persona en este dispositivo.",
"PROFILE_TARGET_IMMUTABLE": "La persona y el dispositivo de un perfil no pueden cambiar.",
"PROFILE_ACTIVE": "Primero desactiva el perfil antes de eliminarlo.",
"PROFILE_IN_USE": "El perfil tiene configuraciones vinculadas de correo, carpetas, impresoras, aplicaciones o certificados, incluidas sus revisiones. Elimina esos registros antes de borrar el perfil.",
"PROFILE_NOT_FOUND": "El perfil ya no está disponible.",
"COMPANY_NOT_FOUND": "La compañía ya no está disponible.",
"INTERNAL_ERROR": "No se pudo completar la operación. Inténtalo de nuevo."
},
"deactivate": "Desactivar perfil",
"delete": "Eliminar perfil",
"deactivateDescription": "¿Desactivar «{{name}}»? Se conservan sus datos, historial y configuraciones. Esta acción no retira permisos ni configuraciones del equipo.",
"deleteDescription": "¿Eliminar definitivamente «{{name}}» y su historial? Debe estar desactivado y no tener configuraciones vinculadas, ni actuales ni históricas, de correo, carpetas, impresoras, aplicaciones o certificados.",
"lifecyclePending": "Procesando…",
"lifecycleHelp": "Para eliminar un perfil, primero desactívalo y elimina sus configuraciones vinculadas. Desactivar conserva el historial y no retira accesos externos.",
"workstation": "Puesto de trabajo",
"chooseWorkstation": "Selecciona un puesto de trabajo",
"workstationNeedsComputer": "Edita el puesto e identifica un ordenador principal disponible antes de crear el perfil."

View File

@ -72,17 +72,12 @@ export function CurrentWorkstations({
?.name ?? t("noAssignment")}
</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
{[...station.values.assetIds, ...station.values.sharedAssetIds].map(
(id) => (
<BrowserLink
href={`/companies/${company.id}/assets/${id}`}
key={id}
>
{inventory.data?.assets.find((asset) => asset.id === id)?.values
.name ?? id}
</BrowserLink>
)
)}
{station.values.assetIds.map((id) => (
<BrowserLink href={`/companies/${company.id}/assets/${id}`} key={id}>
{inventory.data?.assets.find((asset) => asset.id === id)?.values
.name ?? id}
</BrowserLink>
))}
</div>
</div>
))}

View File

@ -1,3 +1,4 @@
import { AssetSelect } from "@metis/assets/client";
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
@ -75,12 +76,13 @@ export function PrinterConfigurationForm(props: PrinterConfigurationFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-printer`}>{t("printers.printer")}</Label>
<SelectWithAction
<AssetSelect
action={
configuration
? undefined
: { label: t("printers.newAsset"), onClick: () => setQuick("printer") }
}
companyId={company.id}
disabled={!!configuration || !ready || !profile}
id={`${prefix}-printer`}
label={t("printers.printer")}
@ -92,6 +94,7 @@ export function PrinterConfigurationForm(props: PrinterConfigurationFormProps) {
)}
placeholder={t("mail.choose")}
required
sites={company.sites}
value={values.printerId}
/>
</div>

View File

@ -1,3 +1,4 @@
import { AssetSelect } from "@metis/assets/client";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Label } from "@repo/shadcn-ui/components/label";
import type { UseFormReturn } from "react-hook-form";
@ -8,13 +9,9 @@ import type { RemoteConnectionInput } from "../../../../common/remote-connection
import { RemoteConnectionInputSchema } from "../../../../common/remote-connections";
import type { RemoteQuickKind } from "../../create";
type Option = { value: string; label: string };
import { RemoteTargetField } from "./remote-target-field";
const targetActions = {
network: { label: "newSite", kind: "site" },
asset: { label: "remote.newAsset", kind: "target" },
resource: { label: "remote.newResource", kind: "resource" },
} satisfies Record<RemoteConnectionInput["targetKind"], { label: string; kind: RemoteQuickKind }>;
type Option = { value: string; label: string };
export function RemoteEndpointFields({
company,
@ -37,15 +34,6 @@ export function RemoteEndpointFields({
}) {
const { t } = useTranslation("companies");
const v = form.watch();
const targetAction = targetActions[v.targetKind];
const targetOptions = {
network: [
{ value: "", label: t("remote.allCompany") },
...company.sites.map((s) => ({ value: s.id, label: s.name })),
],
asset: assetOptions.filter((a) => a.value !== v.sourceAssetId),
resource: resourceOptions,
};
return (
<>
@ -74,10 +62,11 @@ export function RemoteEndpointFields({
{v.sourceKind === "company_asset" && (
<div className="space-y-2">
<Label htmlFor={`${prefix}-source`}>{t("remote.sourceAsset")}</Label>
<SelectWithAction
<AssetSelect
action={
fixed ? undefined : { label: t("remote.newAsset"), onClick: () => setQuick("source") }
}
companyId={company.id}
disabled={fixed || !ready}
id={`${prefix}-source`}
label={t("remote.sourceAsset")}
@ -85,6 +74,7 @@ export function RemoteEndpointFields({
options={assetOptions}
placeholder={t("mail.choose")}
required
sites={company.sites}
value={v.sourceAssetId ?? ""}
/>
</div>
@ -112,31 +102,16 @@ export function RemoteEndpointFields({
value={v.targetKind}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-target`}>
{t(v.targetKind === "network" ? "remote.site" : "remote.target")}
</Label>
<SelectWithAction
action={
fixed
? undefined
: {
label: t(targetAction.label),
onClick: () => setQuick(targetAction.kind),
}
}
disabled={fixed || !ready}
id={`${prefix}-target`}
label={t(v.targetKind === "network" ? "remote.site" : "remote.target")}
onValueChange={(id) =>
form.setValue(v.targetKind === "network" ? "siteId" : "targetId", id || null)
}
options={targetOptions[v.targetKind]}
placeholder={t(v.targetKind === "network" ? "remote.allCompany" : "mail.choose")}
required={v.targetKind !== "network"}
value={(v.targetKind === "network" ? v.siteId : v.targetId) ?? ""}
/>
</div>
<RemoteTargetField
assetOptions={assetOptions}
company={company}
fixed={fixed}
form={form}
prefix={prefix}
ready={ready}
resourceOptions={resourceOptions}
setQuick={setQuick}
/>
</>
);
}

View File

@ -0,0 +1,95 @@
import { AssetSelect } from "@metis/assets/client";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Label } from "@repo/shadcn-ui/components/label";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import type { RemoteConnectionInput } from "../../../../common/remote-connections";
import type { RemoteQuickKind } from "../../create";
const targetActions = {
network: { label: "newSite", kind: "site" },
asset: { label: "remote.newAsset", kind: "target" },
resource: { label: "remote.newResource", kind: "resource" },
} satisfies Record<RemoteConnectionInput["targetKind"], { label: string; kind: RemoteQuickKind }>;
export function RemoteTargetField({
company,
form,
prefix,
fixed,
ready,
assetOptions,
resourceOptions,
setQuick,
}: {
company: CompanyDTO;
form: UseFormReturn<RemoteConnectionInput>;
prefix: string;
fixed: boolean;
ready: boolean;
assetOptions: readonly { value: string; label: string }[];
resourceOptions: readonly { value: string; label: string }[];
setQuick: (kind: RemoteQuickKind) => void;
}) {
const { t } = useTranslation("companies");
const v = form.watch();
const targetAction = targetActions[v.targetKind];
const targetOptions = {
network: [
{ value: "", label: t("remote.allCompany") },
...company.sites.map((s) => ({ value: s.id, label: s.name })),
],
asset: assetOptions.filter((a) => a.value !== v.sourceAssetId),
resource: resourceOptions,
};
const disabled = fixed || !ready;
return (
<div className="space-y-2">
<Label htmlFor={`${prefix}-target`}>
{t(v.targetKind === "network" ? "remote.site" : "remote.target")}
</Label>
{v.targetKind === "asset" ? (
<AssetSelect
action={
fixed ? undefined : { label: t("remote.newAsset"), onClick: () => setQuick("target") }
}
companyId={company.id}
disabled={disabled}
id={`${prefix}-target`}
label={t("remote.target")}
onValueChange={(id) => form.setValue("targetId", id || null)}
options={targetOptions.asset}
placeholder={t("mail.choose")}
required
sites={company.sites}
value={v.targetId ?? ""}
/>
) : (
<SelectWithAction
action={
fixed
? undefined
: {
label: t(targetAction.label),
onClick: () => setQuick(targetAction.kind),
}
}
disabled={disabled}
id={`${prefix}-target`}
label={t(v.targetKind === "network" ? "remote.site" : "remote.target")}
onValueChange={(id) =>
form.setValue(v.targetKind === "network" ? "siteId" : "targetId", id || null)
}
options={targetOptions[v.targetKind]}
placeholder={t(v.targetKind === "network" ? "remote.allCompany" : "mail.choose")}
required={v.targetKind !== "network"}
value={(v.targetKind === "network" ? v.siteId : v.targetId) ?? ""}
/>
)}
</div>
);
}

View File

@ -1,3 +1,4 @@
import { AssetSelect } from "@metis/assets/client";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Button } from "@repo/shadcn-ui/components/button";
import { Label } from "@repo/shadcn-ui/components/label";
@ -67,26 +68,57 @@ export function TicketTargets({
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-item`}>{t("tickets.addTarget")}</Label>
<SelectWithAction
action={{
label: t("tickets.newTarget"),
onClick: () => setQuick(kind),
disabled: values.length >= 200,
}}
disabled={values.length >= 200}
id={`${prefix}-item`}
label={t("tickets.addTarget")}
onValueChange={(id) => add(id)}
options={ticketTargetOptions(lookups, kind)
.filter(
(item) =>
item.active &&
!values.some((target) => target.kind === kind && target.targetId === item.id)
)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("tickets.choose")}
value=""
/>
{kind === "asset" ? (
<AssetSelect
action={{
label: t("tickets.newTarget"),
onClick: () => setQuick("asset"),
disabled: values.length >= 200,
}}
companyId={companyId}
disabled={values.length >= 200}
id={`${prefix}-item`}
label={t("tickets.addTarget")}
maxSelection={200 - values.length}
mode="multiple"
onValueChange={(ids) =>
onChange([
...values,
...ids.map((targetId) => ({ kind: "asset" as const, targetId })),
])
}
options={lookups.assets
.filter(
(item) =>
item.active &&
!values.some((target) => target.kind === "asset" && target.targetId === item.id)
)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("tickets.choose")}
value={[]}
/>
) : (
<SelectWithAction
action={{
label: t("tickets.newTarget"),
onClick: () => setQuick(kind),
disabled: values.length >= 200,
}}
disabled={values.length >= 200}
id={`${prefix}-item`}
label={t("tickets.addTarget")}
onValueChange={(id) => add(id)}
options={ticketTargetOptions(lookups, kind)
.filter(
(item) =>
item.active &&
!values.some((target) => target.kind === kind && target.targetId === item.id)
)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("tickets.choose")}
value=""
/>
)}
</div>
</div>
<ul className="space-y-2">

View File

@ -120,17 +120,10 @@ export function EnvironmentContent({
{model.stationAssets.length > 0 && (
<ul className="grid gap-2 sm:grid-cols-2">
{model.stationAssets.map((asset) => {
const shared = model.stations.some((station) =>
station.values.sharedAssetIds.includes(asset.id)
);
return (
<li className="rounded-md border p-3 text-sm" key={asset.id}>
<span className="font-medium">{asset.values.name}</span>
<p className="text-muted-foreground">
{asset.specification.templateName} ·{" "}
{t(shared ? "environment.shared" : "environment.exclusive")}
</p>
<p className="text-muted-foreground">{asset.specification.templateName}</p>
{asset.values.status === "retired" && (
<p className="text-amber-700">{t("environment.retiredAsset")}</p>
)}
@ -144,6 +137,45 @@ export function EnvironmentContent({
)}
</CardContent>
</Card>
<section aria-label={t("environment.configurations")} className="space-y-4">
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{(["configured", "pending", "removal", "review"] as const).map((key) => (
<div className="rounded-md border p-3" key={key}>
<dt className="text-sm text-muted-foreground">{t(`environment.${key}`)}</dt>
<dd className="text-2xl font-semibold">{model.counts[key]}</dd>
</div>
))}
</dl>
<label className="flex items-center gap-2 text-sm">
<input
checked={attention}
onChange={(event) => setAttention(event.target.checked)}
type="checkbox"
/>
{t("environment.onlyAttention")}
</label>
{model.unlinkedMail > 0 && (
<p className="text-sm text-amber-700">
{t("environment.unlinkedMail", { count: model.unlinkedMail })}
</p>
)}
<div className="grid items-start gap-4 xl:grid-cols-2">
{(["folders", "mail", "applications", "printers", "certificates"] as const).map(
(kind) => (
<EnvironmentSection
attention={attention}
key={kind}
kind={kind}
onManage={() => onManage(kind)}
rows={model.rows.filter(
(item) =>
item.kind === kind && (!attention || item.state !== "configured" || item.review)
)}
/>
)
)}
</div>
</section>
<Card>
<CardContent className="space-y-3 pt-5">
<header className="flex items-center justify-between gap-3">
@ -190,45 +222,6 @@ export function EnvironmentContent({
companyId={company.id}
retired={model.host?.values.status === "retired"}
/>
<section aria-label={t("environment.configurations")} className="space-y-4">
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{(["configured", "pending", "removal", "review"] as const).map((key) => (
<div className="rounded-md border p-3" key={key}>
<dt className="text-sm text-muted-foreground">{t(`environment.${key}`)}</dt>
<dd className="text-2xl font-semibold">{model.counts[key]}</dd>
</div>
))}
</dl>
<label className="flex items-center gap-2 text-sm">
<input
checked={attention}
onChange={(event) => setAttention(event.target.checked)}
type="checkbox"
/>
{t("environment.onlyAttention")}
</label>
{model.unlinkedMail > 0 && (
<p className="text-sm text-amber-700">
{t("environment.unlinkedMail", { count: model.unlinkedMail })}
</p>
)}
<div className="grid items-start gap-4 xl:grid-cols-2">
{(["mail", "folders", "printers", "applications", "certificates"] as const).map(
(kind) => (
<EnvironmentSection
attention={attention}
key={kind}
kind={kind}
onManage={() => onManage(kind)}
rows={model.rows.filter(
(item) =>
item.kind === kind && (!attention || item.state !== "configured" || item.review)
)}
/>
)
)}
</div>
</section>
<p className="text-sm text-muted-foreground">{t("environment.unknown")}</p>
</div>
);

View File

@ -89,15 +89,13 @@ export function buildEnvironment(profile: WorkProfileDTO, sources: EnvironmentSo
item.values.active &&
item.values.computerAssetId === profile.values.assetId
);
const stationAssets = [
...new Set(
stations.flatMap((item) => [...item.values.assetIds, ...item.values.sharedAssetIds])
),
].flatMap((id) => {
const asset = assets.get(id);
const stationAssets = [...new Set(stations.flatMap((item) => item.values.assetIds))].flatMap(
(id) => {
const asset = assets.get(id);
return asset ? [asset] : [];
});
return asset ? [asset] : [];
}
);
const unlinkedMail = sources.mail.filter(
(item) =>
item.companyId === profile.companyId &&

View File

@ -186,8 +186,7 @@ const station = WorkstationSchema.parse(
siteId: null,
personId: null,
computerAssetId: host.id,
assetIds: [host.id],
sharedAssetIds: [monitor.id],
assetIds: [host.id, monitor.id],
})
);
const sources: EnvironmentSources = {
@ -424,6 +423,22 @@ test("entorno: ficha es/en con estados y acciones, sin formularios; muestra fals
assert.ok(html.includes(text.environment.title));
assert.ok(html.includes(text.environment.onlyAttention));
assert.ok(html.includes(text.environment.samePerson));
const headings = [
text.environment.equipment,
text.folders.title,
text.mail.title,
text.applications.title,
text.printers.title,
text.certificates.title,
text.telework.title,
text.remote.title,
].map((title) => html.indexOf(`<h3 class="font-semibold">${title}</h3>`));
assert.ok(headings.every((position) => position >= 0));
assert.deepEqual(
headings,
[...headings].sort((left, right) => left - right)
);
assert.ok(html.includes("ana@example.test") && html.includes("pedidos@example.test"));
assert.ok(html.includes("BitLocker") && html.includes(text.no));
assert.ok(html.includes(text.environment.needsReview));

View File

@ -92,6 +92,7 @@ export function useWorkProfilesPageController({
};
const [editor, setEditor] = useState<string>();
const [lifecycle, setLifecycle] = useState<{ id: string; action: "deactivate" | "delete" }>();
const [view, setView] = useState<string>();
const { searchParams, setSearchParams } = useBrowserSearchParams();
const search = searchParams.get("q") ?? "";
@ -164,6 +165,8 @@ export function useWorkProfilesPageController({
setConfiguration,
closeConfiguration,
editor,
lifecycle,
setLifecycle,
setEditor,
setView,
search,

View File

@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import { ProfileConfigurations } from "../../../work-environments";
import { WorkProfileHistoryDialog } from "../../history";
import { ProfileFeedback, WorkProfileEditorDialog } from "../../shared";
import { WorkProfileLifecycleDialog } from "../../shared/ui/work-profile-lifecycle-dialog";
import { useWorkProfilesPageController } from "../controllers";
import type { WorkProfilesPageProps } from "../types";
@ -22,6 +23,8 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
setConfiguration,
closeConfiguration,
editor,
lifecycle,
setLifecycle,
setEditor,
setView,
search,
@ -42,6 +45,7 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
missingProfile,
} = useWorkProfilesPageController(props);
const { t, i18n } = useTranslation("companies");
const lifecycleProfile = profiles.find((item) => item.id === lifecycle?.id);
if (missingProfile) {
return <EntityNotFound />;
@ -182,6 +186,18 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
{t("profiles.history")}
</EntityAction>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
<EntityAction
disabled={!item.values.active}
onClick={() => setLifecycle({ id: item.id, action: "deactivate" })}
>
{t("profiles.deactivate")}
</EntityAction>
<EntityAction
disabled={item.values.active}
onClick={() => setLifecycle({ id: item.id, action: "delete" })}
>
{t("profiles.delete")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
@ -209,6 +225,7 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
title={t("profiles.title")}
/>
)}
{ready && <p className="text-sm text-muted-foreground">{t("profiles.lifecycleHelp")}</p>}
{editor && (
<WorkProfileEditorDialog
company={company}
@ -216,6 +233,13 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
profile={edited}
/>
)}
{lifecycle && lifecycleProfile && (
<WorkProfileLifecycleDialog
action={lifecycle.action}
onClose={() => setLifecycle(undefined)}
profile={lifecycleProfile}
/>
)}
{viewed && (
<WorkProfileHistoryDialog
account={account}

View File

@ -34,6 +34,26 @@ async function request(companyId: string, values?: WorkProfileInput, id?: string
}
export const profileApi = {
async changeLifecycle(companyId: string, id: string, action: "deactivate" | "delete") {
const response = await csrfFetch(`/api/work-profiles/${companyId}/${id}/${action}`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: "{}",
});
const json: unknown = await response.json();
if (!response.ok) {
const parsed = z.object({ code: z.string() }).safeParse(json);
throw new WorkProfileApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR");
}
if (action === "delete") {
z.strictObject({ deleted: z.literal(true) }).parse(json);
} else {
WorkProfileResponseSchema.parse(json);
}
},
async get(companyId: string) {
return WorkProfilesResponseSchema.parse(await request(companyId));
},

View File

@ -63,6 +63,7 @@ test("perfiles: listado es/en, alta en cabecera y ausencia de formulario junto a
assert.equal(html.includes("<form"), false);
assert.ok(html.includes(language === "es" ? "Nuevo perfil" : "New profile"));
assert.ok(html.includes(language === "es" ? "perfiles archivados" : "archived profiles"));
assert.ok(html.includes(language === "es" ? "primero desactívalo" : "deactivate it first"));
}
});
test("perfiles: cuenta opcional, altas rápidas y ayuda anterior al pie", async () => {
@ -93,7 +94,6 @@ test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsa
siteId: null,
computerAssetId: computerId,
assetIds: [computerId],
sharedAssetIds: [],
},
history: [],
};

View File

@ -0,0 +1,59 @@
import { ConfirmDeleteDialog } from "@repo/rdx-ui/components/confirm-delete-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { WorkProfileDTO } from "../../../../common/work-profiles";
import { dashboardKeys } from "../../../dashboard/shared/api";
import { processKeys } from "../../../processes/shared/queries";
import { WorkProfileApiError, profileApi, profileKeys } from "../api";
export function WorkProfileLifecycleDialog({
profile,
action,
onClose,
}: {
profile: WorkProfileDTO;
action: "deactivate" | "delete";
onClose: () => void;
}) {
const { t } = useTranslation("companies");
const client = useQueryClient();
const mutation = useMutation({
mutationFn: () => profileApi.changeLifecycle(profile.companyId, profile.id, action),
onSuccess: async () => {
await Promise.all([
client.invalidateQueries({ queryKey: profileKeys.company(profile.companyId) }),
client.invalidateQueries({
queryKey: processKeys.review(profile.companyId, profile.values.personId),
}),
...[profile.companyId, undefined].flatMap((companyId) =>
["summary", "attention", "details"].map((block) =>
client.invalidateQueries({
queryKey: dashboardKeys.block(block, companyId).slice(0, 3),
})
)
),
]);
},
});
return (
<ConfirmDeleteDialog
cancelLabel={t("cancel")}
confirmLabel={t(`profiles.${action}`)}
description={t(`profiles.${action}Description`, { name: profile.values.name })}
errorLabel={t(
`profiles.errors.${mutation.error instanceof WorkProfileApiError ? mutation.error.code : "INTERNAL_ERROR"}`
)}
onConfirm={() => mutation.mutateAsync()}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
pendingLabel={t("profiles.lifecyclePending")}
title={t(`profiles.${action}`)}
/>
);
}

View File

@ -182,7 +182,13 @@ function queryMatchesLocation(
if (location.area === "company" && query.companyId && query.companyId !== location.id) {
return false;
}
if (query.template && !(location.area === "company" && location.section === "inventory")) {
if (
query.template &&
!(
location.area === "company" &&
(location.section === "inventory" || location.section === "resources")
)
) {
return false;
}

View File

@ -12,17 +12,24 @@ const COMPANY_ID = "019b8838-44af-776d-a406-af74452b6f26";
const ENTITY_ID = "019b8838-44af-776d-a406-af74452b6f27";
const TEMPLATE_ID = "01a1276d-2138-711e-b3d2-99258a02a80f";
test("el filtro de plantilla permite abrir el listado y las fichas de activos", () => {
for (const suffix of ["", `/${ENTITY_ID}`]) {
assert.deepEqual(
parseWorkspaceUrl(`/companies/${COMPANY_ID}/assets${suffix}?template=${TEMPLATE_ID}&q=Alpha`),
{
area: "company",
id: COMPANY_ID,
section: "inventory",
...(suffix ? { entityId: ENTITY_ID } : {}),
}
);
test("el filtro de plantilla permite abrir listados y fichas de activos y recursos", () => {
for (const [path, section] of [
["assets", "inventory"],
["resources", "resources"],
]) {
for (const suffix of ["", `/${ENTITY_ID}`]) {
assert.deepEqual(
parseWorkspaceUrl(
`/companies/${COMPANY_ID}/${path}${suffix}?template=${TEMPLATE_ID}&q=Alpha`
),
{
area: "company",
id: COMPANY_ID,
section,
...(suffix ? { entityId: ENTITY_ID } : {}),
}
);
}
}
assert.deepEqual(
parseWorkspaceUrl(
@ -35,6 +42,16 @@ test("el filtro de plantilla permite abrir el listado y las fichas de activos",
entityId: "01a127a0-190b-70fc-bf70-4e41990367b8",
}
);
assert.deepEqual(
parseWorkspaceUrl(
"http://192.168.0.2:5173/companies/01a1016a-289c-761d-9d11-bf10359c0b4e/resources?template=01a10ba8-31cb-70a1-9950-1cc51a827295"
),
{
area: "company",
id: "01a1016a-289c-761d-9d11-bf10359c0b4e",
section: "resources",
}
);
});
test("el filtro de plantilla rechaza UUID inválidos, parámetros repetidos y otros ámbitos", () => {
@ -42,7 +59,10 @@ test("el filtro de plantilla rechaza UUID inválidos, parámetros repetidos y ot
`/companies/${COMPANY_ID}/assets?template=invalid`,
`/companies/${COMPANY_ID}/assets?template=`,
`/companies/${COMPANY_ID}/assets?template=${TEMPLATE_ID}&template=${TEMPLATE_ID}`,
`/companies/${COMPANY_ID}/resources?template=${TEMPLATE_ID}`,
`/companies/${COMPANY_ID}/resources?template=invalid`,
`/companies/${COMPANY_ID}/resources?template=`,
`/companies/${COMPANY_ID}/resources?template=${TEMPLATE_ID}&template=${TEMPLATE_ID}`,
`/companies/${COMPANY_ID}/catalogs/resources?template=${TEMPLATE_ID}`,
`/companies/${COMPANY_ID}/catalogs/assets?template=${TEMPLATE_ID}`,
`/catalogs/assets?template=${TEMPLATE_ID}`,
`/tickets?template=${TEMPLATE_ID}`,

View File

@ -17,7 +17,6 @@ const values = {
personId: id(3),
computerAssetId: id(4),
assetIds: [id(4)],
sharedAssetIds: [id(5)],
};
const history = {
id: id(9),
@ -83,7 +82,7 @@ test("puesto inexistente o sin composición no hereda perfiles de otros activos"
);
const empty = WorkstationSchema.parse({
...workstation,
values: { ...values, computerAssetId: null, assetIds: [], sharedAssetIds: [] },
values: { ...values, computerAssetId: null, assetIds: [] },
});
const result = workstationRelations(empty, [profile(11, id(4), id(6))]);

View File

@ -16,7 +16,7 @@ export function WorkstationAssets({
}) {
const { t } = useTranslation("assets");
const root = `/companies/${companyId}`;
const assetIds = new Set([...workstation.values.assetIds, ...workstation.values.sharedAssetIds]);
const assetIds = new Set(workstation.values.assetIds);
const assetName = (id: string) =>
assets.find((asset) => asset.id === id)?.values.name ?? t("noValue");
@ -44,11 +44,7 @@ export function WorkstationAssets({
<p className="text-xs text-muted-foreground">
{id === workstation.values.computerAssetId
? t("workstationComputer")
: t(
workstation.values.sharedAssetIds.includes(id)
? "sharedWorkstationAssets"
: "exclusiveWorkstationAssets"
)}
: t("workstationAssets")}
</p>
</div>
{asset ? (

View File

@ -33,7 +33,6 @@ const values = {
siteId: null,
personId: null,
assetIds: [],
sharedAssetIds: [],
};
const workstation = WorkstationSchema.parse({
id: workstationId,
@ -99,7 +98,7 @@ test("ficha de puesto es/en: cabecera, retorno, bloques vacíos y edición modal
assert.ok(html.includes("Administración"));
assert.ok(html.includes(`href="/companies/${companyId}/workstations"`));
assert.ok(html.includes(language === "es" ? "Editar puesto" : "Edit workstation"));
assert.ok(html.includes(language === "es" ? "Dar de baja" : "Decommission"));
assert.ok(html.includes(language === "es" ? "Desactivar" : "Deactivate"));
assert.ok(html.includes(language === "es" ? "Eliminar definitivamente" : "Delete permanently"));
assert.ok(html.includes(language === "es" ? "Perfiles de usuario" : "User profiles"));
assert.ok(html.includes(language === "es" ? "Personas relacionadas" : "Related people"));

View File

@ -1,4 +1,6 @@
{
"allResources": "All",
"filterByTemplate": "Filter by template",
"inventory": "Resources",
"mailboxKind": "Mailbox type",
"corporate": "Corporate",

View File

@ -1,4 +1,6 @@
{
"allResources": "Todo",
"filterByTemplate": "Filtrar por plantilla",
"inventory": "Recursos",
"mailboxKind": "Tipo de buzón",
"corporate": "Corporativo",

View File

@ -1,3 +1,4 @@
import { FormProgress } from "@repo/rdx-ui/components/form-progress";
import { useTranslation } from "react-i18next";
export function ResourceCreateProgress({
@ -8,71 +9,23 @@ export function ResourceCreateProgress({
templateName?: string | undefined;
}) {
const { t } = useTranslation("resources");
const currentStep = STEP_KEYS[step - 1];
const keys = ["template", "fields", "name"] as const;
return (
<div className="space-y-5">
<ol
aria-label={t("creationSteps.label")}
className="grid grid-cols-3 gap-3 border-b border-border/60 pb-5"
>
{STEP_KEYS.map((key, index) => {
const current = step === index + 1;
const completed = step > index + 1;
return (
<li
aria-current={current ? "step" : undefined}
className="flex min-w-0 flex-col items-start gap-2 sm:flex-row sm:items-center sm:gap-3"
key={key}
>
<span
aria-hidden="true"
className={`flex size-8 shrink-0 items-center justify-center rounded-full border text-sm font-semibold ${current ? "border-primary bg-primary text-primary-foreground" : completed ? "border-primary/30 bg-primary/10 text-primary" : "border-border bg-muted/50 text-muted-foreground"}`}
>
{completed ? "✓" : index + 1}
</span>
<span
className={`text-xs leading-snug sm:text-sm ${current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
>
{t(`creationSteps.${key}`)}
{completed && <span className="sr-only">{t("creationSteps.completed")}</span>}
</span>
</li>
);
})}
</ol>
<div className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<h2 className="text-base font-semibold text-foreground">
{t(`creationSteps.${currentStep}`)}
</h2>
<p
aria-live="polite"
className="text-xs text-muted-foreground focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-ring"
key={step}
ref={focusStep}
role="status"
tabIndex={-1}
>
{t("creationSteps.progress", { step, total: 3 })}
</p>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">
{t(`creationSteps.${currentStep}Description`)}
<div className="space-y-2">
<FormProgress
completedLabel={t("creationSteps.completed")}
description={t(`creationSteps.${keys[step - 1]}Description`)}
label={t("creationSteps.label")}
labels={keys.map((key) => t(`creationSteps.${key}`))}
progress={t("creationSteps.progress", { step, total: 3 })}
step={step}
/>
{templateName && step > 1 && (
<p className="text-sm text-muted-foreground">
{t("template")}: <span className="font-medium text-foreground">{templateName}</span>
</p>
{templateName && step > 1 && (
<p className="text-sm text-muted-foreground">
{t("template")}: <span className="font-medium text-foreground">{templateName}</span>
</p>
)}
</div>
)}
</div>
);
}
const STEP_KEYS = ["template", "fields", "name"] as const;
function focusStep(element: HTMLParagraphElement | null) {
element?.focus();
}

Some files were not shown because too many files have changed in this diff Show More