From 503ea30f58182e0ad75e84ec7d445995f8dd9a72 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 15:43:44 +0300 Subject: [PATCH] feat(overview): resolve dashboard layout from permissions, not role keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit role-dashboards.config.ts's ROLE_LAYOUTS was a hand-typed table of ~40 raw role/position keys (including edr_freight_app/xxx ad-hoc sub-positions), matched against the user's held keys client-side. Replaced with resolveOverviewLayout(), which just picks the highest-priority key out of whatever GET /overview/layouts returns — the same 'server filters by permission, frontend renders what comes back' shape Reports already uses, so the frontend no longer needs to know any individual permission key. Hard cutover: no fallback to the old key table. Positions not covered by the previous commit's grants (the ad-hoc department sub-positions) will render the executive layout until granted a layout permission. --- .../overview/role-dashboards.config.ts | 100 +++++------------- .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../backoffice/src/constants/URLS.ts | 1 + .../backoffice/src/hooks/useOverview.ts | 9 ++ .../src/pages/dashboard/OverviewPage.tsx | 12 +-- .../src/services/overview.service.ts | 13 +++ 6 files changed, 56 insertions(+), 80 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts index b683a3fbe..f059eae72 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts +++ b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts @@ -1,6 +1,3 @@ -import type { AuthUser } from "@/auth/types"; -import { getPositionKeys } from "@/lib/permissions"; - /** One overview composition. Every backoffice user lands on exactly one of these. */ export type OverviewLayoutKey = | "executive" @@ -20,80 +17,35 @@ export const OVERVIEW_LAYOUT_LABEL: Record = { }; /** - * Position/role key → layout, in match priority order: a user holding several - * of these keys gets the first match, so the specific operational view wins - * over the broad executive one. Roles are matched alongside positions because - * the IAM payload models the GL desks as positions (`ethiopian_gl`) on some - * accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`. - * - * The `edr_freight_app/…` keys are the org's real position keys (root desks and - * their sub-positions) as configured under Unit → Departments. They are typed - * by hand in the Add/Edit Department form, so a new sub-position appears here - * only once someone adds it — unmapped keys fall through to `executive`. + * Priority order: a caller who holds more than one of the six + * `edr_freight_app:overview::view` permissions gets the FIRST match + * here — the specific operational view wins over the broad executive one. + * Mirrors `OVERVIEW_LAYOUT_KEYS` in the API's freight-permissions.registry.ts + * bit for bit; keep the two in sync if this ever changes. */ -const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [ - // ── Clearance & logistics: both GL desks, root and sub-positions ────────── - ["ethiopian_gl", "clearance"], - ["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief - ["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director - ["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer - ["djibouti_gl", "clearance"], - ["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director - ["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief - ["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer - ["edr_gl_ethiopia", "clearance"], // legacy role form - ["edr_gl_djibouti", "clearance"], // legacy role form - - // ── Control centre ─────────────────────────────────────────────────────── - ["edr_freight_app/occ_001", "occ"], // OCC - ["edr_freight_app/occ_005", "occ"], // OCC Director - ["edr_line_staff", "occ"], // legacy role form - - // ── Operations: operations desk, track & machinery, rolling stock ───────── - ["edr_freight_app/opn", "operation"], // Operation - ["edr_freight_app/opcf", "operation"], // Operation Chief - ["edr_freight_app/opdr", "operation"], // Operation Director - ["edr_freight_app/opco", "operation"], // Operation Officer - ["edr_freight_app/opp_005", "operation"], // Operation Dispatcher - ["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director - ["edr_freight_app/track_001", "operation"], // Track And Machinery - ["edr_freight_app/ttk_001", "operation"], // Track Director - ["edr_freight_app/tto_001", "operation"], // Track Operator - ["edr_freight_app/rool_001", "operation"], // Rolling Stock - ["edr_freight_app/rl_003", "operation"], // Rolling Stock Director - ["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead - ["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher - ["operation", "operation"], - ["operations_chief", "operation"], - ["dispatcher", "operation"], - ["truck_machinery_chief", "operation"], - ["edr_operations_officer", "operation"], // legacy role form - - // ── Marketing ──────────────────────────────────────────────────────────── - ["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing - ["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director - ["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief - ["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer - ["marketer", "marketer"], - ["edr_marketing", "marketer"], // legacy role form - - // ── Finance ────────────────────────────────────────────────────────────── - ["edr_freight_app/finance", "finance"], - ["edr_finance", "finance"], // legacy role form - - // ── Executive: org-wide desks with no operational queue of their own ────── - ["ceo", "executive"], - ["director", "executive"], - ["chief", "executive"], - ["edr_ceo", "executive"], // legacy role form - ["edr_director", "executive"], // legacy role form - ["edr_org_manager", "executive"], // legacy role form +const LAYOUT_PRIORITY: OverviewLayoutKey[] = [ + "clearance", + "occ", + "operation", + "marketer", + "finance", + "executive", ]; -/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */ +/** + * Which layout to render, given the keys `GET /overview/layouts` said the + * caller may see — the endpoint already filtered those by permission, so + * this only breaks the tie when a caller holds more than one. Same shape as + * the Reports page trusting `GET /reports`'s catalog rather than re-deriving + * access from permission keys client-side. + * + * Empty/unmapped falls back to the executive layout — same default the old + * role/position-key table used for superadmin, IAM admins, and any position + * that hasn't been granted one of these permissions yet. + */ export function resolveOverviewLayout( - user: AuthUser | null | undefined, + allowed: OverviewLayoutKey[] | undefined, ): OverviewLayoutKey { - const held = new Set(getPositionKeys(user)); - return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive"; + const held = new Set(allowed ?? []); + return LAYOUT_PRIORITY.find((key) => held.has(key)) ?? "executive"; } diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 7f6f4993c..3f1cce1b5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -235,6 +235,7 @@ export const QUERY_KEYS = { OVERVIEW: { ROOT: ["overview"] as const, + layouts: () => ["overview", "layouts"] as const, dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const, bookingsTab: (range?: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 68fefa3c9..bb3d88d2a 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -184,6 +184,7 @@ export const URL_CONSTANTS = { OVERVIEW: { BASE: "/overview", + LAYOUTS: "/overview/layouts", BOOKINGS: "/overview/bookings", CONTRACTS: "/overview/contracts", BILLING: "/overview/billing", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts index 7225ee5bc..42abe59e1 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts @@ -11,6 +11,15 @@ export function useOverview(range: OverviewRange = "30d") { }); } +/** Layouts the caller may render — server-filtered by permission, same shape as useReports' catalog. */ +export function useOverviewLayouts() { + return useQuery({ + queryKey: QUERY_KEYS.OVERVIEW.layouts(), + queryFn: () => overviewService.getLayouts(), + staleTime: 5 * 60 * 1000, + }); +} + export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) { return useQuery({ queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range), diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index eadd2f1c6..4c316477f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -3,7 +3,6 @@ import { AlertCircle } from "lucide-react"; import { Alert, Button, Skeleton, Stack } from "@mantine/core"; import { useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/auth/useAuth"; import { PageContainer } from "@/components/page"; import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview"; import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview"; @@ -20,7 +19,7 @@ import { import { OverviewHero } from "@/components/overview/summary/OverviewHero"; import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import { useOverview } from "@/hooks/useOverview"; +import { useOverview, useOverviewLayouts } from "@/hooks/useOverview"; import type { OverviewRange } from "@/types/overview"; import "@/components/overview/summary/overview-summary.css"; @@ -57,13 +56,14 @@ function OverviewSkeleton() { const OverviewPage = () => { const [range, setRange] = useState("30d"); const queryClient = useQueryClient(); - const { user } = useAuth(); const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range); + const { data: layouts, isLoading: layoutsLoading } = useOverviewLayouts(); // Hero, range control and headline KPIs are role-neutral; everything below - // them is chosen by role key. - const layoutKey = resolveOverviewLayout(user); + // them is chosen by which overview::view permissions the caller holds + // (GET /overview/layouts already filtered these server-side). + const layoutKey = resolveOverviewLayout(layouts?.map((l) => l.key)); const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null; const accessDenied = @@ -129,7 +129,7 @@ const OverviewPage = () => { )} - {isLoading && !data ? ( + {(isLoading || layoutsLoading) && !data ? ( diff --git a/apps/edr-freight-web/backoffice/src/services/overview.service.ts b/apps/edr-freight-web/backoffice/src/services/overview.service.ts index 9229b0b4f..471b3538d 100644 --- a/apps/edr-freight-web/backoffice/src/services/overview.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/overview.service.ts @@ -1,6 +1,7 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; +import type { OverviewLayoutKey } from "@/components/overview/role-dashboards.config"; import type { IOverviewBillingTab, IOverviewBookingsTab, @@ -16,7 +17,19 @@ import type { const O = URL_CONSTANTS.OVERVIEW; +/** Mirrors the API's OverviewLayoutDto — one entry per GET /overview/layouts item. */ +export interface IOverviewLayoutOption { + key: OverviewLayoutKey; + label: string; +} + export const overviewService = { + /** Layouts the caller has permission to render, in server priority order. */ + getLayouts: async (): Promise => { + const response = await client.get(O.LAYOUTS); + return unwrap(response); + }, + getDashboard: async (range?: OverviewRange): Promise => { const response = await client.get(O.BASE, { params: range ? { range } : undefined,