283 lines
12 KiB
Markdown
283 lines
12 KiB
Markdown
|
|
# Boss phone dashboard — design
|
||
|
|
|
||
|
|
**Date:** 2026-08-27
|
||
|
|
**Status:** Approved (pending implementation plan)
|
||
|
|
**Owner:** Dave (boss's view)
|
||
|
|
|
||
|
|
## Problem
|
||
|
|
|
||
|
|
The boss wants to keep track of where the business stands and what's
|
||
|
|
happening, on their phone. The existing `/dashboard` is desktop-first
|
||
|
|
(1074 lines, 6 chart types, 5 rows of cards) and the `/board` TV view is
|
||
|
|
designed for a wall-mounted display. Neither fits a phone glance.
|
||
|
|
|
||
|
|
## Goal
|
||
|
|
|
||
|
|
A standalone, phone-first page at `/m` that answers, in a single
|
||
|
|
glance, three questions:
|
||
|
|
|
||
|
|
1. How many quotes did each rep load, do for their accounts, and push through to emailed/approved/sent today / this week / this month?
|
||
|
|
2. How many orders did each rep place today / this week / this month?
|
||
|
|
3. How many driver stops are scheduled today, and how many are left?
|
||
|
|
|
||
|
|
The boss opens `/m` on their phone, sees the numbers refresh in
|
||
|
|
realtime, taps a rep row to verify the work behind a number, and
|
||
|
|
closes the app. Done.
|
||
|
|
|
||
|
|
## Non-goals (YAGNI)
|
||
|
|
|
||
|
|
- PWA install prompt UI (manifest exists but no "Add to home screen" button).
|
||
|
|
- Push notifications on KPI thresholds.
|
||
|
|
- Historical week-over-week comparison.
|
||
|
|
- CSV export / share.
|
||
|
|
- Settings screen.
|
||
|
|
- Stock alerts, follow-ups overdue, supplier PO queues — those live on
|
||
|
|
the operator dashboard. Out of scope per the user's explicit "just
|
||
|
|
the KPIs you listed" answer.
|
||
|
|
|
||
|
|
## Data model
|
||
|
|
|
||
|
|
Per-rep aggregations on the **rep perspective**:
|
||
|
|
|
||
|
|
| Stat | Definition |
|
||
|
|
|---|---|
|
||
|
|
| `quotesLoaded` | `quotes.claimed_by` = rep.id, count of rows where `created >= windowStart` and `created < windowEnd`. |
|
||
|
|
| `quotesDoneFor` | `quotes` joined to `customer` where `customer.rep` = rep.id, count of rows in window. |
|
||
|
|
| `quotesDoneBy` | `quotes.claimed_by` = rep.id, count of rows in window where `status` ∈ {`emailed`, `approved`, `sent`}. |
|
||
|
|
| `ordersPlaced` | `orders.rep` matches rep (PB id or resolved name), count of rows in window where `item_status` ∈ {`pending`, `po_generated`, `assigned`, `collection_pool`, `collected`, `delivered`, `invoiced`} (i.e. not cancelled). |
|
||
|
|
|
||
|
|
`window` is one of `today | week | month`:
|
||
|
|
- `today`: `[startOfDay(UTC), startOfTomorrow(UTC))`
|
||
|
|
- `week`: `[startOfWeek(UTC, monday), startOfNextWeek(UTC, monday))`
|
||
|
|
- `month`: `[startOfMonth(UTC), startOfNextMonth(UTC))`
|
||
|
|
|
||
|
|
Driver stops are always today-only regardless of the active tab.
|
||
|
|
|
||
|
|
The `orders.rep` field historically stored a free-text name and
|
||
|
|
sometimes a 15-char PB id. The service uses the existing
|
||
|
|
`resolveRepDisplayName` helper from `src/lib/server/order-rep.ts` to
|
||
|
|
normalise to a rep id, then joins on `users.id` — same pattern the
|
||
|
|
operator dashboard already uses for per-customer rep resolution.
|
||
|
|
|
||
|
|
Users with `claimed_by` / rep values that no longer resolve to a
|
||
|
|
`users` row are bucketed under a single "(unknown)" rep in the output
|
||
|
|
so the page never silently drops activity.
|
||
|
|
|
||
|
|
## Output shape
|
||
|
|
|
||
|
|
```ts
|
||
|
|
type BossDashboardData = {
|
||
|
|
window: "today" | "week" | "month";
|
||
|
|
rangeStart: string; // ISO
|
||
|
|
rangeEnd: string; // ISO
|
||
|
|
orderTotals: {
|
||
|
|
total: number; // orders created in window, not cancelled
|
||
|
|
delivered: number; // item_status === "delivered"
|
||
|
|
invoiced: number; // order.invoiced === true
|
||
|
|
};
|
||
|
|
reps: Array<{
|
||
|
|
id: string; // PB users.id, or "" for the (unknown) bucket
|
||
|
|
name: string; // "Gary Ndlovu" or "(unknown)"
|
||
|
|
isUnknown: boolean; // true → render muted, no avatar, no drill-down
|
||
|
|
quotesLoaded: number;
|
||
|
|
quotesDoneFor: number;
|
||
|
|
quotesDoneBy: number;
|
||
|
|
ordersPlaced: number;
|
||
|
|
}>;
|
||
|
|
drivers: Array<{
|
||
|
|
id: string; // PB users.id
|
||
|
|
name: string;
|
||
|
|
stopsToday: number;
|
||
|
|
stopsDone: number;
|
||
|
|
stopsLeft: number;
|
||
|
|
}>;
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
The endpoint returns ONE window at a time, not all three. Tab change →
|
||
|
|
new fetch with `?window=week`. Payload stays small (~3-5 KB).
|
||
|
|
|
||
|
|
## Files to create
|
||
|
|
|
||
|
|
```
|
||
|
|
src/lib/server/boss-dashboard-service.ts # getBossDashboardData({ window })
|
||
|
|
src/lib/server/boss-dashboard-service.test.ts # PB mocked, per-rep math + window boundaries
|
||
|
|
src/app/api/v1/boss-dashboard/route.ts # GET ?window=today|week|month, asserts session
|
||
|
|
src/app/api/v1/boss-dashboard/
|
||
|
|
└── rep-activity/route.ts # GET ?rep=<id>&window=<window>&kind=quotes|orders
|
||
|
|
# → paginated list for the bottom sheet
|
||
|
|
src/app/(mobile)/m/layout.tsx # phone shell, manifest link, no nav
|
||
|
|
src/app/(mobile)/m/page.tsx # server component, auth gate, initial fetch
|
||
|
|
src/app/(mobile)/m/dashboard-view.tsx # client: tabs, realtime, drill-downs
|
||
|
|
public/manifest-mobile.json # name, short_name, theme_color, display=standalone
|
||
|
|
```
|
||
|
|
|
||
|
|
## Files to modify
|
||
|
|
|
||
|
|
- `next.config.*` or `app/layout.tsx` — register `/m` layout's manifest link in the
|
||
|
|
document head. (Confirmed: adding a `<link rel="manifest">` inside
|
||
|
|
`(mobile)/m/layout.tsx` is enough; Next.js merges per-route head tags.)
|
||
|
|
|
||
|
|
## Architecture & data flow
|
||
|
|
|
||
|
|
1. **First paint (SSR):**
|
||
|
|
- Server Component reads the PB cookie via `cookies()` from `next/headers`
|
||
|
|
and calls `assertBrowserSession()` (no-arg variant for server components —
|
||
|
|
the route-handler variant `assertBrowserSession(req)` takes `NextRequest`).
|
||
|
|
On failure the page throws a redirect to the PB login route.
|
||
|
|
- `getBossDashboardData({ window: "today" })` runs against the admin PB client.
|
||
|
|
- `<BossDashboardView initial={...} />` renders with data — no spinner.
|
||
|
|
|
||
|
|
2. **Tab change (client):**
|
||
|
|
- User taps Week or Month.
|
||
|
|
- Client state updates `activeWindow`.
|
||
|
|
- `useEffect` on `activeWindow` calls `/api/v1/boss-dashboard?window=week`.
|
||
|
|
- Response replaces `data`, skeleton flashes for ~80ms while fetch in flight.
|
||
|
|
|
||
|
|
3. **Realtime (client):**
|
||
|
|
- On mount, subscribe to PB `customers`, `quotes`, `orders` collections
|
||
|
|
with `*` events.
|
||
|
|
- Trailing-edge debounce 1000ms → re-fetch current window.
|
||
|
|
- Unsubscribe on unmount.
|
||
|
|
|
||
|
|
4. **Drill-down:**
|
||
|
|
- Tap rep row → bottom sheet opens with two tabs (Quotes / Orders).
|
||
|
|
- Sheet calls `/api/v1/boss-dashboard/rep-activity?rep=<id>&window=<window>&kind=...`.
|
||
|
|
- Result is a flat array of `{ id, number, customer, status, age }` — page renders rows.
|
||
|
|
- Tap a row → opens `/quotes/<id>` or `/orders/<id>` in a new tab on the phone.
|
||
|
|
|
||
|
|
5. **Auth:** `assertBrowserSession` against the running PB browser session.
|
||
|
|
Boss logs in via the existing PB login page once. No new auth code, no new PIN.
|
||
|
|
Two flavours:
|
||
|
|
- **Page (server component):** `assertBrowserSession()` reads the PB cookie
|
||
|
|
from `next/headers`. Throws redirect on failure.
|
||
|
|
- **Route handler:** `assertBrowserSession(req)` takes `NextRequest`,
|
||
|
|
forwards the bearer token / cookie, returns 401 on failure.
|
||
|
|
|
||
|
|
## Components
|
||
|
|
|
||
|
|
### `(mobile)/m/layout.tsx`
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
export default function MobileLayout({ children }: { children: React.ReactNode }) {
|
||
|
|
return (
|
||
|
|
<main className="min-h-screen bg-background text-foreground">
|
||
|
|
{children}
|
||
|
|
</main>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Also sets `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` and `<link rel="manifest" href="/manifest-mobile.json">` via the route's head export.
|
||
|
|
|
||
|
|
### `(mobile)/m/page.tsx`
|
||
|
|
|
||
|
|
Server component:
|
||
|
|
```tsx
|
||
|
|
export const dynamic = "force-dynamic";
|
||
|
|
export const runtime = "nodejs";
|
||
|
|
|
||
|
|
export default async function BossDashboardPage() {
|
||
|
|
await assertBrowserSession();
|
||
|
|
const initial = await getBossDashboardData({ window: "today" });
|
||
|
|
return <BossDashboardView initial={initial} userName={user.name} />;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### `(mobile)/m/dashboard-view.tsx` (client)
|
||
|
|
|
||
|
|
Sections, top to bottom:
|
||
|
|
|
||
|
|
1. **Header strip** — "Good {morning|afternoon|evening}, {firstName}." (first name = `user.name.split(" ")[0]`, matching the operator dashboard pattern) with refresh button (rotates icon while loading) and `last-refreshed {timeAgo}` subtitle.
|
||
|
|
|
||
|
|
2. **Time tab bar** — segmented control. Today / Week / Month. Active tab highlighted with accent colour, others muted. Single tap re-fetches.
|
||
|
|
|
||
|
|
3. **Order totals strip** — three small tiles: total / delivered / invoiced for the active window. One row, three cells.
|
||
|
|
|
||
|
|
4. **Reps section** — header "Sales reps" with count. One card per rep, sorted `ordersPlaced DESC, quotesLoaded DESC`. Each card:
|
||
|
|
- Avatar + name (left)
|
||
|
|
- 4 metric cells (loaded / done-for / done-by / orders) right-aligned with label-under-number
|
||
|
|
- Progress bar underneath showing `ordersPlaced / totalOrdersThisWindow` as percentage
|
||
|
|
- Tap → bottom sheet
|
||
|
|
|
||
|
|
5. **Drivers section** — header "Driver stops today ({totalStopsToday})" — the count in parentheses is the sum of all drivers' `stopsToday`. One card per driver with stops-left badge coloured by urgency: red if `stopsLeft > 0 && stopsDone === 0` (nothing started yet), yellow if `stopsLeft > 0 && stopsDone > 0` (in progress), green if `stopsLeft === 0` (all done).
|
||
|
|
|
||
|
|
### Bottom sheet (`<dialog>` element)
|
||
|
|
|
||
|
|
Reuses the native `<dialog>` element with `[open]` toggling. Two tabs inside: Quotes / Orders. Each tab lazy-loads its data via the rep-activity endpoint. Rows render with avatar (initial), number, customer, status pill, relative age ("2h ago"). Tap row → `window.open(/quotes/<id>, "_blank")`.
|
||
|
|
|
||
|
|
## Empty / loading / error states
|
||
|
|
|
||
|
|
| State | Behaviour |
|
||
|
|
|---|---|
|
||
|
|
| Loading (first paint) | Server-rendered data, no spinner needed. |
|
||
|
|
| Loading (tab change) | Card-level skeleton, shimmer, ~80ms. |
|
||
|
|
| Error (PB 5xx) | Toast with retry button, single "Couldn't load" tile replaces the affected section. View keeps the previous data. |
|
||
|
|
| Empty reps | "No rep activity in this window." Card centred, muted text. |
|
||
|
|
| Empty drivers | "No driver stops scheduled today." |
|
||
|
|
| Network offline | Toast "Reconnecting…", disable refresh button. |
|
||
|
|
|
||
|
|
## Service contract
|
||
|
|
|
||
|
|
`getBossDashboardData({ window }: { window: "today" | "week" | "month" }): Promise<BossDashboardData>`
|
||
|
|
|
||
|
|
Throws on PB errors with a `.status` property. Caller (`route.ts`) catches and returns 500 with `{ error: "..." }` body. View renders error state on non-OK responses.
|
||
|
|
|
||
|
|
`getBossRepActivity({ rep, window, kind, page }): Promise<{ items: Array<...>, hasMore: boolean }>` — paginated 25 per page for the drill-down sheet.
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
- **`boss-dashboard-service.test.ts`** — PB mocked via `pocketbase-admin` test pattern already in the repo. Cases:
|
||
|
|
- Per-rep math correct (loaded / done-for / done-by / orders) for sample fixture.
|
||
|
|
- Window boundary correctness: today rolls over at UTC midnight, week on Monday, month on day 1.
|
||
|
|
- Unknown rep bucketing: `claimed_by` strings that don't resolve to a `users.id` land in the `(unknown)` row.
|
||
|
|
- Empty rep list still returns valid shape.
|
||
|
|
- **`route.test.ts`** (one test file for both `/api/v1/boss-dashboard` and `/api/v1/boss-dashboard/rep-activity`):
|
||
|
|
- 401 when no session.
|
||
|
|
- 400 on missing/invalid `window`.
|
||
|
|
- 200 with valid payload.
|
||
|
|
- **No browser test in v1.** User has `agent-browser` configured and will verify in the running app.
|
||
|
|
|
||
|
|
## Refresh model
|
||
|
|
|
||
|
|
PB realtime + 1s trailing-edge debounce. Same as `/dashboard`. If the
|
||
|
|
debounce proves noisy on a phone (battery, data), we can add a "Pause
|
||
|
|
live updates" toggle as a follow-up.
|
||
|
|
|
||
|
|
## Out of scope reminders
|
||
|
|
|
||
|
|
Already listed. Re-emphasising here because the boss might be tempted
|
||
|
|
to ask for them at demo time:
|
||
|
|
- PWA install button.
|
||
|
|
- Per-rep drill-down to "orders by status" — current drill-down is a
|
||
|
|
flat list of records, not a per-status breakdown.
|
||
|
|
- Date scrubber — fixed today / this week / this month.
|
||
|
|
- Multi-tenant / white-label — single-tenant WFDASH, single boss.
|
||
|
|
|
||
|
|
## Open questions
|
||
|
|
|
||
|
|
None. All clarifying questions resolved during brainstorm:
|
||
|
|
|
||
|
|
- Rep perspective on quote metrics — confirmed.
|
||
|
|
- Reps-only as the user scope — confirmed.
|
||
|
|
- Same PB session auth — confirmed.
|
||
|
|
- Today + week + month tabs (no toggle) — confirmed.
|
||
|
|
- PB realtime + debounce — confirmed.
|
||
|
|
- Tap-row-to-list drill-downs — confirmed.
|
||
|
|
- Standalone PWA at `/m` — confirmed.
|
||
|
|
- Layout B (tabs top, single table) — confirmed.
|
||
|
|
- "Just the KPIs you listed" — confirmed.
|
||
|
|
|
||
|
|
## How to verify after implementation
|
||
|
|
|
||
|
|
1. `pnpm dev` — confirm no compile errors.
|
||
|
|
2. Open `http://localhost:4001/m` in the browser.
|
||
|
|
3. Confirm greeting + tab bar + reps + drivers render.
|
||
|
|
4. Switch tab — confirm data refreshes.
|
||
|
|
5. Open DevTools network tab, simulate a quote create in another tab —
|
||
|
|
confirm debounced re-fetch fires within ~1s.
|
||
|
|
6. Tap a rep row — confirm bottom sheet opens with quotes + orders.
|
||
|
|
7. Tap a record inside the sheet — confirm `/quotes/<id>` opens in a new tab.
|
||
|
|
8. Sign out, navigate to `/m` — confirm redirect to PB login.
|
||
|
|
9. `pnpm test src/lib/server/boss-dashboard-service.test.ts` — passes.
|
||
|
|
10. `pnpm test src/app/api/v1/boss-dashboard` — passes.
|