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):
- My work now — the queues this role has to clear, sorted by deadline and value, each item one click from the action.
- Signals — alerts, risks and the next best action for this role.
- My numbers — three to five KPIs, each clickable down to the records behind it.
- 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-countsonce a minute. The bell's count also adds the person's unread notes (the same answer carries it; elsewherentf_unread_countonce 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 throughGET /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 |