Navegación por URLs

This commit is contained in:
David Arranz 2026-10-09 10:18:00 +02:00
parent 9733f550a5
commit f54ce79309
86 changed files with 2152 additions and 242 deletions

10
TODO.md
View File

@ -438,3 +438,13 @@ Quiero que hagas esta reestructuración pero antes pregunta todo lo que necesite
- Quiero que en el navegador web del cliente se muestren rutas para seccion. Por ejemplo: http://192.168.0.2:5173/dashboard, http://192.168.0.2:5173/tickets, http://192.168.0.2:5173/companies/, http://192.168.0.2:5173/companies/xxxx, etc. Analiza qué estructura de ruta es la más adecuada las pantallas de la aplicación y muestrala antes de realizar ningún cambio. Intenta encontar ambiguedades o errores también. Como criterio, guíate por la lógica o por lo más común que se hace en este caso. - Quiero que en el navegador web del cliente se muestren rutas para seccion. Por ejemplo: http://192.168.0.2:5173/dashboard, http://192.168.0.2:5173/tickets, http://192.168.0.2:5173/companies/, http://192.168.0.2:5173/companies/xxxx, etc. Analiza qué estructura de ruta es la más adecuada las pantallas de la aplicación y muestrala antes de realizar ningún cambio. Intenta encontar ambiguedades o errores también. Como criterio, guíate por la lógica o por lo más común que se hace en este caso.
- quiero que el estado plegado/desplegado de la barra lateral izquierda se almacene en el almacenamiento local del navegador para que se conserve entre sesiones.
- revisa el codigo para localizar todos los ficheros en el backend que se refieran a value objetcs de DDD. Debes renombrarlos de esta manera:
email-address.ts => email-address.vo.ts
unique-id.ts => unique-id.vo.ts

View File

@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "vite --host --clearScreen false", "dev": "vite --host --clearScreen false",
"typecheck": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/app.test.tsx src/i18n.test.ts", "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/app.test.tsx src/i18n.test.ts src/navigation/app-redirect.test.ts",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 127.0.0.1" "preview": "vite preview --host 127.0.0.1"
}, },

View File

@ -143,9 +143,9 @@ test("solo el administrador inicial ve la navegación de gestión de usuarios",
assert.ok(normal.includes("Tu espacio de soporte")); assert.ok(normal.includes("Tu espacio de soporte"));
assert.ok(normal.includes("Mi contraseña")); assert.ok(normal.includes("Mi contraseña"));
assert.ok(normal.includes("Salir")); assert.ok(normal.includes("Salir"));
assert.equal(normal.includes(">Usuarios</button>"), false); assert.equal(normal.includes('href="/settings/users"'), false);
const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } }); const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } });
assert.ok(admin.includes(">Usuarios</button>")); assert.ok(admin.includes('href="/settings/users"'));
}); });
test("el espacio abre Inicio tras el login y mantiene acceso a las compañías sin formularios visibles", async () => { test("el espacio abre Inicio tras el login y mantiene acceso a las compañías sin formularios visibles", async () => {

View File

@ -3,6 +3,7 @@ import { IdentityWorkspace, identityApi, identityKeys } from "@metis/identity/cl
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { LanguageSwitcher } from "./language-switcher"; import { LanguageSwitcher } from "./language-switcher";
import { useAppNavigation } from "./navigation/use-app-navigation";
export function App() { export function App() {
const session = useQuery({ const session = useQuery({
@ -10,6 +11,7 @@ export function App() {
queryFn: identityApi.session, queryFn: identityApi.session,
retry: false, retry: false,
}); });
useAppNavigation(session.data);
return ( return (
<div className="min-h-dvh bg-shell-content"> <div className="min-h-dvh bg-shell-content">
{!session.data?.user && ( {!session.data?.user && (

View File

@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { SessionResponseDTO } from "@metis/identity/common";
import { appRedirect } from "./app-redirect";
const ANONYMOUS: SessionResponseDTO = {
user: null,
csrfToken: "a".repeat(64),
setupRequired: false,
};
const AUTHENTICATED: SessionResponseDTO = {
...ANONYMOUS,
user: {
id: "019b8838-44af-776d-a406-af74452b6f26",
name: "Test",
email: "test@example.test",
active: true,
isInitialAdmin: false,
},
};
test("el acceso restaura el destino solicitado, sin bucles ni redirecciones externas", () => {
const destination = "/companies/019b8838-44af-776d-a406-af74452b6f26/people";
const login = `/login?${new URLSearchParams({ returnTo: destination })}`;
assert.equal(appRedirect(destination, ANONYMOUS), login);
assert.equal(appRedirect(login, ANONYMOUS), undefined);
assert.equal(appRedirect(login, AUTHENTICATED), destination);
for (const returnTo of [
"//example.test",
"https://example.test",
"/\\example.test",
"/login",
"/setup?returnTo=/login",
]) {
assert.equal(
appRedirect(`/login?${new URLSearchParams({ returnTo })}`, AUTHENTICATED),
"/dashboard"
);
}
});
test("setup depende del servidor y las rutas canónicas no conservan barras finales", () => {
assert.equal(appRedirect("/login", { ...ANONYMOUS, setupRequired: true }), "/setup");
assert.equal(appRedirect("/setup", ANONYMOUS), "/login");
assert.equal(appRedirect("/", AUTHENTICATED), "/dashboard");
assert.equal(
appRedirect("/companies/?activeOnly=true", AUTHENTICATED),
"/companies?activeOnly=true"
);
assert.equal(appRedirect("/catalogs", AUTHENTICATED), undefined);
});

View File

@ -0,0 +1,34 @@
import { isInternalPath } from "@metis/core/client";
import type { SessionResponseDTO } from "@metis/identity/common";
function safeReturnTo(value: string | null | undefined): string | undefined {
return value && isInternalPath(value) && !/^\/(login|setup)(?:[/?]|$)/u.test(value)
? value
: undefined;
}
function accessHref(target: string, requested: string | null) {
const returnTo = safeReturnTo(requested);
return `${target}${returnTo && returnTo !== "/" ? `?${new URLSearchParams({ returnTo })}` : ""}`;
}
export function appRedirect(location: string, session: SessionResponseDTO): string | undefined {
const url = new URL(location, "http://metis.invalid");
const pathname = url.pathname.replace(/\/+$/u, "") || "/";
const accessPage = pathname === "/login" || pathname === "/setup";
if (!session.user) {
const target = session.setupRequired ? "/setup" : "/login";
const requested = accessPage ? url.searchParams.get("returnTo") : location;
const destination = accessHref(target, requested);
return destination === location ? undefined : destination;
}
if (accessPage) {
const requested = url.searchParams.get("returnTo");
return safeReturnTo(requested) ?? "/dashboard";
}
if (pathname === "/") {
return `/dashboard${url.search}`;
}
const canonical = `${pathname}${url.search}`;
return canonical === location ? undefined : canonical;
}

View File

@ -0,0 +1,18 @@
import { navigateTo, useBrowserLocation } from "@metis/core/client";
import type { SessionResponseDTO } from "@metis/identity/common";
import { useEffect } from "react";
import { appRedirect } from "./app-redirect";
export function useAppNavigation(session: SessionResponseDTO | undefined) {
const location = useBrowserLocation();
useEffect(() => {
if (!session) {
return;
}
const destination = appRedirect(location, session);
if (destination) {
navigateTo(destination, { replace: true });
}
}, [location, session]);
}

View File

@ -17,6 +17,9 @@ El código se genera desde el registro oficial y queda versionado en este reposi
## Convención de navegación ## Convención de navegación
Los destinos de secciones y fichas existentes siguen [Rutas del cliente](rutas-del-cliente.md).
Usar enlaces reales para esos destinos y botones para acciones o diálogos locales.
Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios se abren en modal como norma general, con anchura adaptable y desplazamiento si el contenido lo requiere. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, personas, departamentos, asignaciones, sedes y usuarios siguen este criterio. Las personas, los departamentos y las asignaciones se crean y editan en modales. El historial se consulta en un diálogo. Catálogos, cuentas, grupos, recursos, permisos y necesidades siguen la misma convención. Los permisos de una necesidad se eligen mediante casillas shadcn/ui. Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios se abren en modal como norma general, con anchura adaptable y desplazamiento si el contenido lo requiere. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, personas, departamentos, asignaciones, sedes y usuarios siguen este criterio. Las personas, los departamentos y las asignaciones se crean y editan en modales. El historial se consulta en un diálogo. Catálogos, cuentas, grupos, recursos, permisos y necesidades siguen la misma convención. Los permisos de una necesidad se eligen mediante casillas shadcn/ui.
TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos. TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos.
@ -33,6 +36,14 @@ Usar tokens semánticos como bg-background, text-foreground y border-border en v
## Añadir componentes ## Añadir componentes
`SectionNavigation`, en `modules/companies/src/web/shared/ui`, compone Card de la
biblioteca y BrowserLink de core para índices de secciones. Recibe una etiqueta
de navegación y elementos con `href`, título, descripción y valor numérico
opcionales. No conoce DTO, consultas ni endpoints; sus consumidores aportan los
textos traducidos y únicamente valores conocidos. Se reutiliza en el resumen de
compañía y en los índices de catálogos globales y de compañía, con foco visible y
enlaces que pueden abrirse en otra pestaña. No se modifica el paquete vendor.
Para componentes propios de un módulo, consultar primero la Para componentes propios de un módulo, consultar primero la
[estructura web por entidades y operaciones](estructura-web-por-entidades-y-operaciones.md) [estructura web por entidades y operaciones](estructura-web-por-entidades-y-operaciones.md)
y los componentes equivalentes existentes. Ubicar cada componente en la entidad y operación y los componentes equivalentes existentes. Ubicar cada componente en la entidad y operación

View File

@ -50,7 +50,7 @@ Carga inicial y errores se presentan dentro del contenido sin afirmar que no hay
## Inventario de listados y ubicaciones actuales ## Inventario de listados y ubicaciones actuales
La aplicación navega por espacios y secciones en memoria, no mediante rutas URL independientes. Los componentes siguientes se alcanzan desde CompaniesWorkspace, CompanyDetail o IdentityWorkspace. La aplicación navega por espacios y secciones mediante rutas URL independientes, según [Rutas del cliente](rutas-del-cliente.md). Los componentes siguientes se componen desde CompaniesWorkspace, CompanyDetail o IdentityWorkspace; altas, ediciones e historiales conservan sus modales.
| Listado / ámbito | Componente y ubicación actual | Aplicación | | Listado / ámbito | Componente y ubicación actual | Aplicación |
| --- | --- | --- | | --- | --- | --- |
@ -102,7 +102,7 @@ La validación debe incluir tipos, estilo, pruebas y compilación, además de in
### Migración realizada ### Migración realizada
Se han adaptado las 25 familias de listados del inventario y el sublistado de asignaciones departamentales. Los catálogos y accesos reutilizan sus componentes para las distintas secciones y ámbitos; no son nuevas rutas ni nuevas entidades. El inventario conserva la ficha lateral y su modal móvil. Las fichas, historiales y bloques contextuales de las excepciones mantienen su función. Se han adaptado las 25 familias de listados del inventario y el sublistado de asignaciones departamentales. Los catálogos y accesos reutilizan sus componentes para las distintas secciones y ámbitos; las rutas distinguen el ámbito sin crear nuevas entidades. El inventario conserva la ficha lateral y su modal móvil. Las fichas, historiales y bloques contextuales de las excepciones mantienen su función.
Componentes compartidos: EntityListHeader y EntityTable; EntityTableConfiguration para columnas y orden; EntityTableContent para tabla semántica; EntityListPagination, EntityRowActions, EntityAction y EntityStatus para controles. useEntityTable concentra filtros locales, ordenación estable, columnas visibles y paginación. entity-list.ts contiene textos es/en y cálculo de rangos. No se añade selección masiva sin una operación que la consuma. Componentes compartidos: EntityListHeader y EntityTable; EntityTableConfiguration para columnas y orden; EntityTableContent para tabla semántica; EntityListPagination, EntityRowActions, EntityAction y EntityStatus para controles. useEntityTable concentra filtros locales, ordenación estable, columnas visibles y paginación. entity-list.ts contiene textos es/en y cálculo de rangos. No se añade selección masiva sin una operación que la consuma.

View File

@ -17,6 +17,11 @@ pnpm db:up genera .env con secretos locales si no existe e inicia MariaDB en un
Abrir http://localhost:5173. La primera apertura permite crear el administrador inicial; las siguientes muestran el acceso. La API escucha en 127.0.0.1:3001 y la base de datos en 127.0.0.1:3307. Abrir http://localhost:5173. La primera apertura permite crear el administrador inicial; las siguientes muestran el acceso. La API escucha en 127.0.0.1:3001 y la base de datos en 127.0.0.1:3307.
Una vez autenticado, Inicio utiliza `/dashboard` y cada sección tiene URL propia.
Se pueden abrir o recargar directamente `/tickets`, `/catalogs` o
`/companies/:companyId`. Consultar [Rutas del cliente](rutas-del-cliente.md) para
las fichas, los filtros y el requisito de fallback de la SPA en producción.
Consultar [Usuarios y acceso](usuarios-y-acceso.md) para la configuración inicial y protección de cuentas. No borrar el volumen para actualizar o reiniciar: contiene usuarios, compañías, contactos y sedes. Consultar [Usuarios y acceso](usuarios-y-acceso.md) para la configuración inicial y protección de cuentas. No borrar el volumen para actualizar o reiniciar: contiene usuarios, compañías, contactos y sedes.
Si se cambia PORT, configurar API_PROXY_TARGET al arrancar Vite (por defecto http://127.0.0.1:3001). Si cambia el origen del navegador, actualizar APP_ORIGIN en .env. Si se cambia PORT, configurar API_PROXY_TARGET al arrancar Vite (por defecto http://127.0.0.1:3001). Si cambia el origen del navegador, actualizar APP_ORIGIN en .env.

157
docs/rutas-del-cliente.md Normal file
View File

@ -0,0 +1,157 @@
# Rutas del cliente
La URL del navegador identifica el espacio, la sección y, cuando existe una ficha,
el registro seleccionado. No cambia los endpoints HTTP de `/api`, los permisos ni
la persistencia. Los identificadores de compañía y de registros son UUID.
## Destinos generales
| Ruta | Pantalla |
| --- | --- |
| `/dashboard` | Inicio y resumen global; admite `companyId` para limitar el ámbito. |
| `/companies` | Directorio de compañías. |
| `/companies/:companyId` | Estado y resumen operativo de la compañía. |
| `/tickets` | Tickets de todas las compañías, con filtros. |
| `/interventions` | Intervenciones de todas las compañías, con filtros. |
| `/catalogs` | Índice explicativo de catálogos globales. |
| `/settings/users` | Gestión de usuarios, reservada al administrador inicial. |
| `/settings/password` | Cambio de contraseña del usuario conectado. |
| `/login` | Acceso cuando no hay sesión. |
| `/setup` | Alta del administrador inicial únicamente cuando el servidor la requiere. |
`/` se normaliza a `/dashboard`. Las barras finales se eliminan conservando la
consulta. El acceso sin sesión conserva el destino interno en `returnTo`; tras
autenticar se recupera ese destino. No se aceptan redirecciones a otros orígenes.
## Espacio de compañía
Todas las secciones siguientes cuelgan de `/companies/:companyId`:
```text
/companies/:companyId → resumen, no inventario
├── assets
├── resources
├── workstations
├── work-profiles
├── telework-authorizations
├── remote-connections
├── processes
├── intervention-templates
├── interventions
├── tickets
├── people
├── departments
├── sites
├── access
├── folder-mappings
├── certificate-configurations
├── application-configurations
├── printer-configurations
├── mail-configurations
└── catalogs → índice de catálogos de la compañía
├── assets
└── resources
```
`assets` contiene equipos concretos. `workstations` contiene puestos que agrupan
activos exclusivos o compartidos; no es un alias de activos. `work-profiles`
representa el entorno de una persona en un dispositivo. Sus rutas independientes
respetan las responsabilidades existentes, sin introducir entidades nuevas.
Las fichas existentes de activos, recursos, personas, perfiles, procesos, tickets
e intervenciones utilizan `/<section>/:entityId`. Las de tickets e intervenciones
se abren en su compañía incluso cuando proceden del listado global. El inventario
conserva el panel lateral en escritorio y el diálogo de ficha en anchuras menores
de 1100 px, ambos asociados al UUID de la URL.
Las configuraciones de un perfil tienen rutas contextuales:
```text
/companies/:companyId/work-profiles/:profileId
├── folder-mappings
├── mail-configurations
├── certificate-configurations
├── application-configurations
└── printer-configurations
```
Los accesos remotos y el teletrabajo de una persona se consultan en sus secciones
propias con `personId`. Las vistas generales de configuraciones admiten `profileId`.
No se crean fichas URL para entidades que actualmente solo tienen editor o historial.
## Catálogos globales
```text
/catalogs
├── access
├── assets
├── resources
├── brands
├── intervention-templates
└── process-templates
```
El índice ofrece enlaces y explica el ámbito; no inventa estadísticas. Los catálogos
de compañía representan las variantes de activos y recursos disponibles en ese
ámbito, no duplicados de las rutas de inventario.
## Filtros y estado temporal
La consulta conserva los filtros que ya están conectados a la URL:
- Dashboard y directorio: `companyId`; directorio también `activeOnly` y `q`.
- Tickets: `companyId`, `pendingOnly`, `q`, `state`, `priority` y `assignee`.
- Intervenciones: `companyId`, `pendingOnly`, `q`, `state`, `assignee`, `dateFrom` y `dateTo`.
- Activos y recursos: `q`.
- 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.
Los booleanos se serializan como `true` o `false`; las fechas como ISO `YYYY-MM-DD`.
`assignee` admite UUID o `unassigned`. Los estados y prioridades se validan con los
schemas compartidos del dominio; los UUID, las pestañas y los parámetros repetidos
o desconocidos se rechazan antes de seleccionar una pantalla. La búsqueda admite
hasta 200 caracteres. Un `companyId` de consulta no puede contradecir la compañía
del pathname. Esta validación del navegador no sustituye la autorización del servidor.
Cambiar filtros reemplaza la entrada actual del historial; abrir otra sección o
ficha añade una entrada. Atrás y Adelante recuperan los destinos y filtros. Cerrar
una ficha vuelve al destino interno anterior; si se abrió directamente, sustituye
la entrada por su listado o por el contexto del perfil correspondiente.
Altas, ediciones, confirmaciones, historiales y detalles auxiliares del dashboard
siguen siendo diálogos locales. No se añaden rutas `/new`, `/edit` ni parámetros
`view` sin una necesidad concreta. El alta rápida que cambia de sección lleva una
señal temporal en `history.state`, consumida cuando monta la pantalla que abre el
formulario, sin introducirla en la URL compartida. Los restantes filtros locales,
la paginación, ordenación y preferencias de columnas mantienen su estado existente.
## Implementación y futuros destinos
- `@metis/core/client` centraliza la History API, suscripciones con cleanup,
enlaces internos, búsqueda y selección de fichas. No se añade una dependencia
de routing externa.
- `workspace/shared/helpers/workspace-routes.ts` es la fuente de rutas de compañía,
construcción de destinos y validación del espacio. No repartir condiciones de
pathname entre componentes de presentación.
- `apps/web/src/navigation` coordina los destinos según la sesión. El servidor
mantiene su autenticación, autorización y validación HTTP existentes.
- `BrowserLink` produce enlaces reales y conserva abrir en otra pestaña, clic
modificado y navegación por teclado. Las secciones activas indican `aria-current`.
- Un UUID válido ausente en el ámbito muestra ausencia explícita: no selecciona
otro registro silenciosamente. Rutas mal formadas y destinos desconocidos tienen
estados diferenciados y acceso de regreso a Inicio.
Para añadir una sección, actualizar los contratos y el mapa del espacio, revisar sus
consumidores y probar construcción, acceso directo, recarga, historial y casos
inválidos. No cambiar el modelo de dominio únicamente para reproducir una jerarquía URL.
La organización de ficheros sigue el [estándar web](estructura-web-por-entidades-y-operaciones.md).
## Servicio de la SPA
Vite en desarrollo y en preview entrega la SPA para estas rutas. Un servidor de
producción debe servir `index.html` para los destinos del cliente y mantener el
tratamiento propio de `/api` y de los archivos estáticos. Sin ese fallback, abrir
o recargar una ficha dará un 404 del alojamiento aunque la navegación interna funcione.
La API compilada todavía no sirve el frontend estático: no se ha cambiado su
despliegue ni añadido un fallback a Express en esta adaptación.

View File

@ -13,6 +13,7 @@
"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" "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"
}, },
"dependencies": { "dependencies": {
"@metis/core": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*", "@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",

View File

@ -0,0 +1,15 @@
import { useSyncExternalStore } from "react";
const COMPACT_INVENTORY = "(max-width: 1099px)";
function subscribe(listener: () => void) {
const media = window.matchMedia(COMPACT_INVENTORY);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}
export function useCompactInventory() {
return useSyncExternalStore(
subscribe,
() => window.matchMedia(COMPACT_INVENTORY).matches,
() => false
);
}

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -7,6 +8,8 @@ import { inventoryKeys } from "../../../shared/queries";
import { getAssetInventory } from "../api"; import { getAssetInventory } from "../api";
import type { AssetInventoryPageProps } from "../types"; import type { AssetInventoryPageProps } from "../types";
import { useCompactInventory } from "./use-compact-inventory";
export function useAssetInventoryPageController({ export function useAssetInventoryPageController({
companyId, companyId,
lookups, lookups,
@ -22,13 +25,30 @@ export function useAssetInventoryPageController({
queryKey: catalogKeys.scope(companyId), queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId), queryFn: () => catalogApi.get(companyId),
}); });
const [search, setSearch] = useState(""); const { searchParams, setSearchParams } = useBrowserSearchParams();
const search = searchParams.get("q") ?? "";
const setSearch = (value: string) =>
setSearchParams((params) => {
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
});
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined); const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
const [viewId, setViewId] = useState<string | undefined>(); const compact = useCompactInventory();
const [selectedId, setSelectedId] = useState<string | undefined>(initialId); const [localViewId, setLocalViewId] = useState<string | undefined>();
const {
selectedId,
selectEntity: setSelectedId,
entityHref,
routed,
} = useEntityNavigation(`/companies/${companyId}/assets`, initialId);
const viewId = compact && routed ? selectedId : localViewId;
const setViewId = compact && routed ? setSelectedId : setLocalViewId;
const [detailOpen, setDetailOpen] = useState(true); const [detailOpen, setDetailOpen] = useState(true);
const assets = query.data?.assets ?? []; const assets = query.data?.assets ?? [];
const selected = assets.find((asset) => asset.id === selectedId) ?? assets[0]; const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : assets[0];
const edited = assets.find((asset) => asset.id === editor); const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId); const viewed = assets.find((asset) => asset.id === viewId);
const filtered = assets.filter((asset) => { const filtered = assets.filter((asset) => {
@ -44,6 +64,8 @@ export function useAssetInventoryPageController({
}); });
return { return {
query, query,
entityHref,
missingView: Boolean(selectedId && query.isSuccess && !selected),
catalog, catalog,
search, search,
setSearch, setSearch,

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { import {
EntityAction, EntityAction,
@ -37,8 +38,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
edited, edited,
viewed, viewed,
filtered, filtered,
missingView,
entityHref,
} = useAssetInventoryPageController(props); } = useAssetInventoryPageController(props);
const { t, i18n } = useTranslation("assets"); const { t, i18n } = useTranslation("assets");
if (missingView) {
return <EntityNotFound />;
}
return ( return (
<div <div
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_280px] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]" className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_280px] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
@ -92,20 +98,14 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
value: (asset) => asset.values.name, value: (asset) => asset.values.name,
hideable: false, hideable: false,
render: (asset) => ( render: (asset) => (
<button <BrowserLink
aria-pressed={selected?.id === asset.id && detailOpen} aria-pressed={selected?.id === asset.id && detailOpen}
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] hover:underline focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring" className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] hover:underline focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
onClick={() => { href={entityHref(asset.id)}
setSelectedId(asset.id); onClick={() => setDetailOpen(true)}
setDetailOpen(true);
if (window.matchMedia("(max-width: 1099px)").matches) {
setViewId(asset.id);
}
}}
type="button"
> >
{asset.values.name} {asset.values.name}
</button> </BrowserLink>
), ),
}, },
{ {

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
@ -8,8 +9,27 @@ import type { AssetCatalogPageProps } from "../types";
export function useAssetCatalogPageController({ scope, csrfToken }: AssetCatalogPageProps) { export function useAssetCatalogPageController({ scope, csrfToken }: AssetCatalogPageProps) {
const client = useQueryClient(); const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template" | "relation">("template"); const { searchParams, setSearchParams } = useBrowserSearchParams();
const [search, setSearch] = useState(""); const requestedTab = searchParams.get("tab");
const tab: "field" | "template" | "relation" =
requestedTab === "fields" ? "field" : requestedTab === "relations" ? "relation" : "template";
const setTab = (value: "field" | "template" | "relation") =>
setSearchParams((params) => {
if (value === "template") {
params.delete("tab");
} else {
params.set("tab", value === "field" ? "fields" : "relations");
}
});
const search = searchParams.get("q") ?? "";
const setSearch = (value: string) =>
setSearchParams((params) => {
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
});
const [viewId, setViewId] = useState<string>(); const [viewId, setViewId] = useState<string>();
const [editor, setEditor] = useState<CatalogEditor>(); const [editor, setEditor] = useState<CatalogEditor>();
const query = useQuery({ const query = useQuery({

View File

@ -10,9 +10,10 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts" "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workspace/shared/workspace-routes.test.ts"
}, },
"dependencies": { "dependencies": {
"@metis/core": "workspace:*",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
"@metis/assets": "workspace:*", "@metis/assets": "workspace:*",
"@metis/resources": "workspace:*", "@metis/resources": "workspace:*",

View File

@ -1,5 +1,47 @@
{ {
"companies": { "companies": {
"navigation": {
"invalidRoute": "Invalid address",
"notFound": "Page not found",
"routeHelp": "Check the address or return to the dashboard."
},
"companyOverview": {
"title": "Company overview",
"description": "Status, pending work and alerts for {{name}}.",
"directory": "Company information"
},
"catalogOverview": {
"globalTitle": "Global catalogs",
"companyTitle": "Company catalogs",
"globalDescription": "Shared definitions for recording and managing data across companies.",
"companyDescription": "Browse available global definitions and this company's own variants.",
"items": {
"access": {
"title": "Access",
"description": "Systems, resource types, permissions and access templates."
},
"assets": {
"title": "Assets",
"description": "Fields, templates and relationship types for the inventory."
},
"resources": {
"title": "Resources",
"description": "Fields and templates for recording resources."
},
"brands": {
"title": "Brands",
"description": "Shared brands for the asset inventory."
},
"intervention-templates": {
"title": "Intervention templates",
"description": "Reusable procedures for technical interventions."
},
"process-templates": {
"title": "Process templates",
"description": "Checklists for onboarding, changes and offboarding."
}
}
},
"interventionTemplates": { "interventionTemplates": {
"title": "Intervention templates", "title": "Intervention templates",
"new": "New intervention template", "new": "New intervention template",

View File

@ -1,5 +1,47 @@
{ {
"companies": { "companies": {
"navigation": {
"invalidRoute": "La dirección no es válida",
"notFound": "No se ha encontrado esta página",
"routeHelp": "Comprueba la dirección o vuelve al inicio."
},
"companyOverview": {
"title": "Resumen de la compañía",
"description": "Estado, trabajo pendiente y avisos de {{name}}.",
"directory": "Información de la compañía"
},
"catalogOverview": {
"globalTitle": "Catálogos globales",
"companyTitle": "Catálogos de la compañía",
"globalDescription": "Definiciones compartidas para registrar y gestionar datos de todas las compañías.",
"companyDescription": "Consulta las definiciones globales disponibles y las variantes propias de esta compañía.",
"items": {
"access": {
"title": "Accesos",
"description": "Sistemas, tipos de recursos, permisos y plantillas de acceso."
},
"assets": {
"title": "Activos",
"description": "Campos, plantillas y tipos de relaciones para el inventario."
},
"resources": {
"title": "Recursos",
"description": "Campos y plantillas para registrar recursos."
},
"brands": {
"title": "Marcas",
"description": "Marcas compartidas del inventario de activos."
},
"intervention-templates": {
"title": "Plantillas de intervenciones",
"description": "Procedimientos reutilizables para las intervenciones técnicas."
},
"process-templates": {
"title": "Plantillas de procesos",
"description": "Listas de comprobación para altas, cambios y salidas de personas."
}
}
},
"interventionTemplates": { "interventionTemplates": {
"title": "Plantillas de intervención", "title": "Plantillas de intervención",
"new": "Nueva plantilla de intervención", "new": "Nueva plantilla de intervención",

View File

@ -1,8 +1,10 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import type { AccessKind } from "../../../../common"; import type { AccessKind } from "../../../../common";
import { accessFields, accessLabel } from "../../shared/helpers"; import { accessFields, accessLabel } from "../../shared/helpers";
import { accessKinds } from "../../shared/helpers/access-kinds";
import { accessKeys } from "../../shared/queries"; import { accessKeys } from "../../shared/queries";
import { getAccess } from "../api"; import { getAccess } from "../api";
import type { AccessWorkspacePageProps } from "../types"; import type { AccessWorkspacePageProps } from "../types";
@ -12,30 +14,30 @@ export function useAccessWorkspacePageController({ scope, people = [] }: AccessW
queryKey: accessKeys.scope(scope), queryKey: accessKeys.scope(scope),
queryFn: () => getAccess(scope), queryFn: () => getAccess(scope),
}); });
const [kind, setKind] = useState<AccessKind>(scope === "global" ? "system" : "account"); const { searchParams, setSearchParams } = useBrowserSearchParams();
const [editor, setEditor] = useState<string>(); const [editor, setEditor] = useState<string>();
const [history, setHistory] = useState<string>(); const [history, setHistory] = useState<string>();
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const [search, setSearch] = useState(""); const search = searchParams.get("q") ?? "";
const setSearch = (value: string) =>
setSearchParams((params) => {
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
});
const setKind = (value: AccessKind) =>
setSearchParams((params) => {
params.set("kind", value);
});
const data = query.data; const data = query.data;
const selected = data?.items.find((item) => item.id === editor); const selected = data?.items.find((item) => item.id === editor);
const historical = data?.items.find((item) => item.id === history); const historical = data?.items.find((item) => item.id === history);
const kinds: readonly AccessKind[] = const kinds = accessKinds(scope);
scope === "global" const kind =
? ["system", "resource_type", "permission", "template"] kinds.find((value) => value === searchParams.get("kind")) ??
: [ (scope === "global" ? "system" : "account");
"account",
"account_person",
"group",
"membership",
"grant",
"need",
"coverage",
"environment",
"system",
"resource_type",
"permission",
];
const visible = const visible =
data?.items.filter( data?.items.filter(
(item) => (item) =>

View File

@ -0,0 +1,19 @@
import type { AccessKind } from "../../../../common";
export function accessKinds(scope: string): readonly AccessKind[] {
return scope === "global"
? ["system", "resource_type", "permission", "template"]
: [
"account",
"account_person",
"group",
"membership",
"grant",
"need",
"coverage",
"environment",
"system",
"resource_type",
"permission",
];
}

View File

@ -8,12 +8,15 @@ import type { CompanyDetailProps } from "../types";
export function useCompanyDetailController({ export function useCompanyDetailController({
id, id,
embedded = false,
initialSection, initialSection,
section,
onSectionChange,
}: CompanyDetailProps) { }: CompanyDetailProps) {
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => getCompany(id) }); const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => getCompany(id) });
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [tab, setTab] = useState<CompanyTab>(initialSection ?? (embedded ? "inventory" : "people")); const [localTab, setLocalTab] = useState<CompanyTab>(initialSection ?? "overview");
const tab = section ?? localTab;
const setTab = onSectionChange ?? setLocalTab;
const company = query.data?.company; const company = query.data?.company;
return { query, editOpen, setEditOpen, tab, setTab, company }; return { query, editOpen, setEditOpen, tab, setTab, company };

View File

@ -9,6 +9,15 @@ export type CompanyDetailProps = {
embedded?: boolean; embedded?: boolean;
accountNavigation?: ReactNode; accountNavigation?: ReactNode;
initialSection?: CompanyTab; initialSection?: CompanyTab;
section?: CompanyTab;
onSectionChange?: (section: CompanyTab) => void;
initialConfiguration?:
| "folders"
| "mail"
| "certificates"
| "applications"
| "printers"
| undefined;
initialEntityId?: string | undefined; initialEntityId?: string | undefined;
initialCreate?: boolean | undefined; initialCreate?: boolean | undefined;
}; };

View File

@ -1,7 +1,10 @@
import { BrowserLink } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { import {
BoxIcon, BoxIcon,
BriefcaseIcon, BriefcaseIcon,
DatabaseIcon,
HomeIcon,
KeyRoundIcon, KeyRoundIcon,
LayersIcon, LayersIcon,
LayoutTemplateIcon, LayoutTemplateIcon,
@ -11,6 +14,7 @@ import {
} from "@repo/shadcn-ui/lib/icons"; } from "@repo/shadcn-ui/lib/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { companySectionHref } from "../../../workspace/shared/helpers";
import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared"; import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
import { useCompanyDetailController } from "../controllers"; import { useCompanyDetailController } from "../controllers";
import type { CompanyDetailProps } from "../types"; import type { CompanyDetailProps } from "../types";
@ -26,6 +30,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
initialSection, initialSection,
initialEntityId, initialEntityId,
initialCreate, initialCreate,
initialConfiguration,
} = props; } = props;
const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props); const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props);
const { t } = useTranslation("companies"); const { t } = useTranslation("companies");
@ -111,6 +116,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
<nav aria-label={t("companySections")} className={layout.navigation}> <nav aria-label={t("companySections")} className={layout.navigation}>
{( {(
[ [
["overview", t("companyOverview.title"), HomeIcon],
["inventory", assetTranslation("inventory"), BoxIcon], ["inventory", assetTranslation("inventory"), BoxIcon],
["resources", resourceTranslation("inventory"), LayersIcon], ["resources", resourceTranslation("inventory"), LayersIcon],
["workstations", assetTranslation("workstations"), BriefcaseIcon], ["workstations", assetTranslation("workstations"), BriefcaseIcon],
@ -134,6 +140,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
["applications", t("applications.title"), LayersIcon], ["applications", t("applications.title"), LayersIcon],
["printers", t("printers.title"), LayersIcon], ["printers", t("printers.title"), LayersIcon],
["mail", t("mail.title"), MailIcon], ["mail", t("mail.title"), MailIcon],
["catalogs", t("catalogOverview.companyTitle"), DatabaseIcon],
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon], ["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon], ["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
] as const ] as const
@ -146,7 +153,13 @@ export function CompanyDetail(props: CompanyDetailProps) {
: undefined : undefined
} }
key={section} key={section}
onClick={() => setTab(section)} {...(props.onSectionChange
? {
nativeButton: false,
render: <BrowserLink href={companySectionHref(company.id, section)} />,
"aria-current": tab === section ? "page" : undefined,
}
: { onClick: () => setTab(section) })}
variant="ghost" variant="ghost"
> >
<Icon aria-hidden="true" className="size-4 shrink-0" /> <Icon aria-hidden="true" className="size-4 shrink-0" />
@ -169,8 +182,11 @@ export function CompanyDetail(props: CompanyDetailProps) {
<CompanySection <CompanySection
company={company} company={company}
csrfToken={csrfToken} csrfToken={csrfToken}
initialCreate={tab === initialSection ? initialCreate : false} initialConfiguration={initialConfiguration}
initialEntityId={tab === initialSection ? initialEntityId : undefined} initialCreate={props.section || tab === initialSection ? initialCreate : false}
initialEntityId={
props.section || tab === initialSection ? initialEntityId : undefined
}
tab={tab} tab={tab}
/> />
</main> </main>

View File

@ -0,0 +1,50 @@
import { navigateTo } from "@metis/core/client";
import { useTranslation } from "react-i18next";
import type { CompanyDTO } from "../../../../common";
import { DashboardPage } from "../../../dashboard/view/ui/dashboard-page";
import { SectionNavigation } from "../../../shared/ui/section-navigation";
import { companySectionHref, workspaceHref } from "../../../workspace/shared/helpers";
export function CompanyOverview({
company,
csrfToken,
}: {
company: CompanyDTO;
csrfToken: string;
}) {
const { t } = useTranslation("companies");
return (
<div className="space-y-6">
<SectionNavigation
items={[
{
href: companySectionHref(company.id, "people"),
title: t("people"),
value: company.people.length,
},
{
href: companySectionHref(company.id, "departments"),
title: t("departments"),
value: company.departments.length,
},
{
href: companySectionHref(company.id, "sites"),
title: t("sites"),
value: company.sites.length,
},
]}
label={t("companyOverview.directory")}
/>
<DashboardPage
companyId={company.id}
csrfToken={csrfToken}
description={t("companyOverview.description", { name: company.name })}
onNavigate={(destination) =>
navigateTo(workspaceHref(destination), { create: destination.create === true })
}
title={t("companyOverview.title")}
/>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { AssetCatalogPage } from "@metis/assets/client"; import { AssetCatalogPage } from "@metis/assets/client";
import { useBrowserSearchParams, useCreateNavigation } from "@metis/core/client";
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client"; import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
import type { CompanyDTO } from "../../../../common"; import type { CompanyDTO } from "../../../../common";
@ -19,9 +20,11 @@ import { SiteSection } from "../../../sites";
import { TeleworkAuthorizationsPage } from "../../../telework-authorizations"; import { TeleworkAuthorizationsPage } from "../../../telework-authorizations";
import { TicketsPage } from "../../../tickets"; import { TicketsPage } from "../../../tickets";
import { WorkProfilesPage } from "../../../work-profiles"; import { WorkProfilesPage } from "../../../work-profiles";
import { CatalogOverview } from "../../../workspace/catalogs/ui/catalog-overview";
import type { CompanyTab } from "../../shared/types"; import type { CompanyTab } from "../../shared/types";
import { CompanyInventory } from "./company-inventory"; import { CompanyInventory } from "./company-inventory";
import { CompanyOverview } from "./company-overview";
export function CompanySection({ export function CompanySection({
company, company,
@ -29,26 +32,58 @@ export function CompanySection({
tab, tab,
initialEntityId, initialEntityId,
initialCreate, initialCreate,
initialConfiguration,
}: { }: {
company: CompanyDTO; company: CompanyDTO;
csrfToken: string; csrfToken: string;
tab: CompanyTab; tab: CompanyTab;
initialEntityId?: string | undefined; initialEntityId?: string | undefined;
initialCreate?: boolean | undefined; initialCreate?: boolean | undefined;
initialConfiguration?:
| "folders"
| "mail"
| "certificates"
| "applications"
| "printers"
| undefined;
}) { }) {
const { searchParams } = useBrowserSearchParams();
const create = useCreateNavigation() || initialCreate;
const personId = searchParams.get("personId") ?? undefined;
const profileId = searchParams.get("profileId") ?? undefined;
switch (tab) { switch (tab) {
case "overview":
return <CompanyOverview company={company} csrfToken={csrfToken} />;
case "catalogs":
return <CatalogOverview companyId={company.id} />;
case "intervention-templates": case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />; return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />;
case "interventions": case "interventions":
return <InterventionsPage companyId={company.id} csrfToken={csrfToken} />; return (
<InterventionsPage
companyId={company.id}
csrfToken={csrfToken}
initialId={initialEntityId}
/>
);
case "tickets": case "tickets":
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />; return (
<TicketsPage companyId={company.id} csrfToken={csrfToken} initialId={initialEntityId} />
);
case "processes": case "processes":
return <ProcessesPage company={company} csrfToken={csrfToken} />; return <ProcessesPage company={company} csrfToken={csrfToken} />;
case "remote": case "remote":
return <RemoteConnectionsPage company={company} csrfToken={csrfToken} />; return (
<RemoteConnectionsPage company={company} csrfToken={csrfToken} initialPersonId={personId} />
);
case "telework": case "telework":
return <TeleworkAuthorizationsPage company={company} csrfToken={csrfToken} />; return (
<TeleworkAuthorizationsPage
company={company}
csrfToken={csrfToken}
initialPersonId={personId}
/>
);
case "people": case "people":
return <PeopleSection company={company} csrfToken={csrfToken} initialId={initialEntityId} />; return <PeopleSection company={company} csrfToken={csrfToken} initialId={initialEntityId} />;
case "departments": case "departments":
@ -61,18 +96,53 @@ export function CompanySection({
); );
case "profiles": case "profiles":
return ( return (
<WorkProfilesPage company={company} csrfToken={csrfToken} initialId={initialEntityId} /> <WorkProfilesPage
company={company}
csrfToken={csrfToken}
initialConfiguration={initialConfiguration}
initialId={initialEntityId}
/>
); );
case "certificates": case "certificates":
return <CertificateConfigurationsPage company={company} csrfToken={csrfToken} />; return (
<CertificateConfigurationsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "applications": case "applications":
return <ApplicationConfigurationsPage company={company} csrfToken={csrfToken} />; return (
<ApplicationConfigurationsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "printers": case "printers":
return <PrinterConfigurationsPage company={company} csrfToken={csrfToken} />; return (
<PrinterConfigurationsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "folders": case "folders":
return <FolderMappingsPage company={company} csrfToken={csrfToken} />; return (
<FolderMappingsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "mail": case "mail":
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />; return (
<MailConfigurationsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "workstations": case "workstations":
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />; return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
case "inventory": case "inventory":
@ -80,7 +150,7 @@ export function CompanySection({
<CompanyInventory <CompanyInventory
company={company} company={company}
csrfToken={csrfToken} csrfToken={csrfToken}
initialCreate={initialCreate} initialCreate={create}
initialId={initialEntityId} initialId={initialEntityId}
/> />
); );
@ -89,7 +159,7 @@ export function CompanySection({
<ResourcesPage <ResourcesPage
companyId={company.id} companyId={company.id}
csrfToken={csrfToken} csrfToken={csrfToken}
initialCreate={initialCreate} initialCreate={create}
initialId={initialEntityId} initialId={initialEntityId}
relatedContent={(resource) => relatedContent={(resource) =>
resource.values.integration?.kind === "mailbox" ? ( resource.values.integration?.kind === "mailbox" ? (

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table"; import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
@ -21,10 +22,37 @@ export function useCompaniesPageController({
}: CompaniesPageProps) { }: CompaniesPageProps) {
const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies }); const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
const [selectedId, setSelectedId] = useState<string | undefined>(); const [selectedId, setSelectedId] = useState<string | undefined>();
const [companyId, setCompanyId] = useState(initialCompanyId); const { searchParams, setSearchParams } = useBrowserSearchParams();
const [activeOnly, setActiveOnly] = useState(initialActiveOnly); const companyId = searchParams.get("companyId") ?? initialCompanyId;
const activeOnly = searchParams.has("activeOnly")
? searchParams.get("activeOnly") === "true"
: initialActiveOnly;
const setCompanyId = (id: string | undefined) =>
setSearchParams((params) => {
if (id) {
params.set("companyId", id);
} else {
params.delete("companyId");
}
});
const setActiveOnly = (active: boolean) =>
setSearchParams((params) => {
if (active) {
params.set("activeOnly", "true");
} else {
params.delete("activeOnly");
}
});
const [createOpen, setCreateOpen] = useState(initialCreate); const [createOpen, setCreateOpen] = useState(initialCreate);
const [search, setSearch] = useState(""); const search = searchParams.get("q") ?? "";
const setSearch = (value: string) =>
setSearchParams((params) => {
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
});
const table = useReactTable({ const table = useReactTable({
data: query.data?.companies ?? EMPTY_COMPANIES, data: query.data?.companies ?? EMPTY_COMPANIES,
columns: COMPANY_COLUMNS, columns: COMPANY_COLUMNS,

View File

@ -1,3 +1,4 @@
import { BrowserLink } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { import {
EntityAction, EntityAction,
@ -8,6 +9,7 @@ import {
} from "@repo/shadcn-ui/components/entity-list"; } from "@repo/shadcn-ui/components/entity-list";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { companySectionHref } from "../../../workspace/shared/helpers";
import { CompanyDetail } from "../../detail"; import { CompanyDetail } from "../../detail";
import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared"; import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
import { useCompaniesPageController } from "../controllers"; import { useCompaniesPageController } from "../controllers";
@ -82,17 +84,16 @@ export function CompaniesPage(props: CompaniesPageProps) {
value: (company) => company.name, value: (company) => company.name,
hideable: false, hideable: false,
render: (company) => ( render: (company) => (
<button <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => selectCompany(company.id)} href={companySectionHref(company.id, "overview")}
type="button"
> >
<CompanyIdentity <CompanyIdentity
companyId={company.id} companyId={company.id}
logoVersion={company.logoVersion} logoVersion={company.logoVersion}
name={company.name} name={company.name}
/> />
</button> </BrowserLink>
), ),
}, },
{ {

View File

@ -11,6 +11,7 @@ import { AssignmentForm } from "../../assignments";
import { PersonForm } from "../../people"; import { PersonForm } from "../../people";
import { companiesEnglish, companiesSpanish } from "../../shared"; import { companiesEnglish, companiesSpanish } from "../../shared";
import { SiteForm } from "../../sites"; import { SiteForm } from "../../sites";
import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview";
import { CompanyDetail } from "../detail"; import { CompanyDetail } from "../detail";
import { CompaniesPage } from "../list"; import { CompaniesPage } from "../list";
@ -43,6 +44,58 @@ async function renderPage(
client.clear(); client.clear();
} }
} }
test("el resumen de compañía es la entrada por defecto y ofrece destinos reales", async () => {
for (const language of ["es", "en"]) {
const markup = await renderPage(
language,
<CompanyDetail csrfToken="test" id={companyId} onBack={() => undefined} />,
(client) =>
client.setQueryData(companyKeys.detail(companyId), {
company: {
id: companyId,
name: "Empresa",
active: true,
people: [],
contacts: [],
departments: [],
assignments: [],
sites: [],
},
})
);
assert.ok(markup.includes(language === "es" ? "Resumen de la compañía" : "Company overview"));
for (const section of ["people", "departments", "sites"]) {
assert.ok(markup.includes(`href="/companies/${companyId}/${section}"`));
}
assert.equal(markup.includes('name="name"'), false);
}
});
test("los índices de catálogos distinguen definiciones globales y variantes de compañía", async () => {
for (const language of ["es", "en"]) {
const global = await renderPage(language, <CatalogOverview />, () => undefined);
for (const catalog of [
"access",
"assets",
"resources",
"brands",
"intervention-templates",
"process-templates",
]) {
assert.ok(global.includes(`href="/catalogs/${catalog}"`));
}
const scoped = await renderPage(
language,
<CatalogOverview companyId={companyId} />,
() => undefined
);
assert.ok(scoped.includes(`href="/companies/${companyId}/catalogs/assets"`));
assert.ok(scoped.includes(`href="/companies/${companyId}/catalogs/resources"`));
assert.equal(scoped.includes('href="/catalogs/access"'), false);
assert.equal(scoped.includes("<form"), false);
}
});
test("el listado presenta estado vacío y acceso al alta sin formularios visibles", async () => { test("el listado presenta estado vacío y acceso al alta sin formularios visibles", async () => {
for (const [language, title, empty] of [ for (const [language, title, empty] of [
["es", "Compañías", "Todavía no hay compañías"], ["es", "Compañías", "Todavía no hay compañías"],
@ -61,7 +114,12 @@ test("el listado presenta estado vacío y acceso al alta sin formularios visible
test("la ficha presenta datos de la compañía, contactos y acceso a sedes", async () => { test("la ficha presenta datos de la compañía, contactos y acceso a sedes", async () => {
const markup = await renderPage( const markup = await renderPage(
"es", "es",
<CompanyDetail csrfToken="test" id={companyId} onBack={() => undefined} />, <CompanyDetail
csrfToken="test"
id={companyId}
initialSection="people"
onBack={() => undefined}
/>,
(client) => (client) =>
client.setQueryData(companyKeys.detail(companyId), { client.setQueryData(companyKeys.detail(companyId), {
company: { company: {

View File

@ -1,4 +1,6 @@
export type CompanyTab = export type CompanyTab =
| "overview"
| "catalogs"
| "interventions" | "interventions"
| "intervention-templates" | "intervention-templates"
| "tickets" | "tickets"

View File

@ -3,7 +3,8 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompanyEditorDialog, CompanyForm } from "../../../companies"; import { CompanyEditorDialog } from "../../../companies/shared/ui/company-editor-dialog";
import { CompanyForm } from "../../../companies/shared/ui/company-form";
import { InterventionForm } from "../../../interventions"; import { InterventionForm } from "../../../interventions";
import { TicketForm } from "../../../tickets"; import { TicketForm } from "../../../tickets";
import { BlockState } from "../../shared"; import { BlockState } from "../../shared";

View File

@ -4,7 +4,7 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { entityListLabels } from "@repo/shadcn-ui/lib/entity-list"; import { entityListLabels } from "@repo/shadcn-ui/lib/entity-list";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "../../../companies"; import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
import { BlockState, DashboardNavigationButton, DashboardTable } from "../../shared"; import { BlockState, DashboardNavigationButton, DashboardTable } from "../../shared";
import { entryDestination } from "../../shared/helpers"; import { entryDestination } from "../../shared/helpers";
import { useDashboardDetailsDialogController } from "../controllers"; import { useDashboardDetailsDialogController } from "../controllers";

View File

@ -1,3 +1,5 @@
import type { CompanyTab } from "../../../companies/shared/types";
export type DashboardNavigation = export type DashboardNavigation =
| { | {
area: "directory"; area: "directory";
@ -15,9 +17,10 @@ export type DashboardNavigation =
| { | {
area: "company"; area: "company";
id: string; id: string;
section: "inventory" | "resources" | "profiles" | "people"; section: CompanyTab;
entityId?: string | undefined; entityId?: string | undefined;
create?: boolean | undefined; create?: boolean | undefined;
configuration?: "folders" | "mail" | "certificates" | "applications" | "printers" | undefined;
}; };
export function entryDestination(item: { export function entryDestination(item: {

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { useState } from "react"; import { useState } from "react";
@ -8,8 +9,17 @@ import type { DashboardQuickCreate } from "../../create";
import type { DashboardDetailsDialog } from "../../detail"; import type { DashboardDetailsDialog } from "../../detail";
import { dashboardApi, dashboardKeys } from "../../shared/api"; import { dashboardApi, dashboardKeys } from "../../shared/api";
export function useDashboardPageController() { export function useDashboardPageController(fixedCompanyId?: string) {
const [companyId, setCompanyId] = useState(""); const { searchParams, setSearchParams } = useBrowserSearchParams();
const companyId = fixedCompanyId ?? searchParams.get("companyId") ?? "";
const setCompanyId = (id: string) =>
setSearchParams((params) => {
if (id) {
params.set("companyId", id);
} else {
params.delete("companyId");
}
});
const scope = companyId || undefined; const scope = companyId || undefined;
const [details, setDetails] = const [details, setDetails] =
useState<ComponentProps<typeof DashboardDetailsDialog>["details"]>(); useState<ComponentProps<typeof DashboardDetailsDialog>["details"]>();

View File

@ -3,4 +3,7 @@ import type { DashboardNavigation } from "../../shared/helpers";
export type DashboardPageProps = { export type DashboardPageProps = {
onNavigate: (destination: DashboardNavigation) => void; onNavigate: (destination: DashboardNavigation) => void;
csrfToken: string; csrfToken: string;
companyId?: string;
title?: string;
description?: string;
}; };

View File

@ -2,7 +2,7 @@ import { ClockIcon } from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "../../../companies"; import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
import { BlockState, DashboardNavigationButton, DashboardPanel } from "../../shared"; import { BlockState, DashboardNavigationButton, DashboardPanel } from "../../shared";
import { dashboardApi, dashboardKeys } from "../../shared/api"; import { dashboardApi, dashboardKeys } from "../../shared/api";
import type { DashboardNavigation } from "../../shared/helpers"; import type { DashboardNavigation } from "../../shared/helpers";

View File

@ -2,7 +2,7 @@ import { Building2Icon } from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "../../../companies"; import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
import { import {
BlockState, BlockState,
DashboardNavigationButton, DashboardNavigationButton,

View File

@ -2,7 +2,7 @@ import { Button } from "@repo/shadcn-ui/components/button";
import { Building2Icon } from "@repo/shadcn-ui/lib/icons"; import { Building2Icon } from "@repo/shadcn-ui/lib/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompanySelect } from "../../../companies"; import { CompanySelect } from "../../../companies/shared/ui/company-select";
import { DashboardQuickCreate } from "../../create"; import { DashboardQuickCreate } from "../../create";
import { DashboardDetailsDialog } from "../../detail"; import { DashboardDetailsDialog } from "../../detail";
import { DashboardPanel } from "../../shared"; import { DashboardPanel } from "../../shared";
@ -28,27 +28,35 @@ export function DashboardPage(props: DashboardPageProps) {
setQuick, setQuick,
companies, companies,
summary, summary,
} = useDashboardPageController(); } = useDashboardPageController(props.companyId);
const { t } = useTranslation("companies"); const { t } = useTranslation("companies");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h1 className="text-3xl font-semibold tracking-tight">{t("dashboard.title")}</h1> <h1 className="text-3xl font-semibold tracking-tight">
<p className="mt-1 text-sm text-muted-foreground">{t("dashboard.description")}</p> {props.title ?? t("dashboard.title")}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{props.description ?? t("dashboard.description")}
</p>
</div> </div>
<div className="w-full sm:w-64 space-y-2"> <div className="w-full sm:w-64 space-y-2">
<CompanySelect {!props.companyId && (
id="dashboard-company" <CompanySelect
label={t("tickets.company")} id="dashboard-company"
onValueChange={setCompanyId} label={t("tickets.company")}
options={[ onValueChange={setCompanyId}
...(companies.data?.companies.map((item) => ({ value: item.id, label: item.name })) ?? options={[
[]), ...(companies.data?.companies.map((item) => ({
]} value: item.id,
placeholder={t("interventions.allCompanies")} label: item.name,
value={companyId} })) ?? []),
/> ]}
placeholder={t("interventions.allCompanies")}
value={companyId}
/>
)}
{summary.isSuccess && ( {summary.isSuccess && (
<p className="text-xs text-muted-foreground flex items-center gap-2"> <p className="text-xs text-muted-foreground flex items-center gap-2">
<span aria-hidden="true" className="size-2 rounded-full bg-primary" /> <span aria-hidden="true" className="size-2 rounded-full bg-primary" />

View File

@ -2,7 +2,7 @@ import { TicketIcon } from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "../../../companies"; import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
import { import {
BlockState, BlockState,
DashboardNavigationButton, DashboardNavigationButton,

View File

@ -1,3 +1,4 @@
import { navigateTo, useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -25,16 +26,59 @@ export function useInterventionsPageController({
queryKey: interventionKeys.list(companyId), queryKey: interventionKeys.list(companyId),
queryFn: () => interventionApi.list(companyId), queryFn: () => interventionApi.list(companyId),
}); });
const [filters, setFilters] = useState<InterventionFilters>({ const { searchParams, setSearchParams } = useBrowserSearchParams();
search: "", const filters: InterventionFilters = {
company: "", search: searchParams.get("q") ?? "",
state: "", company: searchParams.get("companyId") ?? "",
dateFrom: "", state: searchParams.get("state") ?? "",
dateTo: "", dateFrom: searchParams.get("dateFrom") ?? "",
assignee: "", dateTo: searchParams.get("dateTo") ?? "",
}); assignee: searchParams.get("assignee") ?? "",
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly); };
const [viewId, setViewId] = useState(initialId); const setFilters = (value: InterventionFilters) =>
setSearchParams((params) => {
for (const [key, text] of Object.entries({
q: value.search,
companyId: value.company,
state: value.state,
dateFrom: value.dateFrom,
dateTo: value.dateTo,
assignee: value.assignee,
})) {
if (text) {
params.set(key, text);
} else {
params.delete(key);
}
}
});
const pendingOnly = searchParams.has("pendingOnly")
? searchParams.get("pendingOnly") === "true"
: initialPendingOnly;
const setPendingOnly = (value: boolean) =>
setSearchParams((params) => {
if (value) {
params.set("pendingOnly", "true");
} else {
params.delete("pendingOnly");
}
});
const {
selectedId: viewId,
selectEntity,
routed,
} = useEntityNavigation(
companyId ? `/companies/${companyId}/interventions` : "/interventions",
initialId
);
const setViewId = (id: string | undefined) => {
const intervention = query.data?.interventions.find((item) => item.id === id);
if (routed && !companyId && intervention) {
navigateTo(`/companies/${intervention.companyId}/interventions/${intervention.id}`);
} else {
selectEntity(id);
}
};
const [open, setOpen] = useState(initialCreate); const [open, setOpen] = useState(initialCreate);
const [edit, setEdit] = useState<InterventionDTO | undefined>(); const [edit, setEdit] = useState<InterventionDTO | undefined>();
const currentView = query.data?.interventions.find((intervention) => intervention.id === viewId); const currentView = query.data?.interventions.find((intervention) => intervention.id === viewId);
@ -52,6 +96,7 @@ export function useInterventionsPageController({
edit, edit,
setEdit, setEdit,
currentView, currentView,
missingView: Boolean(viewId && query.isSuccess && !currentView),
items, items,
date, date,
}; };

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { import {
EntityAction, EntityAction,
@ -30,10 +31,14 @@ export function InterventionsPage(props: InterventionsPageProps) {
edit, edit,
setEdit, setEdit,
currentView, currentView,
missingView,
items, items,
date, date,
} = useInterventionsPageController(props); } = useInterventionsPageController(props);
const { t, i18n } = useTranslation("companies"); const { t, i18n } = useTranslation("companies");
if (missingView) {
return <EntityNotFound />;
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<EntityListHeader <EntityListHeader
@ -78,13 +83,12 @@ export function InterventionsPage(props: InterventionsPageProps) {
value: (intervention) => intervention.reference, value: (intervention) => intervention.reference,
hideable: false, hideable: false,
render: (intervention) => ( render: (intervention) => (
<button <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setViewId(intervention.id)} href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
type="button"
> >
{intervention.reference} {intervention.reference}
</button> </BrowserLink>
), ),
}, },
{ {
@ -93,13 +97,12 @@ export function InterventionsPage(props: InterventionsPageProps) {
value: (intervention) => intervention.values.subject, value: (intervention) => intervention.values.subject,
hideable: false, hideable: false,
render: (intervention) => ( render: (intervention) => (
<button <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setViewId(intervention.id)} href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
type="button"
> >
{intervention.values.subject} {intervention.values.subject}
</button> </BrowserLink>
), ),
}, },
{ {

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound, useEntityNavigation } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { import {
@ -29,9 +30,13 @@ export function PeopleSection({
const [selection, setSelection] = useState< const [selection, setSelection] = useState<
{ mode: "new" | "detail" | "edit"; id?: string } | undefined { mode: "new" | "detail" | "edit"; id?: string } | undefined
>(initialId ? { mode: "detail", id: initialId } : undefined); >(initialId ? { mode: "detail", id: initialId } : undefined);
const { selectedId, selectEntity, entityHref, routed } = useEntityNavigation(
`/companies/${company.id}/people`,
initialId
);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [contactsOnly, setContactsOnly] = useState(false); const [contactsOnly, setContactsOnly] = useState(false);
const selected = company.people.find((item) => item.id === selection?.id); const selected = company.people.find((item) => item.id === (routed ? selectedId : selection?.id));
const personEditor = ( const personEditor = (
<PersonEditorDialog <PersonEditorDialog
companyId={company.id} companyId={company.id}
@ -41,11 +46,17 @@ export function PeopleSection({
setSelection(selected ? { mode: "detail", id: selected.id } : undefined); setSelection(selected ? { mode: "detail", id: selected.id } : undefined);
} }
}} }}
onSaved={(id) => setSelection({ mode: "detail", id })} onSaved={(id) => {
setSelection({ mode: "detail", id });
selectEntity(id);
}}
open={selection?.mode === "new" || selection?.mode === "edit"} open={selection?.mode === "new" || selection?.mode === "edit"}
person={selected} person={selected}
/> />
); );
if (routed && selectedId && !selected) {
return <EntityNotFound />;
}
if (selected) { if (selected) {
return ( return (
<> <>
@ -54,7 +65,10 @@ export function PeopleSection({
company={company} company={company}
csrfToken={csrfToken} csrfToken={csrfToken}
key={selected.id} key={selected.id}
onBack={() => setSelection(undefined)} onBack={() => {
setSelection(undefined);
selectEntity(undefined);
}}
onEdit={() => setSelection({ mode: "edit", id: selected.id })} onEdit={() => setSelection({ mode: "edit", id: selected.id })}
person={selected} person={selected}
/> />
@ -89,13 +103,12 @@ export function PeopleSection({
hideable: false, hideable: false,
render: (person) => ( render: (person) => (
<> <>
<button <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setSelection({ mode: "detail", id: person.id })} href={entityHref(person.id)}
type="button"
> >
{person.name} {person.name}
</button> </BrowserLink>
{person.isContact && ( {person.isContact && (
<p className="text-xs text-muted-foreground">{t("isContact")}</p> <p className="text-xs text-muted-foreground">{t("isContact")}</p>
)} )}
@ -140,7 +153,12 @@ export function PeopleSection({
hideable: false, hideable: false,
render: (person) => ( render: (person) => (
<EntityRowActions label={t("actions")}> <EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setSelection({ mode: "detail", id: person.id })}> <EntityAction
onClick={() => {
setSelection({ mode: "detail", id: person.id });
selectEntity(person.id);
}}
>
{t("open")} {t("open")}
</EntityAction> </EntityAction>
</EntityRowActions> </EntityRowActions>

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -8,11 +9,29 @@ import type { ProcessesPageProps } from "../types";
export function useProcessesPageController({ company }: ProcessesPageProps) { export function useProcessesPageController({ company }: ProcessesPageProps) {
const { t } = useTranslation("companies"); const { t } = useTranslation("companies");
const [templates, setTemplates] = useState(false);
const [create, setCreate] = useState(false); const [create, setCreate] = useState(false);
const [selected, setSelected] = useState<string>(); const { selectedId: selected, selectEntity: setSelected } = useEntityNavigation(
const [search, setSearch] = useState(""); `/companies/${company.id}/processes`
const [closed, setClosed] = useState(false); );
const { searchParams, setSearchParams } = useBrowserSearchParams();
const search = searchParams.get("q") ?? "";
const setSearch = (value: string) =>
setSearchParams((params) => {
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
});
const closed = searchParams.get("closed") === "true";
const setClosed = (value: boolean) =>
setSearchParams((params) => {
if (value) {
params.set("closed", "true");
} else {
params.delete("closed");
}
});
const query = useQuery({ const query = useQuery({
queryKey: processKeys.company(company.id), queryKey: processKeys.company(company.id),
queryFn: () => getProcesses(company.id), queryFn: () => getProcesses(company.id),
@ -28,8 +47,6 @@ export function useProcessesPageController({ company }: ProcessesPageProps) {
) )
); );
return { return {
templates,
setTemplates,
create, create,
setCreate, setCreate,
selected, selected,
@ -41,6 +58,7 @@ export function useProcessesPageController({ company }: ProcessesPageProps) {
query, query,
person, person,
current, current,
missingView: Boolean(selected && query.isSuccess && !current),
filtered, filtered,
}; };
} }

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { import {
EntityAction, EntityAction,
@ -8,7 +9,6 @@ import {
} from "@repo/shadcn-ui/components/entity-list"; } from "@repo/shadcn-ui/components/entity-list";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ProcessTemplatesPage } from "../../../process-templates";
import { ProcessDetail } from "../../detail"; import { ProcessDetail } from "../../detail";
import { ProcessFeedback } from "../../shared"; import { ProcessFeedback } from "../../shared";
import { ProcessStartDialog } from "../../start"; import { ProcessStartDialog } from "../../start";
@ -18,8 +18,6 @@ import type { ProcessesPageProps } from "../types";
export function ProcessesPage(props: ProcessesPageProps) { export function ProcessesPage(props: ProcessesPageProps) {
const { company, csrfToken } = props; const { company, csrfToken } = props;
const { const {
templates,
setTemplates,
create, create,
setCreate, setCreate,
selected, selected,
@ -31,18 +29,12 @@ export function ProcessesPage(props: ProcessesPageProps) {
query, query,
person, person,
current, current,
missingView,
filtered, filtered,
} = useProcessesPageController(props); } = useProcessesPageController(props);
const { t, i18n } = useTranslation("companies"); const { t, i18n } = useTranslation("companies");
if (templates) { if (missingView) {
return ( return <EntityNotFound />;
<div className="space-y-5">
<Button onClick={() => setTemplates(false)} variant="outline">
{t("processes.back")}
</Button>
<ProcessTemplatesPage csrfToken={csrfToken} />
</div>
);
} }
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@ -77,7 +69,11 @@ export function ProcessesPage(props: ProcessesPageProps) {
<> <>
{" "} {" "}
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={() => setTemplates(true)} variant="outline"> <Button
nativeButton={false}
render={<BrowserLink href="/catalogs/process-templates" />}
variant="outline"
>
{t("processes.templates")} {t("processes.templates")}
</Button> </Button>
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}> <Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
@ -99,13 +95,12 @@ export function ProcessesPage(props: ProcessesPageProps) {
value: (p) => p.values.name, value: (p) => p.values.name,
hideable: false, hideable: false,
render: (p) => ( render: (p) => (
<button <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setSelected(p.id)} href={`/companies/${company.id}/processes/${p.id}`}
type="button"
> >
{p.values.name} {p.values.name}
</button> </BrowserLink>
), ),
}, },
{ {

View File

@ -0,0 +1,32 @@
import { BrowserLink } from "@metis/core/client";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
export function SectionNavigation({
label,
items,
}: {
label: string;
items: readonly { href: string; title: string; description?: string; value?: number }[];
}) {
return (
<nav aria-label={label} className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<BrowserLink
className="rounded-xl outline-offset-4 focus-visible:outline-2 focus-visible:outline-ring"
href={item.href}
key={item.href}
>
<Card className="h-full transition-colors hover:bg-accent">
<CardContent className="space-y-2">
<h2 className="font-semibold">{item.title}</h2>
{item.description && (
<p className="text-sm text-muted-foreground">{item.description}</p>
)}
{item.value !== undefined && <p className="text-2xl font-semibold">{item.value}</p>}
</CardContent>
</Card>
</BrowserLink>
))}
</nav>
);
}

View File

@ -1,3 +1,4 @@
import { navigateTo, useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -25,15 +26,54 @@ export function useTicketsPageController({
queryKey: ticketKeys.list(companyId), queryKey: ticketKeys.list(companyId),
queryFn: () => ticketApi.list(companyId), queryFn: () => ticketApi.list(companyId),
}); });
const [filters, setFilters] = useState<TicketFilters>({ const { searchParams, setSearchParams } = useBrowserSearchParams();
search: "", const filters: TicketFilters = {
company: "", search: searchParams.get("q") ?? "",
state: "", company: searchParams.get("companyId") ?? "",
priority: "", state: searchParams.get("state") ?? "",
assignee: "", priority: searchParams.get("priority") ?? "",
}); assignee: searchParams.get("assignee") ?? "",
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly); };
const [viewId, setViewId] = useState(initialId); const setFilters = (value: TicketFilters) =>
setSearchParams((params) => {
for (const [key, text] of Object.entries({
q: value.search,
companyId: value.company,
state: value.state,
priority: value.priority,
assignee: value.assignee,
})) {
if (text) {
params.set(key, text);
} else {
params.delete(key);
}
}
});
const pendingOnly = searchParams.has("pendingOnly")
? searchParams.get("pendingOnly") === "true"
: initialPendingOnly;
const setPendingOnly = (value: boolean) =>
setSearchParams((params) => {
if (value) {
params.set("pendingOnly", "true");
} else {
params.delete("pendingOnly");
}
});
const {
selectedId: viewId,
selectEntity,
routed,
} = useEntityNavigation(companyId ? `/companies/${companyId}/tickets` : "/tickets", initialId);
const setViewId = (id: string | undefined) => {
const ticket = query.data?.tickets.find((item) => item.id === id);
if (routed && !companyId && ticket) {
navigateTo(`/companies/${ticket.companyId}/tickets/${ticket.id}`);
} else {
selectEntity(id);
}
};
const [open, setOpen] = useState(initialCreate); const [open, setOpen] = useState(initialCreate);
const [edit, setEdit] = useState<TicketDTO | undefined>(); const [edit, setEdit] = useState<TicketDTO | undefined>();
const [interventionTicketId, setInterventionTicketId] = useState<string>(); const [interventionTicketId, setInterventionTicketId] = useState<string>();
@ -56,6 +96,7 @@ export function useTicketsPageController({
setEdit, setEdit,
setInterventionTicketId, setInterventionTicketId,
currentView, currentView,
missingView: Boolean(viewId && query.isSuccess && !currentView),
interventionTicket, interventionTicket,
items, items,
date, date,

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { import {
EntityAction, EntityAction,
@ -32,11 +33,15 @@ export function TicketsPage(props: TicketsPageProps) {
setEdit, setEdit,
setInterventionTicketId, setInterventionTicketId,
currentView, currentView,
missingView,
interventionTicket, interventionTicket,
items, items,
date, date,
} = useTicketsPageController(props); } = useTicketsPageController(props);
const { t, i18n } = useTranslation("companies"); const { t, i18n } = useTranslation("companies");
if (missingView) {
return <EntityNotFound />;
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<EntityListHeader <EntityListHeader
@ -81,13 +86,12 @@ export function TicketsPage(props: TicketsPageProps) {
value: (ticket) => ticket.reference, value: (ticket) => ticket.reference,
hideable: false, hideable: false,
render: (ticket) => ( render: (ticket) => (
<button <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setViewId(ticket.id)} href={`/companies/${ticket.companyId}/tickets/${ticket.id}`}
type="button"
> >
{ticket.reference} {ticket.reference}
</button> </BrowserLink>
), ),
}, },
{ {
@ -96,13 +100,12 @@ export function TicketsPage(props: TicketsPageProps) {
value: (ticket) => ticket.values.subject, value: (ticket) => ticket.values.subject,
hideable: false, hideable: false,
render: (ticket) => ( render: (ticket) => (
<button <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setViewId(ticket.id)} href={`/companies/${ticket.companyId}/tickets/${ticket.id}`}
type="button"
> >
{ticket.values.subject} {ticket.values.subject}
</button> </BrowserLink>
), ),
}, },
{ {

View File

@ -1,4 +1,10 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client"; import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import {
closeRoute,
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
} from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -6,19 +12,98 @@ import { useTranslation } from "react-i18next";
import { getAccess } from "../../../access/list/api"; import { getAccess } from "../../../access/list/api";
import { accessLabel } from "../../../access/shared/helpers"; import { accessLabel } from "../../../access/shared/helpers";
import { accessKeys } from "../../../access/shared/queries"; import { accessKeys } from "../../../access/shared/queries";
import { companySectionHref, workspaceHref } from "../../../workspace/shared/helpers";
import { profileApi, profileKeys } from "../../shared/api"; import { profileApi, profileKeys } from "../../shared/api";
import type { ProfileSelection } from "../../shared/types"; import type { ProfileSelection } from "../../shared/types";
import type { WorkProfilesPageProps } from "../types"; import type { WorkProfilesPageProps } from "../types";
export function useWorkProfilesPageController({ company, initialId }: WorkProfilesPageProps) { export function useWorkProfilesPageController({
company,
initialId,
initialConfiguration,
}: WorkProfilesPageProps) {
const { t } = useTranslation("companies"); const { t } = useTranslation("companies");
const [configuration, setConfiguration] = useState<ProfileSelection | undefined>( const [localConfiguration, setLocalConfiguration] = useState<ProfileSelection | undefined>(
initialId ? { kind: "environment", profileId: initialId } : undefined initialId ? { kind: "environment", profileId: initialId } : undefined
); );
const { selectedId, routed } = useEntityNavigation(
`/companies/${company.id}/work-profiles`,
initialId
);
const configuration: ProfileSelection | undefined = routed
? selectedId
? {
kind: initialConfiguration ?? "environment",
profileId: selectedId,
fromEnvironment: Boolean(initialConfiguration),
}
: undefined
: localConfiguration;
const setConfiguration = (selection: ProfileSelection | undefined) => {
if (!routed) {
setLocalConfiguration(selection);
return;
}
if (!selection) {
closeRoute(companySectionHref(company.id, "profiles"));
return;
}
if (selection.kind === "remote" || selection.kind === "telework") {
const profile = profiles.find((item) => item.id === selection.profileId);
if (!profile) {
return;
}
navigateTo(
`${companySectionHref(company.id, selection.kind)}?${new URLSearchParams({ personId: profile.values.personId })}`
);
return;
}
navigateTo(
workspaceHref({
area: "company",
id: company.id,
section: "profiles",
entityId: selection.profileId,
configuration: selection.kind === "environment" ? undefined : selection.kind,
})
);
};
const closeConfiguration = () => {
if (routed) {
closeRoute(
initialConfiguration && selectedId
? `${companySectionHref(company.id, "profiles")}/${selectedId}`
: companySectionHref(company.id, "profiles")
);
return;
}
setLocalConfiguration(
configuration?.fromEnvironment
? { kind: "environment", profileId: configuration.profileId }
: undefined
);
};
const [editor, setEditor] = useState<string>(); const [editor, setEditor] = useState<string>();
const [view, setView] = useState<string>(); const [view, setView] = useState<string>();
const [search, setSearch] = useState(""); const { searchParams, setSearchParams } = useBrowserSearchParams();
const [archived, setArchived] = useState(false); const search = searchParams.get("q") ?? "";
const setSearch = (value: string) =>
setSearchParams((params) => {
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
});
const archived = searchParams.get("archived") === "true";
const setArchived = (value: boolean) =>
setSearchParams((params) => {
if (value) {
params.set("archived", "true");
} else {
params.delete("archived");
}
});
const query = useQuery({ const query = useQuery({
queryKey: profileKeys.company(company.id), queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id), queryFn: () => profileApi.get(company.id),
@ -53,7 +138,13 @@ export function useWorkProfilesPageController({ company, initialId }: WorkProfil
const ready = [query.data, inventory.data, access.data].every(Boolean); const ready = [query.data, inventory.data, access.data].every(Boolean);
return { return {
configuration, configuration,
missingProfile: Boolean(
configuration &&
query.isSuccess &&
!profiles.some((item) => item.id === configuration.profileId)
),
setConfiguration, setConfiguration,
closeConfiguration,
editor, editor,
setEditor, setEditor,
setView, setView,

View File

@ -4,4 +4,11 @@ export type WorkProfilesPageProps = {
company: CompanyDTO; company: CompanyDTO;
csrfToken: string; csrfToken: string;
initialId?: string | undefined; initialId?: string | undefined;
initialConfiguration?:
| "folders"
| "mail"
| "certificates"
| "applications"
| "printers"
| undefined;
}; };

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { import {
EntityAction, EntityAction,
@ -19,6 +20,7 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
const { const {
configuration, configuration,
setConfiguration, setConfiguration,
closeConfiguration,
editor, editor,
setEditor, setEditor,
setView, setView,
@ -37,20 +39,18 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
account, account,
filtered, filtered,
ready, ready,
missingProfile,
} = useWorkProfilesPageController(props); } = useWorkProfilesPageController(props);
const { t, i18n } = useTranslation("companies"); const { t, i18n } = useTranslation("companies");
if (missingProfile) {
return <EntityNotFound />;
}
if (configuration) { if (configuration) {
return ( return (
<ProfileConfigurations <ProfileConfigurations
company={company} company={company}
csrfToken={csrfToken} csrfToken={csrfToken}
onBack={() => onBack={closeConfiguration}
setConfiguration(
configuration.fromEnvironment
? { kind: "environment", profileId: configuration.profileId }
: undefined
)
}
onSelection={setConfiguration} onSelection={setConfiguration}
personId={profiles.find((item) => item.id === configuration.profileId)?.values.personId} personId={profiles.find((item) => item.id === configuration.profileId)?.values.personId}
selection={configuration} selection={configuration}
@ -97,13 +97,12 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
value: (item) => item.values.name, value: (item) => item.values.name,
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<button <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })} href={`/companies/${company.id}/work-profiles/${item.id}`}
type="button"
> >
{item.values.name} {item.values.name}
</button> </BrowserLink>
), ),
}, },
{ {

View File

@ -1 +1,2 @@
export { GlobalCatalogContent } from "./ui"; export { GlobalCatalogContent } from "./ui";
export { CatalogOverview } from "./ui/catalog-overview";

View File

@ -0,0 +1,32 @@
import { useTranslation } from "react-i18next";
import { SectionNavigation } from "../../../shared/ui/section-navigation";
export function CatalogOverview({ companyId }: { companyId?: string }) {
const { t } = useTranslation("companies");
const catalogs = companyId
? ["assets", "resources"]
: ["access", "assets", "resources", "brands", "intervention-templates", "process-templates"];
const prefix = companyId ? `/companies/${companyId}/catalogs` : "/catalogs";
const title = t(companyId ? "catalogOverview.companyTitle" : "catalogOverview.globalTitle");
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t(
companyId ? "catalogOverview.companyDescription" : "catalogOverview.globalDescription"
)}
</p>
</header>
<SectionNavigation
items={catalogs.map((catalog) => ({
href: `${prefix}/${catalog}`,
title: t(`catalogOverview.items.${catalog}.title`),
description: t(`catalogOverview.items.${catalog}.description`),
}))}
label={title}
/>
</div>
);
}

View File

@ -3,15 +3,19 @@ import { ResourceCatalogPage } from "@metis/resources/client";
import { AccessWorkspacePage } from "../../../access"; import { AccessWorkspacePage } from "../../../access";
import { InterventionTemplatesPage } from "../../../intervention-templates"; import { InterventionTemplatesPage } from "../../../intervention-templates";
import { ProcessTemplatesPage } from "../../../process-templates";
import type { GlobalCatalog } from "../../shared/types";
export function GlobalCatalogContent({ export function GlobalCatalogContent({
catalog, catalog,
csrfToken, csrfToken,
}: { }: {
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates"; catalog: GlobalCatalog;
csrfToken: string; csrfToken: string;
}) { }) {
switch (catalog) { switch (catalog) {
case "process-templates":
return <ProcessTemplatesPage csrfToken={csrfToken} />;
case "intervention-templates": case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} />; return <InterventionTemplatesPage csrfToken={csrfToken} />;
case "access": case "access":

View File

@ -0,0 +1,6 @@
export {
COMPANY_SECTION_PATHS,
companySectionHref,
parseWorkspaceUrl,
workspaceHref,
} from "./workspace-routes";

View File

@ -0,0 +1,318 @@
import { z } from "zod/v4";
import { AccessKindSchema } from "../../../../common/dto/shared/access.dto";
import { InterventionStateSchema } from "../../../../common/interventions";
import { TicketPrioritySchema, TicketStateSchema } from "../../../../common/tickets";
import { accessKinds } from "../../../access/shared/helpers/access-kinds";
import type { CompanyTab } from "../../../companies/shared/types";
import type { GlobalCatalog, WorkspaceLocation } from "../types/workspace-location";
export const COMPANY_SECTION_PATHS = {
overview: "",
inventory: "assets",
resources: "resources",
workstations: "workstations",
profiles: "work-profiles",
telework: "telework-authorizations",
remote: "remote-connections",
processes: "processes",
"intervention-templates": "intervention-templates",
interventions: "interventions",
tickets: "tickets",
people: "people",
departments: "departments",
sites: "sites",
access: "access",
folders: "folder-mappings",
certificates: "certificate-configurations",
applications: "application-configurations",
printers: "printer-configurations",
mail: "mail-configurations",
catalogs: "catalogs",
assets: "catalogs/assets",
"resource-catalog": "catalogs/resources",
} satisfies Record<CompanyTab, string>;
const GLOBAL_CATALOGS: readonly GlobalCatalog[] = [
"access",
"assets",
"resources",
"brands",
"intervention-templates",
"process-templates",
];
const DETAIL_SECTIONS: readonly CompanyTab[] = [
"inventory",
"resources",
"people",
"tickets",
"interventions",
"processes",
"profiles",
];
const PROFILE_CONFIGURATION_PATHS = {
folders: "folder-mappings",
mail: "mail-configurations",
certificates: "certificate-configurations",
applications: "application-configurations",
printers: "printer-configurations",
} as const;
const RouteQuerySchema = z.strictObject({
companyId: z.uuid().optional(),
personId: z.uuid().optional(),
profileId: z.uuid().optional(),
activeOnly: z.enum(["true", "false"]).optional(),
pendingOnly: z.enum(["true", "false"]).optional(),
archived: z.enum(["true", "false"]).optional(),
closed: z.enum(["true", "false"]).optional(),
contactsOnly: z.enum(["true", "false"]).optional(),
q: z.string().max(200).optional(),
tab: z.enum(["templates", "fields", "relations"]).optional(),
kind: AccessKindSchema.optional(),
state: z.union([TicketStateSchema, InterventionStateSchema]).optional(),
priority: TicketPrioritySchema.optional(),
assignee: z.union([z.uuid(), z.literal("unassigned")]).optional(),
dateFrom: z.iso.date().optional(),
dateTo: z.iso.date().optional(),
});
export function companySectionHref(companyId: string, section: CompanyTab): string {
const path = COMPANY_SECTION_PATHS[section];
return `/companies/${encodeURIComponent(companyId)}${path ? `/${path}` : ""}`;
}
function companyLocationHref(location: Extract<WorkspaceLocation, { area: "company" }>): string {
const entity = location.entityId ? `/${encodeURIComponent(location.entityId)}` : "";
const configuration = location.configuration
? `/${PROFILE_CONFIGURATION_PATHS[location.configuration]}`
: "";
return companySectionHref(location.id, location.section) + entity + configuration;
}
function supportLocationHref(
location: Extract<WorkspaceLocation, { area: "tickets" | "interventions" }>
): string {
if (location.id && location.companyId) {
return `${companySectionHref(location.companyId, location.area)}/${encodeURIComponent(location.id)}`;
}
return appendFilters(`/${location.area}`, {
companyId: location.companyId,
pendingOnly: location.pendingOnly ? "true" : undefined,
});
}
function appendFilters(path: string, filters: Record<string, string | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value) {
params.set(key, value);
}
}
return path + (params.size ? `?${params.toString()}` : "");
}
export function workspaceHref(location: WorkspaceLocation): string {
switch (location.area) {
case "home":
return appendFilters("/dashboard", { companyId: location.companyId });
case "directory":
return appendFilters("/companies", {
companyId: location.companyId,
activeOnly: location.activeOnly ? "true" : undefined,
});
case "tickets":
case "interventions":
return supportLocationHref(location);
case "company":
return companyLocationHref(location);
case "catalogs":
return `/catalogs${location.catalog ? `/${location.catalog}` : ""}`;
case "not-found":
case "invalid-route":
return "/dashboard";
}
}
export function parseWorkspaceUrl(href: string): WorkspaceLocation {
const url = new URL(href, "http://metis.invalid");
const raw = Object.fromEntries(url.searchParams);
const query = RouteQuerySchema.safeParse(raw);
if (!query.success || Object.keys(raw).some((key) => url.searchParams.getAll(key).length !== 1)) {
return { area: "invalid-route" };
}
const location = parseWorkspacePath(url.pathname.replace(/\/+$/u, ""));
if (!queryMatchesLocation(location, query.data)) {
return { area: "invalid-route" };
}
switch (location.area) {
case "home":
return { ...location, companyId: query.data.companyId };
case "directory":
return {
...location,
companyId: query.data.companyId,
activeOnly: query.data.activeOnly === "true",
};
case "tickets":
case "interventions":
return {
...location,
companyId: query.data.companyId,
pendingOnly: query.data.pendingOnly === "true",
};
default:
return location;
}
}
function queryMatchesLocation(
location: WorkspaceLocation,
query: z.infer<typeof RouteQuerySchema>
): boolean {
if (location.area === "company" && query.companyId && query.companyId !== location.id) {
return false;
}
return (
stateMatchesLocation(location, query.state) &&
kindMatchesLocation(location, query.kind) &&
tabMatchesLocation(location, query.tab)
);
}
function stateMatchesLocation(location: WorkspaceLocation, state: string | undefined): boolean {
if (!state) {
return true;
}
const section = location.area === "company" ? location.section : location.area;
switch (section) {
case "tickets":
return TicketStateSchema.safeParse(state).success;
case "interventions":
return InterventionStateSchema.safeParse(state).success;
default:
return false;
}
}
function kindMatchesLocation(
location: WorkspaceLocation,
kind: z.infer<typeof AccessKindSchema> | undefined
): boolean {
if (!kind) {
return true;
}
if (location.area === "company" && location.section === "access") {
return accessKinds(location.id).includes(kind);
}
return (
location.area === "catalogs" &&
location.catalog === "access" &&
accessKinds("global").includes(kind)
);
}
function tabMatchesLocation(location: WorkspaceLocation, tab: string | undefined): boolean {
if (!tab) {
return true;
}
const catalog =
location.area === "catalogs"
? location.catalog
: location.area === "company"
? location.section
: undefined;
return (
["assets", "resources", "resource-catalog"].includes(catalog ?? "") &&
(tab !== "relations" || catalog === "assets")
);
}
function parseWorkspacePath(path: string): WorkspaceLocation {
if (["", "/dashboard", "/settings/users", "/settings/password"].includes(path)) {
return { area: "home" };
}
if (path === "/companies") {
return { area: "directory" };
}
if (path === "/tickets" || path === "/interventions") {
return { area: path === "/tickets" ? "tickets" : "interventions" };
}
if (path === "/catalogs") {
return { area: "catalogs" };
}
const catalog = GLOBAL_CATALOGS.find((value) => path === `/catalogs/${value}`);
if (catalog) {
return { area: "catalogs", catalog };
}
return parseCompanyPath(path);
}
function parseCompanyPath(path: string): WorkspaceLocation {
const [, area, companyId, ...segments] = path.split("/");
if (area !== "companies") {
return { area: "not-found" };
}
if (!(companyId && z.uuid().safeParse(companyId).success)) {
return { area: "invalid-route" };
}
for (const [section, sectionPath] of Object.entries(COMPANY_SECTION_PATHS)) {
if (!isCompanyTab(section)) {
continue;
}
const match = matchCompanySection(companyId, section, sectionPath, segments);
if (match) {
return match;
}
}
return { area: "not-found" };
}
function matchCompanySection(
companyId: string,
section: CompanyTab,
sectionPath: string,
segments: string[]
): WorkspaceLocation | undefined {
const location = { area: "company", id: companyId, section } satisfies WorkspaceLocation;
if (segments.join("/") === sectionPath) {
return location;
}
if (!(DETAIL_SECTIONS.includes(section) && sectionPath)) {
return undefined;
}
const sectionSegments = sectionPath.split("/");
if (!sectionSegments.every((value, index) => segments[index] === value)) {
return undefined;
}
const entityId = segments[sectionSegments.length];
if (!(entityId && z.uuid().safeParse(entityId).success)) {
return { area: "invalid-route" };
}
const remainder = segments.slice(sectionSegments.length + 1);
if (!remainder.length) {
return { ...location, entityId };
}
return matchProfileConfiguration({ ...location, entityId }, remainder);
}
function matchProfileConfiguration(
location: Extract<WorkspaceLocation, { area: "company" }>,
remainder: string[]
): WorkspaceLocation {
if (location.section === "profiles" && remainder.length === 1) {
for (const [configuration, value] of Object.entries(PROFILE_CONFIGURATION_PATHS)) {
if (value === remainder[0] && isProfileConfiguration(configuration)) {
return { ...location, configuration };
}
}
}
return { area: "not-found" };
}
function isCompanyTab(value: string): value is CompanyTab {
return Object.hasOwn(COMPANY_SECTION_PATHS, value);
}
function isProfileConfiguration(value: string): value is keyof typeof PROFILE_CONFIGURATION_PATHS {
return Object.hasOwn(PROFILE_CONFIGURATION_PATHS, value);
}

View File

@ -1,2 +1,3 @@
export { companySectionHref, parseWorkspaceUrl, workspaceHref } from "./helpers";
export type { WorkspaceLocation } from "./types"; export type { WorkspaceLocation } from "./types";
export { WorkspaceNavigationButton } from "./ui"; export { WorkspaceNavigationButton } from "./ui";

View File

@ -1 +1 @@
export type { WorkspaceLocation } from "./workspace-location"; export type { GlobalCatalog, WorkspaceLocation } from "./workspace-location";

View File

@ -1,3 +1,14 @@
import type { DashboardNavigation } from "../../../dashboard/shared/helpers"; import type { DashboardNavigation } from "../../../dashboard/shared/helpers";
export type WorkspaceLocation = { area: "home" } | DashboardNavigation | { area: "catalogs" }; export type GlobalCatalog =
| "access"
| "assets"
| "brands"
| "resources"
| "intervention-templates"
| "process-templates";
export type WorkspaceLocation =
| { area: "home"; companyId?: string | undefined }
| DashboardNavigation
| { area: "catalogs"; catalog?: GlobalCatalog | undefined }
| { area: "not-found" | "invalid-route" };

View File

@ -0,0 +1,17 @@
import { BrowserLink } from "@metis/core/client";
import { useTranslation } from "react-i18next";
export function RouteFeedback({ invalid = false }: { invalid?: boolean }) {
const { t } = useTranslation("companies");
return (
<section className="space-y-4" role="alert">
<h1 className="text-2xl font-semibold">
{t(invalid ? "navigation.invalidRoute" : "navigation.notFound")}
</h1>
<p className="text-muted-foreground">{t("navigation.routeHelp")}</p>
<BrowserLink className="text-primary underline underline-offset-4" href="/dashboard">
{t("dashboard.title")}
</BrowserLink>
</section>
);
}

View File

@ -1,3 +1,4 @@
import { BrowserLink } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
@ -5,11 +6,19 @@ import type { ComponentProps } from "react";
export function WorkspaceNavigationButton({ export function WorkspaceNavigationButton({
level = "primary", level = "primary",
className, className,
href,
...props ...props
}: ComponentProps<typeof Button> & { level?: "primary" | "catalog" }) { }: ComponentProps<typeof Button> & { level?: "primary" | "catalog"; href?: string }) {
return ( return (
<Button <Button
{...props} {...props}
{...(href
? {
render: <BrowserLink href={href} />,
nativeButton: false,
"aria-current": props["aria-pressed"] ? "page" : undefined,
}
: {})}
className={cn( className={cn(
"shell:group-data-[collapsed=true]/sidebar:justify-center shell:group-data-[collapsed=true]/sidebar:px-0 shell:group-data-[collapsed=true]/sidebar:[&>span:not([data-slot$=avatar])]:hidden", "shell:group-data-[collapsed=true]/sidebar:justify-center shell:group-data-[collapsed=true]/sidebar:px-0 shell:group-data-[collapsed=true]/sidebar:[&>span:not([data-slot$=avatar])]:hidden",
level === "catalog" level === "catalog"

View File

@ -0,0 +1,121 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
COMPANY_SECTION_PATHS,
companySectionHref,
parseWorkspaceUrl,
workspaceHref,
} from "./helpers/workspace-routes";
const COMPANY_ID = "019b8838-44af-776d-a406-af74452b6f26";
const ENTITY_ID = "019b8838-44af-776d-a406-af74452b6f27";
test("todas las secciones de compañía tienen una ruta propia y el índice es el resumen", () => {
for (const section of [
"overview",
"inventory",
"resources",
"workstations",
"profiles",
"telework",
"remote",
"processes",
"intervention-templates",
"interventions",
"tickets",
"people",
"departments",
"sites",
"access",
"folders",
"certificates",
"applications",
"printers",
"mail",
"catalogs",
"assets",
"resource-catalog",
] as const) {
assert.ok(Object.hasOwn(COMPANY_SECTION_PATHS, section));
const href = companySectionHref(COMPANY_ID, section);
assert.deepEqual(parseWorkspaceUrl(href), { area: "company", id: COMPANY_ID, section });
}
assert.equal(companySectionHref(COMPANY_ID, "overview"), `/companies/${COMPANY_ID}`);
assert.notEqual(
companySectionHref(COMPANY_ID, "inventory"),
companySectionHref(COMPANY_ID, "assets")
);
});
test("los índices de catálogos y los seis catálogos globales son destinos independientes", () => {
assert.deepEqual(parseWorkspaceUrl("/catalogs/"), { area: "catalogs" });
for (const catalog of [
"access",
"assets",
"resources",
"brands",
"intervention-templates",
"process-templates",
] as const) {
assert.deepEqual(parseWorkspaceUrl(workspaceHref({ area: "catalogs", catalog })), {
area: "catalogs",
catalog,
});
}
});
test("las fichas de soporte conservan compañía y UUID y los filtros no se confunden con secciones", () => {
const href = workspaceHref({ area: "tickets", companyId: COMPANY_ID, id: ENTITY_ID });
assert.equal(href, `/companies/${COMPANY_ID}/tickets/${ENTITY_ID}`);
assert.deepEqual(parseWorkspaceUrl(href), {
area: "company",
id: COMPANY_ID,
section: "tickets",
entityId: ENTITY_ID,
});
assert.deepEqual(parseWorkspaceUrl(`/tickets?companyId=${COMPANY_ID}&pendingOnly=true`), {
area: "tickets",
companyId: COMPANY_ID,
pendingOnly: true,
});
assert.deepEqual(
parseWorkspaceUrl(`/companies/${COMPANY_ID}/work-profiles/${ENTITY_ID}/mail-configurations`),
{
area: "company",
id: COMPANY_ID,
section: "profiles",
entityId: ENTITY_ID,
configuration: "mail",
}
);
});
test("las rutas desconocidas y los parámetros inválidos no abren una sección por defecto", () => {
for (const href of [
"/missing",
"/catalogs/missing",
"/settings/missing",
`/companies/${COMPANY_ID}/missing`,
`/companies/${COMPANY_ID}/people/${ENTITY_ID}/missing`,
]) {
assert.equal(parseWorkspaceUrl(href).area, "not-found");
}
for (const href of [
"/companies/not-a-uuid",
`/companies/${COMPANY_ID}/people/not-a-uuid`,
"/tickets?companyId=x",
"/tickets?pendingOnly=yes",
"/tickets?pendingOnly=true&pendingOnly=false",
"/catalogs/assets?tab=invalid",
"/tickets?unknown=1",
"/tickets?state=finished",
"/interventions?state=open",
"/catalogs/access?kind=account",
"/catalogs/resources?tab=relations",
`/companies/${COMPANY_ID}/access?kind=template`,
`/companies/${COMPANY_ID}?companyId=${ENTITY_ID}`,
]) {
assert.equal(parseWorkspaceUrl(href).area, "invalid-route");
}
});

View File

@ -1,29 +1,33 @@
import { isCreateNavigation, navigateTo, useBrowserLocation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { listCompanies } from "../../../companies/list/api"; import { listCompanies } from "../../../companies/list/api";
import { companyKeys } from "../../../companies/shared/queries"; import { companyKeys } from "../../../companies/shared/queries";
import { parseWorkspaceUrl, workspaceHref } from "../../shared/helpers";
import type { WorkspaceLocation } from "../../shared/types"; import type { WorkspaceLocation } from "../../shared/types";
export function useCompaniesWorkspaceController() { export function useCompaniesWorkspaceController() {
const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies }); const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
const [location, setLocation] = useState<WorkspaceLocation>(); const url = useBrowserLocation();
const [catalog, setCatalog] = useState< const current = parseWorkspaceUrl(url);
"access" | "assets" | "brands" | "resources" | "intervention-templates" const setLocation = (destination: WorkspaceLocation) =>
>("access"); navigateTo(workspaceHref(destination), {
create: "create" in destination && destination.create === true,
});
const catalog = current.area === "catalogs" ? current.catalog : undefined;
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const companies = query.data?.companies ?? []; const companies = query.data?.companies ?? [];
const current = location ?? { area: "home" }; const selectCompany = (id: string) => setLocation({ area: "company", id, section: "overview" });
const selectCompany = (id: string) => setLocation({ area: "company", id, section: "inventory" });
return { return {
query, query,
setLocation, setLocation,
catalog, catalog,
setCatalog,
settingsOpen, settingsOpen,
setSettingsOpen, setSettingsOpen,
companies, companies,
current, current,
initialCreate: isCreateNavigation(),
selectCompany, selectCompany,
}; };
} }

View File

@ -16,12 +16,12 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
query, query,
setLocation, setLocation,
catalog, catalog,
setCatalog,
settingsOpen, settingsOpen,
setSettingsOpen, setSettingsOpen,
companies, companies,
current, current,
selectCompany, selectCompany,
initialCreate,
} = useCompaniesWorkspaceController(); } = useCompaniesWorkspaceController();
const { t } = useTranslation("companies"); const { t } = useTranslation("companies");
const { t: coreTranslation } = useTranslation("core"); const { t: coreTranslation } = useTranslation("core");
@ -34,9 +34,6 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
catalog={catalog} catalog={catalog}
companies={companies} companies={companies}
current={current} current={current}
onNavigate={setLocation}
onSelectCatalog={setCatalog}
onSelectCompany={selectCompany}
onSettings={() => setSettingsOpen(true)} onSettings={() => setSettingsOpen(true)}
/> />
<div className="min-w-0 flex-1 overflow-y-auto bg-shell-content"> <div className="min-w-0 flex-1 overflow-y-auto bg-shell-content">
@ -44,8 +41,6 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
accountNavigation={accountNavigation} accountNavigation={accountNavigation}
companies={companies} companies={companies}
languageSwitcher={languageSwitcher} languageSwitcher={languageSwitcher}
onNavigate={setLocation}
onSelectCompany={selectCompany}
userName={userName} userName={userName}
/> />
<WorkspaceSettingsDialog <WorkspaceSettingsDialog
@ -79,11 +74,13 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
csrfToken={csrfToken} csrfToken={csrfToken}
embedded embedded
id={current.id} id={current.id}
initialCreate={current.create} initialConfiguration={current.configuration}
initialCreate={initialCreate}
initialEntityId={current.entityId} initialEntityId={current.entityId}
initialSection={current.section}
key={current.id} key={current.id}
onBack={() => setLocation({ area: "directory" })} onBack={() => setLocation({ area: "directory" })}
onSectionChange={(section) => setLocation({ area: "company", id: current.id, section })}
section={current.section}
/> />
) : ( ) : (
<main className="min-w-0 p-4 shell:p-6"> <main className="min-w-0 p-4 shell:p-6">
@ -91,7 +88,8 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
area={current.area} area={current.area}
catalog={catalog} catalog={catalog}
csrfToken={csrfToken} csrfToken={csrfToken}
key={JSON.stringify(current)} initialCreate={initialCreate}
key={current.area === "catalogs" ? `catalogs:${catalog ?? "overview"}` : current.area}
navigation={current} navigation={current}
onNavigate={setLocation} onNavigate={setLocation}
onSelectCompany={selectCompany} onSelectCompany={selectCompany}

View File

@ -1,10 +1,13 @@
import { useCreateNavigation } from "@metis/core/client";
import { CompaniesPage } from "../../../companies"; import { CompaniesPage } from "../../../companies";
import { DashboardPage } from "../../../dashboard"; import { DashboardPage } from "../../../dashboard";
import type { DashboardNavigation } from "../../../dashboard/shared/helpers"; import type { DashboardNavigation } from "../../../dashboard/shared/helpers";
import { InterventionsPage } from "../../../interventions"; import { InterventionsPage } from "../../../interventions";
import { TicketsPage } from "../../../tickets"; import { TicketsPage } from "../../../tickets";
import { GlobalCatalogContent } from "../../catalogs"; import { CatalogOverview, GlobalCatalogContent } from "../../catalogs";
import type { WorkspaceLocation } from "../../shared/types"; import type { GlobalCatalog, WorkspaceLocation } from "../../shared/types";
import { RouteFeedback } from "../../shared/ui/route-feedback";
export function WorkspaceContent({ export function WorkspaceContent({
area, area,
@ -13,14 +16,17 @@ export function WorkspaceContent({
csrfToken, csrfToken,
onNavigate, onNavigate,
onSelectCompany, onSelectCompany,
initialCreate,
}: { }: {
area: WorkspaceLocation["area"]; area: WorkspaceLocation["area"];
navigation: WorkspaceLocation; navigation: WorkspaceLocation;
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates"; catalog: GlobalCatalog | undefined;
initialCreate?: boolean | undefined;
csrfToken: string; csrfToken: string;
onNavigate: (destination: DashboardNavigation) => void; onNavigate: (destination: DashboardNavigation) => void;
onSelectCompany: (id: string) => void; onSelectCompany: (id: string) => void;
}) { }) {
const create = useCreateNavigation() || initialCreate;
switch (area) { switch (area) {
case "interventions": case "interventions":
return ( return (
@ -28,9 +34,8 @@ export function WorkspaceContent({
csrfToken={csrfToken} csrfToken={csrfToken}
{...(navigation.area === "interventions" {...(navigation.area === "interventions"
? { ? {
companyId: navigation.companyId,
initialId: navigation.id, initialId: navigation.id,
initialCreate: navigation.create, initialCreate: create,
initialPendingOnly: navigation.pendingOnly, initialPendingOnly: navigation.pendingOnly,
} }
: {})} : {})}
@ -42,9 +47,8 @@ export function WorkspaceContent({
csrfToken={csrfToken} csrfToken={csrfToken}
{...(navigation.area === "tickets" {...(navigation.area === "tickets"
? { ? {
companyId: navigation.companyId,
initialId: navigation.id, initialId: navigation.id,
initialCreate: navigation.create, initialCreate: create,
initialPendingOnly: navigation.pendingOnly, initialPendingOnly: navigation.pendingOnly,
} }
: {})} : {})}
@ -59,16 +63,23 @@ export function WorkspaceContent({
? { ? {
initialActiveOnly: navigation.activeOnly, initialActiveOnly: navigation.activeOnly,
initialCompanyId: navigation.companyId, initialCompanyId: navigation.companyId,
initialCreate: navigation.create, initialCreate: create,
} }
: {})} : {})}
/> />
); );
case "catalogs": case "catalogs":
return <GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />; return catalog ? (
<GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />
) : (
<CatalogOverview />
);
case "company": case "company":
return null; return null;
case "home": case "home":
return <DashboardPage csrfToken={csrfToken} onNavigate={onNavigate} />; return <DashboardPage csrfToken={csrfToken} onNavigate={onNavigate} />;
case "not-found":
case "invalid-route":
return <RouteFeedback invalid={area === "invalid-route"} />;
} }
} }

View File

@ -1,3 +1,4 @@
import { BrowserLink } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input"; import { Input } from "@repo/shadcn-ui/components/input";
import { BellIcon, ChevronDownIcon, SearchIcon } from "@repo/shadcn-ui/lib/icons"; import { BellIcon, ChevronDownIcon, SearchIcon } from "@repo/shadcn-ui/lib/icons";
@ -7,18 +8,15 @@ import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "../../../companies"; import { CompanyIdentity } from "../../../companies";
import { InitialsAvatar } from "../../../shared"; import { InitialsAvatar } from "../../../shared";
import type { WorkspaceLocation } from "../../shared/types"; import { companySectionHref, workspaceHref } from "../../shared/helpers";
export function WorkspaceHeader({ export function WorkspaceHeader({
companies, companies,
onNavigate,
onSelectCompany,
accountNavigation, accountNavigation,
userName, userName,
languageSwitcher, languageSwitcher,
}: { }: {
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[]; companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
onNavigate: (location: WorkspaceLocation) => void;
onSelectCompany: (id: string) => void;
accountNavigation: ReactNode; accountNavigation: ReactNode;
userName: string; userName: string;
languageSwitcher: ReactNode; languageSwitcher: ReactNode;
@ -58,10 +56,9 @@ export function WorkspaceHeader({
<Button <Button
className="justify-start" className="justify-start"
key={company.id} key={company.id}
onClick={() => { nativeButton={false}
onSelectCompany(company.id); onClick={() => setSearch("")}
setSearch(""); render={<BrowserLink href={companySectionHref(company.id, "overview")} />}
}}
variant="ghost" variant="ghost"
> >
<CompanyIdentity <CompanyIdentity
@ -87,10 +84,9 @@ export function WorkspaceHeader({
<Button <Button
className="justify-start" className="justify-start"
key={area} key={area}
onClick={() => { nativeButton={false}
onNavigate({ area }); onClick={() => setSearch("")}
setSearch(""); render={<BrowserLink href={workspaceHref({ area })} />}
}}
variant="ghost" variant="ghost"
> >
{t(label)} {t(label)}

View File

@ -11,30 +11,23 @@ import {
TicketIcon, TicketIcon,
WrenchIcon, WrenchIcon,
} from "@repo/shadcn-ui/lib/icons"; } from "@repo/shadcn-ui/lib/icons";
import type { ComponentProps } from "react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompanyAvatar } from "../../../companies"; import { CompanyAvatar } from "../../../companies";
import { WorkspaceNavigationButton } from "../../shared"; import { WorkspaceNavigationButton } from "../../shared";
import type { WorkspaceLocation } from "../../shared/types"; import { companySectionHref, workspaceHref } from "../../shared/helpers";
import type { GlobalCatalog, WorkspaceLocation } from "../../shared/types";
import type { WorkspaceContent } from "./workspace-content";
export function WorkspaceSidebar({ export function WorkspaceSidebar({
companies, companies,
current, current,
catalog, catalog,
onNavigate,
onSelectCompany,
onSelectCatalog,
onSettings, onSettings,
}: { }: {
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[]; companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
onNavigate: (location: WorkspaceLocation) => void;
onSelectCompany: (id: string) => void;
current: WorkspaceLocation; current: WorkspaceLocation;
catalog: ComponentProps<typeof WorkspaceContent>["catalog"]; catalog: GlobalCatalog | undefined;
onSelectCatalog: (catalog: ComponentProps<typeof WorkspaceContent>["catalog"]) => void;
onSettings: () => void; onSettings: () => void;
}) { }) {
const { t } = useTranslation("companies"); const { t } = useTranslation("companies");
@ -85,8 +78,8 @@ export function WorkspaceSidebar({
<WorkspaceNavigationButton <WorkspaceNavigationButton
aria-label={t(label)} aria-label={t(label)}
aria-pressed={current.area === area} aria-pressed={current.area === area}
href={workspaceHref({ area })}
key={area} key={area}
onClick={() => onNavigate({ area })}
title={t(label)} title={t(label)}
> >
<Icon aria-hidden="true" className="size-5 shell:size-6" /> <Icon aria-hidden="true" className="size-5 shell:size-6" />
@ -108,8 +101,8 @@ export function WorkspaceSidebar({
<WorkspaceNavigationButton <WorkspaceNavigationButton
aria-label={accessTranslation("catalogTitle")} aria-label={accessTranslation("catalogTitle")}
aria-pressed={catalog === "access"} aria-pressed={catalog === "access"}
href="/catalogs/access"
level="catalog" level="catalog"
onClick={() => onSelectCatalog("access")}
title={accessTranslation("catalogTitle")} title={accessTranslation("catalogTitle")}
> >
<KeyRoundIcon aria-hidden="true" className="size-[18px]" /> <KeyRoundIcon aria-hidden="true" className="size-[18px]" />
@ -119,8 +112,8 @@ export function WorkspaceSidebar({
<WorkspaceNavigationButton <WorkspaceNavigationButton
aria-label={assetTranslation("title")} aria-label={assetTranslation("title")}
aria-pressed={catalog === "assets"} aria-pressed={catalog === "assets"}
href="/catalogs/assets"
level="catalog" level="catalog"
onClick={() => onSelectCatalog("assets")}
title={assetTranslation("title")} title={assetTranslation("title")}
> >
<BoxIcon aria-hidden="true" className="size-[18px]" /> <BoxIcon aria-hidden="true" className="size-[18px]" />
@ -130,8 +123,8 @@ export function WorkspaceSidebar({
<WorkspaceNavigationButton <WorkspaceNavigationButton
aria-label={t("interventionTemplates.title")} aria-label={t("interventionTemplates.title")}
aria-pressed={catalog === "intervention-templates"} aria-pressed={catalog === "intervention-templates"}
href="/catalogs/intervention-templates"
level="catalog" level="catalog"
onClick={() => onSelectCatalog("intervention-templates")}
title={t("interventionTemplates.title")} title={t("interventionTemplates.title")}
> >
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" /> <LayoutTemplateIcon aria-hidden="true" className="size-[18px]" />
@ -141,8 +134,8 @@ export function WorkspaceSidebar({
<WorkspaceNavigationButton <WorkspaceNavigationButton
aria-label={assetTranslation("brands.title")} aria-label={assetTranslation("brands.title")}
aria-pressed={catalog === "brands"} aria-pressed={catalog === "brands"}
href="/catalogs/brands"
level="catalog" level="catalog"
onClick={() => onSelectCatalog("brands")}
title={assetTranslation("brands.title")} title={assetTranslation("brands.title")}
> >
<LayersIcon aria-hidden="true" className="size-[18px]" /> <LayersIcon aria-hidden="true" className="size-[18px]" />
@ -152,13 +145,23 @@ export function WorkspaceSidebar({
<WorkspaceNavigationButton <WorkspaceNavigationButton
aria-label={resourceTranslation("title")} aria-label={resourceTranslation("title")}
aria-pressed={catalog === "resources"} aria-pressed={catalog === "resources"}
href="/catalogs/resources"
level="catalog" level="catalog"
onClick={() => onSelectCatalog("resources")}
title={resourceTranslation("title")} title={resourceTranslation("title")}
> >
<DatabaseIcon aria-hidden="true" className="size-[18px]" /> <DatabaseIcon aria-hidden="true" className="size-[18px]" />
<span>{resourceTranslation("title")}</span> <span>{resourceTranslation("title")}</span>
</WorkspaceNavigationButton> </WorkspaceNavigationButton>
<WorkspaceNavigationButton
aria-label={t("processes.templates")}
aria-pressed={catalog === "process-templates"}
href="/catalogs/process-templates"
level="catalog"
title={t("processes.templates")}
>
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" />
<span>{t("processes.templates")}</span>
</WorkspaceNavigationButton>
</> </>
</nav> </nav>
)} )}
@ -167,8 +170,8 @@ export function WorkspaceSidebar({
<WorkspaceNavigationButton <WorkspaceNavigationButton
aria-label={company.name} aria-label={company.name}
aria-pressed={current.area === "company" && current.id === company.id} aria-pressed={current.area === "company" && current.id === company.id}
href={companySectionHref(company.id, "overview")}
key={company.id} key={company.id}
onClick={() => onSelectCompany(company.id)}
title={company.name} title={company.name}
> >
<CompanyAvatar <CompanyAvatar

View File

@ -10,7 +10,7 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json",
"test": "node --import tsx --test src/common/dto/response/get-health.response.dto.test.ts" "test": "node --import tsx --test src/common/dto/response/get-health.response.dto.test.ts src/web/navigation/shared/browser-navigation.test.ts"
}, },
"dependencies": { "dependencies": {
"zod": "4.3.6", "zod": "4.3.6",

View File

@ -1,4 +1,5 @@
{ {
"navigation": { "recordNotFound": "The record was not found in this scope." },
"subtitle": "Technical support, with context.", "subtitle": "Technical support, with context.",
"workspace": "Your support workspace", "workspace": "Your support workspace",
"description": "Customers, devices and interventions connected in one place.", "description": "Customers, devices and interventions connected in one place.",

View File

@ -1,4 +1,5 @@
{ {
"navigation": { "recordNotFound": "No se ha encontrado el registro en este ámbito." },
"subtitle": "Soporte técnico, con contexto.", "subtitle": "Soporte técnico, con contexto.",
"workspace": "Tu espacio de soporte", "workspace": "Tu espacio de soporte",
"description": "Clientes, equipos e intervenciones conectados en un mismo lugar.", "description": "Clientes, equipos e intervenciones conectados en un mismo lugar.",

View File

@ -1,4 +1,18 @@
export { default as coreEnglish } from "../common/locales/en.json"; export { default as coreEnglish } from "../common/locales/en.json";
export { default as coreSpanish } from "../common/locales/es.json"; export { default as coreSpanish } from "../common/locales/es.json";
export {
BrowserLink,
EntityNotFound,
clearCreateNavigation,
closeRoute,
getBrowserLocation,
isCreateNavigation,
isInternalPath,
navigateTo,
useBrowserLocation,
useBrowserSearchParams,
useCreateNavigation,
useEntityNavigation,
} from "./navigation";
export { HomePage } from "./workspace"; export { HomePage } from "./workspace";

View File

@ -0,0 +1,13 @@
export {
clearCreateNavigation,
closeRoute,
getBrowserLocation,
isCreateNavigation,
isInternalPath,
navigateTo,
} from "./shared/helpers/browser-navigation";
export { useBrowserLocation, useBrowserSearchParams } from "./shared/hooks/use-browser-location";
export { useCreateNavigation } from "./shared/hooks/use-create-navigation";
export { useEntityNavigation } from "./shared/hooks/use-entity-navigation";
export { BrowserLink } from "./shared/ui/browser-link";
export { EntityNotFound } from "./shared/ui/entity-not-found";

View File

@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
clearCreateNavigation,
closeRoute,
getBrowserLocation,
isCreateNavigation,
isInternalPath,
navigateTo,
subscribeToNavigation,
} from "./helpers/browser-navigation";
class BrowserFixture extends EventTarget {
location = new URL("http://localhost/dashboard");
private entries: { href: string; state: unknown }[] = [{ href: "/dashboard", state: null }];
private index = 0;
history = {
state: null as unknown,
pushState: (state: unknown, _title: string, href: string) => {
this.entries.splice(this.index + 1);
this.entries.push({ href, state });
this.index += 1;
this.restore();
},
replaceState: (state: unknown, _title: string, href: string) => {
this.entries[this.index] = { href, state };
this.restore();
},
back: () => {
this.index = Math.max(0, this.index - 1);
this.restore();
this.dispatchEvent(new Event("popstate"));
},
forward: () => {
this.index = Math.min(this.entries.length - 1, this.index + 1);
this.restore();
this.dispatchEvent(new Event("popstate"));
},
};
private restore() {
const entry = this.entries[this.index];
assert.ok(entry);
this.location = new URL(entry.href, "http://localhost");
this.history.state = entry.state;
}
}
test("History mantiene URL, filtros, Atrás/Adelante, cierre y suscripciones sin entradas duplicadas", () => {
const browser = new BrowserFixture();
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { value: browser, configurable: true });
try {
let updates = 0;
const unsubscribe = subscribeToNavigation(() => {
updates += 1;
});
navigateTo("/tickets?pendingOnly=true");
assert.equal(getBrowserLocation(), "/tickets?pendingOnly=true");
navigateTo("/tickets?pendingOnly=true");
assert.equal(updates, 1);
navigateTo("/companies/company/tickets/ticket");
closeRoute("/dashboard");
assert.equal(getBrowserLocation(), "/tickets?pendingOnly=true");
browser.history.forward();
assert.equal(getBrowserLocation(), "/companies/company/tickets/ticket");
navigateTo("/catalogs", { replace: true });
browser.history.back();
assert.equal(getBrowserLocation(), "/tickets?pendingOnly=true");
unsubscribe();
const observed = updates;
navigateTo("/companies", { create: true });
assert.equal(isCreateNavigation(), true);
clearCreateNavigation("/dashboard");
assert.equal(isCreateNavigation(), true);
clearCreateNavigation("/companies");
assert.equal(isCreateNavigation(), false);
browser.history.back();
browser.history.forward();
assert.equal(isCreateNavigation(), false);
assert.equal(updates, observed);
browser.history.replaceState(null, "", "/companies/company/people/person");
closeRoute("/companies/company/people");
assert.equal(getBrowserLocation(), "/companies/company/people");
assert.throws(() => navigateTo("//example.test"));
} finally {
if (descriptor) {
Object.defineProperty(globalThis, "window", descriptor);
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
});
test("las redirecciones internas rechazan destinos externos, barras invertidas y caracteres de control", () => {
for (const href of [
"https://example.test",
"//example.test",
"/\\example.test",
"/\nexample.test",
"tickets",
"",
]) {
assert.equal(isInternalPath(href), false);
}
assert.equal(isInternalPath("/companies/019?tab=fields"), true);
});

View File

@ -0,0 +1,79 @@
const NAVIGATION_EVENT = "metis:navigation";
export function isInternalPath(value: string): boolean {
return (
value.startsWith("/") &&
!value.startsWith("//") &&
!value.includes("\\") &&
Array.from(value).every((character) => character.charCodeAt(0) > 32)
);
}
export function getBrowserLocation(): string {
return typeof window === "undefined"
? "/dashboard"
: `${window.location.pathname}${window.location.search}`;
}
export function subscribeToNavigation(listener: () => void): () => void {
window.addEventListener("popstate", listener);
window.addEventListener(NAVIGATION_EVENT, listener);
return () => {
window.removeEventListener("popstate", listener);
window.removeEventListener(NAVIGATION_EVENT, listener);
};
}
export function navigateTo(href: string, options: { replace?: boolean; create?: boolean } = {}) {
if (!isInternalPath(href)) {
throw new Error("Invalid internal navigation destination");
}
if (typeof window === "undefined") {
return;
}
const previous = getBrowserLocation();
if (previous === href && !options.create) {
return;
}
if (options.replace) {
window.history.replaceState(window.history.state, "", href);
} else {
window.history.pushState({ previous, create: options.create === true }, "", href);
}
window.dispatchEvent(new Event(NAVIGATION_EVENT));
}
export function closeRoute(fallback: string) {
if (typeof window !== "undefined") {
const state: unknown = window.history.state;
if (
typeof state === "object" &&
state !== null &&
"previous" in state &&
typeof state.previous === "string" &&
isInternalPath(state.previous)
) {
window.history.back();
return;
}
}
navigateTo(fallback, { replace: true });
}
export function isCreateNavigation(): boolean {
if (typeof window === "undefined") {
return false;
}
const state: unknown = window.history.state;
return typeof state === "object" && state !== null && "create" in state && state.create === true;
}
export function clearCreateNavigation(expectedLocation: string) {
if (typeof window === "undefined" || getBrowserLocation() !== expectedLocation) {
return;
}
const state: unknown = window.history.state;
if (typeof state === "object" && state !== null && "create" in state && state.create === true) {
window.history.replaceState({ ...state, create: false }, "", getBrowserLocation());
}
}

View File

@ -0,0 +1,24 @@
import { useSyncExternalStore } from "react";
import {
getBrowserLocation,
navigateTo,
subscribeToNavigation,
} from "../helpers/browser-navigation";
export function useBrowserLocation() {
return useSyncExternalStore(subscribeToNavigation, getBrowserLocation, getBrowserLocation);
}
export function useBrowserSearchParams() {
const location = useBrowserLocation();
const searchParams = new URL(location, "http://metis.invalid").searchParams;
const setSearchParams = (update: (params: URLSearchParams) => void) => {
const params = new URL(getBrowserLocation(), "http://metis.invalid").searchParams;
update(params);
const search = params.toString();
const pathname = getBrowserLocation().split("?")[0] ?? "/dashboard";
navigateTo(`${pathname}${search ? `?${search}` : ""}`, { replace: true });
};
return { searchParams, setSearchParams };
}

View File

@ -0,0 +1,15 @@
import { useEffect } from "react";
import { clearCreateNavigation, isCreateNavigation } from "../helpers/browser-navigation";
import { useBrowserLocation } from "./use-browser-location";
/** Consumir el alta cuando se monta su pantalla, no mientras todavía carga la compañía. */
export function useCreateNavigation(): boolean {
const location = useBrowserLocation();
const create = isCreateNavigation();
useEffect(() => {
clearCreateNavigation(location);
}, [location]);
return create;
}

View File

@ -0,0 +1,27 @@
import { useState } from "react";
import { closeRoute, navigateTo } from "../helpers/browser-navigation";
import { useBrowserLocation } from "./use-browser-location";
/** La selección es URL cuando la lista está en una ruta; los consumidores embebidos conservan selección local. */
export function useEntityNavigation(basePath: string, initialId?: string) {
const location = useBrowserLocation();
const pathname = location.split("?")[0] ?? "";
const routed = pathname === basePath || pathname.startsWith(`${basePath}/`);
const [localId, setLocalId] = useState(initialId);
const selectedId = routed
? pathname.slice(basePath.length + 1).split("/")[0] || undefined
: localId;
const entityHref = (id: string) => `${basePath}/${encodeURIComponent(id)}`;
const selectEntity = (id: string | undefined) => {
if (!routed) {
setLocalId(id);
} else if (id) {
navigateTo(entityHref(id));
} else {
closeRoute(basePath);
}
};
return { selectedId, selectEntity, entityHref, routed };
}

View File

@ -0,0 +1,30 @@
import type { ComponentProps } from "react";
import { isInternalPath, navigateTo } from "../helpers/browser-navigation";
export function BrowserLink({ href, onClick, ...props }: ComponentProps<"a"> & { href: string }) {
return (
<a
{...props}
href={href}
onClick={(event) => {
onClick?.(event);
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
props.download !== undefined ||
(props.target && props.target !== "_self") ||
!isInternalPath(href)
) {
return;
}
event.preventDefault();
navigateTo(href);
}}
/>
);
}

View File

@ -0,0 +1,10 @@
import { useTranslation } from "react-i18next";
export function EntityNotFound() {
const { t } = useTranslation("core");
return (
<p className="text-sm text-destructive" role="alert">
{t("navigation.recordNotFound")}
</p>
);
}

View File

@ -13,6 +13,7 @@
"test": "node --import tsx --test src/api/domain/user.aggregate.test.ts src/api/infrastructure/persistence/user-persistence.mapper.test.ts src/api/infrastructure/security/scrypt-password-hasher.test.ts src/web/shared/api/identity-api.test.ts" "test": "node --import tsx --test src/api/domain/user.aggregate.test.ts src/api/infrastructure/persistence/user-persistence.mapper.test.ts src/api/infrastructure/security/scrypt-password-hasher.test.ts src/web/shared/api/identity-api.test.ts"
}, },
"dependencies": { "dependencies": {
"@metis/core": "workspace:*",
"@repo/rdx-utils": "workspace:*", "@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",
"zod": "4.3.6", "zod": "4.3.6",

View File

@ -1,5 +1,5 @@
import { closeRoute, navigateTo, useBrowserLocation } from "@metis/core/client";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { logout as logoutSession } from "../../../sessions/shared/api"; import { logout as logoutSession } from "../../../sessions/shared/api";
import { identityKeys } from "../../../shared/queries"; import { identityKeys } from "../../../shared/queries";
@ -7,15 +7,29 @@ import type { AuthenticatedWorkspaceProps } from "../types";
export function useAuthenticatedWorkspaceController({ snapshot }: AuthenticatedWorkspaceProps) { export function useAuthenticatedWorkspaceController({ snapshot }: AuthenticatedWorkspaceProps) {
const client = useQueryClient(); const client = useQueryClient();
const [page, setPage] = useState("home"); const location = useBrowserLocation();
const pathname = location.split("?")[0];
const page =
pathname === "/settings/users"
? "users"
: pathname === "/settings/password"
? "password"
: "home";
const setPage = (value: string) => {
if (value === "home") {
closeRoute("/dashboard");
} else if (value === "users" || value === "password") {
navigateTo(`/settings/${value}`);
}
};
const logout = useMutation({ const logout = useMutation({
mutationFn: () => logoutSession(snapshot.csrfToken), mutationFn: () => logoutSession(snapshot.csrfToken),
onSuccess: async () => { onSuccess: async () => {
setPage("home");
client.removeQueries({ client.removeQueries({
predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session", predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session",
}); });
await client.invalidateQueries({ queryKey: identityKeys.session }); await client.invalidateQueries({ queryKey: identityKeys.session });
navigateTo("/login", { replace: true });
}, },
}); });
return { page, setPage, logout }; return { page, setPage, logout };

View File

@ -1,3 +1,4 @@
import { BrowserLink } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -28,6 +29,16 @@ export function CustomWorkspace({
if (!snapshot.user) { if (!snapshot.user) {
return null; return null;
} }
if (page === "users" && !snapshot.user.isInitialAdmin) {
return (
<section className="space-y-4 p-6" role="alert">
<p>{t("errors.FORBIDDEN")}</p>
<BrowserLink className="underline" href="/dashboard">
{t("workspace")}
</BrowserLink>
</section>
);
}
return ( return (
<> <>
{renderWorkspace( {renderWorkspace(
@ -36,11 +47,21 @@ export function CustomWorkspace({
<p className="truncate text-sm font-medium">{snapshot.user.name}</p> <p className="truncate text-sm font-medium">{snapshot.user.name}</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{snapshot.user.isInitialAdmin && ( {snapshot.user.isInitialAdmin && (
<Button onClick={() => setPage("users")} size="sm" variant="ghost"> <Button
nativeButton={false}
render={<BrowserLink href="/settings/users" />}
size="sm"
variant="ghost"
>
{t("users")} {t("users")}
</Button> </Button>
)} )}
<Button onClick={() => setPage("password")} size="sm" variant="ghost"> <Button
nativeButton={false}
render={<BrowserLink href="/settings/password" />}
size="sm"
variant="ghost"
>
{t("changePassword")} {t("changePassword")}
</Button> </Button>
<Button disabled={logoutPending} onClick={onLogout} size="sm" variant="ghost"> <Button disabled={logoutPending} onClick={onLogout} size="sm" variant="ghost">

View File

@ -14,6 +14,7 @@
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts" "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts"
}, },
"dependencies": { "dependencies": {
"@metis/core": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*", "@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*", "@repo/shadcn-ui": "workspace:*",

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
@ -8,8 +9,26 @@ import type { ResourceCatalogPageProps } from "../types";
export function useResourceCatalogPageController({ scope, csrfToken }: ResourceCatalogPageProps) { export function useResourceCatalogPageController({ scope, csrfToken }: ResourceCatalogPageProps) {
const client = useQueryClient(); const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template">("template"); const { searchParams, setSearchParams } = useBrowserSearchParams();
const [search, setSearch] = useState(""); const requestedTab = searchParams.get("tab");
const tab: "field" | "template" = requestedTab === "fields" ? "field" : "template";
const setTab = (value: "field" | "template") =>
setSearchParams((params) => {
if (value === "template") {
params.delete("tab");
} else {
params.set("tab", "fields");
}
});
const search = searchParams.get("q") ?? "";
const setSearch = (value: string) =>
setSearchParams((params) => {
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
});
const [viewId, setViewId] = useState<string>(); const [viewId, setViewId] = useState<string>();
const [editor, setEditor] = useState<CatalogEditor>(); const [editor, setEditor] = useState<CatalogEditor>();
const query = useQuery({ const query = useQuery({

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
@ -13,9 +14,22 @@ export function useResourcesPageController({
initialId, initialId,
initialCreate = false, initialCreate = false,
}: ResourcesPageProps) { }: ResourcesPageProps) {
const [search, setSearch] = useState(""); const { searchParams, setSearchParams } = useBrowserSearchParams();
const search = searchParams.get("q") ?? "";
const setSearch = (value: string) =>
setSearchParams((params) => {
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
});
const [editor, setEditor] = useState<string | undefined>(() => initialEditor(initialCreate)); const [editor, setEditor] = useState<string | undefined>(() => initialEditor(initialCreate));
const [viewId, setViewId] = useState<string | undefined>(initialId); const {
selectedId: viewId,
selectEntity: setViewId,
entityHref,
} = useEntityNavigation(`/companies/${companyId}/resources`, initialId);
const query = useQuery({ const query = useQuery({
queryKey: inventoryKeys.company(companyId), queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId), queryFn: () => inventoryApi.get(companyId),
@ -42,6 +56,8 @@ export function useResourcesPageController({
catalog, catalog,
edited, edited,
viewed, viewed,
entityHref,
missingView: Boolean(viewId && query.isSuccess && !viewed),
visible, visible,
}; };
} }

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { import {
EntityAction, EntityAction,
@ -26,9 +27,14 @@ export function ResourcesPage(props: ResourcesPageProps) {
catalog, catalog,
edited, edited,
viewed, viewed,
entityHref,
missingView,
visible, visible,
} = useResourcesPageController(props); } = useResourcesPageController(props);
const { t, i18n } = useTranslation("resources"); const { t, i18n } = useTranslation("resources");
if (missingView) {
return <EntityNotFound />;
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<EntityListHeader <EntityListHeader
@ -65,13 +71,12 @@ export function ResourcesPage(props: ResourcesPageProps) {
value: (item) => item.values.name, value: (item) => item.values.name,
hideable: false, hideable: false,
render: (item) => ( render: (item) => (
<button <BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring" className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setViewId(item.id)} href={entityHref(item.id)}
type="button"
> >
{item.values.name} {item.values.name}
</button> </BrowserLink>
), ),
}, },
{ {

View File

@ -139,6 +139,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: 5.2.2 specifier: 5.2.2
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5)) version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
'@metis/core':
specifier: workspace:*
version: link:../core
'@repo/rdx-ddd': '@repo/rdx-ddd':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-ddd version: link:../../packages/rdx-ddd
@ -203,6 +206,9 @@ importers:
'@metis/assets': '@metis/assets':
specifier: workspace:* specifier: workspace:*
version: link:../assets version: link:../assets
'@metis/core':
specifier: workspace:*
version: link:../core
'@metis/resources': '@metis/resources':
specifier: workspace:* specifier: workspace:*
version: link:../resources version: link:../resources
@ -310,6 +316,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: 5.2.2 specifier: 5.2.2
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5)) version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
'@metis/core':
specifier: workspace:*
version: link:../core
'@repo/rdx-ddd': '@repo/rdx-ddd':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-ddd version: link:../../packages/rdx-ddd
@ -377,6 +386,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: 5.2.2 specifier: 5.2.2
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5)) version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
'@metis/core':
specifier: workspace:*
version: link:../core
'@repo/rdx-ddd': '@repo/rdx-ddd':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-ddd version: link:../../packages/rdx-ddd