Refactor asset and company management features

- Simplified asset assignment validation in SequelizeAssetRepository.
- Updated asset module to include legacy asset assignment migration.
- Enhanced inventory assignment logic to better handle asset-person relationships.
- Improved localization strings for asset guidance and scope.
- Removed person assignment display from asset detail view.
- Streamlined inventory page by removing person-related filters and displays.
- Adjusted asset form to eliminate person assignment input.
- Updated tests to reflect changes in asset and person handling.
- Added phone link component for better phone number formatting and linking.
- Enhanced person details view to include current workstations and improved layout.
- Refactored person avatar component to support children and improved rendering.
This commit is contained in:
David Arranz 2026-10-10 01:37:10 +02:00
parent 6c6eee8f93
commit 4edc1268b4
45 changed files with 635 additions and 341 deletions

View File

@ -136,7 +136,6 @@ export async function verifyBrands(
name: "PORT-MARCA",
templateId: template.id,
siteId: null,
personId: null,
status: "available",
notes: "",
brandId: created.id,

View File

@ -12,12 +12,12 @@ async function start(): Promise<void> {
const server = createApp(undefined, identity).listen(environment.PORT, "127.0.0.1", () => {
console.info(JSON.stringify({ event: "server_started", port: environment.PORT }));
});
server.on("error", () => {
server.on("error", (error) => {
identity.stop();
database
.close()
.catch(() => console.error(JSON.stringify({ event: "database_close_failed" })));
console.error(JSON.stringify({ event: "server_start_failed" }));
console.error(JSON.stringify({ event: "server_start_failed", message: error.message }));
process.exitCode = 1;
});
let stopping = false;
@ -48,7 +48,8 @@ async function start(): Promise<void> {
throw error;
}
}
start().catch(() => {
console.error(JSON.stringify({ event: "server_start_failed" }));
start().catch((error: unknown) => {
const message = error instanceof Error ? error.message : "Unknown startup failure";
console.error(JSON.stringify({ event: "server_start_failed", message }));
process.exitCode = 1;
});

View File

@ -42,20 +42,6 @@ export async function verifyInventory(
assert.ok(laptop);
const inch = catalog.items.find((item) => item.values.name === "Pulgadas");
assert.ok(inch);
async function person(company: string) {
const response = await request(`/companies/${company}/people`, {
name: "Persona inventario",
position: null,
email: null,
phone: null,
active: true,
isContact: false,
});
assert.equal(response.status, 201);
const saved = CompanyResponseSchema.parse(await response.json()).company.people.at(-1);
assert.ok(saved);
return saved;
}
async function site(company: string) {
const response = await request(`/companies/${company}/sites`, {
name: "Sede inventario",
@ -66,15 +52,12 @@ export async function verifyInventory(
assert.ok(saved);
return saved;
}
const ownPerson = await person(companyId);
const foreignPerson = await person(otherId);
const ownSite = await site(companyId);
const foreignSite = await site(otherId);
const input = {
name: "MON-01",
templateId: monitor.id,
siteId: null,
personId: null,
status: "available",
notes: "",
characteristics: {},
@ -89,60 +72,35 @@ export async function verifyInventory(
assert.equal((await read(`/assets/${otherId}`)).assets.length, 0);
await error(`/assets/${otherId}/${created.id}/update`, input, "ASSET_NOT_FOUND", 404);
await error(path, { ...input, siteId: foreignSite.id }, "SITE_NOT_FOUND", 404);
await error(path, { ...input, personId: foreignPerson.id }, "PERSON_UNAVAILABLE");
await error(path, { ...input, characteristics: { [inch.id]: "24" } }, "INVALID_CHARACTERISTICS");
await error(
path,
{ ...input, characteristics: { [ownPerson.id]: 24 } },
{ ...input, characteristics: { [foreignSite.id]: 24 } },
"INVALID_CHARACTERISTICS"
);
await error(path, { ...input, actorId: ownPerson.id }, "INVALID_REQUEST", 400);
await error(path, { ...input, actorId: foreignSite.id }, "INVALID_REQUEST", 400);
assert.equal((await request(path, { values: input }, "https://other.example")).status, 403);
assert.equal((await request(path, { values: input }, undefined, "a".repeat(64))).status, 403);
const assigned = await save(`${path}/${created.id}/update`, {
const inUse = await save(`${path}/${created.id}/update`, {
...input,
siteId: ownSite.id,
personId: ownPerson.id,
status: "in_use",
characteristics: { [inch.id]: 24 },
});
assert.equal(assigned.id, created.id);
assert.equal(assigned.history.length, 2);
assert.equal(assigned.history[0]?.values.personId, null);
const unchanged = await save(`${path}/${created.id}/update`, assigned.values);
assert.deepEqual(unchanged.history, assigned.history);
assert.equal(inUse.id, created.id);
assert.equal(inUse.history.length, 2);
const unchanged = await save(`${path}/${created.id}/update`, inUse.values);
assert.deepEqual(unchanged.history, inUse.history);
await error(
`${path}/${created.id}/update`,
{ ...assigned.values, templateId: laptop.id },
{ ...inUse.values, templateId: laptop.id },
"TEMPLATE_FIXED"
);
await error(
`${path}/${created.id}/update`,
{ ...assigned.values, status: "retired" },
"RETIRED_ASSIGNED"
);
const deactivated = await request(`/companies/${companyId}/people/${ownPerson.id}/update`, {
name: ownPerson.name,
position: null,
email: null,
phone: null,
active: false,
isContact: false,
});
assert.equal(deactivated.status, 200);
const kept = await save(`${path}/${created.id}/update`, {
...assigned.values,
notes: "Persona inactiva: revisar entrega.",
});
assert.equal(kept.values.personId, ownPerson.id);
await error(path, { ...input, personId: ownPerson.id }, "PERSON_UNAVAILABLE");
const retired = await save(`${path}/${created.id}/update`, {
...kept.values,
personId: null,
...inUse.values,
status: "retired",
});
assert.equal(retired.history.length, 4);
assert.equal(retired.history[1]?.values.personId, ownPerson.id);
assert.equal(retired.history.length, 3);
const section = { id: UniqueID.generateNewID().toPrimitive(), name: "Pantalla", description: "" };
const templateResponse = await request(`/asset-catalog/${companyId}`, {
@ -201,43 +159,5 @@ export async function verifyInventory(
assert.equal(updated.history[0]?.id, revisionId);
const stored = (await read()).assets.find((asset) => asset.id === updated.id);
assert.deepEqual(stored, updated);
// Corrupción cruzada simulada solo en la base de pruebas y restaurada incluso ante fallo.
const corrupt = { ...updated.values, personId: foreignPerson.id };
const lastRevision = updated.history.at(-1);
assert.ok(lastRevision);
await database.query("UPDATE assets SET personId=:person, `values`=:values WHERE id=:id", {
replacements: { person: foreignPerson.id, values: JSON.stringify(corrupt), id: updated.id },
});
await database.query(
"UPDATE asset_revisions SET personId=:person, `values`=:values WHERE id=:id",
{
replacements: {
person: foreignPerson.id,
values: JSON.stringify(corrupt),
id: lastRevision.id,
},
}
);
try {
assert.equal((await request(path)).status, 500);
} finally {
await database.query(
"UPDATE asset_revisions SET personId=:person, `values`=:values WHERE id=:id",
{
replacements: {
person: updated.values.personId,
values: JSON.stringify(updated.values),
id: lastRevision.id,
},
}
);
await database.query("UPDATE assets SET personId=:person, `values`=:values WHERE id=:id", {
replacements: {
person: updated.values.personId,
values: JSON.stringify(updated.values),
id: updated.id,
},
});
}
assert.equal((await request("/assets/00000000-0000-4000-8000-000000000001")).status, 404);
}

View File

@ -50,7 +50,6 @@ export async function verifyPrinters(
templateId: template.id,
status: "available",
siteId: null,
personId: null,
notes: "",
characteristics: {},
},

View File

@ -58,14 +58,6 @@ export async function verifyProcesses(
(a) => a.values.status !== "retired"
);
assert.ok(host);
assert.equal(
(
await request(`/assets/${companyId}/${host.id}/update`, {
values: { ...host.values, personId: person.id },
})
).status,
200
);
const profileResponse = await request(`/work-profiles/${companyId}`, {
values: {
personId: person.id,
@ -78,6 +70,17 @@ export async function verifyProcesses(
});
assert.equal(profileResponse.status, 201);
const profile = WorkProfileResponseSchema.parse(await profileResponse.json()).profile;
const workstationResponse = await request(`/assets/${companyId}/workstations`, {
values: {
name: "Puesto proceso",
active: true,
siteId: null,
personId: person.id,
assetIds: [host.id],
sharedAssetIds: [],
},
});
assert.equal(workstationResponse.status, 201);
const resource = ResourcesSchema.parse(
await (await request(`/resources/${companyId}`)).json()
).resources.find((r) => r.values.active && r.specification.templateName === "Aplicación");

View File

@ -39,7 +39,6 @@ export async function verifyRelationships(
templateId: template.id,
status: "available",
siteId: null,
personId: null,
characteristics: {},
notes: "",
},

View File

@ -39,6 +39,10 @@ export async function verifyWorkstations(
assert.equal(personResponse.status, 201);
const person = CompanyResponseSchema.parse(await personResponse.json()).company.people.at(-1);
assert.ok(person);
const otherPersonResponse = await request(`/companies/${otherId}/people`, personFields);
assert.equal(otherPersonResponse.status, 201);
const otherPerson = CompanyResponseSchema.parse(await otherPersonResponse.json()).company.people.at(-1);
assert.ok(otherPerson);
const input = {
name: "Puesto Ana",
active: true,
@ -59,7 +63,7 @@ export async function verifyWorkstations(
};
await error({ ...input, assetIds: [foreign.id] }, "ASSET_NOT_FOUND", undefined, 404);
await error({ ...input, siteId: other.sites[0]?.id }, "SITE_NOT_FOUND", undefined, 404);
await error({ ...input, personId: other.people[0]?.id }, "PERSON_UNAVAILABLE");
await error({ ...input, personId: otherPerson.id }, "PERSON_UNAVAILABLE");
await error({ ...input, assetIds: [first.id, first.id] }, "INVALID_MEMBERS");
await error({ ...input, actorId: first.id }, "INVALID_REQUEST", undefined, 400);
assert.equal((await request(path, { values: input }, "https://other.example")).status, 403);

View File

@ -21,7 +21,7 @@ paquetes genéricos.
- Textarea: dirección opcional de las sedes, con varias líneas.
- Dialog: primitiva de shadcn/ui, con foco y cierre por teclado.
- RDX UI: FormDialog, FormActions, ContextHelp, SelectWithAction, SwitchField, EntityTable y
StatisticCard.
StatisticCard y PhoneLink.
## Convención de navegación
@ -143,7 +143,7 @@ Uso de cuenta, estado de configuración e idioma son listas cerradas. Las refere
El catálogo de activos utiliza SelectWithAction para añadir campos a una plantilla y crearlos mediante un modal adicional. Los tipos de dato constituyen una lista cerrada. Las definiciones de serie se consultan en modales de lectura; su personalización se realiza mediante copias.
El inventario añade altas rápidas de plantilla, sede y persona en los activos. La plantilla creada desde un activo solo ofrece campos existentes para mantener un único nivel adicional de modal. Las características utilizan controles según su tipo; los valores sí/no conservan un estado vacío distinto de No.
El inventario añade altas rápidas de plantilla y sede en los activos. La plantilla creada desde un activo solo ofrece campos existentes para mantener un único nivel adicional de modal. Las características utilizan controles según su tipo; los valores sí/no conservan un estado vacío distinto de No.
## Listados de entidades
@ -308,12 +308,15 @@ Las secciones de consulta evitan recuadros anidados y rellenos laterales: usan t
`DetailSection` ofrece agrupación plegable nativa (`details/summary`), abierta inicialmente, accesible por teclado. `DetailField` devuelve una fila semántica `dt/dd` para incluir en un `dl`: etiqueta y valor se alinean horizontalmente cuando el contenedor dispone de al menos 240 px y se apilan en espacios menores. El consumidor aporta los enlaces; el componente común les aplica cursor de enlace, color semántico y subrayado al pasar el puntero. Los valores ausentes permanecen como texto.
La ficha lateral de activos utiliza un ancho adaptable entre 320 y 400 px, acciones debajo del título y bloques de identificación, ubicación/asignación, características, observaciones, relaciones y asistencia. Conserva las secciones capturadas de la plantilla, incluyendo la compatibilidad con especificaciones antiguas sin agrupación; no modifica valores ni instantáneas. Las relaciones llevan a destinos existentes: persona y activo relacionado a su ficha; sede, puesto y plantilla a sus listados/catálogos actuales. La vista completa e historial mantienen sus funciones. Las fichas de recursos reutilizan las filas de características. No se crean nuevas fichas laterales ni nuevas rutas.
La ficha lateral de activos utiliza un ancho adaptable entre 320 y 400 px, acciones debajo del título y bloques de identificación, ubicación/asignación, características, observaciones, relaciones y asistencia. Conserva las secciones capturadas de la plantilla, incluyendo la compatibilidad con especificaciones antiguas sin agrupación; no modifica valores ni instantáneas. Las relaciones llevan a destinos existentes: activo relacionado a su ficha; sede, puesto y plantilla a sus listados/catálogos actuales. La vista completa e historial mantienen sus funciones. Las fichas de recursos reutilizan las filas de características. No se crean nuevas fichas laterales ni nuevas rutas.
## Enlaces y controles de navegación
Los enlaces de contenido usan `app-link`: color semántico `primary`, cursor de mano, subrayado al pasar el puntero y foco visible por teclado. `BrowserLink` lo incorpora de forma predeterminada, conservando la navegación interna, los clics modificados, destinos externos y descargas. Los botones que presentan nombres navegables o «Ver todos» comparten este estilo sin alterar sus callbacks. Los controles deshabilitados no muestran cursor de enlace ni subrayado. La navegación lateral y los enlaces presentados como botones conservan los colores de su contexto para mantener contraste; las acciones de guardar, editar o cancelar mantienen su presentación de botón.
`PhoneLink` centraliza la visualización no editable de teléfonos: utiliza un enlace `tel:`, conserva extensiones para la llamada y presenta los números españoles de nueve dígitos en grupos de tres. Los números internacionales que no se pueden agrupar con seguridad conservan su estructura, sin inventar un formato nacional. Los formularios no lo usan: mantienen el valor editable original.
En el menú de secciones de una compañía, los elementos utilizan el color normal de texto y solo destacan su texto al pasar el puntero. Se mantienen el cursor de mano, el foco por teclado y el indicador de la sección actual. Esta excepción no afecta a enlaces dentro de listados, fichas o bloques del dashboard.
El cuerpo desplazable `FormBody` reserva 4 px interiores en ambos laterales y al inicio para el anillo exterior de foco (3 px). No debe eliminarse ese espacio: `overflow-y-auto` también recorta horizontalmente y produciría un foco asimétrico en campos pegados al borde. Se conserva el scroll y las acciones fijas.

View File

@ -28,7 +28,7 @@ Los contadores corresponden al conjunto completo, nunca a las filas visibles. Tr
- Certificados y licencias activos cuya fecha de caducidad está entre hoy y hoy + 30 días, incluidos los extremos. La fecha de referencia es UTC. Se identifican por stockKey de las plantillas de serie certificate/software_license y del campo expires, nunca por nombres. No se incluyen recursos sin fecha, inactivos, ya caducados o plantillas personalizadas cuyo significado no está declarado.
- Perfiles activos que requieren revisión porque su persona está inactiva, su activo retirado o su cuenta ya no es válida para ese perfil (cuenta inactiva, técnica, caducada, sistema/entorno desactivado o sin vinculación vigente a la persona). Los campos opcionales vacíos no constituyen errores.
- Activos sin sede asignada, incluidos los retirados, porque el listado representa la ausencia de esa referencia.
- Personas inactivas con activos no retirados asignados, perfiles activos, vinculaciones de cuentas aún pendientes/configuradas/pendientes de retirar (aunque haya cerrado su periodo), necesidades vigentes, conexiones remotas y configuraciones de correo/aplicaciones/certificados/carpetas/impresoras sin retirar, o autorización de teletrabajo activa no caducada.
- Personas inactivas con puestos de trabajo activos, perfiles activos, vinculaciones de cuentas aún pendientes/configuradas/pendientes de retirar (aunque haya cerrado su periodo), necesidades vigentes, conexiones remotas y configuraciones de correo/aplicaciones/certificados/carpetas/impresoras sin retirar, o autorización de teletrabajo activa no caducada.
Estos avisos representan información registrada pendiente de revisión; no verifican permisos efectivos en servidores externos ni ejecutan retiradas. Cada contador abre el conjunto que lo origina. Los accesos personales registrados también se contemplan a través de conexiones remotas y configuraciones de correo.

View File

@ -2,11 +2,11 @@
## Uso
La ficha de compañía incorpora la sección Activos. El listado permite buscar por nombre, plantilla, estado, sede o persona. Nuevo activo abre un modal; Editar y Ficha e historial son acciones explícitas. No se muestran formularios junto al listado.
La ficha de compañía incorpora la sección Activos. El listado permite buscar por nombre, plantilla, estado o sede. Nuevo activo abre un modal; Editar y Ficha e historial son acciones explícitas. No se muestran formularios junto al listado.
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, persona, notas y características son opcionales salvo los campos marcados obligatorios en la plantilla.
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: Disponible, En uso, En mantenimiento y Retirado. Estado y asignación se registran de forma explícita: un equipo compartido puede estar en uso sin una persona única. Para retirarlo debe quedar sin persona asignada; puede conservar una sede y todo su historial. No hay borrado de activos ni transferencia entre compañías.
Estados: Disponible, En uso, En mantenimiento y Retirado. El estado se registra de forma explícita: un equipo compartido puede estar en uso sin una persona única. Para retirarlo puede conservar una sede y todo su historial. La asignación a una persona se consulta a través de los puestos activos. No hay borrado de activos ni 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.
@ -20,7 +20,7 @@ Se validan texto, número, fecha real, sí/no y lista. No se aceptan campos ajen
## Alta rápida y ayuda
Plantilla, sede y persona reutilizan SelectWithAction. El botón «+» abre un modal adicional; guardar selecciona el elemento creado sin perder los datos del activo y cancelar conserva el formulario principal. La nueva plantilla es exclusiva de esa compañía.
Plantilla y sede reutilizan SelectWithAction. El botón «+» abre un modal adicional; guardar selecciona el elemento creado sin perder los datos del activo y cancelar conserva el formulario principal. La nueva plantilla es exclusiva de esa compañía.
Solo se permite un nivel de alta adicional: al crear una plantilla desde el activo se seleccionan campos existentes, sin abrir un tercer modal de alta de campo. Los elementos guardados permanecen aunque después se cancele el activo. El modal explica este comportamiento. Los estados y las opciones de una característica son valores cerrados para esa ficha.
@ -30,7 +30,7 @@ La ayuda aparece inmediatamente antes de Guardar y Cancelar. Los formularios amp
Asset es la raíz de consistencia de cada activo. AssetRevision conserva una instantánea completa de cada cambio con UUID propio, actor autenticado y fecha UTC. AssetValues y AssetSpecification son Value Objects inmutables con igualdad semántica. Las referencias se reconstruyen como UniqueID; Name representa los nombres.
El alta crea una primera revisión. Cambiar datos, estado, sede, persona o características añade una revisión, conservando la identidad del activo y las revisiones anteriores. Guardar los mismos valores normalizados no crea otra revisión. La ficha muestra todas las revisiones y sus valores, no solo un contador.
El alta crea una primera revisión. Cambiar datos, estado, sede o características añade una revisión, conservando la identidad del activo y las revisiones anteriores. Guardar los mismos valores normalizados no crea otra revisión. La ficha muestra todas las revisiones y sus valores, no solo un contador.
Las migraciones explícitas crean assets y asset_revisions, con claves foráneas para compañía, plantilla, sede, persona y actor; las revisiones también conservan las referencias de sede y persona. Infrastructure reconstruye y comprueba identidades, tipos, historial y pertenencia. No regenera identificadores ni sustituye datos corruptos.

View File

@ -14,7 +14,7 @@ Persona y dispositivo son la identidad funcional del perfil y no cambian despué
Un puesto agrupa activos; el perfil pertenece a una persona y a un dispositivo. No se almacena otra asignación de puesto en el perfil: la ficha consulta los puestos vigentes que incluyen el dispositivo. Esta relación no presupone que todas las personas que usan un ordenador sean titulares del puesto o responsables del activo.
La persona asignada al activo, la persona 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.
La persona 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).

View File

@ -91,7 +91,7 @@ Este orden se concreta antes de cada entrega. El registro de licencias como recu
## Inventario: implementado
Activos por compañía, plantilla capturada, sede y persona opcionales, cuatro estados, características tipadas y revisiones con actor y fecha. Altas rápidas y consulta del historial mediante modales. Consultar [Inventario de activos](inventario-de-activos.md).
Activos por compañía, plantilla capturada, sede opcional, cuatro estados, características tipadas y revisiones con actor y fecha. La persona se relaciona mediante puestos de trabajo y perfiles de usuario. Altas rápidas y consulta del historial mediante modales. Consultar [Inventario de activos](inventario-de-activos.md).
Las [relaciones entre activos](relaciones-entre-activos.md) están implementadas: catálogo global y local, cinco tipos de serie, vínculos en ambos sentidos y retirada con auditoría. Los [puestos de trabajo](puestos-de-trabajo.md) están implementados, sin anidación, con sede y persona opcionales, composición exclusiva, recursos compartidos entre puestos vigentes e historial.

View File

@ -28,7 +28,7 @@ Cada actualización conserva actor autenticado y fecha UTC. La revisión esperad
Cambio y Salida añaden automáticamente tareas para revisar los registros documentados de esa persona:
- Activos disponibles asignados directamente a la persona.
- Activos disponibles incluidos en puestos de trabajo activos asignados a la persona.
- Perfiles vigentes, relaciones con cuentas y pertenencias a grupos.
- Necesidades de recursos y periodos de uso de correo.
- Configuraciones abiertas de correo, carpetas, impresoras, aplicaciones y certificados, incluso en perfiles archivados.

View File

@ -4,7 +4,7 @@
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.
Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. 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, sedes y personas existentes para evitar un tercer modal.
Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. 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.
La ayuda aparece inmediatamente antes de Guardar y Cancelar. El listado permite buscar por nombre, persona o sede. Historial del puesto muestra las versiones completas, con estado, asignación y composición, fecha y responsable. La ficha de cada activo muestra los puestos vigentes en los que se utiliza.
@ -16,7 +16,7 @@ Desactivar un puesto conserva su composición y libera sus reservas. Un activo p
Persona, sede y activos deben pertenecer a la misma compañía. Una persona debe estar activa para una nueva asignación. Si se desactiva después, se conserva la asignación anterior para revisión y puede retirarse. La persona no recibe acceso a Metis Desk.
La asignación del puesto no cambia automáticamente sede, persona o estado de cada activo; son registros independientes. Tampoco se crean conexiones entre equipos automáticamente: se registran mediante relaciones entre activos. Los activos retirados pueden permanecer en una composición para revisión.
La asignación del puesto no cambia automáticamente sede ni estado de cada activo; la persona se determina por el propio puesto. Tampoco se crean conexiones entre equipos automáticamente: se registran mediante relaciones entre activos. Los activos retirados pueden permanecer en una composición para revisión.
No se eliminan puestos ni revisiones. Guardar valores normalizados equivalentes no añade una revisión; cambiar solo el orden de selección de activos tampoco. Cambiar nombre, estado, sede, persona o composición registra una instantánea nueva con UUID propio y conserva el UUID del puesto.

View File

@ -42,21 +42,10 @@ export interface IAssetRepository {
action: (context: InventoryContext) => Result<Asset, AssetCatalogError>
): Promise<Result<{ asset: Asset; actorNames: ReadonlyMap<string, string> }, AssetCatalogError>>;
}
export function validateAssetReferences(
context: InventoryContext,
values: AssetValues,
previous?: Asset
) {
export function validateAssetReferences(context: InventoryContext, values: AssetValues) {
const next = values.toPrimitive();
const current = previous?.values.toPrimitive();
if (next.siteId && !context.sites.some((site) => site.toPrimitive() === next.siteId)) {
return Result.fail(new AssetCatalogError("SITE_NOT_FOUND"));
}
if (next.personId) {
const person = context.people.find((person) => person.id.toPrimitive() === next.personId);
if (!person || (!person.active && current?.personId !== next.personId)) {
return Result.fail(new AssetCatalogError("PERSON_UNAVAILABLE"));
}
}
return Result.ok<true>(true);
}

View File

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

View File

@ -17,7 +17,6 @@ export const AssetInputSchema = z.strictObject({
name: z.string().min(1).max(100),
templateId: z.uuid(),
siteId: z.uuid().nullable(),
personId: z.uuid().nullable(),
brandId: z.uuid().nullable().optional(),
status: AssetStatusSchema,
notes: z.string().max(10000),

View File

@ -4,7 +4,7 @@ import { test } from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Asset } from "./asset.aggregate";
import type { AssetInput } from "./asset-values";
import { type AssetInput, AssetInputSchema } from "./asset-values";
import { AssetSpecification, AssetValues } from "./value-objects/asset-values.vo";
const company = UniqueID.generateNewID();
@ -33,7 +33,6 @@ function input(characteristics: AssetInput["characteristics"] = {}): AssetInput
name: " Equipo ",
templateId,
siteId: null,
personId: null,
status: "available",
notes: "",
characteristics,
@ -48,6 +47,11 @@ test("Características opcionales, tipos y límites; cero y falso son valores pr
const valid = AssetValues.create(input(typed), spec);
assert.ok(valid.isSuccess);
assert.equal(valid.data.toPrimitive().name, "Equipo");
assert.equal(
AssetInputSchema.safeParse({ ...input(), personId: UniqueID.generateNewID().toPrimitive() })
.success,
false
);
for (const [index, value] of [
[1, "12"],
[2, "2026-02-30"],
@ -75,7 +79,7 @@ test("Características opcionales, tipos y límites; cero y falso son valores pr
false
);
});
test("Activo conserva UUID y revisiones; los cambios iguales son idempotentes y retirado no admite asignación", () => {
test("Activo conserva UUID y revisiones; los cambios iguales son idempotentes", () => {
const spec = specification();
const first = AssetValues.create(input(), spec);
assert.ok(first.isSuccess);
@ -87,19 +91,12 @@ test("Activo conserva UUID y revisiones; los cambios iguales son idempotentes y
const same = asset.update(first.data, actor, "2026-10-04T13:00:00.000Z");
assert.ok(same.isSuccess);
assert.equal(same.data, asset);
const next = AssetValues.create(
{ ...input(), personId: UniqueID.generateNewID().toPrimitive(), status: "in_use" },
spec
);
const next = AssetValues.create({ ...input(), status: "in_use" }, spec);
assert.ok(next.isSuccess);
const changed = asset.update(next.data, actor, "2026-10-04T13:00:00.000Z");
assert.ok(changed.isSuccess);
assert.ok(changed.data.id.equals(asset.id));
assert.equal(changed.data.history.length, 2);
assert.equal(changed.data.history[0]?.values.toPrimitive().personId, null);
assert.equal(
AssetValues.create({ ...next.data.toPrimitive(), status: "retired" }, spec).isSuccess,
false
);
assert.ok(AssetValues.create({ ...next.data.toPrimitive(), status: "retired" }, spec).isSuccess);
assert.ok(Object.isFrozen(next.data.toPrimitive().characteristics));
});

View File

@ -194,7 +194,6 @@ test("especificación: secciones capturadas, edición posterior y obligatoriedad
name: "Registro",
templateId: selected.id.toPrimitive(),
siteId: null,
personId: null,
status: "available",
notes: "",
characteristics: {},

View File

@ -106,7 +106,6 @@ export class AssetValues {
private readonly data: AssetInput,
public readonly name: Name,
public readonly siteId: Maybe<UniqueID>,
public readonly personId: Maybe<UniqueID>,
public readonly brandId: Maybe<UniqueID>
) {
Object.freeze(this);
@ -128,15 +127,11 @@ export class AssetValues {
}
const data = parsed.data;
const site = optionalIdentity(data.siteId);
const person = optionalIdentity(data.personId);
const parsedCharacteristics = normalizeCharacteristics(data.characteristics, specification);
if (!parsedCharacteristics.isSuccess) {
return parsedCharacteristics;
}
const characteristics = parsedCharacteristics.data;
if (data.status === "retired" && data.personId !== null) {
return Result.fail(new AssetCatalogError("RETIRED_ASSIGNED"));
}
return Result.ok(
new AssetValues(
Object.freeze({
@ -144,14 +139,12 @@ export class AssetValues {
name: name.data.toPrimitive(),
templateId: data.templateId.toLowerCase(),
siteId: data.siteId?.toLowerCase() ?? null,
personId: data.personId?.toLowerCase() ?? null,
...(data.brandId === undefined ? {} : { brandId: data.brandId?.toLowerCase() ?? null }),
notes: data.notes.trim(),
characteristics: Object.freeze(characteristics),
}),
name.data,
site,
person,
optionalIdentity(data.brandId ?? null)
)
);
@ -166,7 +159,6 @@ export class AssetValues {
first.name === second.name &&
first.templateId === second.templateId &&
first.siteId === second.siteId &&
first.personId === second.personId &&
(first.brandId ?? null) === (second.brandId ?? null) &&
first.status === second.status &&
first.notes === second.notes &&

View File

@ -37,7 +37,7 @@ export class AssetDashboardReader {
}
inactivePeople(companyId?: string) {
return this.database.query<{ id: string }>(
"SELECT DISTINCT p.id FROM assets a JOIN company_contacts p ON p.id=a.personId AND p.companyId=a.companyId WHERE p.active=0 AND JSON_UNQUOTE(JSON_EXTRACT(a.`values`,'$.status')) <> 'retired' AND (:companyId IS NULL OR a.companyId=:companyId)",
"SELECT DISTINCT p.id FROM workstations w JOIN company_contacts p ON p.id=w.personId AND p.companyId=w.companyId WHERE p.active=0 AND JSON_EXTRACT(w.`values`,'$.active')=true AND (:companyId IS NULL OR w.companyId=:companyId)",
{ type: QueryTypes.SELECT, replacements: { companyId: companyId ?? null } }
);
}

View File

@ -30,7 +30,6 @@ export class AssetPersistenceMapper {
if (
row.templateId !== current.templateId ||
row.siteId !== current.siteId ||
row.personId !== current.personId ||
row.brandId !== (current.brandId ?? null)
) {
throw new Error("Referencias de activo persistidas inconsistentes.");
@ -51,7 +50,6 @@ export class AssetPersistenceMapper {
}
if (
revision.siteId !== previous.data.toPrimitive().siteId ||
revision.personId !== previous.data.toPrimitive().personId ||
revision.brandId !== (previous.data.toPrimitive().brandId ?? null)
) {
throw new Error("Referencias de revisión persistidas inconsistentes.");
@ -86,7 +84,6 @@ export class AssetPersistenceMapper {
companyId: asset.companyId.toPrimitive(),
templateId: values.templateId,
siteId: values.siteId,
personId: values.personId,
brandId: values.brandId ?? null,
specification: asset.specification.toPrimitive(),
values,
@ -96,7 +93,6 @@ export class AssetPersistenceMapper {
assetId: asset.id.toPrimitive(),
actorId: revision.actorId.toPrimitive(),
siteId: revision.values.toPrimitive().siteId,
personId: revision.values.toPrimitive().personId,
brandId: revision.values.toPrimitive().brandId ?? null,
recordedAt: revision.recordedAt,
values: revision.values.toPrimitive(),

View File

@ -1,5 +1,7 @@
import { UniqueID } from "@repo/rdx-ddd";
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model, QueryTypes } from "sequelize";
export class AssetModel extends Model<
InferAttributes<AssetModel>,
InferCreationAttributes<AssetModel>
@ -8,7 +10,6 @@ export class AssetModel extends Model<
declare companyId: string;
declare templateId: string;
declare siteId: string | null;
declare personId: string | null;
declare brandId: string | null;
declare specification: unknown;
declare values: unknown;
@ -24,7 +25,6 @@ export class AssetRevisionModel extends Model<
declare values: unknown;
declare position: number;
declare siteId: string | null;
declare personId: string | null;
declare brandId: string | null;
}
const id = { type: DataTypes.UUID, primaryKey: true };
@ -34,6 +34,7 @@ const fk = (model: string, nullable = false) => ({
references: { model, key: "id" },
onDelete: "RESTRICT",
});
export async function migrateInventory(database: Sequelize) {
const query = database.getQueryInterface();
const tables = await database.query<{ name: string }>(
@ -45,7 +46,6 @@ export async function migrateInventory(database: Sequelize) {
companyId: fk("companies"),
templateId: fk("asset_definitions"),
siteId: fk("company_sites", true),
personId: fk("company_contacts", true),
brandId: fk("asset_brands", true),
specification: { type: DataTypes.JSON, allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
@ -55,7 +55,6 @@ export async function migrateInventory(database: Sequelize) {
assetId: fk("assets"),
actorId: fk("users"),
siteId: fk("company_sites", true),
personId: fk("company_contacts", true),
brandId: fk("asset_brands", true),
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
values: { type: DataTypes.JSON, allowNull: false },
@ -73,23 +72,6 @@ export async function migrateInventory(database: Sequelize) {
await query.addColumn(table, "brandId", fk("asset_brands", true));
}
}
const revisionColumns = await query.describeTable("asset_revisions");
for (const [column, reference] of [
["siteId", "company_sites"],
["personId", "company_contacts"],
] as const) {
if (!(column in revisionColumns)) {
await query.addColumn("asset_revisions", column, fk(reference, true));
}
// Reconstruye las referencias de instalaciones que ya hubieran registrado revisiones.
const source =
column === "siteId"
? "UPDATE asset_revisions SET siteId = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.siteId')), 'null')"
: "UPDATE asset_revisions SET personId = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.personId')), 'null')";
await database.query(
source + (column === "siteId" ? " WHERE siteId IS NULL" : " WHERE personId IS NULL")
);
}
AssetModel.init(asset, { sequelize: database, tableName: "assets", timestamps: false });
AssetRevisionModel.init(revision, {
sequelize: database,
@ -97,3 +79,143 @@ export async function migrateInventory(database: Sequelize) {
timestamps: false,
});
}
type LegacyAssignment = {
id: string;
companyId: string;
siteId: string | null;
personId: string;
personName: string;
siteName: string | null;
actorId: string | null;
};
type ActiveWorkstationAsset = { assetId: string; personId: string | null };
/**
* Convierte la asignación histórica directa activo-persona en puestos activos antes de
* retirar el campo. Si ya existe un puesto activo para el mismo activo y persona, la
* relación ya está representada y no se duplica.
*/
export async function migrateLegacyAssetAssignments(database: Sequelize) {
const query = database.getQueryInterface();
const assetColumns = await query.describeTable("assets");
if (!("personId" in assetColumns)) {
return;
}
const legacy = await database.query<LegacyAssignment>(
"SELECT a.id,a.companyId,a.siteId,a.personId,p.name AS personName,s.name AS siteName,(SELECT r.actorId FROM asset_revisions r WHERE r.assetId=a.id ORDER BY r.position DESC LIMIT 1) AS actorId FROM assets a JOIN company_contacts p ON p.id=a.personId AND p.companyId=a.companyId LEFT JOIN company_sites s ON s.id=a.siteId AND s.companyId=a.companyId WHERE a.personId IS NOT NULL AND JSON_UNQUOTE(JSON_EXTRACT(a.`values`,'$.status')) <> 'retired'",
{ type: QueryTypes.SELECT }
);
const active = await database.query<ActiveWorkstationAsset>(
"SELECT wa.assetId,w.personId FROM workstation_assets wa JOIN workstations w ON w.id=wa.workstationId WHERE wa.activeKey IS NOT NULL",
{ type: QueryTypes.SELECT }
);
const activeByAsset = new Map(active.map((item) => [item.assetId, item]));
const groups = new Map<string, LegacyAssignment[]>();
for (const assignment of legacy) {
const existing = activeByAsset.get(assignment.id);
if (existing) {
if (existing.personId !== assignment.personId) {
throw new Error(
`No se puede migrar el activo ${assignment.id}: ya pertenece a un puesto de otra persona.`
);
}
continue;
}
const key = [assignment.companyId, assignment.personId, assignment.siteId ?? ""].join(":");
const entries = groups.get(key) ?? [];
entries.push(assignment);
groups.set(key, entries);
}
await database.transaction(async (transaction) => {
for (const entries of groups.values()) {
const first = entries[0];
if (!(first?.actorId && first.personName)) {
throw new Error("No se puede reconstruir el autor de la asignación histórica del activo.");
}
const workstationId = UniqueID.generateNewID().toPrimitive();
const revisionId = UniqueID.generateNewID().toPrimitive();
const values = {
name: `Puesto migrado: ${first.personName}${first.siteName ? ` · ${first.siteName}` : ""}`.slice(
0,
100
),
active: true,
siteId: first.siteId,
personId: first.personId,
assetIds: entries.map((entry) => entry.id),
sharedAssetIds: [],
};
const recordedAt = new Date().toISOString();
await database.query(
"INSERT INTO workstations (id,companyId,siteId,personId,`values`) VALUES (:id,:companyId,:siteId,:personId,:values)",
{
replacements: {
id: workstationId,
companyId: first.companyId,
siteId: first.siteId,
personId: first.personId,
values: JSON.stringify(values),
},
transaction,
}
);
await database.query(
"INSERT INTO workstation_revisions (id,workstationId,actorId,siteId,personId,`values`,recordedAt,position) VALUES (:id,:workstationId,:actorId,:siteId,:personId,:values,:recordedAt,0)",
{
replacements: {
id: revisionId,
workstationId,
actorId: first.actorId,
siteId: first.siteId,
personId: first.personId,
values: JSON.stringify(values),
recordedAt,
},
transaction,
}
);
for (const entry of entries) {
await database.query(
"INSERT INTO workstation_assets (workstationId,companyId,assetId,activeKey) VALUES (:workstationId,:companyId,:assetId,:activeKey)",
{
replacements: {
workstationId,
companyId: first.companyId,
assetId: entry.id,
activeKey: `${first.companyId}:${entry.id}`,
},
transaction,
}
);
}
}
await database.query("UPDATE assets SET `values`=JSON_REMOVE(`values`,'$.personId')", {
transaction,
});
await database.query("UPDATE asset_revisions SET `values`=JSON_REMOVE(`values`,'$.personId')", {
transaction,
});
});
const revisionColumns = await query.describeTable("asset_revisions");
for (const [table, columns] of [
["asset_revisions", revisionColumns],
["assets", assetColumns],
] as const) {
if (!("personId" in columns)) {
continue;
}
const constraints = await database.query<{ constraintName: string }>(
"SELECT CONSTRAINT_NAME AS constraintName FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=:table AND COLUMN_NAME='personId' AND REFERENCED_TABLE_NAME IS NOT NULL",
{ replacements: { table }, type: QueryTypes.SELECT }
);
for (const { constraintName } of constraints) {
await database.query(
`ALTER TABLE \`${table}\` DROP FOREIGN KEY \`${constraintName.replaceAll("`", "``")}\``
);
}
// Sequelize 6 y mariadb 3 devuelven una matriz inmutable para ALTER TABLE,
// que hace fallar QueryInterface.removeColumn después de ejecutar correctamente la migración.
await database.query(`ALTER TABLE \`${table}\` DROP COLUMN personId`);
}
}

View File

@ -108,10 +108,7 @@ export class SequelizeAssetRepository implements IAssetRepository {
}
for (const revision of asset.history) {
const values = revision.values.toPrimitive();
if (
(values.siteId && !sites.some((site) => site.id === values.siteId)) ||
(values.personId && !people.some((person) => person.id === values.personId))
) {
if (values.siteId && !sites.some((site) => site.id === values.siteId)) {
throw new Error("Asignación de inventario fuera de su compañía.");
}
}

View File

@ -3,6 +3,7 @@ import { Maybe, Result } from "@repo/rdx-utils";
import type { RequestHandler } from "express";
import type { Sequelize } from "sequelize";
import type { InventoryContext } from "./application/asset.repository";
import { AssetUseCases } from "./application/asset.use-cases";
import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases";
import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases";
@ -16,7 +17,10 @@ import { registerRelationshipRoutes } from "./infrastructure/http/asset-relation
import { registerBrandRoutes } from "./infrastructure/http/brand.routes";
import { registerSupportRoutes } from "./infrastructure/http/support.routes";
import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
import { migrateInventory } from "./infrastructure/persistence/asset.model";
import {
migrateInventory,
migrateLegacyAssetAssignments,
} from "./infrastructure/persistence/asset.model";
import {
CatalogModel,
migrateAssetCatalog,
@ -53,6 +57,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
const brands = new BrandUseCases(new SequelizeBrandRepository(database));
await migrateRelationships(database);
await migrateWorkstations(database);
await migrateLegacyAssetAssignments(database);
await migrateSupport(database);
const support = new SupportUseCases(new SequelizeSupportRepository(database));
const assetRepository = new SequelizeAssetRepository(database);
@ -80,12 +85,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
if (!result.isSuccess) {
throw new Error("Inventario no disponible.");
}
return result.data.assets.map((asset) => ({
id: asset.id,
name: asset.values.toPrimitive().name,
personId: asset.values.toPrimitive().personId,
available: asset.values.toPrimitive().status !== "retired",
}));
return inventoryAssignments(result.data);
},
},
},
@ -99,3 +99,46 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
},
};
}
function inventoryAssignments(context: InventoryContext) {
const peopleByAsset = new Map<string, Set<string>>();
for (const workstation of context.workstations) {
const values = workstation.values.toPrimitive();
if (!(values.active && values.personId)) {
continue;
}
for (const assetId of [...values.assetIds, ...values.sharedAssetIds]) {
const people = peopleByAsset.get(assetId) ?? new Set<string>();
people.add(values.personId);
peopleByAsset.set(assetId, people);
}
}
const assignments: {
id: UniqueID;
name: string;
personId: string | null;
available: boolean;
}[] = [];
for (const asset of context.assets) {
const values = asset.values.toPrimitive();
const people = peopleByAsset.get(asset.id.toPrimitive()) ?? new Set<string>();
if (people.size) {
for (const personId of people) {
assignments.push({
id: asset.id,
name: values.name,
personId,
available: values.status !== "retired",
});
}
} else {
assignments.push({
id: asset.id,
name: values.name,
personId: null,
available: values.status !== "retired",
});
}
}
return assignments;
}

View File

@ -123,8 +123,8 @@
"newPerson": "New person",
"assetDescription": "An asset is a specific IT item belonging to this company. Its template defines the characteristics you can record.",
"assetExamples": "Ana's laptop, the reception monitor or the main site's switch.",
"assetGuidance": "Only the name, template and characteristics required by your template are mandatory. Complete other data later. Unassign the person before retiring an asset; its history is retained.",
"assetScope": "The asset belongs to this company. Site and person are optional and must belong to it.",
"assetGuidance": "Only the name, template and characteristics required by your template are mandatory. Complete other data later. Retiring an asset retains its history and historical workstation membership.",
"assetScope": "The asset belongs to this company. Site is optional and must belong to it; its relationship with a person is recorded through a workstation or user profile.",
"saveAssetError": "The asset could not be saved.",
"inventoryErrors": {
"BRAND_UNAVAILABLE": "This brand is unavailable for new assignments.",
@ -138,7 +138,6 @@
"INVALID_REQUEST": "Check the form data.",
"INVALID_CHARACTERISTICS": "A characteristic does not match its field or type.",
"REQUIRED_CHARACTERISTICS": "Complete the template's required characteristics.",
"RETIRED_ASSIGNED": "Unassign the person before retiring the asset.",
"TEMPLATE_FIXED": "An existing asset's template cannot change.",
"TEMPLATE_UNAVAILABLE": "The template must be active and available to this company.",
"SITE_NOT_FOUND": "The site does not belong to this company.",

View File

@ -123,8 +123,8 @@
"newPerson": "Nueva persona",
"assetDescription": "Un activo es un elemento informático concreto de esta compañía. La plantilla indica qué características puedes registrar.",
"assetExamples": "Portátil de Ana, monitor de recepción o switch de la sede central.",
"assetGuidance": "Solo son obligatorios el nombre, la plantilla y los campos exigidos por tu plantilla. Puedes completar el resto más adelante. Retirar un activo requiere dejarlo sin persona asignada; su historial se conserva.",
"assetScope": "El activo pertenece a esta compañía. La sede y la persona son opcionales y deben pertenecer a ella.",
"assetGuidance": "Solo son obligatorios el nombre, la plantilla y los campos exigidos por tu plantilla. Puedes completar el resto más adelante. Retirar un activo conserva su historial y su pertenencia histórica a puestos.",
"assetScope": "El activo pertenece a esta compañía. La sede es opcional y debe pertenecer a ella; la relación con una persona se registra desde un puesto de trabajo o un perfil de usuario.",
"saveAssetError": "No se pudo guardar el activo.",
"inventoryErrors": {
"BRAND_UNAVAILABLE": "La marca no está disponible para nuevas asignaciones.",
@ -138,7 +138,6 @@
"INVALID_REQUEST": "Revisa los datos del formulario.",
"INVALID_CHARACTERISTICS": "Alguna característica no corresponde a su campo o tipo.",
"REQUIRED_CHARACTERISTICS": "Completa las características obligatorias de la plantilla.",
"RETIRED_ASSIGNED": "Para retirar el activo, deja la persona asignada en «Sin asignar».",
"TEMPLATE_FIXED": "No se puede cambiar la plantilla de un activo existente.",
"TEMPLATE_UNAVAILABLE": "La plantilla debe estar activa y disponible para esta compañía.",
"SITE_NOT_FOUND": "La sede no pertenece a esta compañía.",

View File

@ -48,15 +48,6 @@ export function AssetDetail(props: AssetDetailProps) {
t("noAssignment")
)}
</DetailField>
<DetailField label={t("person")}>
{asset.values.personId ? (
<BrowserLink href={`/companies/${asset.companyId}/people/${asset.values.personId}`}>
{nameOf(asset.values.personId, lookups.people)}
</BrowserLink>
) : (
t("noAssignment")
)}
</DetailField>
<DetailField label={t("workstationInAsset")}>
{workstations?.length
? workstations.map((item) => (
@ -118,8 +109,7 @@ export function AssetDetail(props: AssetDetailProps) {
{revision.values.name} · {t(revision.values.status)}
</p>
<p>
{t("site")}: {nameOf(revision.values.siteId, lookups.sites)} · {t("person")}:{" "}
{nameOf(revision.values.personId, lookups.people)}
{t("site")}: {nameOf(revision.values.siteId, lookups.sites)}
</p>
<details>
<summary className="cursor-pointer">{t("characteristics")}</summary>

View File

@ -52,12 +52,10 @@ export function useAssetInventoryPageController({
const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId);
const filtered = assets.filter((asset) => {
const person = lookups.people.find((person) => person.id === asset.values.personId);
const site = lookups.sites.find((site) => site.id === asset.values.siteId);
return [
asset.values.name,
asset.specification.templateName,
person?.name ?? "",
site?.name ?? "",
t(asset.values.status),
].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));

View File

@ -137,19 +137,6 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
</>
),
},
{
id: "person",
label: t("person"),
value: (asset) =>
lookups.people.find((person) => person.id === asset.values.personId)?.name ??
t("noAssignment"),
render: (asset) => (
<>
{lookups.people.find((person) => person.id === asset.values.personId)?.name ??
t("noAssignment")}
</>
),
},
{
id: "actions",
label: t("actions"),

View File

@ -31,7 +31,6 @@ export function useAssetFormController({
name: "",
templateId: "",
siteId: null,
personId: null,
status: "available",
notes: "",
characteristics: {},
@ -112,11 +111,6 @@ export function useAssetFormController({
form.setValue("siteId", id);
}
};
const personCreated = (id: string) => {
if (mounted.current) {
form.setValue("personId", id);
}
};
return {
creatingTemplate,
setCreatingTemplate,
@ -133,7 +127,6 @@ export function useAssetFormController({
changeStatus,
templateCreated,
siteCreated,
personCreated,
};
}

View File

@ -87,7 +87,7 @@ test("El activo ofrece altas relacionadas, ayuda inferior y no encadena una terc
onSaved={() => undefined}
/>
);
for (const label of ["Nueva plantilla", "Nueva sede", "Nueva persona"]) {
for (const label of ["Nueva plantilla", "Nueva sede"]) {
assert.ok(html.includes(`aria-label="${label}"`));
}
assert.ok(html.indexOf('name="notes"') < html.indexOf("<aside"));
@ -216,7 +216,7 @@ test("Puestos de trabajo: altas rápidas y ayuda inferior; un activo anidado no
onSaved={() => undefined}
/>
);
for (const label of ["Nueva sede", "Nueva persona", "Nueva plantilla"]) {
for (const label of ["Nueva sede", "Nueva plantilla"]) {
assert.ok(!nested.includes(`aria-label="${label}"`));
}
});
@ -239,7 +239,6 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
name: "Impresora de administración",
templateId: fieldId,
siteId,
personId,
status: "in_use",
notes: "Uso compartido",
characteristics: { [fieldId]: "ABC123" },
@ -254,7 +253,6 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
name: "Impresora de administración",
templateId: fieldId,
siteId,
personId,
status: "in_use",
notes: "Uso compartido",
characteristics: { [fieldId]: "ABC123" },
@ -271,7 +269,6 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
csrfToken="test"
lookups={{
...lookups,
people: [{ id: personId, name: "Ana", active: true }],
sites: [{ id: siteId, name: "Fábrica" }],
}}
onClose={() => undefined}
@ -292,7 +289,6 @@ test("la ficha lateral agrupa información y ofrece acciones superiores y enlace
}
assert.ok(html.includes("<summary"));
assert.ok(html.includes('open=""'));
assert.ok(html.includes(`href="/companies/${companyId}/people/${personId}"`));
assert.ok(html.includes(`href="/companies/${companyId}/sites"`));
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
assert.ok(html.includes("ABC123"));

View File

@ -37,7 +37,6 @@ export function AssetForm(props: AssetFormProps) {
changeStatus,
templateCreated,
siteCreated,
personCreated,
} = useAssetFormController(props);
const { t } = useTranslation("assets");
const prefix = useId();
@ -112,31 +111,6 @@ export function AssetForm(props: AssetFormProps) {
value={values.siteId ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-person`}>{t("person")}</Label>
<SelectWithAction
{...selectProps}
action={
allowQuickCreate
? {
label: t("newPerson"),
onClick: () => lookups.onCreatePerson(personCreated),
}
: undefined
}
id={`${prefix}-person`}
label={t("person")}
onValueChange={(id) => form.setValue("personId", id || null)}
options={lookups.people
.filter((person) => person.active || person.id === asset?.values.personId)
.map((person) => ({
value: person.id,
label: person.name,
disabled: !person.active && person.id !== values.personId,
}))}
value={values.personId ?? ""}
/>
</div>
</div>
</FormSection>
{fields.length > 0 && (

View File

@ -812,6 +812,10 @@
"contactsOnly": "Contacts only",
"searchPerson": "Find person",
"noPeople": "No people in this company yet.",
"currentWorkstations": "Current workstations",
"noCurrentWorkstations": "This person has no active workstations assigned.",
"noWorkstationAssets": "This workstation has no assets yet.",
"workstationsLoadError": "Workstations could not be loaded.",
"departments": "Departments",
"department": "Department",
"newDepartment": "New department",

View File

@ -812,6 +812,10 @@
"contactsOnly": "Solo contactos",
"searchPerson": "Buscar persona",
"noPeople": "Todavía no hay personas en esta compañía.",
"currentWorkstations": "Puestos de trabajo actuales",
"noCurrentWorkstations": "Esta persona no tiene puestos de trabajo activos asignados.",
"noWorkstationAssets": "Este puesto todavía no tiene activos.",
"workstationsLoadError": "No se han podido cargar los puestos de trabajo.",
"departments": "Departamentos",
"department": "Departamento",
"newDepartment": "Nuevo departamento",

View File

@ -14,11 +14,13 @@ import {
UsersIcon,
WrenchIcon,
} from "@repo/rdx-ui/lib/icons";
import { Avatar, AvatarBadge } from "@repo/shadcn-ui/components/avatar";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { companySectionHref } from "../../../workspace/shared/helpers";
import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
import { CompanyAvatar, CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
import { useCompanyDetailController } from "../controllers";
import type { CompanyDetailProps } from "../types";
@ -81,25 +83,12 @@ export function CompanyDetail(props: CompanyDetailProps) {
{company && (
<>
<div className={layout.heading}>
<div>
<h1
className={
embedded
? "text-base font-semibold leading-[1.3333333333] [overflow-wrap:anywhere]"
: "text-2xl font-semibold"
}
>
<CompanyIdentity
className={embedded ? "size-10" : "size-12"}
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t(company.active ? "activeCompany" : "inactiveCompany")}
</p>
</div>
<CompanyHeader
activeLabel={t("activeCompany")}
company={company}
embedded={embedded}
inactiveLabel={t("inactiveCompany")}
/>
<Button
className={embedded ? "shell:mt-4 shell:w-full" : undefined}
onClick={() => setEditOpen(true)}
@ -211,3 +200,54 @@ export function CompanyDetail(props: CompanyDetailProps) {
</div>
);
}
function CompanyHeader({
activeLabel,
company,
embedded,
inactiveLabel,
}: {
activeLabel: string;
company: CompanyDTO;
embedded: boolean;
inactiveLabel: string;
}) {
const statusLabel = company.active ? activeLabel : inactiveLabel;
if (embedded) {
return (
<div className="flex flex-col items-center text-center">
<Avatar className="size-16">
<CompanyAvatar
aria-hidden="true"
className="size-full"
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
<AvatarBadge
className={company.active ? "size-4 bg-emerald-500" : "size-4 bg-muted-foreground"}
title={statusLabel}
>
<span className="sr-only">{statusLabel}</span>
</AvatarBadge>
</Avatar>
<h1 className="mt-3 text-base font-semibold leading-[1.3333333333] [overflow-wrap:anywhere]">
{company.name}
</h1>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-semibold">
<CompanyIdentity
className="size-12"
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
</h1>
<p className="mt-1 text-sm text-muted-foreground">{statusLabel}</p>
</div>
);
}

View File

@ -353,4 +353,5 @@ test("el menú de compañía ordena trabajo y personas por grupos sin repetir la
assert.ok(nav.indexOf("Puestos de trabajo") < nav.indexOf("Perfiles de usuario"));
assert.equal(html.includes("[grid-area:account]"), false);
assert.equal(html.includes("Mi contraseña"), false);
assert.ok(html.includes('data-slot="avatar-badge"'));
});

View File

@ -1,19 +1,24 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { BrowserLink } from "@metis/core/client";
import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { ChevronLeftIcon } from "@repo/rdx-ui/lib/icons";
import { PhoneLink } from "@repo/rdx-ui/components/phone-link";
import { BriefcaseIcon, ChevronLeftIcon, MailIcon, PhoneIcon } from "@repo/rdx-ui/lib/icons";
import { AvatarBadge } from "@repo/shadcn-ui/components/avatar";
import { Button } from "@repo/shadcn-ui/components/button";
import { PersonAvatar } from "../../shared";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CompanyDTO, PersonDTO } from "../../../../common";
import { AssignmentEditorDialog, AssignmentHistoryDialog } from "../../../assignments";
import { deleteAssignment } from "../../../assignments/shared/api";
import { PersonAvatar } from "../../shared";
import { deletePerson } from "../../shared/api";
export function PersonDetails({
@ -50,12 +55,57 @@ export function PersonDetails({
{t("backToPeople")}
</button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3"><PersonAvatar companyId={company.id} personId={person.id} photoVersion={person.photoVersion} name={person.name} className="size-12" /><h2 className="text-2xl font-semibold">{person.name}</h2></div>
<p className="mt-1 text-muted-foreground">
{t(person.active ? "activePerson" : "inactivePerson")}
{person.isContact ? ` · ${t("isContact")}` : ""}
</p>
<div className="flex min-w-0 flex-1 items-start gap-4">
<PersonAvatar
className="size-14"
companyId={company.id}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
>
<AvatarBadge
className={person.active ? "bg-emerald-500" : "bg-muted-foreground"}
title={t(person.active ? "activePerson" : "inactivePerson")}
>
<span className="sr-only">
{t(person.active ? "activePerson" : "inactivePerson")}
</span>
</AvatarBadge>
</PersonAvatar>
<div className="min-w-0 space-y-2">
<h2 className="break-words text-2xl font-semibold">{person.name}</h2>
<div className="flex flex-wrap gap-x-5 gap-y-2 text-sm text-muted-foreground">
<span className="inline-flex min-w-0 items-center gap-2">
<BriefcaseIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="sr-only">{t("generalPosition")}: </span>
<span className="break-words">{person.position ?? "—"}</span>
</span>
<span className="inline-flex min-w-0 items-center gap-2">
<MailIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="sr-only">{t("email")}: </span>
{person.email ? (
<a className="app-link break-all" href={`mailto:${person.email}`}>
{person.email}
</a>
) : (
"—"
)}
</span>
<span className="inline-flex min-w-0 items-center gap-2">
<span
className="group/contact"
data-contact={person.isContact}
title={person.isContact ? t("isContact") : undefined}
>
<PhoneIcon
aria-hidden="true"
className="size-4 shrink-0 group-data-[contact=true]/contact:text-primary"
/>
</span>
{person.phone ? <PhoneLink label={t("phone")} phone={person.phone} /> : "—"}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={onEdit} variant="outline">
@ -83,28 +133,12 @@ export function PersonDetails({
</Button>
</div>
</div>
<Card>
<CardContent className="grid gap-4 pt-6 sm:grid-cols-3">
<div>
<p className="text-sm text-muted-foreground">{t("generalPosition")}</p>
<p>{person.position ?? "—"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("email")}</p>
<p className="break-all">{person.email ?? "—"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">{t("phone")}</p>
<p>{person.phone ?? "—"}</p>
</div>
</CardContent>
</Card>
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
{!person.active && assignments.some((item) => item.endDate === null) && (
<p className="rounded-md border p-3" role="status">
{t("inactiveMembershipWarning")}
</p>
)}
<CurrentWorkstations company={company} personId={person.id} />
<Card>
<CardContent className="space-y-4 pt-6">
<EntityListHeader
@ -230,3 +264,71 @@ export function PersonDetails({
</div>
);
}
function CurrentWorkstations({ company, personId }: { company: CompanyDTO; personId: string }) {
const { t, i18n } = useTranslation("companies");
const inventory = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const workstations = (inventory.data?.workstations ?? []).filter(
(item) => item.values.active && item.values.personId === personId
);
const assetName = (id: string) =>
inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id;
const siteName = (id: string | null) =>
company.sites.find((item) => item.id === id)?.name ?? t("noAssignment");
return (
<Card>
<CardContent className="space-y-4 pt-6">
<EntityListHeader locale={i18n.language} title={t("currentWorkstations")} />
{inventory.isPending && <p className="text-sm text-muted-foreground">{t("loading")}</p>}
{inventory.isError && (
<p className="text-sm text-muted-foreground">{t("workstationsLoadError")}</p>
)}
{inventory.data &&
(workstations.length ? (
<div className="divide-y">
{workstations.map((workstation) => {
const assetIds = [
...workstation.values.assetIds,
...workstation.values.sharedAssetIds,
];
return (
<div className="py-4 first:pt-0 last:pb-0" key={workstation.id}>
<BrowserLink
className="app-link font-medium"
href={`/companies/${company.id}/workstations`}
>
{workstation.values.name}
</BrowserLink>
<p className="mt-1 text-sm text-muted-foreground">
{siteName(workstation.values.siteId)}
</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm">
{assetIds.length ? (
assetIds.map((assetId) => (
<BrowserLink
className="app-link"
href={`/companies/${company.id}/assets/${assetId}`}
key={assetId}
>
{assetName(assetId)}
</BrowserLink>
))
) : (
<span className="text-muted-foreground">{t("noWorkstationAssets")}</span>
)}
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">{t("noCurrentWorkstations")}</p>
))}
</CardContent>
</Card>
);
}

View File

@ -6,6 +6,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { PhoneLink } from "@repo/rdx-ui/components/phone-link";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { Label } from "@repo/shadcn-ui/components/label";
@ -103,20 +104,25 @@ export function PeopleSection({
hideable: false,
render: (person) => (
<div className="flex items-center gap-3">
<PersonAvatar companyId={company.id} personId={person.id} photoVersion={person.photoVersion} name={person.name} />
<PersonAvatar
companyId={company.id}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
<div>
<BrowserLink
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={entityHref(person.id)}
>
{person.name}
</BrowserLink>
{person.position && (
<p className="text-xs text-muted-foreground">{person.position}</p>
)}
{person.isContact && (
<p className="text-xs text-muted-foreground">{t("isContact")}</p>
)}
<BrowserLink
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
href={entityHref(person.id)}
>
{person.name}
</BrowserLink>
{person.position && (
<p className="text-xs text-muted-foreground">{person.position}</p>
)}
{person.isContact && (
<p className="text-xs text-muted-foreground">{t("isContact")}</p>
)}
</div>
</div>
),
@ -125,7 +131,8 @@ export function PeopleSection({
id: "phone",
label: t("phone"),
value: (person) => person.phone,
render: (person) => <>{person.phone ?? "—"}</>,
render: (person) =>
person.phone ? <PhoneLink label={t("phone")} phone={person.phone} /> : "—",
},
{
id: "departments",

View File

@ -1,20 +1,74 @@
import { Avatar, AvatarFallback, AvatarImage } from "@repo/shadcn-ui/components/avatar";
export function PersonAvatar({ companyId, personId, photoVersion, name, className }: { companyId: string; personId: string; photoVersion?: string | null | undefined; name: string; className?: string }) {
const initials = name.split(" ").map((part) => part[0]).join("").slice(0,2).toUpperCase();
const src = photoVersion ? `/api/companies/${companyId}/people/${personId}/photo?version=${photoVersion}` : undefined;
return <Avatar className={className}><AvatarImage src={src} alt="" /><AvatarFallback>{initials}</AvatarFallback></Avatar>;
import type { ReactNode } from "react";
export function PersonAvatar({
companyId,
personId,
photoVersion,
name,
className,
children,
}: {
companyId: string;
personId: string;
photoVersion?: string | null | undefined;
name: string;
className?: string;
children?: ReactNode;
}) {
const initials = name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
const src = photoVersion
? `/api/companies/${companyId}/people/${personId}/photo?version=${photoVersion}`
: undefined;
return (
<Avatar className={className}>
<AvatarImage alt="" src={src} />
<AvatarFallback>{initials}</AvatarFallback>
{children}
</Avatar>
);
}
export function PersonAvatarGroup({ companyId, people, max = 3, peopleLabel = "Personas" }: { companyId: string; people: readonly { id: string; name: string; photoVersion?: string | null | undefined }[]; max?: number; peopleLabel?: string }) {
const visible = people.slice(0, max);
const remaining = people.length - visible.length;
if (!people.length) return <span className="text-muted-foreground">—</span>;
return (
<div className="flex items-center" aria-label={`${people.length} ${peopleLabel}`}>
<div className="flex -space-x-2">
{visible.map((person) => <PersonAvatar key={person.id} companyId={companyId} personId={person.id} photoVersion={person.photoVersion} name={person.name} className="size-8 border-2 border-background" />)}
</div>
{remaining > 0 && <span className="ml-2 inline-flex size-8 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">+{remaining}</span>}
</div>
);
export function PersonAvatarGroup({
companyId,
people,
max = 3,
peopleLabel = "Personas",
}: {
companyId: string;
people: readonly { id: string; name: string; photoVersion?: string | null | undefined }[];
max?: number;
peopleLabel?: string;
}) {
const visible = people.slice(0, max);
const remaining = people.length - visible.length;
if (!people.length) {
return <span className="text-muted-foreground">—</span>;
}
return (
<div aria-label={`${people.length} ${peopleLabel}`} className="flex items-center" role="group">
<div className="flex -space-x-2">
{visible.map((person) => (
<PersonAvatar
className="size-8 border-2 border-background"
companyId={companyId}
key={person.id}
name={person.name}
personId={person.id}
photoVersion={person.photoVersion}
/>
))}
</div>
{remaining > 0 && (
<span className="ml-2 inline-flex size-8 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
+{remaining}
</span>
)}
</div>
);
}

View File

@ -84,7 +84,6 @@ const host = AssetSchema.parse({
...audited(5, {
name: "LG Gram",
templateId: id(9),
personId: null,
siteId: null,
status: "in_use",
notes: "",

View File

@ -11,7 +11,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx src/field-sections.test.tsx"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx"
},
"dependencies": {
"@base-ui/react": "1.8.0",

View File

@ -0,0 +1,59 @@
import { cn } from "cn";
import type { ComponentProps } from "react";
export function formatPhoneNumber(phone: string): string {
const trimmed = phone.trim();
const extension = trimmed.match(/(?:\s*(?:x|ext\.?)\s*)(\d{1,8})$/i);
const base = extension ? trimmed.slice(0, extension.index).trim() : trimmed;
const digits = base.replace(/\D/g, "");
const formattedBase = formatSpanishPhoneNumber(base, digits);
return extension ? `${formattedBase} ext. ${extension[1]}` : formattedBase;
}
export function phoneHref(phone: string): string {
const trimmed = phone.trim();
const extension = trimmed.match(/(?:\s*(?:x|ext\.?)\s*)(\d{1,8})$/i);
const base = extension ? trimmed.slice(0, extension.index).trim() : trimmed;
const prefix = base.startsWith("+") ? "+" : "";
const digits = base.replace(/\D/g, "");
return `tel:${prefix}${digits}${extension ? `;ext=${extension[1]}` : ""}`;
}
function formatSpanishPhoneNumber(base: string, digits: string): string {
if (digits.length === 9) {
return digits.replace(/(\d{3})(\d{3})(\d{3})/, "$1 $2 $3");
}
if (base.startsWith("+34") && digits.length === 11) {
return `+34 ${digits.slice(2).replace(/(\d{3})(\d{3})(\d{3})/, "$1 $2 $3")}`;
}
return base
.replace(/[().-]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function PhoneLink({
phone,
className,
label,
...props
}: {
phone: string;
label?: string;
} & Omit<ComponentProps<"a">, "children" | "href">) {
const formattedPhone = formatPhoneNumber(phone);
return (
<a
aria-label={label ? `${label}: ${formattedPhone}` : formattedPhone}
className={cn("app-link", className)}
href={phoneHref(phone)}
{...props}
>
{formattedPhone}
</a>
);
}

View File

@ -4,8 +4,8 @@ export {
BriefcaseIcon,
Building2Icon,
ChevronDownIcon,
ChevronRightIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
DatabaseIcon,
FileKeyIcon,
@ -16,6 +16,7 @@ export {
LayoutTemplateIcon,
MailIcon,
MapPinIcon,
PhoneIcon,
SearchIcon,
SettingsIcon,
ShieldCheckIcon,

View File

@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { PhoneLink, formatPhoneNumber, phoneHref } from "./components/phone-link";
test("formatea números españoles y conserva extensiones para visualización y llamada", () => {
assert.equal(formatPhoneNumber("900123456"), "900 123 456");
assert.equal(formatPhoneNumber("+34 612345678 ext. 12"), "+34 612 345 678 ext. 12");
assert.equal(phoneHref("+34 612345678 ext. 12"), "tel:+34612345678;ext=12");
});
test("preserva números internacionales no españoles sin inventar agrupaciones", () => {
assert.equal(formatPhoneNumber("+44 20 7123 4567"), "+44 20 7123 4567");
assert.equal(phoneHref("+44 20 7123 4567"), "tel:+442071234567");
});
test("el enlace telefónico usa el número formateado y mantiene un nombre accesible", () => {
const markup = renderToStaticMarkup(<PhoneLink label="Teléfono" phone="900123456" />);
assert.ok(markup.includes('href="tel:900123456"'));
assert.ok(markup.includes('aria-label="Teléfono: 900 123 456"'));
assert.ok(markup.includes(">900 123 456</a>"));
});