Navegación por URLs
This commit is contained in:
parent
9733f550a5
commit
f54ce79309
10
TODO.md
10
TODO.md
@ -438,3 +438,13 @@ Quiero que hagas esta reestructuración pero antes pregunta todo lo que necesite
|
|||||||
|
|
||||||
- Quiero que en el navegador web del cliente se muestren rutas para seccion. Por ejemplo: http://192.168.0.2:5173/dashboard, http://192.168.0.2:5173/tickets, http://192.168.0.2:5173/companies/, http://192.168.0.2:5173/companies/xxxx, etc. Analiza qué estructura de ruta es la más adecuada las pantallas de la aplicación y muestrala antes de realizar ningún cambio. Intenta encontar ambiguedades o errores también. Como criterio, guíate por la lógica o por lo más común que se hace en este caso.
|
- Quiero que en el navegador web del cliente se muestren rutas para seccion. Por ejemplo: http://192.168.0.2:5173/dashboard, http://192.168.0.2:5173/tickets, http://192.168.0.2:5173/companies/, http://192.168.0.2:5173/companies/xxxx, etc. Analiza qué estructura de ruta es la más adecuada las pantallas de la aplicación y muestrala antes de realizar ningún cambio. Intenta encontar ambiguedades o errores también. Como criterio, guíate por la lógica o por lo más común que se hace en este caso.
|
||||||
|
|
||||||
|
- quiero que el estado plegado/desplegado de la barra lateral izquierda se almacene en el almacenamiento local del navegador para que se conserve entre sesiones.
|
||||||
|
|
||||||
|
- revisa el codigo para localizar todos los ficheros en el backend que se refieran a value objetcs de DDD. Debes renombrarlos de esta manera:
|
||||||
|
|
||||||
|
email-address.ts => email-address.vo.ts
|
||||||
|
unique-id.ts => unique-id.vo.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host --clearScreen false",
|
"dev": "vite --host --clearScreen false",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/app.test.tsx src/i18n.test.ts",
|
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/app.test.tsx src/i18n.test.ts src/navigation/app-redirect.test.ts",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 127.0.0.1"
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -143,9 +143,9 @@ test("solo el administrador inicial ve la navegación de gestión de usuarios",
|
|||||||
assert.ok(normal.includes("Tu espacio de soporte"));
|
assert.ok(normal.includes("Tu espacio de soporte"));
|
||||||
assert.ok(normal.includes("Mi contraseña"));
|
assert.ok(normal.includes("Mi contraseña"));
|
||||||
assert.ok(normal.includes("Salir"));
|
assert.ok(normal.includes("Salir"));
|
||||||
assert.equal(normal.includes(">Usuarios</button>"), false);
|
assert.equal(normal.includes('href="/settings/users"'), false);
|
||||||
const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } });
|
const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } });
|
||||||
assert.ok(admin.includes(">Usuarios</button>"));
|
assert.ok(admin.includes('href="/settings/users"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("el espacio abre Inicio tras el login y mantiene acceso a las compañías sin formularios visibles", async () => {
|
test("el espacio abre Inicio tras el login y mantiene acceso a las compañías sin formularios visibles", async () => {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { IdentityWorkspace, identityApi, identityKeys } from "@metis/identity/cl
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { LanguageSwitcher } from "./language-switcher";
|
import { LanguageSwitcher } from "./language-switcher";
|
||||||
|
import { useAppNavigation } from "./navigation/use-app-navigation";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const session = useQuery({
|
const session = useQuery({
|
||||||
@ -10,6 +11,7 @@ export function App() {
|
|||||||
queryFn: identityApi.session,
|
queryFn: identityApi.session,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
useAppNavigation(session.data);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-shell-content">
|
<div className="min-h-dvh bg-shell-content">
|
||||||
{!session.data?.user && (
|
{!session.data?.user && (
|
||||||
|
|||||||
53
apps/web/src/navigation/app-redirect.test.ts
Normal file
53
apps/web/src/navigation/app-redirect.test.ts
Normal 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);
|
||||||
|
});
|
||||||
34
apps/web/src/navigation/app-redirect.ts
Normal file
34
apps/web/src/navigation/app-redirect.ts
Normal 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;
|
||||||
|
}
|
||||||
18
apps/web/src/navigation/use-app-navigation.ts
Normal file
18
apps/web/src/navigation/use-app-navigation.ts
Normal 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]);
|
||||||
|
}
|
||||||
@ -17,6 +17,9 @@ El código se genera desde el registro oficial y queda versionado en este reposi
|
|||||||
|
|
||||||
## Convención de navegación
|
## Convención de navegación
|
||||||
|
|
||||||
|
Los destinos de secciones y fichas existentes siguen [Rutas del cliente](rutas-del-cliente.md).
|
||||||
|
Usar enlaces reales para esos destinos y botones para acciones o diálogos locales.
|
||||||
|
|
||||||
Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios se abren en modal como norma general, con anchura adaptable y desplazamiento si el contenido lo requiere. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, personas, departamentos, asignaciones, sedes y usuarios siguen este criterio. Las personas, los departamentos y las asignaciones se crean y editan en modales. El historial se consulta en un diálogo. Catálogos, cuentas, grupos, recursos, permisos y necesidades siguen la misma convención. Los permisos de una necesidad se eligen mediante casillas shadcn/ui.
|
Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios se abren en modal como norma general, con anchura adaptable y desplazamiento si el contenido lo requiere. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, personas, departamentos, asignaciones, sedes y usuarios siguen este criterio. Las personas, los departamentos y las asignaciones se crean y editan en modales. El historial se consulta en un diálogo. Catálogos, cuentas, grupos, recursos, permisos y necesidades siguen la misma convención. Los permisos de una necesidad se eligen mediante casillas shadcn/ui.
|
||||||
|
|
||||||
TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos.
|
TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos.
|
||||||
@ -33,6 +36,14 @@ Usar tokens semánticos como bg-background, text-foreground y border-border en v
|
|||||||
|
|
||||||
## Añadir componentes
|
## Añadir componentes
|
||||||
|
|
||||||
|
`SectionNavigation`, en `modules/companies/src/web/shared/ui`, compone Card de la
|
||||||
|
biblioteca y BrowserLink de core para índices de secciones. Recibe una etiqueta
|
||||||
|
de navegación y elementos con `href`, título, descripción y valor numérico
|
||||||
|
opcionales. No conoce DTO, consultas ni endpoints; sus consumidores aportan los
|
||||||
|
textos traducidos y únicamente valores conocidos. Se reutiliza en el resumen de
|
||||||
|
compañía y en los índices de catálogos globales y de compañía, con foco visible y
|
||||||
|
enlaces que pueden abrirse en otra pestaña. No se modifica el paquete vendor.
|
||||||
|
|
||||||
Para componentes propios de un módulo, consultar primero la
|
Para componentes propios de un módulo, consultar primero la
|
||||||
[estructura web por entidades y operaciones](estructura-web-por-entidades-y-operaciones.md)
|
[estructura web por entidades y operaciones](estructura-web-por-entidades-y-operaciones.md)
|
||||||
y los componentes equivalentes existentes. Ubicar cada componente en la entidad y operación
|
y los componentes equivalentes existentes. Ubicar cada componente en la entidad y operación
|
||||||
|
|||||||
@ -50,7 +50,7 @@ Carga inicial y errores se presentan dentro del contenido sin afirmar que no hay
|
|||||||
|
|
||||||
## Inventario de listados y ubicaciones actuales
|
## Inventario de listados y ubicaciones actuales
|
||||||
|
|
||||||
La aplicación navega por espacios y secciones en memoria, no mediante rutas URL independientes. Los componentes siguientes se alcanzan desde CompaniesWorkspace, CompanyDetail o IdentityWorkspace.
|
La aplicación navega por espacios y secciones mediante rutas URL independientes, según [Rutas del cliente](rutas-del-cliente.md). Los componentes siguientes se componen desde CompaniesWorkspace, CompanyDetail o IdentityWorkspace; altas, ediciones e historiales conservan sus modales.
|
||||||
|
|
||||||
| Listado / ámbito | Componente y ubicación actual | Aplicación |
|
| Listado / ámbito | Componente y ubicación actual | Aplicación |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@ -102,7 +102,7 @@ La validación debe incluir tipos, estilo, pruebas y compilación, además de in
|
|||||||
|
|
||||||
### Migración realizada
|
### Migración realizada
|
||||||
|
|
||||||
Se han adaptado las 25 familias de listados del inventario y el sublistado de asignaciones departamentales. Los catálogos y accesos reutilizan sus componentes para las distintas secciones y ámbitos; no son nuevas rutas ni nuevas entidades. El inventario conserva la ficha lateral y su modal móvil. Las fichas, historiales y bloques contextuales de las excepciones mantienen su función.
|
Se han adaptado las 25 familias de listados del inventario y el sublistado de asignaciones departamentales. Los catálogos y accesos reutilizan sus componentes para las distintas secciones y ámbitos; las rutas distinguen el ámbito sin crear nuevas entidades. El inventario conserva la ficha lateral y su modal móvil. Las fichas, historiales y bloques contextuales de las excepciones mantienen su función.
|
||||||
|
|
||||||
Componentes compartidos: EntityListHeader y EntityTable; EntityTableConfiguration para columnas y orden; EntityTableContent para tabla semántica; EntityListPagination, EntityRowActions, EntityAction y EntityStatus para controles. useEntityTable concentra filtros locales, ordenación estable, columnas visibles y paginación. entity-list.ts contiene textos es/en y cálculo de rangos. No se añade selección masiva sin una operación que la consuma.
|
Componentes compartidos: EntityListHeader y EntityTable; EntityTableConfiguration para columnas y orden; EntityTableContent para tabla semántica; EntityListPagination, EntityRowActions, EntityAction y EntityStatus para controles. useEntityTable concentra filtros locales, ordenación estable, columnas visibles y paginación. entity-list.ts contiene textos es/en y cálculo de rangos. No se añade selección masiva sin una operación que la consuma.
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,11 @@ pnpm db:up genera .env con secretos locales si no existe e inicia MariaDB en un
|
|||||||
|
|
||||||
Abrir http://localhost:5173. La primera apertura permite crear el administrador inicial; las siguientes muestran el acceso. La API escucha en 127.0.0.1:3001 y la base de datos en 127.0.0.1:3307.
|
Abrir http://localhost:5173. La primera apertura permite crear el administrador inicial; las siguientes muestran el acceso. La API escucha en 127.0.0.1:3001 y la base de datos en 127.0.0.1:3307.
|
||||||
|
|
||||||
|
Una vez autenticado, Inicio utiliza `/dashboard` y cada sección tiene URL propia.
|
||||||
|
Se pueden abrir o recargar directamente `/tickets`, `/catalogs` o
|
||||||
|
`/companies/:companyId`. Consultar [Rutas del cliente](rutas-del-cliente.md) para
|
||||||
|
las fichas, los filtros y el requisito de fallback de la SPA en producción.
|
||||||
|
|
||||||
Consultar [Usuarios y acceso](usuarios-y-acceso.md) para la configuración inicial y protección de cuentas. No borrar el volumen para actualizar o reiniciar: contiene usuarios, compañías, contactos y sedes.
|
Consultar [Usuarios y acceso](usuarios-y-acceso.md) para la configuración inicial y protección de cuentas. No borrar el volumen para actualizar o reiniciar: contiene usuarios, compañías, contactos y sedes.
|
||||||
|
|
||||||
Si se cambia PORT, configurar API_PROXY_TARGET al arrancar Vite (por defecto http://127.0.0.1:3001). Si cambia el origen del navegador, actualizar APP_ORIGIN en .env.
|
Si se cambia PORT, configurar API_PROXY_TARGET al arrancar Vite (por defecto http://127.0.0.1:3001). Si cambia el origen del navegador, actualizar APP_ORIGIN en .env.
|
||||||
|
|||||||
157
docs/rutas-del-cliente.md
Normal file
157
docs/rutas-del-cliente.md
Normal 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.
|
||||||
@ -13,6 +13,7 @@
|
|||||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx"
|
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@metis/core": "workspace:*",
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"@repo/shadcn-ui": "workspace:*",
|
"@repo/shadcn-ui": "workspace:*",
|
||||||
|
|||||||
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -7,6 +8,8 @@ import { inventoryKeys } from "../../../shared/queries";
|
|||||||
import { getAssetInventory } from "../api";
|
import { getAssetInventory } from "../api";
|
||||||
import type { AssetInventoryPageProps } from "../types";
|
import type { AssetInventoryPageProps } from "../types";
|
||||||
|
|
||||||
|
import { useCompactInventory } from "./use-compact-inventory";
|
||||||
|
|
||||||
export function useAssetInventoryPageController({
|
export function useAssetInventoryPageController({
|
||||||
companyId,
|
companyId,
|
||||||
lookups,
|
lookups,
|
||||||
@ -22,13 +25,30 @@ export function useAssetInventoryPageController({
|
|||||||
queryKey: catalogKeys.scope(companyId),
|
queryKey: catalogKeys.scope(companyId),
|
||||||
queryFn: () => catalogApi.get(companyId),
|
queryFn: () => catalogApi.get(companyId),
|
||||||
});
|
});
|
||||||
const [search, setSearch] = useState("");
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
|
const search = searchParams.get("q") ?? "";
|
||||||
|
const setSearch = (value: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("q", value);
|
||||||
|
} else {
|
||||||
|
params.delete("q");
|
||||||
|
}
|
||||||
|
});
|
||||||
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
|
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
|
||||||
const [viewId, setViewId] = useState<string | undefined>();
|
const compact = useCompactInventory();
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>(initialId);
|
const [localViewId, setLocalViewId] = useState<string | undefined>();
|
||||||
|
const {
|
||||||
|
selectedId,
|
||||||
|
selectEntity: setSelectedId,
|
||||||
|
entityHref,
|
||||||
|
routed,
|
||||||
|
} = useEntityNavigation(`/companies/${companyId}/assets`, initialId);
|
||||||
|
const viewId = compact && routed ? selectedId : localViewId;
|
||||||
|
const setViewId = compact && routed ? setSelectedId : setLocalViewId;
|
||||||
const [detailOpen, setDetailOpen] = useState(true);
|
const [detailOpen, setDetailOpen] = useState(true);
|
||||||
const assets = query.data?.assets ?? [];
|
const assets = query.data?.assets ?? [];
|
||||||
const selected = assets.find((asset) => asset.id === selectedId) ?? assets[0];
|
const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : assets[0];
|
||||||
const edited = assets.find((asset) => asset.id === editor);
|
const edited = assets.find((asset) => asset.id === editor);
|
||||||
const viewed = assets.find((asset) => asset.id === viewId);
|
const viewed = assets.find((asset) => asset.id === viewId);
|
||||||
const filtered = assets.filter((asset) => {
|
const filtered = assets.filter((asset) => {
|
||||||
@ -44,6 +64,8 @@ export function useAssetInventoryPageController({
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
|
entityHref,
|
||||||
|
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
||||||
catalog,
|
catalog,
|
||||||
search,
|
search,
|
||||||
setSearch,
|
setSearch,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink, EntityNotFound } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
@ -37,8 +38,13 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
edited,
|
edited,
|
||||||
viewed,
|
viewed,
|
||||||
filtered,
|
filtered,
|
||||||
|
missingView,
|
||||||
|
entityHref,
|
||||||
} = useAssetInventoryPageController(props);
|
} = useAssetInventoryPageController(props);
|
||||||
const { t, i18n } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
|
if (missingView) {
|
||||||
|
return <EntityNotFound />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_280px] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
|
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_280px] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
|
||||||
@ -92,20 +98,14 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
value: (asset) => asset.values.name,
|
value: (asset) => asset.values.name,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (asset) => (
|
render: (asset) => (
|
||||||
<button
|
<BrowserLink
|
||||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
aria-pressed={selected?.id === asset.id && detailOpen}
|
||||||
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] hover:underline focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
|
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] hover:underline focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
|
||||||
onClick={() => {
|
href={entityHref(asset.id)}
|
||||||
setSelectedId(asset.id);
|
onClick={() => setDetailOpen(true)}
|
||||||
setDetailOpen(true);
|
|
||||||
if (window.matchMedia("(max-width: 1099px)").matches) {
|
|
||||||
setViewId(asset.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{asset.values.name}
|
{asset.values.name}
|
||||||
</button>
|
</BrowserLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useBrowserSearchParams } from "@metis/core/client";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@ -8,8 +9,27 @@ import type { AssetCatalogPageProps } from "../types";
|
|||||||
|
|
||||||
export function useAssetCatalogPageController({ scope, csrfToken }: AssetCatalogPageProps) {
|
export function useAssetCatalogPageController({ scope, csrfToken }: AssetCatalogPageProps) {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
const [search, setSearch] = useState("");
|
const requestedTab = searchParams.get("tab");
|
||||||
|
const tab: "field" | "template" | "relation" =
|
||||||
|
requestedTab === "fields" ? "field" : requestedTab === "relations" ? "relation" : "template";
|
||||||
|
const setTab = (value: "field" | "template" | "relation") =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value === "template") {
|
||||||
|
params.delete("tab");
|
||||||
|
} else {
|
||||||
|
params.set("tab", value === "field" ? "fields" : "relations");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const search = searchParams.get("q") ?? "";
|
||||||
|
const setSearch = (value: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("q", value);
|
||||||
|
} else {
|
||||||
|
params.delete("q");
|
||||||
|
}
|
||||||
|
});
|
||||||
const [viewId, setViewId] = useState<string>();
|
const [viewId, setViewId] = useState<string>();
|
||||||
const [editor, setEditor] = useState<CatalogEditor>();
|
const [editor, setEditor] = useState<CatalogEditor>();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
|
|||||||
@ -10,9 +10,10 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts"
|
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workspace/shared/workspace-routes.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@metis/core": "workspace:*",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@metis/assets": "workspace:*",
|
"@metis/assets": "workspace:*",
|
||||||
"@metis/resources": "workspace:*",
|
"@metis/resources": "workspace:*",
|
||||||
|
|||||||
@ -1,5 +1,47 @@
|
|||||||
{
|
{
|
||||||
"companies": {
|
"companies": {
|
||||||
|
"navigation": {
|
||||||
|
"invalidRoute": "Invalid address",
|
||||||
|
"notFound": "Page not found",
|
||||||
|
"routeHelp": "Check the address or return to the dashboard."
|
||||||
|
},
|
||||||
|
"companyOverview": {
|
||||||
|
"title": "Company overview",
|
||||||
|
"description": "Status, pending work and alerts for {{name}}.",
|
||||||
|
"directory": "Company information"
|
||||||
|
},
|
||||||
|
"catalogOverview": {
|
||||||
|
"globalTitle": "Global catalogs",
|
||||||
|
"companyTitle": "Company catalogs",
|
||||||
|
"globalDescription": "Shared definitions for recording and managing data across companies.",
|
||||||
|
"companyDescription": "Browse available global definitions and this company's own variants.",
|
||||||
|
"items": {
|
||||||
|
"access": {
|
||||||
|
"title": "Access",
|
||||||
|
"description": "Systems, resource types, permissions and access templates."
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"title": "Assets",
|
||||||
|
"description": "Fields, templates and relationship types for the inventory."
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"title": "Resources",
|
||||||
|
"description": "Fields and templates for recording resources."
|
||||||
|
},
|
||||||
|
"brands": {
|
||||||
|
"title": "Brands",
|
||||||
|
"description": "Shared brands for the asset inventory."
|
||||||
|
},
|
||||||
|
"intervention-templates": {
|
||||||
|
"title": "Intervention templates",
|
||||||
|
"description": "Reusable procedures for technical interventions."
|
||||||
|
},
|
||||||
|
"process-templates": {
|
||||||
|
"title": "Process templates",
|
||||||
|
"description": "Checklists for onboarding, changes and offboarding."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"interventionTemplates": {
|
"interventionTemplates": {
|
||||||
"title": "Intervention templates",
|
"title": "Intervention templates",
|
||||||
"new": "New intervention template",
|
"new": "New intervention template",
|
||||||
|
|||||||
@ -1,5 +1,47 @@
|
|||||||
{
|
{
|
||||||
"companies": {
|
"companies": {
|
||||||
|
"navigation": {
|
||||||
|
"invalidRoute": "La dirección no es válida",
|
||||||
|
"notFound": "No se ha encontrado esta página",
|
||||||
|
"routeHelp": "Comprueba la dirección o vuelve al inicio."
|
||||||
|
},
|
||||||
|
"companyOverview": {
|
||||||
|
"title": "Resumen de la compañía",
|
||||||
|
"description": "Estado, trabajo pendiente y avisos de {{name}}.",
|
||||||
|
"directory": "Información de la compañía"
|
||||||
|
},
|
||||||
|
"catalogOverview": {
|
||||||
|
"globalTitle": "Catálogos globales",
|
||||||
|
"companyTitle": "Catálogos de la compañía",
|
||||||
|
"globalDescription": "Definiciones compartidas para registrar y gestionar datos de todas las compañías.",
|
||||||
|
"companyDescription": "Consulta las definiciones globales disponibles y las variantes propias de esta compañía.",
|
||||||
|
"items": {
|
||||||
|
"access": {
|
||||||
|
"title": "Accesos",
|
||||||
|
"description": "Sistemas, tipos de recursos, permisos y plantillas de acceso."
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"title": "Activos",
|
||||||
|
"description": "Campos, plantillas y tipos de relaciones para el inventario."
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"title": "Recursos",
|
||||||
|
"description": "Campos y plantillas para registrar recursos."
|
||||||
|
},
|
||||||
|
"brands": {
|
||||||
|
"title": "Marcas",
|
||||||
|
"description": "Marcas compartidas del inventario de activos."
|
||||||
|
},
|
||||||
|
"intervention-templates": {
|
||||||
|
"title": "Plantillas de intervenciones",
|
||||||
|
"description": "Procedimientos reutilizables para las intervenciones técnicas."
|
||||||
|
},
|
||||||
|
"process-templates": {
|
||||||
|
"title": "Plantillas de procesos",
|
||||||
|
"description": "Listas de comprobación para altas, cambios y salidas de personas."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"interventionTemplates": {
|
"interventionTemplates": {
|
||||||
"title": "Plantillas de intervención",
|
"title": "Plantillas de intervención",
|
||||||
"new": "Nueva plantilla de intervención",
|
"new": "Nueva plantilla de intervención",
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
|
import { useBrowserSearchParams } from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { AccessKind } from "../../../../common";
|
import type { AccessKind } from "../../../../common";
|
||||||
import { accessFields, accessLabel } from "../../shared/helpers";
|
import { accessFields, accessLabel } from "../../shared/helpers";
|
||||||
|
import { accessKinds } from "../../shared/helpers/access-kinds";
|
||||||
import { accessKeys } from "../../shared/queries";
|
import { accessKeys } from "../../shared/queries";
|
||||||
import { getAccess } from "../api";
|
import { getAccess } from "../api";
|
||||||
import type { AccessWorkspacePageProps } from "../types";
|
import type { AccessWorkspacePageProps } from "../types";
|
||||||
@ -12,30 +14,30 @@ export function useAccessWorkspacePageController({ scope, people = [] }: AccessW
|
|||||||
queryKey: accessKeys.scope(scope),
|
queryKey: accessKeys.scope(scope),
|
||||||
queryFn: () => getAccess(scope),
|
queryFn: () => getAccess(scope),
|
||||||
});
|
});
|
||||||
const [kind, setKind] = useState<AccessKind>(scope === "global" ? "system" : "account");
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
const [editor, setEditor] = useState<string>();
|
const [editor, setEditor] = useState<string>();
|
||||||
const [history, setHistory] = useState<string>();
|
const [history, setHistory] = useState<string>();
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const search = searchParams.get("q") ?? "";
|
||||||
|
const setSearch = (value: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("q", value);
|
||||||
|
} else {
|
||||||
|
params.delete("q");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const setKind = (value: AccessKind) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
params.set("kind", value);
|
||||||
|
});
|
||||||
const data = query.data;
|
const data = query.data;
|
||||||
const selected = data?.items.find((item) => item.id === editor);
|
const selected = data?.items.find((item) => item.id === editor);
|
||||||
const historical = data?.items.find((item) => item.id === history);
|
const historical = data?.items.find((item) => item.id === history);
|
||||||
const kinds: readonly AccessKind[] =
|
const kinds = accessKinds(scope);
|
||||||
scope === "global"
|
const kind =
|
||||||
? ["system", "resource_type", "permission", "template"]
|
kinds.find((value) => value === searchParams.get("kind")) ??
|
||||||
: [
|
(scope === "global" ? "system" : "account");
|
||||||
"account",
|
|
||||||
"account_person",
|
|
||||||
"group",
|
|
||||||
"membership",
|
|
||||||
"grant",
|
|
||||||
"need",
|
|
||||||
"coverage",
|
|
||||||
"environment",
|
|
||||||
"system",
|
|
||||||
"resource_type",
|
|
||||||
"permission",
|
|
||||||
];
|
|
||||||
const visible =
|
const visible =
|
||||||
data?.items.filter(
|
data?.items.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
|
|||||||
@ -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",
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -8,12 +8,15 @@ import type { CompanyDetailProps } from "../types";
|
|||||||
|
|
||||||
export function useCompanyDetailController({
|
export function useCompanyDetailController({
|
||||||
id,
|
id,
|
||||||
embedded = false,
|
|
||||||
initialSection,
|
initialSection,
|
||||||
|
section,
|
||||||
|
onSectionChange,
|
||||||
}: CompanyDetailProps) {
|
}: CompanyDetailProps) {
|
||||||
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => getCompany(id) });
|
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => getCompany(id) });
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [tab, setTab] = useState<CompanyTab>(initialSection ?? (embedded ? "inventory" : "people"));
|
const [localTab, setLocalTab] = useState<CompanyTab>(initialSection ?? "overview");
|
||||||
|
const tab = section ?? localTab;
|
||||||
|
const setTab = onSectionChange ?? setLocalTab;
|
||||||
const company = query.data?.company;
|
const company = query.data?.company;
|
||||||
|
|
||||||
return { query, editOpen, setEditOpen, tab, setTab, company };
|
return { query, editOpen, setEditOpen, tab, setTab, company };
|
||||||
|
|||||||
@ -9,6 +9,15 @@ export type CompanyDetailProps = {
|
|||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
accountNavigation?: ReactNode;
|
accountNavigation?: ReactNode;
|
||||||
initialSection?: CompanyTab;
|
initialSection?: CompanyTab;
|
||||||
|
section?: CompanyTab;
|
||||||
|
onSectionChange?: (section: CompanyTab) => void;
|
||||||
|
initialConfiguration?:
|
||||||
|
| "folders"
|
||||||
|
| "mail"
|
||||||
|
| "certificates"
|
||||||
|
| "applications"
|
||||||
|
| "printers"
|
||||||
|
| undefined;
|
||||||
initialEntityId?: string | undefined;
|
initialEntityId?: string | undefined;
|
||||||
initialCreate?: boolean | undefined;
|
initialCreate?: boolean | undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
|
import { BrowserLink } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import {
|
import {
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
BriefcaseIcon,
|
BriefcaseIcon,
|
||||||
|
DatabaseIcon,
|
||||||
|
HomeIcon,
|
||||||
KeyRoundIcon,
|
KeyRoundIcon,
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
LayoutTemplateIcon,
|
LayoutTemplateIcon,
|
||||||
@ -11,6 +14,7 @@ import {
|
|||||||
} from "@repo/shadcn-ui/lib/icons";
|
} from "@repo/shadcn-ui/lib/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { companySectionHref } from "../../../workspace/shared/helpers";
|
||||||
import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
|
import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
|
||||||
import { useCompanyDetailController } from "../controllers";
|
import { useCompanyDetailController } from "../controllers";
|
||||||
import type { CompanyDetailProps } from "../types";
|
import type { CompanyDetailProps } from "../types";
|
||||||
@ -26,6 +30,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
initialSection,
|
initialSection,
|
||||||
initialEntityId,
|
initialEntityId,
|
||||||
initialCreate,
|
initialCreate,
|
||||||
|
initialConfiguration,
|
||||||
} = props;
|
} = props;
|
||||||
const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props);
|
const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props);
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
@ -111,6 +116,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
<nav aria-label={t("companySections")} className={layout.navigation}>
|
<nav aria-label={t("companySections")} className={layout.navigation}>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
|
["overview", t("companyOverview.title"), HomeIcon],
|
||||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||||
["resources", resourceTranslation("inventory"), LayersIcon],
|
["resources", resourceTranslation("inventory"), LayersIcon],
|
||||||
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
||||||
@ -134,6 +140,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
["applications", t("applications.title"), LayersIcon],
|
["applications", t("applications.title"), LayersIcon],
|
||||||
["printers", t("printers.title"), LayersIcon],
|
["printers", t("printers.title"), LayersIcon],
|
||||||
["mail", t("mail.title"), MailIcon],
|
["mail", t("mail.title"), MailIcon],
|
||||||
|
["catalogs", t("catalogOverview.companyTitle"), DatabaseIcon],
|
||||||
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
||||||
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
|
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
|
||||||
] as const
|
] as const
|
||||||
@ -146,7 +153,13 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
key={section}
|
key={section}
|
||||||
onClick={() => setTab(section)}
|
{...(props.onSectionChange
|
||||||
|
? {
|
||||||
|
nativeButton: false,
|
||||||
|
render: <BrowserLink href={companySectionHref(company.id, section)} />,
|
||||||
|
"aria-current": tab === section ? "page" : undefined,
|
||||||
|
}
|
||||||
|
: { onClick: () => setTab(section) })}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||||
@ -169,8 +182,11 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
<CompanySection
|
<CompanySection
|
||||||
company={company}
|
company={company}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
initialCreate={tab === initialSection ? initialCreate : false}
|
initialConfiguration={initialConfiguration}
|
||||||
initialEntityId={tab === initialSection ? initialEntityId : undefined}
|
initialCreate={props.section || tab === initialSection ? initialCreate : false}
|
||||||
|
initialEntityId={
|
||||||
|
props.section || tab === initialSection ? initialEntityId : undefined
|
||||||
|
}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { AssetCatalogPage } from "@metis/assets/client";
|
import { AssetCatalogPage } from "@metis/assets/client";
|
||||||
|
import { useBrowserSearchParams, useCreateNavigation } from "@metis/core/client";
|
||||||
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
|
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
|
||||||
|
|
||||||
import type { CompanyDTO } from "../../../../common";
|
import type { CompanyDTO } from "../../../../common";
|
||||||
@ -19,9 +20,11 @@ import { SiteSection } from "../../../sites";
|
|||||||
import { TeleworkAuthorizationsPage } from "../../../telework-authorizations";
|
import { TeleworkAuthorizationsPage } from "../../../telework-authorizations";
|
||||||
import { TicketsPage } from "../../../tickets";
|
import { TicketsPage } from "../../../tickets";
|
||||||
import { WorkProfilesPage } from "../../../work-profiles";
|
import { WorkProfilesPage } from "../../../work-profiles";
|
||||||
|
import { CatalogOverview } from "../../../workspace/catalogs/ui/catalog-overview";
|
||||||
import type { CompanyTab } from "../../shared/types";
|
import type { CompanyTab } from "../../shared/types";
|
||||||
|
|
||||||
import { CompanyInventory } from "./company-inventory";
|
import { CompanyInventory } from "./company-inventory";
|
||||||
|
import { CompanyOverview } from "./company-overview";
|
||||||
|
|
||||||
export function CompanySection({
|
export function CompanySection({
|
||||||
company,
|
company,
|
||||||
@ -29,26 +32,58 @@ export function CompanySection({
|
|||||||
tab,
|
tab,
|
||||||
initialEntityId,
|
initialEntityId,
|
||||||
initialCreate,
|
initialCreate,
|
||||||
|
initialConfiguration,
|
||||||
}: {
|
}: {
|
||||||
company: CompanyDTO;
|
company: CompanyDTO;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
tab: CompanyTab;
|
tab: CompanyTab;
|
||||||
initialEntityId?: string | undefined;
|
initialEntityId?: string | undefined;
|
||||||
initialCreate?: boolean | undefined;
|
initialCreate?: boolean | undefined;
|
||||||
|
initialConfiguration?:
|
||||||
|
| "folders"
|
||||||
|
| "mail"
|
||||||
|
| "certificates"
|
||||||
|
| "applications"
|
||||||
|
| "printers"
|
||||||
|
| undefined;
|
||||||
}) {
|
}) {
|
||||||
|
const { searchParams } = useBrowserSearchParams();
|
||||||
|
const create = useCreateNavigation() || initialCreate;
|
||||||
|
const personId = searchParams.get("personId") ?? undefined;
|
||||||
|
const profileId = searchParams.get("profileId") ?? undefined;
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
|
case "overview":
|
||||||
|
return <CompanyOverview company={company} csrfToken={csrfToken} />;
|
||||||
|
case "catalogs":
|
||||||
|
return <CatalogOverview companyId={company.id} />;
|
||||||
case "intervention-templates":
|
case "intervention-templates":
|
||||||
return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />;
|
return <InterventionTemplatesPage csrfToken={csrfToken} scope={company.id} />;
|
||||||
case "interventions":
|
case "interventions":
|
||||||
return <InterventionsPage companyId={company.id} csrfToken={csrfToken} />;
|
return (
|
||||||
|
<InterventionsPage
|
||||||
|
companyId={company.id}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
initialId={initialEntityId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "tickets":
|
case "tickets":
|
||||||
return <TicketsPage companyId={company.id} csrfToken={csrfToken} />;
|
return (
|
||||||
|
<TicketsPage companyId={company.id} csrfToken={csrfToken} initialId={initialEntityId} />
|
||||||
|
);
|
||||||
case "processes":
|
case "processes":
|
||||||
return <ProcessesPage company={company} csrfToken={csrfToken} />;
|
return <ProcessesPage company={company} csrfToken={csrfToken} />;
|
||||||
case "remote":
|
case "remote":
|
||||||
return <RemoteConnectionsPage company={company} csrfToken={csrfToken} />;
|
return (
|
||||||
|
<RemoteConnectionsPage company={company} csrfToken={csrfToken} initialPersonId={personId} />
|
||||||
|
);
|
||||||
case "telework":
|
case "telework":
|
||||||
return <TeleworkAuthorizationsPage company={company} csrfToken={csrfToken} />;
|
return (
|
||||||
|
<TeleworkAuthorizationsPage
|
||||||
|
company={company}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
initialPersonId={personId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "people":
|
case "people":
|
||||||
return <PeopleSection company={company} csrfToken={csrfToken} initialId={initialEntityId} />;
|
return <PeopleSection company={company} csrfToken={csrfToken} initialId={initialEntityId} />;
|
||||||
case "departments":
|
case "departments":
|
||||||
@ -61,18 +96,53 @@ export function CompanySection({
|
|||||||
);
|
);
|
||||||
case "profiles":
|
case "profiles":
|
||||||
return (
|
return (
|
||||||
<WorkProfilesPage company={company} csrfToken={csrfToken} initialId={initialEntityId} />
|
<WorkProfilesPage
|
||||||
|
company={company}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
initialConfiguration={initialConfiguration}
|
||||||
|
initialId={initialEntityId}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
case "certificates":
|
case "certificates":
|
||||||
return <CertificateConfigurationsPage company={company} csrfToken={csrfToken} />;
|
return (
|
||||||
|
<CertificateConfigurationsPage
|
||||||
|
company={company}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
{...(profileId ? { initialProfileId: profileId } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "applications":
|
case "applications":
|
||||||
return <ApplicationConfigurationsPage company={company} csrfToken={csrfToken} />;
|
return (
|
||||||
|
<ApplicationConfigurationsPage
|
||||||
|
company={company}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
{...(profileId ? { initialProfileId: profileId } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "printers":
|
case "printers":
|
||||||
return <PrinterConfigurationsPage company={company} csrfToken={csrfToken} />;
|
return (
|
||||||
|
<PrinterConfigurationsPage
|
||||||
|
company={company}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
{...(profileId ? { initialProfileId: profileId } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "folders":
|
case "folders":
|
||||||
return <FolderMappingsPage company={company} csrfToken={csrfToken} />;
|
return (
|
||||||
|
<FolderMappingsPage
|
||||||
|
company={company}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
{...(profileId ? { initialProfileId: profileId } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "mail":
|
case "mail":
|
||||||
return <MailConfigurationsPage company={company} csrfToken={csrfToken} />;
|
return (
|
||||||
|
<MailConfigurationsPage
|
||||||
|
company={company}
|
||||||
|
csrfToken={csrfToken}
|
||||||
|
{...(profileId ? { initialProfileId: profileId } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "workstations":
|
case "workstations":
|
||||||
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
|
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
|
||||||
case "inventory":
|
case "inventory":
|
||||||
@ -80,7 +150,7 @@ export function CompanySection({
|
|||||||
<CompanyInventory
|
<CompanyInventory
|
||||||
company={company}
|
company={company}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
initialCreate={initialCreate}
|
initialCreate={create}
|
||||||
initialId={initialEntityId}
|
initialId={initialEntityId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -89,7 +159,7 @@ export function CompanySection({
|
|||||||
<ResourcesPage
|
<ResourcesPage
|
||||||
companyId={company.id}
|
companyId={company.id}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
initialCreate={initialCreate}
|
initialCreate={create}
|
||||||
initialId={initialEntityId}
|
initialId={initialEntityId}
|
||||||
relatedContent={(resource) =>
|
relatedContent={(resource) =>
|
||||||
resource.values.integration?.kind === "mailbox" ? (
|
resource.values.integration?.kind === "mailbox" ? (
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useBrowserSearchParams } from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
|
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
|
||||||
@ -21,10 +22,37 @@ export function useCompaniesPageController({
|
|||||||
}: CompaniesPageProps) {
|
}: CompaniesPageProps) {
|
||||||
const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
|
const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||||
const [companyId, setCompanyId] = useState(initialCompanyId);
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
const [activeOnly, setActiveOnly] = useState(initialActiveOnly);
|
const companyId = searchParams.get("companyId") ?? initialCompanyId;
|
||||||
|
const activeOnly = searchParams.has("activeOnly")
|
||||||
|
? searchParams.get("activeOnly") === "true"
|
||||||
|
: initialActiveOnly;
|
||||||
|
const setCompanyId = (id: string | undefined) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (id) {
|
||||||
|
params.set("companyId", id);
|
||||||
|
} else {
|
||||||
|
params.delete("companyId");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const setActiveOnly = (active: boolean) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (active) {
|
||||||
|
params.set("activeOnly", "true");
|
||||||
|
} else {
|
||||||
|
params.delete("activeOnly");
|
||||||
|
}
|
||||||
|
});
|
||||||
const [createOpen, setCreateOpen] = useState(initialCreate);
|
const [createOpen, setCreateOpen] = useState(initialCreate);
|
||||||
const [search, setSearch] = useState("");
|
const search = searchParams.get("q") ?? "";
|
||||||
|
const setSearch = (value: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("q", value);
|
||||||
|
} else {
|
||||||
|
params.delete("q");
|
||||||
|
}
|
||||||
|
});
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: query.data?.companies ?? EMPTY_COMPANIES,
|
data: query.data?.companies ?? EMPTY_COMPANIES,
|
||||||
columns: COMPANY_COLUMNS,
|
columns: COMPANY_COLUMNS,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
@ -8,6 +9,7 @@ import {
|
|||||||
} from "@repo/shadcn-ui/components/entity-list";
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { companySectionHref } from "../../../workspace/shared/helpers";
|
||||||
import { CompanyDetail } from "../../detail";
|
import { CompanyDetail } from "../../detail";
|
||||||
import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
|
import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared";
|
||||||
import { useCompaniesPageController } from "../controllers";
|
import { useCompaniesPageController } from "../controllers";
|
||||||
@ -82,17 +84,16 @@ export function CompaniesPage(props: CompaniesPageProps) {
|
|||||||
value: (company) => company.name,
|
value: (company) => company.name,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (company) => (
|
render: (company) => (
|
||||||
<button
|
<BrowserLink
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={() => selectCompany(company.id)}
|
href={companySectionHref(company.id, "overview")}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<CompanyIdentity
|
<CompanyIdentity
|
||||||
companyId={company.id}
|
companyId={company.id}
|
||||||
logoVersion={company.logoVersion}
|
logoVersion={company.logoVersion}
|
||||||
name={company.name}
|
name={company.name}
|
||||||
/>
|
/>
|
||||||
</button>
|
</BrowserLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { AssignmentForm } from "../../assignments";
|
|||||||
import { PersonForm } from "../../people";
|
import { PersonForm } from "../../people";
|
||||||
import { companiesEnglish, companiesSpanish } from "../../shared";
|
import { companiesEnglish, companiesSpanish } from "../../shared";
|
||||||
import { SiteForm } from "../../sites";
|
import { SiteForm } from "../../sites";
|
||||||
|
import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview";
|
||||||
import { CompanyDetail } from "../detail";
|
import { CompanyDetail } from "../detail";
|
||||||
import { CompaniesPage } from "../list";
|
import { CompaniesPage } from "../list";
|
||||||
|
|
||||||
@ -43,6 +44,58 @@ async function renderPage(
|
|||||||
client.clear();
|
client.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
test("el resumen de compañía es la entrada por defecto y ofrece destinos reales", async () => {
|
||||||
|
for (const language of ["es", "en"]) {
|
||||||
|
const markup = await renderPage(
|
||||||
|
language,
|
||||||
|
<CompanyDetail csrfToken="test" id={companyId} onBack={() => undefined} />,
|
||||||
|
(client) =>
|
||||||
|
client.setQueryData(companyKeys.detail(companyId), {
|
||||||
|
company: {
|
||||||
|
id: companyId,
|
||||||
|
name: "Empresa",
|
||||||
|
active: true,
|
||||||
|
people: [],
|
||||||
|
contacts: [],
|
||||||
|
departments: [],
|
||||||
|
assignments: [],
|
||||||
|
sites: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.ok(markup.includes(language === "es" ? "Resumen de la compañía" : "Company overview"));
|
||||||
|
for (const section of ["people", "departments", "sites"]) {
|
||||||
|
assert.ok(markup.includes(`href="/companies/${companyId}/${section}"`));
|
||||||
|
}
|
||||||
|
assert.equal(markup.includes('name="name"'), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("los índices de catálogos distinguen definiciones globales y variantes de compañía", async () => {
|
||||||
|
for (const language of ["es", "en"]) {
|
||||||
|
const global = await renderPage(language, <CatalogOverview />, () => undefined);
|
||||||
|
for (const catalog of [
|
||||||
|
"access",
|
||||||
|
"assets",
|
||||||
|
"resources",
|
||||||
|
"brands",
|
||||||
|
"intervention-templates",
|
||||||
|
"process-templates",
|
||||||
|
]) {
|
||||||
|
assert.ok(global.includes(`href="/catalogs/${catalog}"`));
|
||||||
|
}
|
||||||
|
const scoped = await renderPage(
|
||||||
|
language,
|
||||||
|
<CatalogOverview companyId={companyId} />,
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
assert.ok(scoped.includes(`href="/companies/${companyId}/catalogs/assets"`));
|
||||||
|
assert.ok(scoped.includes(`href="/companies/${companyId}/catalogs/resources"`));
|
||||||
|
assert.equal(scoped.includes('href="/catalogs/access"'), false);
|
||||||
|
assert.equal(scoped.includes("<form"), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("el listado presenta estado vacío y acceso al alta sin formularios visibles", async () => {
|
test("el listado presenta estado vacío y acceso al alta sin formularios visibles", async () => {
|
||||||
for (const [language, title, empty] of [
|
for (const [language, title, empty] of [
|
||||||
["es", "Compañías", "Todavía no hay compañías"],
|
["es", "Compañías", "Todavía no hay compañías"],
|
||||||
@ -61,7 +114,12 @@ test("el listado presenta estado vacío y acceso al alta sin formularios visible
|
|||||||
test("la ficha presenta datos de la compañía, contactos y acceso a sedes", async () => {
|
test("la ficha presenta datos de la compañía, contactos y acceso a sedes", async () => {
|
||||||
const markup = await renderPage(
|
const markup = await renderPage(
|
||||||
"es",
|
"es",
|
||||||
<CompanyDetail csrfToken="test" id={companyId} onBack={() => undefined} />,
|
<CompanyDetail
|
||||||
|
csrfToken="test"
|
||||||
|
id={companyId}
|
||||||
|
initialSection="people"
|
||||||
|
onBack={() => undefined}
|
||||||
|
/>,
|
||||||
(client) =>
|
(client) =>
|
||||||
client.setQueryData(companyKeys.detail(companyId), {
|
client.setQueryData(companyKeys.detail(companyId), {
|
||||||
company: {
|
company: {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
export type CompanyTab =
|
export type CompanyTab =
|
||||||
|
| "overview"
|
||||||
|
| "catalogs"
|
||||||
| "interventions"
|
| "interventions"
|
||||||
| "intervention-templates"
|
| "intervention-templates"
|
||||||
| "tickets"
|
| "tickets"
|
||||||
|
|||||||
@ -3,7 +3,8 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
|||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CompanyEditorDialog, CompanyForm } from "../../../companies";
|
import { CompanyEditorDialog } from "../../../companies/shared/ui/company-editor-dialog";
|
||||||
|
import { CompanyForm } from "../../../companies/shared/ui/company-form";
|
||||||
import { InterventionForm } from "../../../interventions";
|
import { InterventionForm } from "../../../interventions";
|
||||||
import { TicketForm } from "../../../tickets";
|
import { TicketForm } from "../../../tickets";
|
||||||
import { BlockState } from "../../shared";
|
import { BlockState } from "../../shared";
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
|||||||
import { entityListLabels } from "@repo/shadcn-ui/lib/entity-list";
|
import { entityListLabels } from "@repo/shadcn-ui/lib/entity-list";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CompanyIdentity } from "../../../companies";
|
import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
|
||||||
import { BlockState, DashboardNavigationButton, DashboardTable } from "../../shared";
|
import { BlockState, DashboardNavigationButton, DashboardTable } from "../../shared";
|
||||||
import { entryDestination } from "../../shared/helpers";
|
import { entryDestination } from "../../shared/helpers";
|
||||||
import { useDashboardDetailsDialogController } from "../controllers";
|
import { useDashboardDetailsDialogController } from "../controllers";
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { CompanyTab } from "../../../companies/shared/types";
|
||||||
|
|
||||||
export type DashboardNavigation =
|
export type DashboardNavigation =
|
||||||
| {
|
| {
|
||||||
area: "directory";
|
area: "directory";
|
||||||
@ -15,9 +17,10 @@ export type DashboardNavigation =
|
|||||||
| {
|
| {
|
||||||
area: "company";
|
area: "company";
|
||||||
id: string;
|
id: string;
|
||||||
section: "inventory" | "resources" | "profiles" | "people";
|
section: CompanyTab;
|
||||||
entityId?: string | undefined;
|
entityId?: string | undefined;
|
||||||
create?: boolean | undefined;
|
create?: boolean | undefined;
|
||||||
|
configuration?: "folders" | "mail" | "certificates" | "applications" | "printers" | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function entryDestination(item: {
|
export function entryDestination(item: {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useBrowserSearchParams } from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@ -8,8 +9,17 @@ import type { DashboardQuickCreate } from "../../create";
|
|||||||
import type { DashboardDetailsDialog } from "../../detail";
|
import type { DashboardDetailsDialog } from "../../detail";
|
||||||
import { dashboardApi, dashboardKeys } from "../../shared/api";
|
import { dashboardApi, dashboardKeys } from "../../shared/api";
|
||||||
|
|
||||||
export function useDashboardPageController() {
|
export function useDashboardPageController(fixedCompanyId?: string) {
|
||||||
const [companyId, setCompanyId] = useState("");
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
|
const companyId = fixedCompanyId ?? searchParams.get("companyId") ?? "";
|
||||||
|
const setCompanyId = (id: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (id) {
|
||||||
|
params.set("companyId", id);
|
||||||
|
} else {
|
||||||
|
params.delete("companyId");
|
||||||
|
}
|
||||||
|
});
|
||||||
const scope = companyId || undefined;
|
const scope = companyId || undefined;
|
||||||
const [details, setDetails] =
|
const [details, setDetails] =
|
||||||
useState<ComponentProps<typeof DashboardDetailsDialog>["details"]>();
|
useState<ComponentProps<typeof DashboardDetailsDialog>["details"]>();
|
||||||
|
|||||||
@ -3,4 +3,7 @@ import type { DashboardNavigation } from "../../shared/helpers";
|
|||||||
export type DashboardPageProps = {
|
export type DashboardPageProps = {
|
||||||
onNavigate: (destination: DashboardNavigation) => void;
|
onNavigate: (destination: DashboardNavigation) => void;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
|
companyId?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { ClockIcon } from "@repo/shadcn-ui/lib/icons";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CompanyIdentity } from "../../../companies";
|
import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
|
||||||
import { BlockState, DashboardNavigationButton, DashboardPanel } from "../../shared";
|
import { BlockState, DashboardNavigationButton, DashboardPanel } from "../../shared";
|
||||||
import { dashboardApi, dashboardKeys } from "../../shared/api";
|
import { dashboardApi, dashboardKeys } from "../../shared/api";
|
||||||
import type { DashboardNavigation } from "../../shared/helpers";
|
import type { DashboardNavigation } from "../../shared/helpers";
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Building2Icon } from "@repo/shadcn-ui/lib/icons";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CompanyIdentity } from "../../../companies";
|
import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
|
||||||
import {
|
import {
|
||||||
BlockState,
|
BlockState,
|
||||||
DashboardNavigationButton,
|
DashboardNavigationButton,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Button } from "@repo/shadcn-ui/components/button";
|
|||||||
import { Building2Icon } from "@repo/shadcn-ui/lib/icons";
|
import { Building2Icon } from "@repo/shadcn-ui/lib/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CompanySelect } from "../../../companies";
|
import { CompanySelect } from "../../../companies/shared/ui/company-select";
|
||||||
import { DashboardQuickCreate } from "../../create";
|
import { DashboardQuickCreate } from "../../create";
|
||||||
import { DashboardDetailsDialog } from "../../detail";
|
import { DashboardDetailsDialog } from "../../detail";
|
||||||
import { DashboardPanel } from "../../shared";
|
import { DashboardPanel } from "../../shared";
|
||||||
@ -28,27 +28,35 @@ export function DashboardPage(props: DashboardPageProps) {
|
|||||||
setQuick,
|
setQuick,
|
||||||
companies,
|
companies,
|
||||||
summary,
|
summary,
|
||||||
} = useDashboardPageController();
|
} = useDashboardPageController(props.companyId);
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">{t("dashboard.title")}</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t("dashboard.description")}</p>
|
{props.title ?? t("dashboard.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{props.description ?? t("dashboard.description")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full sm:w-64 space-y-2">
|
<div className="w-full sm:w-64 space-y-2">
|
||||||
<CompanySelect
|
{!props.companyId && (
|
||||||
id="dashboard-company"
|
<CompanySelect
|
||||||
label={t("tickets.company")}
|
id="dashboard-company"
|
||||||
onValueChange={setCompanyId}
|
label={t("tickets.company")}
|
||||||
options={[
|
onValueChange={setCompanyId}
|
||||||
...(companies.data?.companies.map((item) => ({ value: item.id, label: item.name })) ??
|
options={[
|
||||||
[]),
|
...(companies.data?.companies.map((item) => ({
|
||||||
]}
|
value: item.id,
|
||||||
placeholder={t("interventions.allCompanies")}
|
label: item.name,
|
||||||
value={companyId}
|
})) ?? []),
|
||||||
/>
|
]}
|
||||||
|
placeholder={t("interventions.allCompanies")}
|
||||||
|
value={companyId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{summary.isSuccess && (
|
{summary.isSuccess && (
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-2">
|
<p className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
<span aria-hidden="true" className="size-2 rounded-full bg-primary" />
|
<span aria-hidden="true" className="size-2 rounded-full bg-primary" />
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { TicketIcon } from "@repo/shadcn-ui/lib/icons";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CompanyIdentity } from "../../../companies";
|
import { CompanyIdentity } from "../../../companies/shared/ui/company-identity";
|
||||||
import {
|
import {
|
||||||
BlockState,
|
BlockState,
|
||||||
DashboardNavigationButton,
|
DashboardNavigationButton,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { navigateTo, useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -25,16 +26,59 @@ export function useInterventionsPageController({
|
|||||||
queryKey: interventionKeys.list(companyId),
|
queryKey: interventionKeys.list(companyId),
|
||||||
queryFn: () => interventionApi.list(companyId),
|
queryFn: () => interventionApi.list(companyId),
|
||||||
});
|
});
|
||||||
const [filters, setFilters] = useState<InterventionFilters>({
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
search: "",
|
const filters: InterventionFilters = {
|
||||||
company: "",
|
search: searchParams.get("q") ?? "",
|
||||||
state: "",
|
company: searchParams.get("companyId") ?? "",
|
||||||
dateFrom: "",
|
state: searchParams.get("state") ?? "",
|
||||||
dateTo: "",
|
dateFrom: searchParams.get("dateFrom") ?? "",
|
||||||
assignee: "",
|
dateTo: searchParams.get("dateTo") ?? "",
|
||||||
});
|
assignee: searchParams.get("assignee") ?? "",
|
||||||
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly);
|
};
|
||||||
const [viewId, setViewId] = useState(initialId);
|
const setFilters = (value: InterventionFilters) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
for (const [key, text] of Object.entries({
|
||||||
|
q: value.search,
|
||||||
|
companyId: value.company,
|
||||||
|
state: value.state,
|
||||||
|
dateFrom: value.dateFrom,
|
||||||
|
dateTo: value.dateTo,
|
||||||
|
assignee: value.assignee,
|
||||||
|
})) {
|
||||||
|
if (text) {
|
||||||
|
params.set(key, text);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const pendingOnly = searchParams.has("pendingOnly")
|
||||||
|
? searchParams.get("pendingOnly") === "true"
|
||||||
|
: initialPendingOnly;
|
||||||
|
const setPendingOnly = (value: boolean) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("pendingOnly", "true");
|
||||||
|
} else {
|
||||||
|
params.delete("pendingOnly");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
selectedId: viewId,
|
||||||
|
selectEntity,
|
||||||
|
routed,
|
||||||
|
} = useEntityNavigation(
|
||||||
|
companyId ? `/companies/${companyId}/interventions` : "/interventions",
|
||||||
|
initialId
|
||||||
|
);
|
||||||
|
const setViewId = (id: string | undefined) => {
|
||||||
|
const intervention = query.data?.interventions.find((item) => item.id === id);
|
||||||
|
if (routed && !companyId && intervention) {
|
||||||
|
navigateTo(`/companies/${intervention.companyId}/interventions/${intervention.id}`);
|
||||||
|
} else {
|
||||||
|
selectEntity(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
const [open, setOpen] = useState(initialCreate);
|
const [open, setOpen] = useState(initialCreate);
|
||||||
const [edit, setEdit] = useState<InterventionDTO | undefined>();
|
const [edit, setEdit] = useState<InterventionDTO | undefined>();
|
||||||
const currentView = query.data?.interventions.find((intervention) => intervention.id === viewId);
|
const currentView = query.data?.interventions.find((intervention) => intervention.id === viewId);
|
||||||
@ -52,6 +96,7 @@ export function useInterventionsPageController({
|
|||||||
edit,
|
edit,
|
||||||
setEdit,
|
setEdit,
|
||||||
currentView,
|
currentView,
|
||||||
|
missingView: Boolean(viewId && query.isSuccess && !currentView),
|
||||||
items,
|
items,
|
||||||
date,
|
date,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink, EntityNotFound } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
@ -30,10 +31,14 @@ export function InterventionsPage(props: InterventionsPageProps) {
|
|||||||
edit,
|
edit,
|
||||||
setEdit,
|
setEdit,
|
||||||
currentView,
|
currentView,
|
||||||
|
missingView,
|
||||||
items,
|
items,
|
||||||
date,
|
date,
|
||||||
} = useInterventionsPageController(props);
|
} = useInterventionsPageController(props);
|
||||||
const { t, i18n } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
|
if (missingView) {
|
||||||
|
return <EntityNotFound />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<EntityListHeader
|
<EntityListHeader
|
||||||
@ -78,13 +83,12 @@ export function InterventionsPage(props: InterventionsPageProps) {
|
|||||||
value: (intervention) => intervention.reference,
|
value: (intervention) => intervention.reference,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (intervention) => (
|
render: (intervention) => (
|
||||||
<button
|
<BrowserLink
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={() => setViewId(intervention.id)}
|
href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{intervention.reference}
|
{intervention.reference}
|
||||||
</button>
|
</BrowserLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -93,13 +97,12 @@ export function InterventionsPage(props: InterventionsPageProps) {
|
|||||||
value: (intervention) => intervention.values.subject,
|
value: (intervention) => intervention.values.subject,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (intervention) => (
|
render: (intervention) => (
|
||||||
<button
|
<BrowserLink
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={() => setViewId(intervention.id)}
|
href={`/companies/${intervention.companyId}/interventions/${intervention.id}`}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{intervention.values.subject}
|
{intervention.values.subject}
|
||||||
</button>
|
</BrowserLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink, EntityNotFound, useEntityNavigation } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||||
import {
|
import {
|
||||||
@ -29,9 +30,13 @@ export function PeopleSection({
|
|||||||
const [selection, setSelection] = useState<
|
const [selection, setSelection] = useState<
|
||||||
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
||||||
>(initialId ? { mode: "detail", id: initialId } : undefined);
|
>(initialId ? { mode: "detail", id: initialId } : undefined);
|
||||||
|
const { selectedId, selectEntity, entityHref, routed } = useEntityNavigation(
|
||||||
|
`/companies/${company.id}/people`,
|
||||||
|
initialId
|
||||||
|
);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [contactsOnly, setContactsOnly] = useState(false);
|
const [contactsOnly, setContactsOnly] = useState(false);
|
||||||
const selected = company.people.find((item) => item.id === selection?.id);
|
const selected = company.people.find((item) => item.id === (routed ? selectedId : selection?.id));
|
||||||
const personEditor = (
|
const personEditor = (
|
||||||
<PersonEditorDialog
|
<PersonEditorDialog
|
||||||
companyId={company.id}
|
companyId={company.id}
|
||||||
@ -41,11 +46,17 @@ export function PeopleSection({
|
|||||||
setSelection(selected ? { mode: "detail", id: selected.id } : undefined);
|
setSelection(selected ? { mode: "detail", id: selected.id } : undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSaved={(id) => setSelection({ mode: "detail", id })}
|
onSaved={(id) => {
|
||||||
|
setSelection({ mode: "detail", id });
|
||||||
|
selectEntity(id);
|
||||||
|
}}
|
||||||
open={selection?.mode === "new" || selection?.mode === "edit"}
|
open={selection?.mode === "new" || selection?.mode === "edit"}
|
||||||
person={selected}
|
person={selected}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
if (routed && selectedId && !selected) {
|
||||||
|
return <EntityNotFound />;
|
||||||
|
}
|
||||||
if (selected) {
|
if (selected) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -54,7 +65,10 @@ export function PeopleSection({
|
|||||||
company={company}
|
company={company}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
key={selected.id}
|
key={selected.id}
|
||||||
onBack={() => setSelection(undefined)}
|
onBack={() => {
|
||||||
|
setSelection(undefined);
|
||||||
|
selectEntity(undefined);
|
||||||
|
}}
|
||||||
onEdit={() => setSelection({ mode: "edit", id: selected.id })}
|
onEdit={() => setSelection({ mode: "edit", id: selected.id })}
|
||||||
person={selected}
|
person={selected}
|
||||||
/>
|
/>
|
||||||
@ -89,13 +103,12 @@ export function PeopleSection({
|
|||||||
hideable: false,
|
hideable: false,
|
||||||
render: (person) => (
|
render: (person) => (
|
||||||
<>
|
<>
|
||||||
<button
|
<BrowserLink
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={() => setSelection({ mode: "detail", id: person.id })}
|
href={entityHref(person.id)}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{person.name}
|
{person.name}
|
||||||
</button>
|
</BrowserLink>
|
||||||
{person.isContact && (
|
{person.isContact && (
|
||||||
<p className="text-xs text-muted-foreground">{t("isContact")}</p>
|
<p className="text-xs text-muted-foreground">{t("isContact")}</p>
|
||||||
)}
|
)}
|
||||||
@ -140,7 +153,12 @@ export function PeopleSection({
|
|||||||
hideable: false,
|
hideable: false,
|
||||||
render: (person) => (
|
render: (person) => (
|
||||||
<EntityRowActions label={t("actions")}>
|
<EntityRowActions label={t("actions")}>
|
||||||
<EntityAction onClick={() => setSelection({ mode: "detail", id: person.id })}>
|
<EntityAction
|
||||||
|
onClick={() => {
|
||||||
|
setSelection({ mode: "detail", id: person.id });
|
||||||
|
selectEntity(person.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("open")}
|
{t("open")}
|
||||||
</EntityAction>
|
</EntityAction>
|
||||||
</EntityRowActions>
|
</EntityRowActions>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -8,11 +9,29 @@ import type { ProcessesPageProps } from "../types";
|
|||||||
|
|
||||||
export function useProcessesPageController({ company }: ProcessesPageProps) {
|
export function useProcessesPageController({ company }: ProcessesPageProps) {
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
const [templates, setTemplates] = useState(false);
|
|
||||||
const [create, setCreate] = useState(false);
|
const [create, setCreate] = useState(false);
|
||||||
const [selected, setSelected] = useState<string>();
|
const { selectedId: selected, selectEntity: setSelected } = useEntityNavigation(
|
||||||
const [search, setSearch] = useState("");
|
`/companies/${company.id}/processes`
|
||||||
const [closed, setClosed] = useState(false);
|
);
|
||||||
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
|
const search = searchParams.get("q") ?? "";
|
||||||
|
const setSearch = (value: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("q", value);
|
||||||
|
} else {
|
||||||
|
params.delete("q");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const closed = searchParams.get("closed") === "true";
|
||||||
|
const setClosed = (value: boolean) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("closed", "true");
|
||||||
|
} else {
|
||||||
|
params.delete("closed");
|
||||||
|
}
|
||||||
|
});
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: processKeys.company(company.id),
|
queryKey: processKeys.company(company.id),
|
||||||
queryFn: () => getProcesses(company.id),
|
queryFn: () => getProcesses(company.id),
|
||||||
@ -28,8 +47,6 @@ export function useProcessesPageController({ company }: ProcessesPageProps) {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
templates,
|
|
||||||
setTemplates,
|
|
||||||
create,
|
create,
|
||||||
setCreate,
|
setCreate,
|
||||||
selected,
|
selected,
|
||||||
@ -41,6 +58,7 @@ export function useProcessesPageController({ company }: ProcessesPageProps) {
|
|||||||
query,
|
query,
|
||||||
person,
|
person,
|
||||||
current,
|
current,
|
||||||
|
missingView: Boolean(selected && query.isSuccess && !current),
|
||||||
filtered,
|
filtered,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink, EntityNotFound } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
@ -8,7 +9,6 @@ import {
|
|||||||
} from "@repo/shadcn-ui/components/entity-list";
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ProcessTemplatesPage } from "../../../process-templates";
|
|
||||||
import { ProcessDetail } from "../../detail";
|
import { ProcessDetail } from "../../detail";
|
||||||
import { ProcessFeedback } from "../../shared";
|
import { ProcessFeedback } from "../../shared";
|
||||||
import { ProcessStartDialog } from "../../start";
|
import { ProcessStartDialog } from "../../start";
|
||||||
@ -18,8 +18,6 @@ import type { ProcessesPageProps } from "../types";
|
|||||||
export function ProcessesPage(props: ProcessesPageProps) {
|
export function ProcessesPage(props: ProcessesPageProps) {
|
||||||
const { company, csrfToken } = props;
|
const { company, csrfToken } = props;
|
||||||
const {
|
const {
|
||||||
templates,
|
|
||||||
setTemplates,
|
|
||||||
create,
|
create,
|
||||||
setCreate,
|
setCreate,
|
||||||
selected,
|
selected,
|
||||||
@ -31,18 +29,12 @@ export function ProcessesPage(props: ProcessesPageProps) {
|
|||||||
query,
|
query,
|
||||||
person,
|
person,
|
||||||
current,
|
current,
|
||||||
|
missingView,
|
||||||
filtered,
|
filtered,
|
||||||
} = useProcessesPageController(props);
|
} = useProcessesPageController(props);
|
||||||
const { t, i18n } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
if (templates) {
|
if (missingView) {
|
||||||
return (
|
return <EntityNotFound />;
|
||||||
<div className="space-y-5">
|
|
||||||
<Button onClick={() => setTemplates(false)} variant="outline">
|
|
||||||
{t("processes.back")}
|
|
||||||
</Button>
|
|
||||||
<ProcessTemplatesPage csrfToken={csrfToken} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@ -77,7 +69,11 @@ export function ProcessesPage(props: ProcessesPageProps) {
|
|||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => setTemplates(true)} variant="outline">
|
<Button
|
||||||
|
nativeButton={false}
|
||||||
|
render={<BrowserLink href="/catalogs/process-templates" />}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
{t("processes.templates")}
|
{t("processes.templates")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
|
<Button disabled={!query.data || query.isError} onClick={() => setCreate(true)}>
|
||||||
@ -99,13 +95,12 @@ export function ProcessesPage(props: ProcessesPageProps) {
|
|||||||
value: (p) => p.values.name,
|
value: (p) => p.values.name,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (p) => (
|
render: (p) => (
|
||||||
<button
|
<BrowserLink
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={() => setSelected(p.id)}
|
href={`/companies/${company.id}/processes/${p.id}`}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{p.values.name}
|
{p.values.name}
|
||||||
</button>
|
</BrowserLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
32
modules/companies/src/web/shared/ui/section-navigation.tsx
Normal file
32
modules/companies/src/web/shared/ui/section-navigation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { navigateTo, useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -25,15 +26,54 @@ export function useTicketsPageController({
|
|||||||
queryKey: ticketKeys.list(companyId),
|
queryKey: ticketKeys.list(companyId),
|
||||||
queryFn: () => ticketApi.list(companyId),
|
queryFn: () => ticketApi.list(companyId),
|
||||||
});
|
});
|
||||||
const [filters, setFilters] = useState<TicketFilters>({
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
search: "",
|
const filters: TicketFilters = {
|
||||||
company: "",
|
search: searchParams.get("q") ?? "",
|
||||||
state: "",
|
company: searchParams.get("companyId") ?? "",
|
||||||
priority: "",
|
state: searchParams.get("state") ?? "",
|
||||||
assignee: "",
|
priority: searchParams.get("priority") ?? "",
|
||||||
});
|
assignee: searchParams.get("assignee") ?? "",
|
||||||
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly);
|
};
|
||||||
const [viewId, setViewId] = useState(initialId);
|
const setFilters = (value: TicketFilters) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
for (const [key, text] of Object.entries({
|
||||||
|
q: value.search,
|
||||||
|
companyId: value.company,
|
||||||
|
state: value.state,
|
||||||
|
priority: value.priority,
|
||||||
|
assignee: value.assignee,
|
||||||
|
})) {
|
||||||
|
if (text) {
|
||||||
|
params.set(key, text);
|
||||||
|
} else {
|
||||||
|
params.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const pendingOnly = searchParams.has("pendingOnly")
|
||||||
|
? searchParams.get("pendingOnly") === "true"
|
||||||
|
: initialPendingOnly;
|
||||||
|
const setPendingOnly = (value: boolean) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("pendingOnly", "true");
|
||||||
|
} else {
|
||||||
|
params.delete("pendingOnly");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
selectedId: viewId,
|
||||||
|
selectEntity,
|
||||||
|
routed,
|
||||||
|
} = useEntityNavigation(companyId ? `/companies/${companyId}/tickets` : "/tickets", initialId);
|
||||||
|
const setViewId = (id: string | undefined) => {
|
||||||
|
const ticket = query.data?.tickets.find((item) => item.id === id);
|
||||||
|
if (routed && !companyId && ticket) {
|
||||||
|
navigateTo(`/companies/${ticket.companyId}/tickets/${ticket.id}`);
|
||||||
|
} else {
|
||||||
|
selectEntity(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
const [open, setOpen] = useState(initialCreate);
|
const [open, setOpen] = useState(initialCreate);
|
||||||
const [edit, setEdit] = useState<TicketDTO | undefined>();
|
const [edit, setEdit] = useState<TicketDTO | undefined>();
|
||||||
const [interventionTicketId, setInterventionTicketId] = useState<string>();
|
const [interventionTicketId, setInterventionTicketId] = useState<string>();
|
||||||
@ -56,6 +96,7 @@ export function useTicketsPageController({
|
|||||||
setEdit,
|
setEdit,
|
||||||
setInterventionTicketId,
|
setInterventionTicketId,
|
||||||
currentView,
|
currentView,
|
||||||
|
missingView: Boolean(viewId && query.isSuccess && !currentView),
|
||||||
interventionTicket,
|
interventionTicket,
|
||||||
items,
|
items,
|
||||||
date,
|
date,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink, EntityNotFound } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
@ -32,11 +33,15 @@ export function TicketsPage(props: TicketsPageProps) {
|
|||||||
setEdit,
|
setEdit,
|
||||||
setInterventionTicketId,
|
setInterventionTicketId,
|
||||||
currentView,
|
currentView,
|
||||||
|
missingView,
|
||||||
interventionTicket,
|
interventionTicket,
|
||||||
items,
|
items,
|
||||||
date,
|
date,
|
||||||
} = useTicketsPageController(props);
|
} = useTicketsPageController(props);
|
||||||
const { t, i18n } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
|
if (missingView) {
|
||||||
|
return <EntityNotFound />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<EntityListHeader
|
<EntityListHeader
|
||||||
@ -81,13 +86,12 @@ export function TicketsPage(props: TicketsPageProps) {
|
|||||||
value: (ticket) => ticket.reference,
|
value: (ticket) => ticket.reference,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (ticket) => (
|
render: (ticket) => (
|
||||||
<button
|
<BrowserLink
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={() => setViewId(ticket.id)}
|
href={`/companies/${ticket.companyId}/tickets/${ticket.id}`}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{ticket.reference}
|
{ticket.reference}
|
||||||
</button>
|
</BrowserLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -96,13 +100,12 @@ export function TicketsPage(props: TicketsPageProps) {
|
|||||||
value: (ticket) => ticket.values.subject,
|
value: (ticket) => ticket.values.subject,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (ticket) => (
|
render: (ticket) => (
|
||||||
<button
|
<BrowserLink
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={() => setViewId(ticket.id)}
|
href={`/companies/${ticket.companyId}/tickets/${ticket.id}`}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{ticket.values.subject}
|
{ticket.values.subject}
|
||||||
</button>
|
</BrowserLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
|
import {
|
||||||
|
closeRoute,
|
||||||
|
navigateTo,
|
||||||
|
useBrowserSearchParams,
|
||||||
|
useEntityNavigation,
|
||||||
|
} from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -6,19 +12,98 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { getAccess } from "../../../access/list/api";
|
import { getAccess } from "../../../access/list/api";
|
||||||
import { accessLabel } from "../../../access/shared/helpers";
|
import { accessLabel } from "../../../access/shared/helpers";
|
||||||
import { accessKeys } from "../../../access/shared/queries";
|
import { accessKeys } from "../../../access/shared/queries";
|
||||||
|
import { companySectionHref, workspaceHref } from "../../../workspace/shared/helpers";
|
||||||
import { profileApi, profileKeys } from "../../shared/api";
|
import { profileApi, profileKeys } from "../../shared/api";
|
||||||
import type { ProfileSelection } from "../../shared/types";
|
import type { ProfileSelection } from "../../shared/types";
|
||||||
import type { WorkProfilesPageProps } from "../types";
|
import type { WorkProfilesPageProps } from "../types";
|
||||||
|
|
||||||
export function useWorkProfilesPageController({ company, initialId }: WorkProfilesPageProps) {
|
export function useWorkProfilesPageController({
|
||||||
|
company,
|
||||||
|
initialId,
|
||||||
|
initialConfiguration,
|
||||||
|
}: WorkProfilesPageProps) {
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
const [configuration, setConfiguration] = useState<ProfileSelection | undefined>(
|
const [localConfiguration, setLocalConfiguration] = useState<ProfileSelection | undefined>(
|
||||||
initialId ? { kind: "environment", profileId: initialId } : undefined
|
initialId ? { kind: "environment", profileId: initialId } : undefined
|
||||||
);
|
);
|
||||||
|
const { selectedId, routed } = useEntityNavigation(
|
||||||
|
`/companies/${company.id}/work-profiles`,
|
||||||
|
initialId
|
||||||
|
);
|
||||||
|
const configuration: ProfileSelection | undefined = routed
|
||||||
|
? selectedId
|
||||||
|
? {
|
||||||
|
kind: initialConfiguration ?? "environment",
|
||||||
|
profileId: selectedId,
|
||||||
|
fromEnvironment: Boolean(initialConfiguration),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
: localConfiguration;
|
||||||
|
const setConfiguration = (selection: ProfileSelection | undefined) => {
|
||||||
|
if (!routed) {
|
||||||
|
setLocalConfiguration(selection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selection) {
|
||||||
|
closeRoute(companySectionHref(company.id, "profiles"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selection.kind === "remote" || selection.kind === "telework") {
|
||||||
|
const profile = profiles.find((item) => item.id === selection.profileId);
|
||||||
|
if (!profile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateTo(
|
||||||
|
`${companySectionHref(company.id, selection.kind)}?${new URLSearchParams({ personId: profile.values.personId })}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateTo(
|
||||||
|
workspaceHref({
|
||||||
|
area: "company",
|
||||||
|
id: company.id,
|
||||||
|
section: "profiles",
|
||||||
|
entityId: selection.profileId,
|
||||||
|
configuration: selection.kind === "environment" ? undefined : selection.kind,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const closeConfiguration = () => {
|
||||||
|
if (routed) {
|
||||||
|
closeRoute(
|
||||||
|
initialConfiguration && selectedId
|
||||||
|
? `${companySectionHref(company.id, "profiles")}/${selectedId}`
|
||||||
|
: companySectionHref(company.id, "profiles")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalConfiguration(
|
||||||
|
configuration?.fromEnvironment
|
||||||
|
? { kind: "environment", profileId: configuration.profileId }
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
const [editor, setEditor] = useState<string>();
|
const [editor, setEditor] = useState<string>();
|
||||||
const [view, setView] = useState<string>();
|
const [view, setView] = useState<string>();
|
||||||
const [search, setSearch] = useState("");
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
const [archived, setArchived] = useState(false);
|
const search = searchParams.get("q") ?? "";
|
||||||
|
const setSearch = (value: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("q", value);
|
||||||
|
} else {
|
||||||
|
params.delete("q");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const archived = searchParams.get("archived") === "true";
|
||||||
|
const setArchived = (value: boolean) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("archived", "true");
|
||||||
|
} else {
|
||||||
|
params.delete("archived");
|
||||||
|
}
|
||||||
|
});
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: profileKeys.company(company.id),
|
queryKey: profileKeys.company(company.id),
|
||||||
queryFn: () => profileApi.get(company.id),
|
queryFn: () => profileApi.get(company.id),
|
||||||
@ -53,7 +138,13 @@ export function useWorkProfilesPageController({ company, initialId }: WorkProfil
|
|||||||
const ready = [query.data, inventory.data, access.data].every(Boolean);
|
const ready = [query.data, inventory.data, access.data].every(Boolean);
|
||||||
return {
|
return {
|
||||||
configuration,
|
configuration,
|
||||||
|
missingProfile: Boolean(
|
||||||
|
configuration &&
|
||||||
|
query.isSuccess &&
|
||||||
|
!profiles.some((item) => item.id === configuration.profileId)
|
||||||
|
),
|
||||||
setConfiguration,
|
setConfiguration,
|
||||||
|
closeConfiguration,
|
||||||
editor,
|
editor,
|
||||||
setEditor,
|
setEditor,
|
||||||
setView,
|
setView,
|
||||||
|
|||||||
@ -4,4 +4,11 @@ export type WorkProfilesPageProps = {
|
|||||||
company: CompanyDTO;
|
company: CompanyDTO;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
initialId?: string | undefined;
|
initialId?: string | undefined;
|
||||||
|
initialConfiguration?:
|
||||||
|
| "folders"
|
||||||
|
| "mail"
|
||||||
|
| "certificates"
|
||||||
|
| "applications"
|
||||||
|
| "printers"
|
||||||
|
| undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink, EntityNotFound } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
@ -19,6 +20,7 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
|
|||||||
const {
|
const {
|
||||||
configuration,
|
configuration,
|
||||||
setConfiguration,
|
setConfiguration,
|
||||||
|
closeConfiguration,
|
||||||
editor,
|
editor,
|
||||||
setEditor,
|
setEditor,
|
||||||
setView,
|
setView,
|
||||||
@ -37,20 +39,18 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
|
|||||||
account,
|
account,
|
||||||
filtered,
|
filtered,
|
||||||
ready,
|
ready,
|
||||||
|
missingProfile,
|
||||||
} = useWorkProfilesPageController(props);
|
} = useWorkProfilesPageController(props);
|
||||||
const { t, i18n } = useTranslation("companies");
|
const { t, i18n } = useTranslation("companies");
|
||||||
|
if (missingProfile) {
|
||||||
|
return <EntityNotFound />;
|
||||||
|
}
|
||||||
if (configuration) {
|
if (configuration) {
|
||||||
return (
|
return (
|
||||||
<ProfileConfigurations
|
<ProfileConfigurations
|
||||||
company={company}
|
company={company}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
onBack={() =>
|
onBack={closeConfiguration}
|
||||||
setConfiguration(
|
|
||||||
configuration.fromEnvironment
|
|
||||||
? { kind: "environment", profileId: configuration.profileId }
|
|
||||||
: undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSelection={setConfiguration}
|
onSelection={setConfiguration}
|
||||||
personId={profiles.find((item) => item.id === configuration.profileId)?.values.personId}
|
personId={profiles.find((item) => item.id === configuration.profileId)?.values.personId}
|
||||||
selection={configuration}
|
selection={configuration}
|
||||||
@ -97,13 +97,12 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
|
|||||||
value: (item) => item.values.name,
|
value: (item) => item.values.name,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (item) => (
|
render: (item) => (
|
||||||
<button
|
<BrowserLink
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={() => setConfiguration({ kind: "environment", profileId: item.id })}
|
href={`/companies/${company.id}/work-profiles/${item.id}`}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{item.values.name}
|
{item.values.name}
|
||||||
</button>
|
</BrowserLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export { GlobalCatalogContent } from "./ui";
|
export { GlobalCatalogContent } from "./ui";
|
||||||
|
export { CatalogOverview } from "./ui/catalog-overview";
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,15 +3,19 @@ import { ResourceCatalogPage } from "@metis/resources/client";
|
|||||||
|
|
||||||
import { AccessWorkspacePage } from "../../../access";
|
import { AccessWorkspacePage } from "../../../access";
|
||||||
import { InterventionTemplatesPage } from "../../../intervention-templates";
|
import { InterventionTemplatesPage } from "../../../intervention-templates";
|
||||||
|
import { ProcessTemplatesPage } from "../../../process-templates";
|
||||||
|
import type { GlobalCatalog } from "../../shared/types";
|
||||||
|
|
||||||
export function GlobalCatalogContent({
|
export function GlobalCatalogContent({
|
||||||
catalog,
|
catalog,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
}: {
|
}: {
|
||||||
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
|
catalog: GlobalCatalog;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
}) {
|
}) {
|
||||||
switch (catalog) {
|
switch (catalog) {
|
||||||
|
case "process-templates":
|
||||||
|
return <ProcessTemplatesPage csrfToken={csrfToken} />;
|
||||||
case "intervention-templates":
|
case "intervention-templates":
|
||||||
return <InterventionTemplatesPage csrfToken={csrfToken} />;
|
return <InterventionTemplatesPage csrfToken={csrfToken} />;
|
||||||
case "access":
|
case "access":
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
COMPANY_SECTION_PATHS,
|
||||||
|
companySectionHref,
|
||||||
|
parseWorkspaceUrl,
|
||||||
|
workspaceHref,
|
||||||
|
} from "./workspace-routes";
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -1,2 +1,3 @@
|
|||||||
|
export { companySectionHref, parseWorkspaceUrl, workspaceHref } from "./helpers";
|
||||||
export type { WorkspaceLocation } from "./types";
|
export type { WorkspaceLocation } from "./types";
|
||||||
export { WorkspaceNavigationButton } from "./ui";
|
export { WorkspaceNavigationButton } from "./ui";
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export type { WorkspaceLocation } from "./workspace-location";
|
export type { GlobalCatalog, WorkspaceLocation } from "./workspace-location";
|
||||||
|
|||||||
@ -1,3 +1,14 @@
|
|||||||
import type { DashboardNavigation } from "../../../dashboard/shared/helpers";
|
import type { DashboardNavigation } from "../../../dashboard/shared/helpers";
|
||||||
|
|
||||||
export type WorkspaceLocation = { area: "home" } | DashboardNavigation | { area: "catalogs" };
|
export type GlobalCatalog =
|
||||||
|
| "access"
|
||||||
|
| "assets"
|
||||||
|
| "brands"
|
||||||
|
| "resources"
|
||||||
|
| "intervention-templates"
|
||||||
|
| "process-templates";
|
||||||
|
export type WorkspaceLocation =
|
||||||
|
| { area: "home"; companyId?: string | undefined }
|
||||||
|
| DashboardNavigation
|
||||||
|
| { area: "catalogs"; catalog?: GlobalCatalog | undefined }
|
||||||
|
| { area: "not-found" | "invalid-route" };
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
@ -5,11 +6,19 @@ import type { ComponentProps } from "react";
|
|||||||
export function WorkspaceNavigationButton({
|
export function WorkspaceNavigationButton({
|
||||||
level = "primary",
|
level = "primary",
|
||||||
className,
|
className,
|
||||||
|
href,
|
||||||
...props
|
...props
|
||||||
}: ComponentProps<typeof Button> & { level?: "primary" | "catalog" }) {
|
}: ComponentProps<typeof Button> & { level?: "primary" | "catalog"; href?: string }) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
|
{...(href
|
||||||
|
? {
|
||||||
|
render: <BrowserLink href={href} />,
|
||||||
|
nativeButton: false,
|
||||||
|
"aria-current": props["aria-pressed"] ? "page" : undefined,
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shell:group-data-[collapsed=true]/sidebar:justify-center shell:group-data-[collapsed=true]/sidebar:px-0 shell:group-data-[collapsed=true]/sidebar:[&>span:not([data-slot$=avatar])]:hidden",
|
"shell:group-data-[collapsed=true]/sidebar:justify-center shell:group-data-[collapsed=true]/sidebar:px-0 shell:group-data-[collapsed=true]/sidebar:[&>span:not([data-slot$=avatar])]:hidden",
|
||||||
level === "catalog"
|
level === "catalog"
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,29 +1,33 @@
|
|||||||
|
import { isCreateNavigation, navigateTo, useBrowserLocation } from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { listCompanies } from "../../../companies/list/api";
|
import { listCompanies } from "../../../companies/list/api";
|
||||||
import { companyKeys } from "../../../companies/shared/queries";
|
import { companyKeys } from "../../../companies/shared/queries";
|
||||||
|
import { parseWorkspaceUrl, workspaceHref } from "../../shared/helpers";
|
||||||
import type { WorkspaceLocation } from "../../shared/types";
|
import type { WorkspaceLocation } from "../../shared/types";
|
||||||
|
|
||||||
export function useCompaniesWorkspaceController() {
|
export function useCompaniesWorkspaceController() {
|
||||||
const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
|
const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies });
|
||||||
const [location, setLocation] = useState<WorkspaceLocation>();
|
const url = useBrowserLocation();
|
||||||
const [catalog, setCatalog] = useState<
|
const current = parseWorkspaceUrl(url);
|
||||||
"access" | "assets" | "brands" | "resources" | "intervention-templates"
|
const setLocation = (destination: WorkspaceLocation) =>
|
||||||
>("access");
|
navigateTo(workspaceHref(destination), {
|
||||||
|
create: "create" in destination && destination.create === true,
|
||||||
|
});
|
||||||
|
const catalog = current.area === "catalogs" ? current.catalog : undefined;
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const companies = query.data?.companies ?? [];
|
const companies = query.data?.companies ?? [];
|
||||||
const current = location ?? { area: "home" };
|
const selectCompany = (id: string) => setLocation({ area: "company", id, section: "overview" });
|
||||||
const selectCompany = (id: string) => setLocation({ area: "company", id, section: "inventory" });
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
setLocation,
|
setLocation,
|
||||||
catalog,
|
catalog,
|
||||||
setCatalog,
|
|
||||||
settingsOpen,
|
settingsOpen,
|
||||||
setSettingsOpen,
|
setSettingsOpen,
|
||||||
companies,
|
companies,
|
||||||
current,
|
current,
|
||||||
|
initialCreate: isCreateNavigation(),
|
||||||
selectCompany,
|
selectCompany,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,12 +16,12 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
|
|||||||
query,
|
query,
|
||||||
setLocation,
|
setLocation,
|
||||||
catalog,
|
catalog,
|
||||||
setCatalog,
|
|
||||||
settingsOpen,
|
settingsOpen,
|
||||||
setSettingsOpen,
|
setSettingsOpen,
|
||||||
companies,
|
companies,
|
||||||
current,
|
current,
|
||||||
selectCompany,
|
selectCompany,
|
||||||
|
initialCreate,
|
||||||
} = useCompaniesWorkspaceController();
|
} = useCompaniesWorkspaceController();
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
const { t: coreTranslation } = useTranslation("core");
|
const { t: coreTranslation } = useTranslation("core");
|
||||||
@ -34,9 +34,6 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
|
|||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
current={current}
|
current={current}
|
||||||
onNavigate={setLocation}
|
|
||||||
onSelectCatalog={setCatalog}
|
|
||||||
onSelectCompany={selectCompany}
|
|
||||||
onSettings={() => setSettingsOpen(true)}
|
onSettings={() => setSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1 overflow-y-auto bg-shell-content">
|
<div className="min-w-0 flex-1 overflow-y-auto bg-shell-content">
|
||||||
@ -44,8 +41,6 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
|
|||||||
accountNavigation={accountNavigation}
|
accountNavigation={accountNavigation}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
languageSwitcher={languageSwitcher}
|
languageSwitcher={languageSwitcher}
|
||||||
onNavigate={setLocation}
|
|
||||||
onSelectCompany={selectCompany}
|
|
||||||
userName={userName}
|
userName={userName}
|
||||||
/>
|
/>
|
||||||
<WorkspaceSettingsDialog
|
<WorkspaceSettingsDialog
|
||||||
@ -79,11 +74,13 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
|
|||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
embedded
|
embedded
|
||||||
id={current.id}
|
id={current.id}
|
||||||
initialCreate={current.create}
|
initialConfiguration={current.configuration}
|
||||||
|
initialCreate={initialCreate}
|
||||||
initialEntityId={current.entityId}
|
initialEntityId={current.entityId}
|
||||||
initialSection={current.section}
|
|
||||||
key={current.id}
|
key={current.id}
|
||||||
onBack={() => setLocation({ area: "directory" })}
|
onBack={() => setLocation({ area: "directory" })}
|
||||||
|
onSectionChange={(section) => setLocation({ area: "company", id: current.id, section })}
|
||||||
|
section={current.section}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<main className="min-w-0 p-4 shell:p-6">
|
<main className="min-w-0 p-4 shell:p-6">
|
||||||
@ -91,7 +88,8 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
|
|||||||
area={current.area}
|
area={current.area}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
key={JSON.stringify(current)}
|
initialCreate={initialCreate}
|
||||||
|
key={current.area === "catalogs" ? `catalogs:${catalog ?? "overview"}` : current.area}
|
||||||
navigation={current}
|
navigation={current}
|
||||||
onNavigate={setLocation}
|
onNavigate={setLocation}
|
||||||
onSelectCompany={selectCompany}
|
onSelectCompany={selectCompany}
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
|
import { useCreateNavigation } from "@metis/core/client";
|
||||||
|
|
||||||
import { CompaniesPage } from "../../../companies";
|
import { CompaniesPage } from "../../../companies";
|
||||||
import { DashboardPage } from "../../../dashboard";
|
import { DashboardPage } from "../../../dashboard";
|
||||||
import type { DashboardNavigation } from "../../../dashboard/shared/helpers";
|
import type { DashboardNavigation } from "../../../dashboard/shared/helpers";
|
||||||
import { InterventionsPage } from "../../../interventions";
|
import { InterventionsPage } from "../../../interventions";
|
||||||
import { TicketsPage } from "../../../tickets";
|
import { TicketsPage } from "../../../tickets";
|
||||||
import { GlobalCatalogContent } from "../../catalogs";
|
import { CatalogOverview, GlobalCatalogContent } from "../../catalogs";
|
||||||
import type { WorkspaceLocation } from "../../shared/types";
|
import type { GlobalCatalog, WorkspaceLocation } from "../../shared/types";
|
||||||
|
import { RouteFeedback } from "../../shared/ui/route-feedback";
|
||||||
|
|
||||||
export function WorkspaceContent({
|
export function WorkspaceContent({
|
||||||
area,
|
area,
|
||||||
@ -13,14 +16,17 @@ export function WorkspaceContent({
|
|||||||
csrfToken,
|
csrfToken,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onSelectCompany,
|
onSelectCompany,
|
||||||
|
initialCreate,
|
||||||
}: {
|
}: {
|
||||||
area: WorkspaceLocation["area"];
|
area: WorkspaceLocation["area"];
|
||||||
navigation: WorkspaceLocation;
|
navigation: WorkspaceLocation;
|
||||||
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
|
catalog: GlobalCatalog | undefined;
|
||||||
|
initialCreate?: boolean | undefined;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
onNavigate: (destination: DashboardNavigation) => void;
|
onNavigate: (destination: DashboardNavigation) => void;
|
||||||
onSelectCompany: (id: string) => void;
|
onSelectCompany: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const create = useCreateNavigation() || initialCreate;
|
||||||
switch (area) {
|
switch (area) {
|
||||||
case "interventions":
|
case "interventions":
|
||||||
return (
|
return (
|
||||||
@ -28,9 +34,8 @@ export function WorkspaceContent({
|
|||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
{...(navigation.area === "interventions"
|
{...(navigation.area === "interventions"
|
||||||
? {
|
? {
|
||||||
companyId: navigation.companyId,
|
|
||||||
initialId: navigation.id,
|
initialId: navigation.id,
|
||||||
initialCreate: navigation.create,
|
initialCreate: create,
|
||||||
initialPendingOnly: navigation.pendingOnly,
|
initialPendingOnly: navigation.pendingOnly,
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
@ -42,9 +47,8 @@ export function WorkspaceContent({
|
|||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
{...(navigation.area === "tickets"
|
{...(navigation.area === "tickets"
|
||||||
? {
|
? {
|
||||||
companyId: navigation.companyId,
|
|
||||||
initialId: navigation.id,
|
initialId: navigation.id,
|
||||||
initialCreate: navigation.create,
|
initialCreate: create,
|
||||||
initialPendingOnly: navigation.pendingOnly,
|
initialPendingOnly: navigation.pendingOnly,
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
@ -59,16 +63,23 @@ export function WorkspaceContent({
|
|||||||
? {
|
? {
|
||||||
initialActiveOnly: navigation.activeOnly,
|
initialActiveOnly: navigation.activeOnly,
|
||||||
initialCompanyId: navigation.companyId,
|
initialCompanyId: navigation.companyId,
|
||||||
initialCreate: navigation.create,
|
initialCreate: create,
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "catalogs":
|
case "catalogs":
|
||||||
return <GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />;
|
return catalog ? (
|
||||||
|
<GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />
|
||||||
|
) : (
|
||||||
|
<CatalogOverview />
|
||||||
|
);
|
||||||
case "company":
|
case "company":
|
||||||
return null;
|
return null;
|
||||||
case "home":
|
case "home":
|
||||||
return <DashboardPage csrfToken={csrfToken} onNavigate={onNavigate} />;
|
return <DashboardPage csrfToken={csrfToken} onNavigate={onNavigate} />;
|
||||||
|
case "not-found":
|
||||||
|
case "invalid-route":
|
||||||
|
return <RouteFeedback invalid={area === "invalid-route"} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { BellIcon, ChevronDownIcon, SearchIcon } from "@repo/shadcn-ui/lib/icons";
|
import { BellIcon, ChevronDownIcon, SearchIcon } from "@repo/shadcn-ui/lib/icons";
|
||||||
@ -7,18 +8,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { CompanyIdentity } from "../../../companies";
|
import { CompanyIdentity } from "../../../companies";
|
||||||
import { InitialsAvatar } from "../../../shared";
|
import { InitialsAvatar } from "../../../shared";
|
||||||
import type { WorkspaceLocation } from "../../shared/types";
|
import { companySectionHref, workspaceHref } from "../../shared/helpers";
|
||||||
|
|
||||||
export function WorkspaceHeader({
|
export function WorkspaceHeader({
|
||||||
companies,
|
companies,
|
||||||
onNavigate,
|
|
||||||
onSelectCompany,
|
|
||||||
accountNavigation,
|
accountNavigation,
|
||||||
userName,
|
userName,
|
||||||
languageSwitcher,
|
languageSwitcher,
|
||||||
}: {
|
}: {
|
||||||
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
|
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
|
||||||
onNavigate: (location: WorkspaceLocation) => void;
|
|
||||||
onSelectCompany: (id: string) => void;
|
|
||||||
accountNavigation: ReactNode;
|
accountNavigation: ReactNode;
|
||||||
userName: string;
|
userName: string;
|
||||||
languageSwitcher: ReactNode;
|
languageSwitcher: ReactNode;
|
||||||
@ -58,10 +56,9 @@ export function WorkspaceHeader({
|
|||||||
<Button
|
<Button
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
key={company.id}
|
key={company.id}
|
||||||
onClick={() => {
|
nativeButton={false}
|
||||||
onSelectCompany(company.id);
|
onClick={() => setSearch("")}
|
||||||
setSearch("");
|
render={<BrowserLink href={companySectionHref(company.id, "overview")} />}
|
||||||
}}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<CompanyIdentity
|
<CompanyIdentity
|
||||||
@ -87,10 +84,9 @@ export function WorkspaceHeader({
|
|||||||
<Button
|
<Button
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
key={area}
|
key={area}
|
||||||
onClick={() => {
|
nativeButton={false}
|
||||||
onNavigate({ area });
|
onClick={() => setSearch("")}
|
||||||
setSearch("");
|
render={<BrowserLink href={workspaceHref({ area })} />}
|
||||||
}}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{t(label)}
|
{t(label)}
|
||||||
|
|||||||
@ -11,30 +11,23 @@ import {
|
|||||||
TicketIcon,
|
TicketIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from "@repo/shadcn-ui/lib/icons";
|
} from "@repo/shadcn-ui/lib/icons";
|
||||||
import type { ComponentProps } from "react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CompanyAvatar } from "../../../companies";
|
import { CompanyAvatar } from "../../../companies";
|
||||||
import { WorkspaceNavigationButton } from "../../shared";
|
import { WorkspaceNavigationButton } from "../../shared";
|
||||||
import type { WorkspaceLocation } from "../../shared/types";
|
import { companySectionHref, workspaceHref } from "../../shared/helpers";
|
||||||
|
import type { GlobalCatalog, WorkspaceLocation } from "../../shared/types";
|
||||||
|
|
||||||
import type { WorkspaceContent } from "./workspace-content";
|
|
||||||
export function WorkspaceSidebar({
|
export function WorkspaceSidebar({
|
||||||
companies,
|
companies,
|
||||||
current,
|
current,
|
||||||
catalog,
|
catalog,
|
||||||
onNavigate,
|
|
||||||
onSelectCompany,
|
|
||||||
onSelectCatalog,
|
|
||||||
onSettings,
|
onSettings,
|
||||||
}: {
|
}: {
|
||||||
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
|
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
|
||||||
onNavigate: (location: WorkspaceLocation) => void;
|
|
||||||
onSelectCompany: (id: string) => void;
|
|
||||||
current: WorkspaceLocation;
|
current: WorkspaceLocation;
|
||||||
catalog: ComponentProps<typeof WorkspaceContent>["catalog"];
|
catalog: GlobalCatalog | undefined;
|
||||||
onSelectCatalog: (catalog: ComponentProps<typeof WorkspaceContent>["catalog"]) => void;
|
|
||||||
onSettings: () => void;
|
onSettings: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
@ -85,8 +78,8 @@ export function WorkspaceSidebar({
|
|||||||
<WorkspaceNavigationButton
|
<WorkspaceNavigationButton
|
||||||
aria-label={t(label)}
|
aria-label={t(label)}
|
||||||
aria-pressed={current.area === area}
|
aria-pressed={current.area === area}
|
||||||
|
href={workspaceHref({ area })}
|
||||||
key={area}
|
key={area}
|
||||||
onClick={() => onNavigate({ area })}
|
|
||||||
title={t(label)}
|
title={t(label)}
|
||||||
>
|
>
|
||||||
<Icon aria-hidden="true" className="size-5 shell:size-6" />
|
<Icon aria-hidden="true" className="size-5 shell:size-6" />
|
||||||
@ -108,8 +101,8 @@ export function WorkspaceSidebar({
|
|||||||
<WorkspaceNavigationButton
|
<WorkspaceNavigationButton
|
||||||
aria-label={accessTranslation("catalogTitle")}
|
aria-label={accessTranslation("catalogTitle")}
|
||||||
aria-pressed={catalog === "access"}
|
aria-pressed={catalog === "access"}
|
||||||
|
href="/catalogs/access"
|
||||||
level="catalog"
|
level="catalog"
|
||||||
onClick={() => onSelectCatalog("access")}
|
|
||||||
title={accessTranslation("catalogTitle")}
|
title={accessTranslation("catalogTitle")}
|
||||||
>
|
>
|
||||||
<KeyRoundIcon aria-hidden="true" className="size-[18px]" />
|
<KeyRoundIcon aria-hidden="true" className="size-[18px]" />
|
||||||
@ -119,8 +112,8 @@ export function WorkspaceSidebar({
|
|||||||
<WorkspaceNavigationButton
|
<WorkspaceNavigationButton
|
||||||
aria-label={assetTranslation("title")}
|
aria-label={assetTranslation("title")}
|
||||||
aria-pressed={catalog === "assets"}
|
aria-pressed={catalog === "assets"}
|
||||||
|
href="/catalogs/assets"
|
||||||
level="catalog"
|
level="catalog"
|
||||||
onClick={() => onSelectCatalog("assets")}
|
|
||||||
title={assetTranslation("title")}
|
title={assetTranslation("title")}
|
||||||
>
|
>
|
||||||
<BoxIcon aria-hidden="true" className="size-[18px]" />
|
<BoxIcon aria-hidden="true" className="size-[18px]" />
|
||||||
@ -130,8 +123,8 @@ export function WorkspaceSidebar({
|
|||||||
<WorkspaceNavigationButton
|
<WorkspaceNavigationButton
|
||||||
aria-label={t("interventionTemplates.title")}
|
aria-label={t("interventionTemplates.title")}
|
||||||
aria-pressed={catalog === "intervention-templates"}
|
aria-pressed={catalog === "intervention-templates"}
|
||||||
|
href="/catalogs/intervention-templates"
|
||||||
level="catalog"
|
level="catalog"
|
||||||
onClick={() => onSelectCatalog("intervention-templates")}
|
|
||||||
title={t("interventionTemplates.title")}
|
title={t("interventionTemplates.title")}
|
||||||
>
|
>
|
||||||
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" />
|
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" />
|
||||||
@ -141,8 +134,8 @@ export function WorkspaceSidebar({
|
|||||||
<WorkspaceNavigationButton
|
<WorkspaceNavigationButton
|
||||||
aria-label={assetTranslation("brands.title")}
|
aria-label={assetTranslation("brands.title")}
|
||||||
aria-pressed={catalog === "brands"}
|
aria-pressed={catalog === "brands"}
|
||||||
|
href="/catalogs/brands"
|
||||||
level="catalog"
|
level="catalog"
|
||||||
onClick={() => onSelectCatalog("brands")}
|
|
||||||
title={assetTranslation("brands.title")}
|
title={assetTranslation("brands.title")}
|
||||||
>
|
>
|
||||||
<LayersIcon aria-hidden="true" className="size-[18px]" />
|
<LayersIcon aria-hidden="true" className="size-[18px]" />
|
||||||
@ -152,13 +145,23 @@ export function WorkspaceSidebar({
|
|||||||
<WorkspaceNavigationButton
|
<WorkspaceNavigationButton
|
||||||
aria-label={resourceTranslation("title")}
|
aria-label={resourceTranslation("title")}
|
||||||
aria-pressed={catalog === "resources"}
|
aria-pressed={catalog === "resources"}
|
||||||
|
href="/catalogs/resources"
|
||||||
level="catalog"
|
level="catalog"
|
||||||
onClick={() => onSelectCatalog("resources")}
|
|
||||||
title={resourceTranslation("title")}
|
title={resourceTranslation("title")}
|
||||||
>
|
>
|
||||||
<DatabaseIcon aria-hidden="true" className="size-[18px]" />
|
<DatabaseIcon aria-hidden="true" className="size-[18px]" />
|
||||||
<span>{resourceTranslation("title")}</span>
|
<span>{resourceTranslation("title")}</span>
|
||||||
</WorkspaceNavigationButton>
|
</WorkspaceNavigationButton>
|
||||||
|
<WorkspaceNavigationButton
|
||||||
|
aria-label={t("processes.templates")}
|
||||||
|
aria-pressed={catalog === "process-templates"}
|
||||||
|
href="/catalogs/process-templates"
|
||||||
|
level="catalog"
|
||||||
|
title={t("processes.templates")}
|
||||||
|
>
|
||||||
|
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" />
|
||||||
|
<span>{t("processes.templates")}</span>
|
||||||
|
</WorkspaceNavigationButton>
|
||||||
</>
|
</>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
@ -167,8 +170,8 @@ export function WorkspaceSidebar({
|
|||||||
<WorkspaceNavigationButton
|
<WorkspaceNavigationButton
|
||||||
aria-label={company.name}
|
aria-label={company.name}
|
||||||
aria-pressed={current.area === "company" && current.id === company.id}
|
aria-pressed={current.area === "company" && current.id === company.id}
|
||||||
|
href={companySectionHref(company.id, "overview")}
|
||||||
key={company.id}
|
key={company.id}
|
||||||
onClick={() => onSelectCompany(company.id)}
|
|
||||||
title={company.name}
|
title={company.name}
|
||||||
>
|
>
|
||||||
<CompanyAvatar
|
<CompanyAvatar
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"test": "node --import tsx --test src/common/dto/response/get-health.response.dto.test.ts"
|
"test": "node --import tsx --test src/common/dto/response/get-health.response.dto.test.ts src/web/navigation/shared/browser-navigation.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "4.3.6",
|
"zod": "4.3.6",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"navigation": { "recordNotFound": "The record was not found in this scope." },
|
||||||
"subtitle": "Technical support, with context.",
|
"subtitle": "Technical support, with context.",
|
||||||
"workspace": "Your support workspace",
|
"workspace": "Your support workspace",
|
||||||
"description": "Customers, devices and interventions connected in one place.",
|
"description": "Customers, devices and interventions connected in one place.",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"navigation": { "recordNotFound": "No se ha encontrado el registro en este ámbito." },
|
||||||
"subtitle": "Soporte técnico, con contexto.",
|
"subtitle": "Soporte técnico, con contexto.",
|
||||||
"workspace": "Tu espacio de soporte",
|
"workspace": "Tu espacio de soporte",
|
||||||
"description": "Clientes, equipos e intervenciones conectados en un mismo lugar.",
|
"description": "Clientes, equipos e intervenciones conectados en un mismo lugar.",
|
||||||
|
|||||||
@ -1,4 +1,18 @@
|
|||||||
export { default as coreEnglish } from "../common/locales/en.json";
|
export { default as coreEnglish } from "../common/locales/en.json";
|
||||||
export { default as coreSpanish } from "../common/locales/es.json";
|
export { default as coreSpanish } from "../common/locales/es.json";
|
||||||
|
|
||||||
|
export {
|
||||||
|
BrowserLink,
|
||||||
|
EntityNotFound,
|
||||||
|
clearCreateNavigation,
|
||||||
|
closeRoute,
|
||||||
|
getBrowserLocation,
|
||||||
|
isCreateNavigation,
|
||||||
|
isInternalPath,
|
||||||
|
navigateTo,
|
||||||
|
useBrowserLocation,
|
||||||
|
useBrowserSearchParams,
|
||||||
|
useCreateNavigation,
|
||||||
|
useEntityNavigation,
|
||||||
|
} from "./navigation";
|
||||||
export { HomePage } from "./workspace";
|
export { HomePage } from "./workspace";
|
||||||
|
|||||||
13
modules/core/src/web/navigation/index.ts
Normal file
13
modules/core/src/web/navigation/index.ts
Normal 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";
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
30
modules/core/src/web/navigation/shared/ui/browser-link.tsx
Normal file
30
modules/core/src/web/navigation/shared/ui/browser-link.tsx
Normal 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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@
|
|||||||
"test": "node --import tsx --test src/api/domain/user.aggregate.test.ts src/api/infrastructure/persistence/user-persistence.mapper.test.ts src/api/infrastructure/security/scrypt-password-hasher.test.ts src/web/shared/api/identity-api.test.ts"
|
"test": "node --import tsx --test src/api/domain/user.aggregate.test.ts src/api/infrastructure/persistence/user-persistence.mapper.test.ts src/api/infrastructure/security/scrypt-password-hasher.test.ts src/web/shared/api/identity-api.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@metis/core": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"@repo/shadcn-ui": "workspace:*",
|
"@repo/shadcn-ui": "workspace:*",
|
||||||
"zod": "4.3.6",
|
"zod": "4.3.6",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
import { closeRoute, navigateTo, useBrowserLocation } from "@metis/core/client";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { logout as logoutSession } from "../../../sessions/shared/api";
|
import { logout as logoutSession } from "../../../sessions/shared/api";
|
||||||
import { identityKeys } from "../../../shared/queries";
|
import { identityKeys } from "../../../shared/queries";
|
||||||
@ -7,15 +7,29 @@ import type { AuthenticatedWorkspaceProps } from "../types";
|
|||||||
|
|
||||||
export function useAuthenticatedWorkspaceController({ snapshot }: AuthenticatedWorkspaceProps) {
|
export function useAuthenticatedWorkspaceController({ snapshot }: AuthenticatedWorkspaceProps) {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [page, setPage] = useState("home");
|
const location = useBrowserLocation();
|
||||||
|
const pathname = location.split("?")[0];
|
||||||
|
const page =
|
||||||
|
pathname === "/settings/users"
|
||||||
|
? "users"
|
||||||
|
: pathname === "/settings/password"
|
||||||
|
? "password"
|
||||||
|
: "home";
|
||||||
|
const setPage = (value: string) => {
|
||||||
|
if (value === "home") {
|
||||||
|
closeRoute("/dashboard");
|
||||||
|
} else if (value === "users" || value === "password") {
|
||||||
|
navigateTo(`/settings/${value}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
const logout = useMutation({
|
const logout = useMutation({
|
||||||
mutationFn: () => logoutSession(snapshot.csrfToken),
|
mutationFn: () => logoutSession(snapshot.csrfToken),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setPage("home");
|
|
||||||
client.removeQueries({
|
client.removeQueries({
|
||||||
predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session",
|
predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session",
|
||||||
});
|
});
|
||||||
await client.invalidateQueries({ queryKey: identityKeys.session });
|
await client.invalidateQueries({ queryKey: identityKeys.session });
|
||||||
|
navigateTo("/login", { replace: true });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { page, setPage, logout };
|
return { page, setPage, logout };
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -28,6 +29,16 @@ export function CustomWorkspace({
|
|||||||
if (!snapshot.user) {
|
if (!snapshot.user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (page === "users" && !snapshot.user.isInitialAdmin) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-4 p-6" role="alert">
|
||||||
|
<p>{t("errors.FORBIDDEN")}</p>
|
||||||
|
<BrowserLink className="underline" href="/dashboard">
|
||||||
|
{t("workspace")}
|
||||||
|
</BrowserLink>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderWorkspace(
|
{renderWorkspace(
|
||||||
@ -36,11 +47,21 @@ export function CustomWorkspace({
|
|||||||
<p className="truncate text-sm font-medium">{snapshot.user.name}</p>
|
<p className="truncate text-sm font-medium">{snapshot.user.name}</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{snapshot.user.isInitialAdmin && (
|
{snapshot.user.isInitialAdmin && (
|
||||||
<Button onClick={() => setPage("users")} size="sm" variant="ghost">
|
<Button
|
||||||
|
nativeButton={false}
|
||||||
|
render={<BrowserLink href="/settings/users" />}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
{t("users")}
|
{t("users")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => setPage("password")} size="sm" variant="ghost">
|
<Button
|
||||||
|
nativeButton={false}
|
||||||
|
render={<BrowserLink href="/settings/password" />}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
{t("changePassword")}
|
{t("changePassword")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={logoutPending} onClick={onLogout} size="sm" variant="ghost">
|
<Button disabled={logoutPending} onClick={onLogout} size="sm" variant="ghost">
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts"
|
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@metis/core": "workspace:*",
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"@repo/shadcn-ui": "workspace:*",
|
"@repo/shadcn-ui": "workspace:*",
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useBrowserSearchParams } from "@metis/core/client";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@ -8,8 +9,26 @@ import type { ResourceCatalogPageProps } from "../types";
|
|||||||
|
|
||||||
export function useResourceCatalogPageController({ scope, csrfToken }: ResourceCatalogPageProps) {
|
export function useResourceCatalogPageController({ scope, csrfToken }: ResourceCatalogPageProps) {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [tab, setTab] = useState<"field" | "template">("template");
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
const [search, setSearch] = useState("");
|
const requestedTab = searchParams.get("tab");
|
||||||
|
const tab: "field" | "template" = requestedTab === "fields" ? "field" : "template";
|
||||||
|
const setTab = (value: "field" | "template") =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value === "template") {
|
||||||
|
params.delete("tab");
|
||||||
|
} else {
|
||||||
|
params.set("tab", "fields");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const search = searchParams.get("q") ?? "";
|
||||||
|
const setSearch = (value: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("q", value);
|
||||||
|
} else {
|
||||||
|
params.delete("q");
|
||||||
|
}
|
||||||
|
});
|
||||||
const [viewId, setViewId] = useState<string>();
|
const [viewId, setViewId] = useState<string>();
|
||||||
const [editor, setEditor] = useState<CatalogEditor>();
|
const [editor, setEditor] = useState<CatalogEditor>();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useBrowserSearchParams, useEntityNavigation } from "@metis/core/client";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@ -13,9 +14,22 @@ export function useResourcesPageController({
|
|||||||
initialId,
|
initialId,
|
||||||
initialCreate = false,
|
initialCreate = false,
|
||||||
}: ResourcesPageProps) {
|
}: ResourcesPageProps) {
|
||||||
const [search, setSearch] = useState("");
|
const { searchParams, setSearchParams } = useBrowserSearchParams();
|
||||||
|
const search = searchParams.get("q") ?? "";
|
||||||
|
const setSearch = (value: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (value) {
|
||||||
|
params.set("q", value);
|
||||||
|
} else {
|
||||||
|
params.delete("q");
|
||||||
|
}
|
||||||
|
});
|
||||||
const [editor, setEditor] = useState<string | undefined>(() => initialEditor(initialCreate));
|
const [editor, setEditor] = useState<string | undefined>(() => initialEditor(initialCreate));
|
||||||
const [viewId, setViewId] = useState<string | undefined>(initialId);
|
const {
|
||||||
|
selectedId: viewId,
|
||||||
|
selectEntity: setViewId,
|
||||||
|
entityHref,
|
||||||
|
} = useEntityNavigation(`/companies/${companyId}/resources`, initialId);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: inventoryKeys.company(companyId),
|
queryKey: inventoryKeys.company(companyId),
|
||||||
queryFn: () => inventoryApi.get(companyId),
|
queryFn: () => inventoryApi.get(companyId),
|
||||||
@ -42,6 +56,8 @@ export function useResourcesPageController({
|
|||||||
catalog,
|
catalog,
|
||||||
edited,
|
edited,
|
||||||
viewed,
|
viewed,
|
||||||
|
entityHref,
|
||||||
|
missingView: Boolean(viewId && query.isSuccess && !viewed),
|
||||||
visible,
|
visible,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { BrowserLink, EntityNotFound } from "@metis/core/client";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import {
|
import {
|
||||||
EntityAction,
|
EntityAction,
|
||||||
@ -26,9 +27,14 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
catalog,
|
catalog,
|
||||||
edited,
|
edited,
|
||||||
viewed,
|
viewed,
|
||||||
|
entityHref,
|
||||||
|
missingView,
|
||||||
visible,
|
visible,
|
||||||
} = useResourcesPageController(props);
|
} = useResourcesPageController(props);
|
||||||
const { t, i18n } = useTranslation("resources");
|
const { t, i18n } = useTranslation("resources");
|
||||||
|
if (missingView) {
|
||||||
|
return <EntityNotFound />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<EntityListHeader
|
<EntityListHeader
|
||||||
@ -65,13 +71,12 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
value: (item) => item.values.name,
|
value: (item) => item.values.name,
|
||||||
hideable: false,
|
hideable: false,
|
||||||
render: (item) => (
|
render: (item) => (
|
||||||
<button
|
<BrowserLink
|
||||||
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
className="font-medium text-left underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
onClick={() => setViewId(item.id)}
|
href={entityHref(item.id)}
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
{item.values.name}
|
{item.values.name}
|
||||||
</button>
|
</BrowserLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -139,6 +139,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: 5.2.2
|
specifier: 5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
|
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
|
||||||
|
'@metis/core':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core
|
||||||
'@repo/rdx-ddd':
|
'@repo/rdx-ddd':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-ddd
|
version: link:../../packages/rdx-ddd
|
||||||
@ -203,6 +206,9 @@ importers:
|
|||||||
'@metis/assets':
|
'@metis/assets':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../assets
|
version: link:../assets
|
||||||
|
'@metis/core':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core
|
||||||
'@metis/resources':
|
'@metis/resources':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../resources
|
version: link:../resources
|
||||||
@ -310,6 +316,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: 5.2.2
|
specifier: 5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
|
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
|
||||||
|
'@metis/core':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core
|
||||||
'@repo/rdx-ddd':
|
'@repo/rdx-ddd':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-ddd
|
version: link:../../packages/rdx-ddd
|
||||||
@ -377,6 +386,9 @@ importers:
|
|||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: 5.2.2
|
specifier: 5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
|
version: 5.2.2(react-hook-form@7.72.1(react@19.2.5))
|
||||||
|
'@metis/core':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core
|
||||||
'@repo/rdx-ddd':
|
'@repo/rdx-ddd':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-ddd
|
version: link:../../packages/rdx-ddd
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user