feat: puntos de extensión por cliente para el menú, las rutas del frontend y los permisos del proxy del Service Layer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8ba845cc23
commit
3caf94609d
10 files changed
+178
-31
No files matched your search
@@ -99,7 +99,8 @@ Pendiente, en orden:
|
|||||||
2. Cada cliente tiene su branch (`cliente/<nombre-cliente>`); ahí viven sus personalizaciones y fixes.
|
2. Cada cliente tiene su branch (`cliente/<nombre-cliente>`); ahí viven sus personalizaciones y fixes.
|
||||||
3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente.
|
3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente.
|
||||||
4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación. Cada paso termina en un commit propio.
|
4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación. Cada paso termina en un commit propio.
|
||||||
5. **Una mejora genérica descubierta en la rama de un cliente** va a `main`, no se queda en el cliente. Mantener el commit del cliente y el genérico **separados** (un solo propósito por commit). Flujo: (a) commit de lo del cliente; (b) `git checkout main` y desarrollar ahí la mejora en su propio commit (o, si ya está hecha en la rama del cliente como commit propio, `git cherry-pick <hash>` desde `main`); (c) `git checkout cliente/<nombre>` y `git merge main`. Desarrollarla dos veces (en el cliente y en `main`) provoca conflictos o código repetido. Como siempre, confirmar con el usuario antes de mergear a `main` o de traer cambios a otra rama.
|
5. **Puntos de extensión por cliente** (archivos vacíos en `main`; la rama del cliente los completa sin tocar nada más): `libs/sap-types/src/menu.extensions.ts` (`CLIENT_MENU_ITEMS`: pantallas nuevas del menú, por id de módulo), `apps/web/src/app/client-routes.ts` (`CLIENT_ROUTES` con `clientRoute(ruta, título, import)`: sus pantallas, en diferido y con permiso), `apps/api/src/sl/sl-resources.extensions.ts` (`CLIENT_RESOURCE_PATHS`: rutas de menú del cliente que además pueden leer o escribir un recurso del Service Layer que ya existe, y `CLIENT_SL_RESOURCES`: recursos nuevos; nunca `EmployeesInfo`) y `apps/api/src/sapdb/sapdb.extensions.ts` (consultas a la base de SAP), más las extensiones de campos de cada módulo (`*.extensions.ts`). Un módulo o recurso que no existe o una ruta repetida lanza un error al arrancar.
|
||||||
6. **Traer lo nuevo de `main` a la rama de un cliente:** `git merge main` no descarga nada de internet: une la `main` que ya está en el equipo. Si hay remoto y otras personas suben a `main`, primero actualizarla: `git checkout main && git pull`, luego `git checkout cliente/<nombre> && git merge main` (o, sin cambiar de rama: `git fetch && git merge origin/main`). El merge añade los commits que faltan y no toca `main`; los conflictos solo aparecen si ambas ramas editaron las mismas líneas. Tras el merge, la rama del cliente solo tiene lo nuevo en el equipo hasta hacer `git push` desde ella (con confirmación del usuario).
|
6. **Una mejora genérica descubierta en la rama de un cliente** va a `main`, no se queda en el cliente. Mantener el commit del cliente y el genérico **separados** (un solo propósito por commit). Flujo: (a) commit de lo del cliente; (b) `git checkout main` y desarrollar ahí la mejora en su propio commit (o, si ya está hecha en la rama del cliente como commit propio, `git cherry-pick <hash>` desde `main`); (c) `git checkout cliente/<nombre>` y `git merge main`. Desarrollarla dos veces (en el cliente y en `main`) provoca conflictos o código repetido. Como siempre, confirmar con el usuario antes de mergear a `main` o de traer cambios a otra rama.
|
||||||
7. **Para que los merges salgan limpios:** lo específico del cliente vive en sus archivos de extensiones (`*.extensions.ts`, vacíos en `main`), su `.env` y su matrícula de reportes (SQLite), nunca en las pantallas base. Un cambio que obligue a editar una pantalla base es genérico y va a `main`. El `.env`, `apps/api/certs/*.pem` y `apps/api/data/` están ignorados por git y no se suben; cada cliente tiene los suyos.
|
7. **Traer lo nuevo de `main` a la rama de un cliente:** `git merge main` no descarga nada de internet: une la `main` que ya está en el equipo. Si hay remoto y otras personas suben a `main`, primero actualizarla: `git checkout main && git pull`, luego `git checkout cliente/<nombre> && git merge main` (o, sin cambiar de rama: `git fetch && git merge origin/main`). El merge añade los commits que faltan y no toca `main`; los conflictos solo aparecen si ambas ramas editaron las mismas líneas. Tras el merge, la rama del cliente solo tiene lo nuevo en el equipo hasta hacer `git push` desde ella (con confirmación del usuario).
|
||||||
8. **Estado al preparar las ramas (2026-10-19):** `main` limpio (120 commits), compila y pasa todo (libs 19, API 115, web 682), sin secretos versionados; el remoto es el Gitea `http://100.68.242.27:30000/george/basic-erp.git` (`origin`; `main` subida el 2026-10-19 y con seguimiento) y aún no hay ramas de cliente creadas.
|
8. **Para que los merges salgan limpios:** lo específico del cliente vive en sus archivos de extensiones (`*.extensions.ts`, vacíos en `main`), su `.env` y su matrícula de reportes (SQLite), nunca en las pantallas base. Un cambio que obligue a editar una pantalla base es genérico y va a `main`. El `.env`, `apps/api/certs/*.pem` y `apps/api/data/` están ignorados por git y no se suben; cada cliente tiene los suyos.
|
||||||
|
9. **Estado al preparar las ramas (2026-10-19):** `main` limpio (120 commits), compila y pasa todo (libs 19, API 115, web 682), sin secretos versionados; el remoto es el Gitea `http://100.68.242.27:30000/george/basic-erp.git` (`origin`; `main` subida el 2026-10-19 y con seguimiento) y aún no hay ramas de cliente creadas.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { SlResource } from './sl-resources.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lo que la rama de un cliente suma al proxy del Service Layer (vacío en `main`).
|
||||||
|
*
|
||||||
|
* `CLIENT_RESOURCE_PATHS`: pantallas suyas (rutas de menú) que además pueden leer (`read`) o escribir (`write`) un
|
||||||
|
* recurso que ya existe, por nombre. Por ejemplo, «Inmovilizar lotes» crea solicitudes de traslado:
|
||||||
|
*
|
||||||
|
* InventoryTransferRequests: { write: ['/inventario/inmovilizar-lotes'], read: ['/inventario/inmovilizar-lotes'] },
|
||||||
|
*
|
||||||
|
* `CLIENT_SL_RESOURCES`: recursos nuevos (tablas de usuario, objetos propios). Nunca `EmployeesInfo`.
|
||||||
|
*/
|
||||||
|
export const CLIENT_RESOURCE_PATHS: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>> = {};
|
||||||
|
|
||||||
|
export const CLIENT_SL_RESOURCES: readonly SlResource[] = [];
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { SL_RESOURCE_MAP, SL_RESOURCES } from './sl-resources.js';
|
import { SL_RESOURCE_MAP, SL_RESOURCES, extendResources } from './sl-resources.js';
|
||||||
|
|
||||||
describe('recursos del Service Layer', () => {
|
describe('recursos del Service Layer', () => {
|
||||||
it('no incluye jamás EmployeesInfo (tiene U_WEB_PASS)', () => {
|
it('no incluye jamás EmployeesInfo (tiene U_WEB_PASS)', () => {
|
||||||
@@ -99,3 +99,24 @@ describe('recursos del Service Layer', () => {
|
|||||||
expect(r.selectOnly).not.toContain('UserPassword');
|
expect(r.selectOnly).not.toContain('UserPassword');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extensiones de recursos por cliente', () => {
|
||||||
|
const base = [
|
||||||
|
{ name: 'Docs', key: 'number', methods: ['GET', 'POST'], read: ['/a'], write: '/a' },
|
||||||
|
{ name: 'Cat', key: 'string', methods: ['GET'], read: null, write: [] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
it('suma pantallas del cliente a los permisos de un recurso, sin tocar los de cualquier sesión', () => {
|
||||||
|
const r = extendResources(base as never, { Docs: { read: ['/x'], write: ['/x'] }, Cat: { read: ['/x'] } }, []);
|
||||||
|
expect(r[0]).toMatchObject({ read: ['/a', '/x'], write: ['/a', '/x'] });
|
||||||
|
expect(r[1].read).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agrega recursos nuevos y rechaza repetidos, inexistentes y EmployeesInfo', () => {
|
||||||
|
const nuevo = { name: 'U_TABLA', key: 'string', methods: ['GET'], read: null, write: [] } as never;
|
||||||
|
expect(extendResources(base as never, {}, [nuevo]).map((x) => x.name)).toEqual(['Docs', 'Cat', 'U_TABLA']);
|
||||||
|
expect(() => extendResources(base as never, { Nada: { read: ['/x'] } }, [])).toThrow(/no existe el recurso/);
|
||||||
|
expect(() => extendResources(base as never, {}, [{ ...(nuevo as object), name: 'Docs' } as never])).toThrow(/ya existe/);
|
||||||
|
expect(() => extendResources(base as never, {}, [{ ...(nuevo as object), name: 'EmployeesInfo' } as never])).toThrow(/EmployeesInfo/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { CLIENT_RESOURCE_PATHS, CLIENT_SL_RESOURCES } from './sl-resources.extensions.js';
|
||||||
|
|
||||||
export type SlMethod = 'GET' | 'POST' | 'PATCH';
|
export type SlMethod = 'GET' | 'POST' | 'PATCH';
|
||||||
|
|
||||||
/** Ruta(s) de menú que el usuario debe tener permitidas (basta con una). */
|
/** Ruta(s) de menú que el usuario debe tener permitidas (basta con una). */
|
||||||
@@ -56,7 +58,7 @@ const queryAction = (name: string): SlResource => ({ name, key: 'string', method
|
|||||||
*
|
*
|
||||||
* Las rutas de menú son las mismas que usará el frontend (permisos por pantalla).
|
* Las rutas de menú son las mismas que usará el frontend (permisos por pantalla).
|
||||||
*/
|
*/
|
||||||
export const SL_RESOURCES: readonly SlResource[] = [
|
const BASE_SL_RESOURCES: readonly SlResource[] = [
|
||||||
// Ventas
|
// Ventas
|
||||||
document('Quotations', '/ventas/ofertas'),
|
document('Quotations', '/ventas/ofertas'),
|
||||||
document('Orders', '/ventas/ordenes'),
|
document('Orders', '/ventas/ordenes'),
|
||||||
@@ -132,4 +134,37 @@ export const SL_RESOURCES: readonly SlResource[] = [
|
|||||||
catalog('AdditionalExpenses', 'number'),
|
catalog('AdditionalExpenses', 'number'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const union = (a: RequiredPath | null, extra: readonly string[] | undefined): RequiredPath | null => {
|
||||||
|
// `null` = cualquier sesión: no se restringe por agregar pantallas
|
||||||
|
if (a === null || !extra?.length) return a;
|
||||||
|
return [...new Set([a, extra].flat())];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suma lo que declara la rama de un cliente: pantallas suyas con permiso sobre recursos que ya existen
|
||||||
|
* (`paths`: por nombre de recurso) y recursos nuevos (tablas de usuario, objetos propios). `EmployeesInfo` nunca se
|
||||||
|
* registra (trae `U_WEB_PASS`); un recurso que no existe o repetido es un error.
|
||||||
|
*/
|
||||||
|
export function extendResources(
|
||||||
|
base: readonly SlResource[],
|
||||||
|
paths: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>>,
|
||||||
|
extra: readonly SlResource[],
|
||||||
|
): readonly SlResource[] {
|
||||||
|
const names = new Set(base.map((r) => r.name));
|
||||||
|
for (const name of Object.keys(paths)) {
|
||||||
|
if (!names.has(name)) throw new Error(`sl-resources.extensions: no existe el recurso «${name}»`);
|
||||||
|
}
|
||||||
|
for (const r of extra) {
|
||||||
|
if (r.name === 'EmployeesInfo') throw new Error('sl-resources.extensions: EmployeesInfo no se puede registrar (trae U_WEB_PASS)');
|
||||||
|
if (names.has(r.name)) throw new Error(`sl-resources.extensions: el recurso «${r.name}» ya existe`);
|
||||||
|
names.add(r.name);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...base.map((r) => (paths[r.name] ? { ...r, read: union(r.read, paths[r.name].read), write: union(r.write, paths[r.name].write) } : r)),
|
||||||
|
...extra,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SL_RESOURCES: readonly SlResource[] = extendResources(BASE_SL_RESOURCES, CLIENT_RESOURCE_PATHS, CLIENT_SL_RESOURCES);
|
||||||
|
|
||||||
export const SL_RESOURCE_MAP: ReadonlyMap<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));
|
export const SL_RESOURCE_MAP: ReadonlyMap<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isDevMode } from '@angular/core';
|
import { isDevMode } from '@angular/core';
|
||||||
import type { Route, Routes } from '@angular/router';
|
import type { Route, Routes } from '@angular/router';
|
||||||
import { MENU } from '@basic-erp/sap-types';
|
import { MENU } from '@basic-erp/sap-types';
|
||||||
|
import { CLIENT_PATHS, CLIENT_ROUTES } from './client-routes';
|
||||||
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
|
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
|
||||||
import { DOCUMENT_PATHS } from './features/documents/document-paths';
|
import { DOCUMENT_PATHS } from './features/documents/document-paths';
|
||||||
import { JOURNAL_PATH } from './features/finance/journal-paths';
|
import { JOURNAL_PATH } from './features/finance/journal-paths';
|
||||||
@@ -146,7 +147,7 @@ const adminRoutes: Routes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
||||||
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']);
|
const builtPaths = new Set([...CLIENT_PATHS, ...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']);
|
||||||
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
||||||
.filter((item) => !builtPaths.has(item.path))
|
.filter((item) => !builtPaths.has(item.path))
|
||||||
.map(
|
.map(
|
||||||
@@ -181,6 +182,7 @@ export const routes: Routes = [
|
|||||||
...journalRoutes,
|
...journalRoutes,
|
||||||
...reportRoutes,
|
...reportRoutes,
|
||||||
...adminRoutes,
|
...adminRoutes,
|
||||||
|
...CLIENT_ROUTES,
|
||||||
...pendingRoutes,
|
...pendingRoutes,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CLIENT_PATHS, CLIENT_ROUTES, clientRoute } from './client-routes';
|
||||||
|
|
||||||
|
describe('rutas de cliente', () => {
|
||||||
|
it('una pantalla de cliente cuelga de su ruta de menú, con permiso y en diferido', () => {
|
||||||
|
const r = clientRoute('/inventario/ejemplo', 'Ejemplo', async () => class {});
|
||||||
|
expect(r.path).toBe('inventario/ejemplo');
|
||||||
|
expect(r.data).toEqual({ path: '/inventario/ejemplo', title: 'Ejemplo' });
|
||||||
|
expect(r.canActivate).toHaveLength(1);
|
||||||
|
expect(r.loadComponent).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('las rutas de cliente salen de la lista de rutas', () => {
|
||||||
|
expect(CLIENT_PATHS).toEqual(CLIENT_ROUTES.map((r) => `/${r.path}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Route, Routes } from '@angular/router';
|
||||||
|
import { permissionGuard } from './core/auth/guards';
|
||||||
|
|
||||||
|
/** Una pantalla de cliente: ruta del menú (`/inventario/inmovilizar-lotes`), título y su componente en diferido. */
|
||||||
|
export const clientRoute = (path: string, title: string, loadComponent: NonNullable<Route['loadComponent']>): Route => ({
|
||||||
|
path: path.slice(1),
|
||||||
|
canActivate: [permissionGuard],
|
||||||
|
data: { path, title },
|
||||||
|
loadComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pantallas propias del cliente (vacío en `main`): cada una con su entrada en `menu.extensions.ts` de `libs/sap-types`.
|
||||||
|
* Se cargan en diferido, como todas.
|
||||||
|
*
|
||||||
|
* Ejemplo:
|
||||||
|
*
|
||||||
|
* clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/inmovilizar.page').then((m) => m.InmovilizarPage)),
|
||||||
|
*/
|
||||||
|
export const CLIENT_ROUTES: Routes = [];
|
||||||
|
|
||||||
|
/** Las rutas de menú que ya tienen pantalla de cliente (no se muestran como «en construcción»). */
|
||||||
|
export const CLIENT_PATHS: readonly string[] = CLIENT_ROUTES.map((r) => `/${r.path}`);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { MenuItem } from './menu.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pantallas que la rama de un cliente suma al menú (vacío en `main`), por id de módulo (`inventario`, `ventas`…): se
|
||||||
|
* agregan al final de ese módulo. Cada ruta necesita su pantalla en `client-routes.ts` del frontend; si el recurso que
|
||||||
|
* usa necesita permiso en el proxy del Service Layer, se declara en `sl-resources.extensions.ts` de la API.
|
||||||
|
*
|
||||||
|
* Ejemplo:
|
||||||
|
*
|
||||||
|
* inventario: [{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' }],
|
||||||
|
*/
|
||||||
|
export const CLIENT_MENU_ITEMS: Readonly<Record<string, readonly MenuItem[]>> = {};
|
||||||
@@ -1,26 +1,24 @@
|
|||||||
import { MENU, filterMenu, menuPaths } from './menu.js';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MENU, extendMenu } from './menu.js';
|
||||||
|
|
||||||
describe('MENU', () => {
|
const base = [
|
||||||
it('no repite rutas y todas son absolutas', () => {
|
{ id: 'a', label: 'A', items: [{ path: '/a/uno', label: 'Uno' }] },
|
||||||
const paths = menuPaths();
|
{ id: 'b', label: 'B', items: [] },
|
||||||
expect(new Set(paths).size).toBe(paths.length);
|
];
|
||||||
expect(paths.every((p) => /^\/[a-z-]+\/[a-z-]+$/.test(p))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('filterMenu', () => {
|
|
||||||
const ids = (allowed: string[]) => filterMenu(MENU, allowed).map((m) => m.id);
|
|
||||||
|
|
||||||
it('"*" ve todo', () => expect(filterMenu(MENU, ['*'])).toHaveLength(MENU.length));
|
describe('extendMenu', () => {
|
||||||
it('sin permisos no ve nada', () => expect(filterMenu(MENU, [])).toEqual([]));
|
it('suma las pantallas del cliente al final de su módulo y deja los demás igual', () => {
|
||||||
|
const m = extendMenu(base, { a: [{ path: '/a/dos', label: 'Dos' }] });
|
||||||
|
expect(m[0].items.map((i) => i.path)).toEqual(['/a/uno', '/a/dos']);
|
||||||
|
expect(m[1]).toBe(base[1]);
|
||||||
|
});
|
||||||
|
|
||||||
it('un módulo completo y una pantalla suelta de otro', () => {
|
it('un módulo que no existe o una ruta repetida es un error', () => {
|
||||||
const result = filterMenu(MENU, ['/ventas/*', '/inventario/articulos']);
|
expect(() => extendMenu(base, { zz: [{ path: '/zz/x', label: 'X' }] })).toThrow(/no existe el módulo/);
|
||||||
expect(result.map((m) => m.id)).toEqual(['ventas', 'inventario']);
|
expect(() => extendMenu(base, { b: [{ path: '/a/uno', label: 'Otra' }] })).toThrow(/ya existe/);
|
||||||
expect(result[1].items.map((i) => i.path)).toEqual(['/inventario/articulos']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omite los módulos sin pantallas permitidas', () => {
|
it('en main el menú es el del base (sin pantallas de cliente)', () => {
|
||||||
expect(ids(['/socios/clientes'])).toEqual(['socios']);
|
expect(MENU.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CLIENT_MENU_ITEMS } from './menu.extensions.js';
|
||||||
import { isPathAllowed } from './permissions.js';
|
import { isPathAllowed } from './permissions.js';
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
@@ -12,11 +13,8 @@ export interface MenuModule {
|
|||||||
items: readonly MenuItem[];
|
items: readonly MenuItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Menú del proyecto base; cada cliente suma sus pantallas en `menu.extensions.ts` (el resultado es `MENU`). */
|
||||||
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado
|
const BASE_MENU: readonly MenuModule[] = [
|
||||||
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
|
|
||||||
*/
|
|
||||||
export const MENU: readonly MenuModule[] = [
|
|
||||||
{
|
{
|
||||||
id: 'ventas',
|
id: 'ventas',
|
||||||
label: 'Ventas',
|
label: 'Ventas',
|
||||||
@@ -101,6 +99,32 @@ export const MENU: readonly MenuModule[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suma las pantallas de un cliente al final de los módulos del menú base (por id de módulo). Un módulo que no existe o
|
||||||
|
* una ruta repetida es un error: un descuido no debe pasar en silencio.
|
||||||
|
*/
|
||||||
|
export function extendMenu(base: readonly MenuModule[], extra: Readonly<Record<string, readonly MenuItem[]>>): readonly MenuModule[] {
|
||||||
|
for (const id of Object.keys(extra)) {
|
||||||
|
if (!base.some((m) => m.id === id)) throw new Error(`menu.extensions: no existe el módulo «${id}»`);
|
||||||
|
}
|
||||||
|
const taken = new Set(base.flatMap((m) => m.items.map((i) => i.path)));
|
||||||
|
return base.map((m) => {
|
||||||
|
const more = extra[m.id];
|
||||||
|
if (!more?.length) return m;
|
||||||
|
for (const item of more) {
|
||||||
|
if (taken.has(item.path)) throw new Error(`menu.extensions: la ruta «${item.path}» ya existe`);
|
||||||
|
taken.add(item.path);
|
||||||
|
}
|
||||||
|
return { ...m, items: [...m.items, ...more] };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado
|
||||||
|
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
|
||||||
|
*/
|
||||||
|
export const MENU: readonly MenuModule[] = extendMenu(BASE_MENU, CLIENT_MENU_ITEMS);
|
||||||
|
|
||||||
/** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */
|
/** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */
|
||||||
export function filterMenu(menu: readonly MenuModule[], allowedPaths: readonly string[]): MenuModule[] {
|
export function filterMenu(menu: readonly MenuModule[], allowedPaths: readonly string[]): MenuModule[] {
|
||||||
return menu
|
return menu
|
||||||
|
|||||||
Reference in new issue
Block a user