Skip to content

Role dashboards

Every staff user lands on /app and sees a dashboard built for their own job. There is no longer one shared dashboard.

Rules: UX-010, UX-011 (a dashboard per role), INT-001, INT-004, INT-160 (what needs attention, why, the daily brief).


1. How the dashboard is chosen

From the permissions the user holds, never from a role name (ACC-001). There are twelve profiles:

Leadership · Finance manager · Chartered accountant · Accountant · Cashier · Operations · Ticketing · Visa · B2B · Sales · Auditor · HR/IT.

Someone who does more than one job gets a tab for each, main profile first. Detectors match on action permissions, not *.view — most staff roles hold most view permissions, so a view permission says nothing about what someone does.

A user whose permissions match no widget in a profile sees a plain message saying so, not an empty page.

The phone app's Dashboard (opened from Home → My dashboard) uses the same profiles, tabs and one call — see The Alhuda Travels app → Dashboard.

2. What a dashboard contains

Four sections, in this order (UX-010):

  1. My work now — the queues this role has to clear, sorted by deadline and value, each item one click from the action.
  2. Signals — alerts, risks and the next best action for this role.
  3. My numbers — three to five KPIs, each clickable down to the records behind it.
  4. Shortcuts — the role's most frequent actions.

Above them sits the daily brief (INT-160): a few lines saying what changed and what is at risk. It is deterministic — built from the same data the widgets already loaded, with no extra requests and no language model (INT-002). It never acts (INT-003).

3. Every number comes from the database

Each widget is backed by one dash_* function that checks the caller's permissions itself and returns the number, the list behind it, and the rule and facts that produced it (INT-004). Nothing is aggregated in the browser.

Examples: dash_approvals_inbox, dash_cash_position, dash_receivables_overdue, dash_receipts_to_verify, dash_departures, dash_visa_stages, dash_deadline_radar, dash_hold_expiry, dash_seat_releases, dash_block_utilisation, dash_users_without_mfa. There are more than fifty.

Every widget renders a skeleton while loading, an empty state when there is nothing, an error with a retry, and an "explain this number" popover carrying the rule id.

How the dashboard loads — one call (PRF-002)

The browser asks the database once for the tab on screen: dashboard_screen(profile, scope, main). The answer holds every tile of that tab, the sidebar badge counts and the unread count. Each tile is the same dash_* function called with the same arguments as before, and that function still checks the person's permissions itself. A tile the person may not see is left out of the answer; the widget then says "You do not have permission to see this", as it always did. A tile whose function fails reports its own error and offers a retry; the rest of the screen still loads.

  • Badges and the bell. While the dashboard is open the sidebar and the header bell take their numbers from this answer and make no request of their own. On other pages they still ask GET /badge-counts once a minute. The bell's count also adds the person's unread notes (the same answer carries it; elsewhere ntf_unread_count once a minute), and opening the bell lists them (Communications → Notification inbox).
  • Coming back. The last answer is kept in the browser for 30 minutes. Returning to the dashboard shows it at once, and fetches a fresh one behind the scenes if it is more than a minute old. While the dashboard is open it refreshes once a minute, and when the window gets focus again if the answer is stale.
  • Switching tab or Mine / All keeps the previous numbers on screen until the new answer arrives, instead of blanking every tile.
  • Open incidents come in the same call on the main tab, for people with incidents.view.
  • The journey board is not in the call. It is the slowest read on the dashboard, so the three journey widgets read get_journey_board(30) in one request of their own, sent at the same moment. They no longer go through GET /journey/board, whose browser-side permission checks added three round trips first.

Before this change a dashboard made 16–19 requests, four round trips one after another; now it makes two, side by side. Measured by src/test/dashboardRoundTrips.test.tsx.

Why the old dashboard was deleted

src/pages/Dashboard.tsx was removed along with its aging, P&L and finance-headline widgets. Its landing KPIs were the ones the finance audit flagged: lifetime receipts presented as liquid funds, and a P&L dated by createdAt. They were replaced by the ledger-backed fin.cash-position, lead.collections-month and fin.receivables-overdue widgets. A number nobody can act on is worse than no number.

4. Scope

A Mine / All toggle appears on profiles whose widgets have an owner column — bookings by createdBy, requests by assignedToId, payments and journals by createdBy. Leads and quotations have no owner column yet, so they cannot be scoped.

ACC-013 (executives see their own records by default, with a recorded All toggle) is a working default, not a decision. Today staff see everything their permissions allow and the toggle is a convenience, not a boundary.

5. What is not built

  • Sales targets, lead owner and next-follow-up date, partner account manager, partner statement due dates, transport gaps, per-deadline supplier instalments — no columns exist. Each widget names the missing data in its own "why" panel rather than showing a blank.
  • Approval routing by limit — the approvals inbox shows everything the user may decide (ACC-030).
  • Margin warnings, FX exposure, cash-flow forecast, duplicate detection — INT-122, INT-151…153, Wave 4.
  • The tour-leader dashboard — FLD-001, Wave 3.
  • A dashboard that paints before sign-in finishes. The last answer lives in memory only, not in browser storage, so a reload or a new tab waits for one call. Keeping business numbers in browser storage on shared office computers was judged not worth it.

6. Where to look

Concern Path
Route /app — src/App.tsx → src/pages/Index.tsx → src/pages/RoleDashboard.tsx
Profiles and detection src/lib/dashboardProfiles.ts (re-exported by src/components/dashboard/widgets/profiles.ts); the phone app carries an identical copy — The Alhuda Travels app → Dashboard
Widget registry and sections src/components/dashboard/widgets/registry.ts
Widget specs src/components/dashboard/widgets/roleWidgets.tsx
Daily brief src/components/dashboard/widgets/DailyBrief.tsx
The one call src/components/dashboard/widgets/screen.ts; dashboard_screen in supabase/migrations/20260930060000_the_dashboard_is_one_call.sql (API)
SQL functions supabase/migrations/20260919140000_role_dashboards.sql, 20260920110300_inventory_deadline_dashboards.sql
Tests supabase/tests/role_dashboards.sql, supabase/tests/the_dashboard_is_one_call.sql, src/components/dashboard/widgets/*.test.ts, src/pages/RoleDashboard.test.tsx, src/test/dashboardRoundTrips.test.tsx, src/test/dashboardScreen.drift.test.ts, src/test/dashboardProfiles.drift.test.ts
Per-role contents PERMISSIONS.md §6.18, UX-011