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