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 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": {
"dev": "vite --host --clearScreen false",
"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",
"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("Mi contraseña"));
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 } });
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 () => {

View File

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

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
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 |
| --- | --- | --- |
@ -102,7 +102,7 @@ La validación debe incluir tipos, estilo, pruebas y compilación, además de in
### 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.

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.
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.
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"
},
"dependencies": {
"@metis/core": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "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 { useState } from "react";
import { useTranslation } from "react-i18next";
@ -7,6 +8,8 @@ import { inventoryKeys } from "../../../shared/queries";
import { getAssetInventory } from "../api";
import type { AssetInventoryPageProps } from "../types";
import { useCompactInventory } from "./use-compact-inventory";
export function useAssetInventoryPageController({
companyId,
lookups,
@ -22,13 +25,30 @@ export function useAssetInventoryPageController({
queryKey: catalogKeys.scope(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 [viewId, setViewId] = useState<string | undefined>();
const [selectedId, setSelectedId] = useState<string | undefined>(initialId);
const compact = useCompactInventory();
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 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 viewed = assets.find((asset) => asset.id === viewId);
const filtered = assets.filter((asset) => {
@ -44,6 +64,8 @@ export function useAssetInventoryPageController({
});
return {
query,
entityHref,
missingView: Boolean(selectedId && query.isSuccess && !selected),
catalog,
search,
setSearch,

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button";
import {
EntityAction,
@ -37,8 +38,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
edited,
viewed,
filtered,
missingView,
entityHref,
} = useAssetInventoryPageController(props);
const { t, i18n } = useTranslation("assets");
if (missingView) {
return <EntityNotFound />;
}
return (
<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)]"
@ -92,20 +98,14 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
value: (asset) => asset.values.name,
hideable: false,
render: (asset) => (
<button
<BrowserLink
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"
onClick={() => {
setSelectedId(asset.id);
setDetailOpen(true);
if (window.matchMedia("(max-width: 1099px)").matches) {
setViewId(asset.id);
}
}}
type="button"
href={entityHref(asset.id)}
onClick={() => setDetailOpen(true)}
>
{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 { useState } from "react";
@ -8,8 +9,27 @@ import type { AssetCatalogPageProps } from "../types";
export function useAssetCatalogPageController({ scope, csrfToken }: AssetCatalogPageProps) {
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
const [search, setSearch] = useState("");
const { searchParams, setSearchParams } = useBrowserSearchParams();
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 [editor, setEditor] = useState<CatalogEditor>();
const query = useQuery({

View File

@ -10,9 +10,10 @@
},
"scripts": {
"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": {
"@metis/core": "workspace:*",
"@hookform/resolvers": "5.2.2",
"@metis/assets": "workspace:*",
"@metis/resources": "workspace:*",

View File

@ -1,5 +1,47 @@
{
"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": {
"title": "Intervention templates",
"new": "New intervention template",

View File

@ -1,5 +1,47 @@
{
"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": {
"title": "Plantillas 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 { useState } from "react";
import type { AccessKind } from "../../../../common";
import { accessFields, accessLabel } from "../../shared/helpers";
import { accessKinds } from "../../shared/helpers/access-kinds";
import { accessKeys } from "../../shared/queries";
import { getAccess } from "../api";
import type { AccessWorkspacePageProps } from "../types";
@ -12,30 +14,30 @@ export function useAccessWorkspacePageController({ scope, people = [] }: AccessW
queryKey: accessKeys.scope(scope),
queryFn: () => getAccess(scope),
});
const [kind, setKind] = useState<AccessKind>(scope === "global" ? "system" : "account");
const { searchParams, setSearchParams } = useBrowserSearchParams();
const [editor, setEditor] = useState<string>();
const [history, setHistory] = useState<string>();
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 selected = data?.items.find((item) => item.id === editor);
const historical = data?.items.find((item) => item.id === history);
const kinds: readonly AccessKind[] =
scope === "global"
? ["system", "resource_type", "permission", "template"]
: [
"account",
"account_person",
"group",
"membership",
"grant",
"need",
"coverage",
"environment",
"system",
"resource_type",
"permission",
];
const kinds = accessKinds(scope);
const kind =
kinds.find((value) => value === searchParams.get("kind")) ??
(scope === "global" ? "system" : "account");
const visible =
data?.items.filter(
(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({
id,
embedded = false,
initialSection,
section,
onSectionChange,
}: CompanyDetailProps) {
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => getCompany(id) });
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;
return { query, editOpen, setEditOpen, tab, setTab, company };

View File

@ -9,6 +9,15 @@ export type CompanyDetailProps = {
embedded?: boolean;
accountNavigation?: ReactNode;
initialSection?: CompanyTab;
section?: CompanyTab;
onSectionChange?: (section: CompanyTab) => void;
initialConfiguration?:
| "folders"
| "mail"
| "certificates"
| "applications"
| "printers"
| undefined;
initialEntityId?: string | 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 {
BoxIcon,
BriefcaseIcon,
DatabaseIcon,
HomeIcon,
KeyRoundIcon,
LayersIcon,
LayoutTemplateIcon,
@ -11,6 +14,7 @@ import {
} from "@repo/shadcn-ui/lib/icons";
import { useTranslation } from "react-i18next";
import { companySectionHref } from "../../../workspace/shared/helpers";
import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
import { useCompanyDetailController } from "../controllers";
import type { CompanyDetailProps } from "../types";
@ -26,6 +30,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
initialSection,
initialEntityId,
initialCreate,
initialConfiguration,
} = props;
const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props);
const { t } = useTranslation("companies");
@ -111,6 +116,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
<nav aria-label={t("companySections")} className={layout.navigation}>
{(
[
["overview", t("companyOverview.title"), HomeIcon],
["inventory", assetTranslation("inventory"), BoxIcon],
["resources", resourceTranslation("inventory"), LayersIcon],
["workstations", assetTranslation("workstations"), BriefcaseIcon],
@ -134,6 +140,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
["applications", t("applications.title"), LayersIcon],
["printers", t("printers.title"), LayersIcon],
["mail", t("mail.title"), MailIcon],
["catalogs", t("catalogOverview.companyTitle"), DatabaseIcon],
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
] as const
@ -146,7 +153,13 @@ export function CompanyDetail(props: CompanyDetailProps) {
: undefined
}
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"
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
@ -169,8 +182,11 @@ export function CompanyDetail(props: CompanyDetailProps) {
<CompanySection
company={company}
csrfToken={csrfToken}
initialCreate={tab === initialSection ? initialCreate : false}
initialEntityId={tab === initialSection ? initialEntityId : undefined}
initialConfiguration={initialConfiguration}
initialCreate={props.section || tab === initialSection ? initialCreate : false}
initialEntityId={
props.section || tab === initialSection ? initialEntityId : undefined
}
tab={tab}
/>
</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 { useBrowserSearchParams, useCreateNavigation } from "@metis/core/client";
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
import type { CompanyDTO } from "../../../../common";
@ -19,9 +20,11 @@ import { SiteSection } from "../../../sites";
import { TeleworkAuthorizationsPage } from "../../../telework-authorizations";
import { TicketsPage } from "../../../tickets";
import { WorkProfilesPage } from "../../../work-profiles";
import { CatalogOverview } from "../../../workspace/catalogs/ui/catalog-overview";
import type { CompanyTab } from "../../shared/types";
import { CompanyInventory } from "./company-inventory";
import { CompanyOverview } from "./company-overview";
export function CompanySection({
company,
@ -29,26 +32,58 @@ export function CompanySection({
tab,
initialEntityId,
initialCreate,
initialConfiguration,
}: {
company: CompanyDTO;
csrfToken: string;
tab: CompanyTab;
initialEntityId?: string | 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) {
case "overview":
return <CompanyOverview company={company} csrfToken={csrfToken} />;
case "catalogs":
return <CatalogOverview companyId={company.id} />;
case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />;
case "interventions":
return <InterventionsPage companyId={company.id} csrfToken={csrfToken} />;
return (
<InterventionsPage
companyId={company.id}
csrfToken={csrfToken}
initialId={initialEntityId}
/>
);
case "tickets":
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
return (
<TicketsPage companyId={company.id} csrfToken={csrfToken} initialId={initialEntityId} />
);
case "processes":
return <ProcessesPage company={company} csrfToken={csrfToken} />;
case "remote":
return <RemoteConnectionsPage company={company} csrfToken={csrfToken} />;
return (
<RemoteConnectionsPage company={company} csrfToken={csrfToken} initialPersonId={personId} />
);
case "telework":
return <TeleworkAuthorizationsPage company={company} csrfToken={csrfToken} />;
return (
<TeleworkAuthorizationsPage
company={company}
csrfToken={csrfToken}
initialPersonId={personId}
/>
);
case "people":
return <PeopleSection company={company} csrfToken={csrfToken} initialId={initialEntityId} />;
case "departments":
@ -61,18 +96,53 @@ export function CompanySection({
);
case "profiles":
return (
<WorkProfilesPage company={company} csrfToken={csrfToken} initialId={initialEntityId} />
<WorkProfilesPage
company={company}
csrfToken={csrfToken}
initialConfiguration={initialConfiguration}
initialId={initialEntityId}
/>
);
case "certificates":
return <CertificateConfigurationsPage company={company} csrfToken={csrfToken} />;
return (
<CertificateConfigurationsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "applications":
return <ApplicationConfigurationsPage company={company} csrfToken={csrfToken} />;
return (
<ApplicationConfigurationsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "printers":
return <PrinterConfigurationsPage company={company} csrfToken={csrfToken} />;
return (
<PrinterConfigurationsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "folders":
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
return (
<FolderMappingsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "mail":
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
return (
<MailConfigurationsPage
company={company}
csrfToken={csrfToken}
{...(profileId ? { initialProfileId: profileId } : {})}
/>
);
case "workstations":
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
case "inventory":
@ -80,7 +150,7 @@ export function CompanySection({
<CompanyInventory
company={company}
csrfToken={csrfToken}
initialCreate={initialCreate}
initialCreate={create}
initialId={initialEntityId}
/>
);
@ -89,7 +159,7 @@ export function CompanySection({
<ResourcesPage
companyId={company.id}
csrfToken={csrfToken}
initialCreate={initialCreate}
initialCreate={create}
initialId={initialEntityId}
relatedContent={(resource) =>
resource.values.integration?.kind === "mailbox" ? (

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
@ -21,10 +22,37 @@ export function useCompaniesPageController({
}: CompaniesPageProps) {
const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
const [selectedId, setSelectedId] = useState<string | undefined>();
const [companyId, setCompanyId] = useState(initialCompanyId);
const [activeOnly, setActiveOnly] = useState(initialActiveOnly);
const { searchParams, setSearchParams } = useBrowserSearchParams();
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 [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({
data: query.data?.companies ?? EMPTY_COMPANIES,
columns: COMPANY_COLUMNS,

View File

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

View File

@ -11,6 +11,7 @@ import { AssignmentForm } from "../../assignments";
import { PersonForm } from "../../people";
import { companiesEnglish, companiesSpanish } from "../../shared";
import { SiteForm } from "../../sites";
import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview";
import { CompanyDetail } from "../detail";
import { CompaniesPage } from "../list";
@ -43,6 +44,58 @@ async function renderPage(
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 () => {
for (const [language, title, empty] of [
["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 () => {
const markup = await renderPage(
"es",
<CompanyDetail csrfToken="test" id={companyId} onBack={() => undefined} />,
<CompanyDetail
csrfToken="test"
id={companyId}
initialSection="people"
onBack={() => undefined}
/>,
(client) =>
client.setQueryData(companyKeys.detail(companyId), {
company: {

View File

@ -1,4 +1,6 @@
export type CompanyTab =
| "overview"
| "catalogs"
| "interventions"
| "intervention-templates"
| "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 { 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 { TicketForm } from "../../../tickets";
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 { useTranslation } from "react-i18next";
import { CompanyIdentity } from "../../../companies";
import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
import { BlockState, DashboardNavigationButton, DashboardTable } from "../../shared";
import { entryDestination } from "../../shared/helpers";
import { useDashboardDetailsDialogController } from "../controllers";

View File

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

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import type { ComponentProps } from "react";
import { useState } from "react";
@ -8,8 +9,17 @@ import type { DashboardQuickCreate } from "../../create";
import type { DashboardDetailsDialog } from "../../detail";
import { dashboardApi, dashboardKeys } from "../../shared/api";
export function useDashboardPageController() {
const [companyId, setCompanyId] = useState("");
export function useDashboardPageController(fixedCompanyId?: string) {
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 [details, setDetails] =
useState<ComponentProps<typeof DashboardDetailsDialog>["details"]>();

View File

@ -3,4 +3,7 @@ import type { DashboardNavigation } from "../../shared/helpers";
export type DashboardPageProps = {
onNavigate: (destination: DashboardNavigation) => void;
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 { useTranslation } from "react-i18next";
import { CompanyIdentity } from "../../../companies";
import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
import { BlockState, DashboardNavigationButton, DashboardPanel } from "../../shared";
import { dashboardApi, dashboardKeys } from "../../shared/api";
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 { useTranslation } from "react-i18next";
import { CompanyIdentity } from "../../../companies";
import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
import {
BlockState,
DashboardNavigationButton,

View File

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

View File

@ -1,3 +1,4 @@
import { navigateTo, useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -25,16 +26,59 @@ export function useInterventionsPageController({
queryKey: interventionKeys.list(companyId),
queryFn: () => interventionApi.list(companyId),
});
const [filters, setFilters] = useState<InterventionFilters>({
search: "",
company: "",
state: "",
dateFrom: "",
dateTo: "",
assignee: "",
});
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly);
const [viewId, setViewId] = useState(initialId);
const { searchParams, setSearchParams } = useBrowserSearchParams();
const filters: InterventionFilters = {
search: searchParams.get("q") ?? "",
company: searchParams.get("companyId") ?? "",
state: searchParams.get("state") ?? "",
dateFrom: searchParams.get("dateFrom") ?? "",
dateTo: searchParams.get("dateTo") ?? "",
assignee: searchParams.get("assignee") ?? "",
};
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 [edit, setEdit] = useState<InterventionDTO | undefined>();
const currentView = query.data?.interventions.find((intervention) => intervention.id === viewId);
@ -52,6 +96,7 @@ export function useInterventionsPageController({
edit,
setEdit,
currentView,
missingView: Boolean(viewId && query.isSuccess && !currentView),
items,
date,
};

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button";
import {
EntityAction,
@ -30,10 +31,14 @@ export function InterventionsPage(props: InterventionsPageProps) {
edit,
setEdit,
currentView,
missingView,
items,
date,
} = useInterventionsPageController(props);
const { t, i18n } = useTranslation("companies");
if (missingView) {
return <EntityNotFound />;
}
return (
<div className="space-y-5">
<EntityListHeader
@ -78,13 +83,12 @@ export function InterventionsPage(props: InterventionsPageProps) {
value: (intervention) => intervention.reference,
hideable: false,
render: (intervention) => (
<button
<BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setViewId(intervention.id)}
type="button"
href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
>
{intervention.reference}
</button>
</BrowserLink>
),
},
{
@ -93,13 +97,12 @@ export function InterventionsPage(props: InterventionsPageProps) {
value: (intervention) => intervention.values.subject,
hideable: false,
render: (intervention) => (
<button
<BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setViewId(intervention.id)}
type="button"
href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
>
{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 { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import {
@ -29,9 +30,13 @@ export function PeopleSection({
const [selection, setSelection] = useState<
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
>(initialId ? { mode: "detail", id: initialId } : undefined);
const { selectedId, selectEntity, entityHref, routed } = useEntityNavigation(
`/companies/${company.id}/people`,
initialId
);
const [search, setSearch] = useState("");
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 = (
<PersonEditorDialog
companyId={company.id}
@ -41,11 +46,17 @@ export function PeopleSection({
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"}
person={selected}
/>
);
if (routed && selectedId && !selected) {
return <EntityNotFound />;
}
if (selected) {
return (
<>
@ -54,7 +65,10 @@ export function PeopleSection({
company={company}
csrfToken={csrfToken}
key={selected.id}
onBack={() => setSelection(undefined)}
onBack={() => {
setSelection(undefined);
selectEntity(undefined);
}}
onEdit={() => setSelection({ mode: "edit", id: selected.id })}
person={selected}
/>
@ -89,13 +103,12 @@ export function PeopleSection({
hideable: false,
render: (person) => (
<>
<button
<BrowserLink
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 })}
type="button"
href={entityHref(person.id)}
>
{person.name}
</button>
</BrowserLink>
{person.isContact && (
<p className="text-xs text-muted-foreground">{t("isContact")}</p>
)}
@ -140,7 +153,12 @@ export function PeopleSection({
hideable: false,
render: (person) => (
<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")}
</EntityAction>
</EntityRowActions>

View File

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

View File

@ -1,3 +1,4 @@
import { BrowserLink, EntityNotFound } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button";
import {
EntityAction,
@ -8,7 +9,6 @@ import {
} from "@repo/shadcn-ui/components/entity-list";
import { useTranslation } from "react-i18next";
import { ProcessTemplatesPage } from "../../../process-templates";
import { ProcessDetail } from "../../detail";
import { ProcessFeedback } from "../../shared";
import { ProcessStartDialog } from "../../start";
@ -18,8 +18,6 @@ import type { ProcessesPageProps } from "../types";
export function ProcessesPage(props: ProcessesPageProps) {
const { company, csrfToken } = props;
const {
templates,
setTemplates,
create,
setCreate,
selected,
@ -31,18 +29,12 @@ export function ProcessesPage(props: ProcessesPageProps) {
query,
person,
current,
missingView,
filtered,
} = useProcessesPageController(props);
const { t, i18n } = useTranslation("companies");
if (templates) {
return (
<div className="space-y-5">
<Button onClick={() => setTemplates(false)} variant="outline">
{t("processes.back")}
</Button>
<ProcessTemplatesPage csrfToken={csrfToken} />
</div>
);
if (missingView) {
return <EntityNotFound />;
}
return (
<div className="space-y-5">
@ -77,7 +69,11 @@ export function ProcessesPage(props: ProcessesPageProps) {
<>
{" "}
<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")}
</Button>
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
@ -99,13 +95,12 @@ export function ProcessesPage(props: ProcessesPageProps) {
value: (p) => p.values.name,
hideable: false,
render: (p) => (
<button
<BrowserLink
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => setSelected(p.id)}
type="button"
href={`/companies/${company.id}/processes/${p.id}`}
>
{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 { useState } from "react";
import { useTranslation } from "react-i18next";
@ -25,15 +26,54 @@ export function useTicketsPageController({
queryKey: ticketKeys.list(companyId),
queryFn: () => ticketApi.list(companyId),
});
const [filters, setFilters] = useState<TicketFilters>({
search: "",
company: "",
state: "",
priority: "",
assignee: "",
});
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly);
const [viewId, setViewId] = useState(initialId);
const { searchParams, setSearchParams } = useBrowserSearchParams();
const filters: TicketFilters = {
search: searchParams.get("q") ?? "",
company: searchParams.get("companyId") ?? "",
state: searchParams.get("state") ?? "",
priority: searchParams.get("priority") ?? "",
assignee: searchParams.get("assignee") ?? "",
};
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 [edit, setEdit] = useState<TicketDTO | undefined>();
const [interventionTicketId, setInterventionTicketId] = useState<string>();
@ -56,6 +96,7 @@ export function useTicketsPageController({
setEdit,
setInterventionTicketId,
currentView,
missingView: Boolean(viewId && query.isSuccess && !currentView),
interventionTicket,
items,
date,

View File

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

View File

@ -1,4 +1,10 @@
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import {
closeRoute,
navigateTo,
useBrowserSearchParams,
useEntityNavigation,
} from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -6,19 +12,98 @@ import { useTranslation } from "react-i18next";
import { getAccess } from "../../../access/list/api";
import { accessLabel } from "../../../access/shared/helpers";
import { accessKeys } from "../../../access/shared/queries";
import { companySectionHref, workspaceHref } from "../../../workspace/shared/helpers";
import { profileApi, profileKeys } from "../../shared/api";
import type { ProfileSelection } from "../../shared/types";
import type { WorkProfilesPageProps } from "../types";
export function useWorkProfilesPageController({ company, initialId }: WorkProfilesPageProps) {
export function useWorkProfilesPageController({
company,
initialId,
initialConfiguration,
}: WorkProfilesPageProps) {
const { t } = useTranslation("companies");
const [configuration, setConfiguration] = useState<ProfileSelection | undefined>(
const [localConfiguration, setLocalConfiguration] = useState<ProfileSelection | 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 [view, setView] = useState<string>();
const [search, setSearch] = useState("");
const [archived, setArchived] = 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 archived = searchParams.get("archived") === "true";
const setArchived = (value: boolean) =>
setSearchParams((params) => {
if (value) {
params.set("archived", "true");
} else {
params.delete("archived");
}
});
const query = useQuery({
queryKey: profileKeys.company(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);
return {
configuration,
missingProfile: Boolean(
configuration &&
query.isSuccess &&
!profiles.some((item) => item.id === configuration.profileId)
),
setConfiguration,
closeConfiguration,
editor,
setEditor,
setView,

View File

@ -4,4 +4,11 @@ export type WorkProfilesPageProps = {
company: CompanyDTO;
csrfToken: string;
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 {
EntityAction,
@ -19,6 +20,7 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
const {
configuration,
setConfiguration,
closeConfiguration,
editor,
setEditor,
setView,
@ -37,20 +39,18 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
account,
filtered,
ready,
missingProfile,
} = useWorkProfilesPageController(props);
const { t, i18n } = useTranslation("companies");
if (missingProfile) {
return <EntityNotFound />;
}
if (configuration) {
return (
<ProfileConfigurations
company={company}
csrfToken={csrfToken}
onBack={() =>
setConfiguration(
configuration.fromEnvironment
? { kind: "environment", profileId: configuration.profileId }
: undefined
)
}
onBack={closeConfiguration}
onSelection={setConfiguration}
personId={profiles.find((item) => item.id === configuration.profileId)?.values.personId}
selection={configuration}
@ -97,13 +97,12 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
value: (item) => item.values.name,
hideable: false,
render: (item) => (
<button
<BrowserLink
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 })}
type="button"
href={`/companies/${company.id}/work-profiles/${item.id}`}
>
{item.values.name}
</button>
</BrowserLink>
),
},
{

View File

@ -1 +1,2 @@
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 { InterventionTemplatesPage } from "../../../intervention-templates";
import { ProcessTemplatesPage } from "../../../process-templates";
import type { GlobalCatalog } from "../../shared/types";
export function GlobalCatalogContent({
catalog,
csrfToken,
}: {
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
catalog: GlobalCatalog;
csrfToken: string;
}) {
switch (catalog) {
case "process-templates":
return <ProcessTemplatesPage csrfToken={csrfToken} />;
case "intervention-templates":
return <InterventionTemplatesPage csrfToken={csrfToken} />;
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 { 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";
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 { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentProps } from "react";
@ -5,11 +6,19 @@ import type { ComponentProps } from "react";
export function WorkspaceNavigationButton({
level = "primary",
className,
href,
...props
}: ComponentProps<typeof Button> & { level?: "primary" | "catalog" }) {
}: ComponentProps<typeof Button> & { level?: "primary" | "catalog"; href?: string }) {
return (
<Button
{...props}
{...(href
? {
render: <BrowserLink href={href} />,
nativeButton: false,
"aria-current": props["aria-pressed"] ? "page" : undefined,
}
: {})}
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",
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 { useState } from "react";
import { listCompanies } from "../../../companies/list/api";
import { companyKeys } from "../../../companies/shared/queries";
import { parseWorkspaceUrl, workspaceHref } from "../../shared/helpers";
import type { WorkspaceLocation } from "../../shared/types";
export function useCompaniesWorkspaceController() {
const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
const [location, setLocation] = useState<WorkspaceLocation>();
const [catalog, setCatalog] = useState<
"access" | "assets" | "brands" | "resources" | "intervention-templates"
>("access");
const url = useBrowserLocation();
const current = parseWorkspaceUrl(url);
const setLocation = (destination: WorkspaceLocation) =>
navigateTo(workspaceHref(destination), {
create: "create" in destination && destination.create === true,
});
const catalog = current.area === "catalogs" ? current.catalog : undefined;
const [settingsOpen, setSettingsOpen] = useState(false);
const companies = query.data?.companies ?? [];
const current = location ?? { area: "home" };
const selectCompany = (id: string) => setLocation({ area: "company", id, section: "inventory" });
const selectCompany = (id: string) => setLocation({ area: "company", id, section: "overview" });
return {
query,
setLocation,
catalog,
setCatalog,
settingsOpen,
setSettingsOpen,
companies,
current,
initialCreate: isCreateNavigation(),
selectCompany,
};
}

View File

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

View File

@ -1,10 +1,13 @@
import { useCreateNavigation } from "@metis/core/client";
import { CompaniesPage } from "../../../companies";
import { DashboardPage } from "../../../dashboard";
import type { DashboardNavigation } from "../../../dashboard/shared/helpers";
import { InterventionsPage } from "../../../interventions";
import { TicketsPage } from "../../../tickets";
import { GlobalCatalogContent } from "../../catalogs";
import type { WorkspaceLocation } from "../../shared/types";
import { CatalogOverview, GlobalCatalogContent } from "../../catalogs";
import type { GlobalCatalog, WorkspaceLocation } from "../../shared/types";
import { RouteFeedback } from "../../shared/ui/route-feedback";
export function WorkspaceContent({
area,
@ -13,14 +16,17 @@ export function WorkspaceContent({
csrfToken,
onNavigate,
onSelectCompany,
initialCreate,
}: {
area: WorkspaceLocation["area"];
navigation: WorkspaceLocation;
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
catalog: GlobalCatalog | undefined;
initialCreate?: boolean | undefined;
csrfToken: string;
onNavigate: (destination: DashboardNavigation) => void;
onSelectCompany: (id: string) => void;
}) {
const create = useCreateNavigation() || initialCreate;
switch (area) {
case "interventions":
return (
@ -28,9 +34,8 @@ export function WorkspaceContent({
csrfToken={csrfToken}
{...(navigation.area === "interventions"
? {
companyId: navigation.companyId,
initialId: navigation.id,
initialCreate: navigation.create,
initialCreate: create,
initialPendingOnly: navigation.pendingOnly,
}
: {})}
@ -42,9 +47,8 @@ export function WorkspaceContent({
csrfToken={csrfToken}
{...(navigation.area === "tickets"
? {
companyId: navigation.companyId,
initialId: navigation.id,
initialCreate: navigation.create,
initialCreate: create,
initialPendingOnly: navigation.pendingOnly,
}
: {})}
@ -59,16 +63,23 @@ export function WorkspaceContent({
? {
initialActiveOnly: navigation.activeOnly,
initialCompanyId: navigation.companyId,
initialCreate: navigation.create,
initialCreate: create,
}
: {})}
/>
);
case "catalogs":
return <GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />;
return catalog ? (
<GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />
) : (
<CatalogOverview />
);
case "company":
return null;
case "home":
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 { Input } from "@repo/shadcn-ui/components/input";
import { BellIcon, ChevronDownIcon, SearchIcon } from "@repo/shadcn-ui/lib/icons";
@ -7,18 +8,15 @@ import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "../../../companies";
import { InitialsAvatar } from "../../../shared";
import type { WorkspaceLocation } from "../../shared/types";
import { companySectionHref, workspaceHref } from "../../shared/helpers";
export function WorkspaceHeader({
companies,
onNavigate,
onSelectCompany,
accountNavigation,
userName,
languageSwitcher,
}: {
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
onNavigate: (location: WorkspaceLocation) => void;
onSelectCompany: (id: string) => void;
accountNavigation: ReactNode;
userName: string;
languageSwitcher: ReactNode;
@ -58,10 +56,9 @@ export function WorkspaceHeader({
<Button
className="justify-start"
key={company.id}
onClick={() => {
onSelectCompany(company.id);
setSearch("");
}}
nativeButton={false}
onClick={() => setSearch("")}
render={<BrowserLink href={companySectionHref(company.id, "overview")} />}
variant="ghost"
>
<CompanyIdentity
@ -87,10 +84,9 @@ export function WorkspaceHeader({
<Button
className="justify-start"
key={area}
onClick={() => {
onNavigate({ area });
setSearch("");
}}
nativeButton={false}
onClick={() => setSearch("")}
render={<BrowserLink href={workspaceHref({ area })} />}
variant="ghost"
>
{t(label)}

View File

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

View File

@ -10,7 +10,7 @@
},
"scripts": {
"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": {
"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.",
"workspace": "Your support workspace",
"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.",
"workspace": "Tu espacio de soporte",
"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 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";

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"
},
"dependencies": {
"@metis/core": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"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 { useState } from "react";
import { logout as logoutSession } from "../../../sessions/shared/api";
import { identityKeys } from "../../../shared/queries";
@ -7,15 +7,29 @@ import type { AuthenticatedWorkspaceProps } from "../types";
export function useAuthenticatedWorkspaceController({ snapshot }: AuthenticatedWorkspaceProps) {
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({
mutationFn: () => logoutSession(snapshot.csrfToken),
onSuccess: async () => {
setPage("home");
client.removeQueries({
predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session",
});
await client.invalidateQueries({ queryKey: identityKeys.session });
navigateTo("/login", { replace: true });
},
});
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 { useTranslation } from "react-i18next";
@ -28,6 +29,16 @@ export function CustomWorkspace({
if (!snapshot.user) {
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 (
<>
{renderWorkspace(
@ -36,11 +47,21 @@ export function CustomWorkspace({
<p className="truncate text-sm font-medium">{snapshot.user.name}</p>
<div className="flex flex-wrap gap-1">
{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")}
</Button>
)}
<Button onClick={() => setPage("password")} size="sm" variant="ghost">
<Button
nativeButton={false}
render={<BrowserLink href="/settings/password" />}
size="sm"
variant="ghost"
>
{t("changePassword")}
</Button>
<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"
},
"dependencies": {
"@metis/core": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "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 { useState } from "react";
@ -8,8 +9,26 @@ import type { ResourceCatalogPageProps } from "../types";
export function useResourceCatalogPageController({ scope, csrfToken }: ResourceCatalogPageProps) {
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template">("template");
const [search, setSearch] = useState("");
const { searchParams, setSearchParams } = useBrowserSearchParams();
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 [editor, setEditor] = useState<CatalogEditor>();
const query = useQuery({

View File

@ -1,3 +1,4 @@
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
@ -13,9 +14,22 @@ export function useResourcesPageController({
initialId,
initialCreate = false,
}: 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 [viewId, setViewId] = useState<string | undefined>(initialId);
const {
selectedId: viewId,
selectEntity: setViewId,
entityHref,
} = useEntityNavigation(`/companies/${companyId}/resources`, initialId);
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
@ -42,6 +56,8 @@ export function useResourcesPageController({
catalog,
edited,
viewed,
entityHref,
missingView: Boolean(viewId && query.isSuccess && !viewed),
visible,
};
}

View File

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

View File

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