mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(overview): resolve dashboard layout from permissions, not role keys
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.
This commit is contained in:
@@ -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. */
|
/** One overview composition. Every backoffice user lands on exactly one of these. */
|
||||||
export type OverviewLayoutKey =
|
export type OverviewLayoutKey =
|
||||||
| "executive"
|
| "executive"
|
||||||
@@ -20,80 +17,35 @@ export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Position/role key → layout, in match priority order: a user holding several
|
* Priority order: a caller who holds more than one of the six
|
||||||
* of these keys gets the first match, so the specific operational view wins
|
* `edr_freight_app:overview:<key>:view` permissions gets the FIRST match
|
||||||
* over the broad executive one. Roles are matched alongside positions because
|
* here — the specific operational view wins over the broad executive one.
|
||||||
* the IAM payload models the GL desks as positions (`ethiopian_gl`) on some
|
* Mirrors `OVERVIEW_LAYOUT_KEYS` in the API's freight-permissions.registry.ts
|
||||||
* accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
|
* bit for bit; keep the two in sync if this ever changes.
|
||||||
*
|
|
||||||
* 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`.
|
|
||||||
*/
|
*/
|
||||||
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
|
const LAYOUT_PRIORITY: OverviewLayoutKey[] = [
|
||||||
// ── Clearance & logistics: both GL desks, root and sub-positions ──────────
|
"clearance",
|
||||||
["ethiopian_gl", "clearance"],
|
"occ",
|
||||||
["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief
|
"operation",
|
||||||
["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director
|
"marketer",
|
||||||
["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer
|
"finance",
|
||||||
["djibouti_gl", "clearance"],
|
"executive",
|
||||||
["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
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 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(
|
export function resolveOverviewLayout(
|
||||||
user: AuthUser | null | undefined,
|
allowed: OverviewLayoutKey[] | undefined,
|
||||||
): OverviewLayoutKey {
|
): OverviewLayoutKey {
|
||||||
const held = new Set(getPositionKeys(user));
|
const held = new Set(allowed ?? []);
|
||||||
return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive";
|
return LAYOUT_PRIORITY.find((key) => held.has(key)) ?? "executive";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export const QUERY_KEYS = {
|
|||||||
|
|
||||||
OVERVIEW: {
|
OVERVIEW: {
|
||||||
ROOT: ["overview"] as const,
|
ROOT: ["overview"] as const,
|
||||||
|
layouts: () => ["overview", "layouts"] as const,
|
||||||
dashboard: (range?: string) =>
|
dashboard: (range?: string) =>
|
||||||
["overview", "dashboard", range ?? "30d"] as const,
|
["overview", "dashboard", range ?? "30d"] as const,
|
||||||
bookingsTab: (range?: string) =>
|
bookingsTab: (range?: string) =>
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
OVERVIEW: {
|
OVERVIEW: {
|
||||||
BASE: "/overview",
|
BASE: "/overview",
|
||||||
|
LAYOUTS: "/overview/layouts",
|
||||||
BOOKINGS: "/overview/bookings",
|
BOOKINGS: "/overview/bookings",
|
||||||
CONTRACTS: "/overview/contracts",
|
CONTRACTS: "/overview/contracts",
|
||||||
BILLING: "/overview/billing",
|
BILLING: "/overview/billing",
|
||||||
|
|||||||
@@ -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) {
|
export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range),
|
queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { AlertCircle } from "lucide-react";
|
|||||||
import { Alert, Button, Skeleton, Stack } from "@mantine/core";
|
import { Alert, Button, Skeleton, Stack } from "@mantine/core";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
|
||||||
import { PageContainer } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview";
|
import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview";
|
||||||
import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview";
|
import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview";
|
||||||
@@ -20,7 +19,7 @@ import {
|
|||||||
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
|
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
|
||||||
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
|
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
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 type { OverviewRange } from "@/types/overview";
|
||||||
import "@/components/overview/summary/overview-summary.css";
|
import "@/components/overview/summary/overview-summary.css";
|
||||||
|
|
||||||
@@ -57,13 +56,14 @@ function OverviewSkeleton() {
|
|||||||
const OverviewPage = () => {
|
const OverviewPage = () => {
|
||||||
const [range, setRange] = useState<OverviewRange>("30d");
|
const [range, setRange] = useState<OverviewRange>("30d");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user } = useAuth();
|
|
||||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||||
useOverview(range);
|
useOverview(range);
|
||||||
|
const { data: layouts, isLoading: layoutsLoading } = useOverviewLayouts();
|
||||||
|
|
||||||
// Hero, range control and headline KPIs are role-neutral; everything below
|
// Hero, range control and headline KPIs are role-neutral; everything below
|
||||||
// them is chosen by role key.
|
// them is chosen by which overview:<key>:view permissions the caller holds
|
||||||
const layoutKey = resolveOverviewLayout(user);
|
// (GET /overview/layouts already filtered these server-side).
|
||||||
|
const layoutKey = resolveOverviewLayout(layouts?.map((l) => l.key));
|
||||||
const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null;
|
const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null;
|
||||||
|
|
||||||
const accessDenied =
|
const accessDenied =
|
||||||
@@ -129,7 +129,7 @@ const OverviewPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && !data ? (
|
{(isLoading || layoutsLoading) && !data ? (
|
||||||
<Stack mt="lg">
|
<Stack mt="lg">
|
||||||
<OverviewSkeleton />
|
<OverviewSkeleton />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { api as client } from "../auth/http";
|
import { api as client } from "../auth/http";
|
||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type { OverviewLayoutKey } from "@/components/overview/role-dashboards.config";
|
||||||
import type {
|
import type {
|
||||||
IOverviewBillingTab,
|
IOverviewBillingTab,
|
||||||
IOverviewBookingsTab,
|
IOverviewBookingsTab,
|
||||||
@@ -16,7 +17,19 @@ import type {
|
|||||||
|
|
||||||
const O = URL_CONSTANTS.OVERVIEW;
|
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 = {
|
export const overviewService = {
|
||||||
|
/** Layouts the caller has permission to render, in server priority order. */
|
||||||
|
getLayouts: async (): Promise<IOverviewLayoutOption[]> => {
|
||||||
|
const response = await client.get<IOverviewLayoutOption[]>(O.LAYOUTS);
|
||||||
|
return unwrap(response);
|
||||||
|
},
|
||||||
|
|
||||||
getDashboard: async (range?: OverviewRange): Promise<IOverviewDashboard> => {
|
getDashboard: async (range?: OverviewRange): Promise<IOverviewDashboard> => {
|
||||||
const response = await client.get<IOverviewDashboard>(O.BASE, {
|
const response = await client.get<IOverviewDashboard>(O.BASE, {
|
||||||
params: range ? { range } : undefined,
|
params: range ? { range } : undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user