2032 lines
66 KiB
Markdown
2032 lines
66 KiB
Markdown
|
|
# Boss Phone Dashboard Implementation Plan
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** Ship a phone-first dashboard at `/m` that shows per-rep quote throughput, per-rep order counts, and driver stops today — for the boss to keep track of business momentum on their phone.
|
|||
|
|
|
|||
|
|
**Architecture:** New service `boss-dashboard-service.ts` exposes `getBossDashboardData({ window })` (single window at a time, ~3-5 KB payload) and `getBossRepActivity({ rep, window, kind, page })` for drill-downs. Two thin `/api/v1/boss-dashboard/*` routes serve the data. New `(mobile)/m` route group with its own layout (no nav, manifest link). Single `"use client"` page that mirrors the existing `/dashboard` pattern: bearer-token auth via the API route, PB realtime subscription + 1s debounce, native `<dialog>` bottom sheet for drill-downs.
|
|||
|
|
|
|||
|
|
**Tech Stack:** Next.js (App Router), TypeScript, PocketBase admin client, Vitest, Tailwind, sonner toasts, lucide-react icons, native `<dialog>` for the bottom sheet.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ⚠️ Spec-to-plan divergence: page.tsx is a client component, not a server component
|
|||
|
|
|
|||
|
|
The spec (`docs/superpowers/specs/2026-08-27-boss-phone-dashboard-design.md`, "Architecture & data flow § First paint") describes `page.tsx` as a Server Component that calls `assertBrowserSession()` and renders with SSR data.
|
|||
|
|
|
|||
|
|
This is **not** how the existing app works. PocketBase's browser session token lives in `localStorage` only (not a cookie), so the route's auth gate cannot read it from a Server Component. The realistic pattern in this repo (matching `/dashboard/page.tsx`) is:
|
|||
|
|
|
|||
|
|
- `page.tsx` is `"use client"` and renders the view with no SSR data.
|
|||
|
|
- The view fetches `/api/v1/boss-dashboard` on mount, forwarding the PB token from `pb.authStore` as a `Authorization` header.
|
|||
|
|
- The API route's local `assertBrowserSession(req)` validates the bearer token against PB's `/auth-refresh`.
|
|||
|
|
|
|||
|
|
Trade-off: first paint shows a brief empty state instead of server-rendered data. In practice this is ~80-120ms on the same LAN as PB — fine for a phone dashboard the boss opens once per hour, and consistent with the rest of the app.
|
|||
|
|
|
|||
|
|
If the spec is later updated to add PB cookie support, the same view can be made SSR-friendly by switching to a Server Component and reading `cookies()` — but no such migration is needed today.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## File Structure
|
|||
|
|
|
|||
|
|
| File | Responsibility |
|
|||
|
|
|---|---|
|
|||
|
|
| `src/lib/server/boss-dashboard-service.ts` | Data: `windowRange()`, `getBossDashboardData({ window })`, `getBossRepActivity({ rep, window, kind, page })`. |
|
|||
|
|
| `src/lib/server/boss-dashboard-service.test.ts` | Unit tests for window boundaries, per-rep math, unknown bucketing, rep-activity pagination. |
|
|||
|
|
| `src/app/api/v1/boss-dashboard/route.ts` | `GET ?window=today\|week\|month`. Auth via local `assertBrowserSession`. |
|
|||
|
|
| `src/app/api/v1/boss-dashboard/rep-activity/route.ts` | `GET ?rep=<id>&window=<window>&kind=quotes\|orders&page=<n>`. Auth same. |
|
|||
|
|
| `src/app/api/v1/boss-dashboard/route.test.ts` | Tests for both routes (auth gate, query parsing, payload shape). |
|
|||
|
|
| `public/manifest-mobile.json` | PWA manifest (name, short_name, theme_color, display=standalone, icons). |
|
|||
|
|
| `src/app/(mobile)/m/layout.tsx` | Phone shell — `<main>` wrapper + `<link rel="manifest">`. |
|
|||
|
|
| `src/app/(mobile)/m/page.tsx` | `"use client"` — renders `<BossDashboardView />`. |
|
|||
|
|
| `src/app/(mobile)/m/dashboard-view.tsx` | Header strip, time tabs, order totals, reps section, drivers section, drill-down sheet. |
|
|||
|
|
| `src/app/(mobile)/m/boss-dashboard.module.css` | (Optional) phone-specific styles if Tailwind utilities get too noisy. |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 1 — Service layer
|
|||
|
|
|
|||
|
|
### Task 1: Window range helper
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `src/lib/server/boss-dashboard-service.ts`
|
|||
|
|
- Test: `src/lib/server/boss-dashboard-service.test.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the failing test for `windowRange`**
|
|||
|
|
|
|||
|
|
Add at the top of `src/lib/server/boss-dashboard-service.test.ts`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { describe, it, expect } from "vitest";
|
|||
|
|
import { windowRange } from "./boss-dashboard-service";
|
|||
|
|
|
|||
|
|
describe("windowRange", () => {
|
|||
|
|
it("today: start of UTC day to start of next UTC day", () => {
|
|||
|
|
const r = windowRange("today", new Date("2026-08-27T14:32:11.000Z"));
|
|||
|
|
expect(r.rangeStart).toBe("2026-08-27T00:00:00.000Z");
|
|||
|
|
expect(r.rangeEnd).toBe("2026-08-28T00:00:00.000Z");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("week: Monday 00:00 UTC to next Monday 00:00 UTC", () => {
|
|||
|
|
// 2026-08-27 is a Thursday. Week starts Mon 2026-08-24.
|
|||
|
|
const r = windowRange("week", new Date("2026-08-27T14:32:11.000Z"));
|
|||
|
|
expect(r.rangeStart).toBe("2026-08-24T00:00:00.000Z");
|
|||
|
|
expect(r.rangeEnd).toBe("2026-08-31T00:00:00.000Z");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("month: first of month to first of next month", () => {
|
|||
|
|
const r = windowRange("month", new Date("2026-08-27T14:32:11.000Z"));
|
|||
|
|
expect(r.rangeStart).toBe("2026-08-01T00:00:00.000Z");
|
|||
|
|
expect(r.rangeEnd).toBe("2026-09-01T00:00:00.000Z");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("week across month boundary: still anchored to Monday", () => {
|
|||
|
|
// 2026-09-02 is a Wednesday. Week starts Mon 2026-08-31, ends Mon 2026-09-07.
|
|||
|
|
const r = windowRange("week", new Date("2026-09-02T09:00:00.000Z"));
|
|||
|
|
expect(r.rangeStart).toBe("2026-08-31T00:00:00.000Z");
|
|||
|
|
expect(r.rangeEnd).toBe("2026-09-07T00:00:00.000Z");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|||
|
|
|
|||
|
|
Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts`
|
|||
|
|
Expected: FAIL — `windowRange` is not exported.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Implement `windowRange`**
|
|||
|
|
|
|||
|
|
In `src/lib/server/boss-dashboard-service.ts`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
export type BossWindow = "today" | "week" | "month";
|
|||
|
|
|
|||
|
|
export interface WindowRange {
|
|||
|
|
rangeStart: string; // ISO
|
|||
|
|
rangeEnd: string; // ISO
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Computes the [start, end) UTC window for a boss-dashboard time tab.
|
|||
|
|
* Today: midnight-to-midnight UTC.
|
|||
|
|
* Week: Monday 00:00 UTC to next Monday 00:00 UTC.
|
|||
|
|
* Month: day-1 00:00 UTC to day-1 of next month.
|
|||
|
|
*
|
|||
|
|
* All UTC because that's how PocketBase `created` timestamps compare.
|
|||
|
|
*/
|
|||
|
|
export function windowRange(window: BossWindow, now: Date = new Date()): WindowRange {
|
|||
|
|
const start = new Date(now);
|
|||
|
|
const end = new Date(now);
|
|||
|
|
|
|||
|
|
switch (window) {
|
|||
|
|
case "today": {
|
|||
|
|
start.setUTCHours(0, 0, 0, 0);
|
|||
|
|
end.setUTCDate(end.getUTCDate() + 1);
|
|||
|
|
end.setUTCHours(0, 0, 0, 0);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
case "week": {
|
|||
|
|
// Monday = 1, Sunday = 0. Shift so Monday is start.
|
|||
|
|
const day = start.getUTCDay();
|
|||
|
|
const diffToMonday = day === 0 ? -6 : 1 - day;
|
|||
|
|
start.setUTCDate(start.getUTCDate() + diffToMonday);
|
|||
|
|
start.setUTCHours(0, 0, 0, 0);
|
|||
|
|
end.setTime(start.getTime());
|
|||
|
|
end.setUTCDate(end.getUTCDate() + 7);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
case "month": {
|
|||
|
|
start.setUTCDate(1);
|
|||
|
|
start.setUTCHours(0, 0, 0, 0);
|
|||
|
|
end.setUTCMonth(end.getUTCMonth() + 1, 1);
|
|||
|
|
end.setUTCHours(0, 0, 0, 0);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { rangeStart: start.toISOString(), rangeEnd: end.toISOString() };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
|||
|
|
|
|||
|
|
Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts`
|
|||
|
|
Expected: PASS — 4 tests green.
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/lib/server/boss-dashboard-service.ts src/lib/server/boss-dashboard-service.test.ts
|
|||
|
|
git commit -m "feat(boss-dashboard): add windowRange helper with UTC boundaries"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 2: `getBossDashboardData` aggregation
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/lib/server/boss-dashboard-service.ts`
|
|||
|
|
- Modify: `src/lib/server/boss-dashboard-service.test.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the failing aggregation tests**
|
|||
|
|
|
|||
|
|
Append to the test file:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { getBossDashboardData } from "./boss-dashboard-service";
|
|||
|
|
import { vi, beforeEach } from "vitest";
|
|||
|
|
|
|||
|
|
// Mock the admin PB client used by the service.
|
|||
|
|
// Pattern lifted from src/lib/server/dashboard-service.test.ts.
|
|||
|
|
const { pbMock, clearStore } = vi.hoisted(() => {
|
|||
|
|
type Row = Record<string, unknown> & { id: string };
|
|||
|
|
type Store = Map<string, Row>;
|
|||
|
|
const stores: Record<string, Store> = {};
|
|||
|
|
let seq = 0;
|
|||
|
|
|
|||
|
|
const ensure = (name: string): Store => {
|
|||
|
|
if (!stores[name]) stores[name] = new Map();
|
|||
|
|
return stores[name];
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const tokeniseLeaf = (
|
|||
|
|
item: Row,
|
|||
|
|
clause: string,
|
|||
|
|
): boolean => {
|
|||
|
|
const m = clause.match(/^(\w+)\s*(>=|<=|!=|=)\s*(?:"([^"]*)"|(\S+))$/);
|
|||
|
|
if (!m) return true;
|
|||
|
|
const [, field, op, quoted, unquoted] = m;
|
|||
|
|
const expected = quoted ?? unquoted ?? "";
|
|||
|
|
const actual = item[field];
|
|||
|
|
const a = actual instanceof Date ? actual.toISOString() : String(actual ?? "");
|
|||
|
|
switch (op) {
|
|||
|
|
case "=": return a === expected;
|
|||
|
|
case "!=": return a !== expected;
|
|||
|
|
case ">=": return a >= expected;
|
|||
|
|
case "<=": return a <= expected;
|
|||
|
|
default: return true;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const matches = (item: Row, filter: string): boolean => {
|
|||
|
|
if (!filter.trim()) return true;
|
|||
|
|
return filter.split("&&").every((part) => {
|
|||
|
|
const trimmed = part.trim();
|
|||
|
|
// Handle "X = Y || X = Z" inside one term.
|
|||
|
|
const alts = trimmed.split("||").map((s) => s.trim());
|
|||
|
|
return alts.some((alt) => tokeniseLeaf(item, alt));
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const buildListFn =
|
|||
|
|
(name: string) =>
|
|||
|
|
async (opts: { filter?: string; fields?: string; expand?: string }) => {
|
|||
|
|
const rows = Array.from(ensure(name).values()).filter((r) =>
|
|||
|
|
matches(r, opts.filter ?? ""),
|
|||
|
|
);
|
|||
|
|
// Mocked expand: pretend the relation is already inlined by callers.
|
|||
|
|
return rows;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const buildGetListFn =
|
|||
|
|
(name: string) =>
|
|||
|
|
async (opts: { filter?: string; sort?: string; perPage?: number; page?: number; expand?: string; fields?: string }) => {
|
|||
|
|
let rows = Array.from(ensure(name).values()).filter((r) =>
|
|||
|
|
matches(r, opts.filter ?? ""),
|
|||
|
|
);
|
|||
|
|
if (opts.sort) {
|
|||
|
|
const desc = opts.sort.startsWith("-");
|
|||
|
|
const key = desc ? opts.sort.slice(1) : opts.sort;
|
|||
|
|
rows = rows.slice().sort((a, b) => {
|
|||
|
|
const av = String(a[key] ?? "");
|
|||
|
|
const bv = String(b[key] ?? "");
|
|||
|
|
return desc ? bv.localeCompare(av) : av.localeCompare(bv);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
const page = opts.page ?? 1;
|
|||
|
|
const perPage = opts.perPage ?? 30;
|
|||
|
|
const start = (page - 1) * perPage;
|
|||
|
|
return { items: rows.slice(start, start + perPage), page, perPage, totalItems: rows.length };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const pbMock = {
|
|||
|
|
collection: (name: string) => {
|
|||
|
|
const list = buildListFn(name);
|
|||
|
|
const getList = buildGetListFn(name);
|
|||
|
|
return {
|
|||
|
|
getFullList: list,
|
|||
|
|
getList,
|
|||
|
|
getOne: async (id: string) => ensure(name).get(id) ?? null,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const clearStore = () => {
|
|||
|
|
for (const k of Object.keys(stores)) delete stores[k];
|
|||
|
|
seq = 0;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const addRow = (collection: string, row: Record<string, unknown>) => {
|
|||
|
|
const id = row.id ?? `mock-${++seq}`;
|
|||
|
|
ensure(collection).set(id, { ...row, id });
|
|||
|
|
return id;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return { pbMock, clearStore, _addRow: addRow, _stores: stores };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// We export the row-builder through the same hoisted object so tests can
|
|||
|
|
// seed fixtures. Tests use `addRow(collection, row)` exposed below.
|
|||
|
|
const { _addRow: addRow, _stores } = pbMock as unknown as {
|
|||
|
|
_addRow: (c: string, r: Record<string, unknown>) => string;
|
|||
|
|
_stores: Record<string, Map<string, Record<string, unknown>>>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Mock the pocketbase-admin module so getAdminPocketBase returns pbMock.
|
|||
|
|
vi.mock("./pocketbase-admin", () => ({
|
|||
|
|
getAdminPocketBase: async () => pbMock,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const fixedNow = new Date("2026-08-27T14:00:00.000Z");
|
|||
|
|
|
|||
|
|
beforeEach(() => {
|
|||
|
|
clearStore();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("getBossDashboardData", () => {
|
|||
|
|
it("returns orderTotals: total/delivered/invoiced for the window", async () => {
|
|||
|
|
// 3 orders in window: 1 delivered, 1 invoiced, 1 pending. 1 cancelled.
|
|||
|
|
addRow("orders", { created: "2026-08-27T08:00:00.000Z", rep: "rep-1", invoiced: false, item_status: "pending" });
|
|||
|
|
addRow("orders", { created: "2026-08-27T09:00:00.000Z", rep: "rep-1", invoiced: false, item_status: "delivered" });
|
|||
|
|
addRow("orders", { created: "2026-08-27T10:00:00.000Z", rep: "rep-2", invoiced: true, item_status: "invoiced" });
|
|||
|
|
addRow("orders", { created: "2026-08-27T11:00:00.000Z", rep: "rep-2", invoiced: false, item_status: "cancelled" });
|
|||
|
|
|
|||
|
|
const data = await getBossDashboardData({ window: "today", now: fixedNow });
|
|||
|
|
|
|||
|
|
expect(data.window).toBe("today");
|
|||
|
|
expect(data.orderTotals.total).toBe(3); // cancelled excluded
|
|||
|
|
expect(data.orderTotals.delivered).toBe(1);
|
|||
|
|
expect(data.orderTotals.invoiced).toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("groups reps with quotesLoaded/doneFor/doneBy and ordersPlaced", async () => {
|
|||
|
|
// Users
|
|||
|
|
addRow("users", { id: "rep-1", name: "Gary Ndlovu" });
|
|||
|
|
addRow("users", { id: "rep-2", name: "Shireen Pillay" });
|
|||
|
|
|
|||
|
|
// Customers with rep pointers
|
|||
|
|
addRow("customers", { id: "cust-a", rep: "rep-1", name: "Acme" });
|
|||
|
|
addRow("customers", { id: "cust-b", rep: "rep-2", name: "Beta" });
|
|||
|
|
|
|||
|
|
// Quotes: rep-1 claimed 2 (one emailed = doneBy); rep-1's customer has 3 quotes.
|
|||
|
|
addRow("quotes", { claimed_by: "rep-1", customer: "cust-a", status: "new", created: "2026-08-27T08:00:00.000Z" });
|
|||
|
|
addRow("quotes", { claimed_by: "rep-1", customer: "cust-a", status: "emailed", created: "2026-08-27T09:00:00.000Z" });
|
|||
|
|
addRow("quotes", { claimed_by: "rep-2", customer: "cust-b", status: "new", created: "2026-08-27T10:00:00.000Z" });
|
|||
|
|
|
|||
|
|
// Orders: rep-1 placed 2.
|
|||
|
|
addRow("orders", { rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T08:00:00.000Z" });
|
|||
|
|
addRow("orders", { rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T09:00:00.000Z" });
|
|||
|
|
addRow("orders", { rep: "rep-2", invoiced: false, item_status: "pending", created: "2026-08-27T10:00:00.000Z" });
|
|||
|
|
|
|||
|
|
const data = await getBossDashboardData({ window: "today", now: fixedNow });
|
|||
|
|
const byId = Object.fromEntries(data.reps.map((r) => [r.id, r]));
|
|||
|
|
|
|||
|
|
expect(byId["rep-1"].quotesLoaded).toBe(2);
|
|||
|
|
expect(byId["rep-1"].quotesDoneFor).toBe(2); // both for cust-a (rep-1's)
|
|||
|
|
expect(byId["rep-1"].quotesDoneBy).toBe(1); // only the emailed one
|
|||
|
|
expect(byId["rep-1"].ordersPlaced).toBe(2);
|
|||
|
|
|
|||
|
|
expect(byId["rep-2"].quotesLoaded).toBe(1);
|
|||
|
|
expect(byId["rep-2"].quotesDoneFor).toBe(1);
|
|||
|
|
expect(byId["rep-2"].quotesDoneBy).toBe(0);
|
|||
|
|
expect(byId["rep-2"].ordersPlaced).toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("buckets unresolved claimed_by into a single (unknown) rep", async () => {
|
|||
|
|
addRow("users", { id: "rep-1", name: "Gary Ndlovu" });
|
|||
|
|
addRow("customers", { id: "cust-a", rep: "rep-1", name: "Acme" });
|
|||
|
|
|
|||
|
|
// claimed_by is a free-text name that doesn't match any PB user id.
|
|||
|
|
addRow("quotes", { claimed_by: "Old Rep Name", customer: "cust-a", status: "new", created: "2026-08-27T08:00:00.000Z" });
|
|||
|
|
|
|||
|
|
const data = await getBossDashboardData({ window: "today", now: fixedNow });
|
|||
|
|
const unknown = data.reps.find((r) => r.isUnknown);
|
|||
|
|
|
|||
|
|
expect(unknown).toBeDefined();
|
|||
|
|
expect(unknown?.name).toBe("(unknown)");
|
|||
|
|
expect(unknown?.id).toBe("");
|
|||
|
|
expect(unknown?.quotesLoaded).toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("excludes quotes and orders outside the active window", async () => {
|
|||
|
|
addRow("users", { id: "rep-1", name: "Gary" });
|
|||
|
|
addRow("customers", { id: "cust-a", rep: "rep-1" });
|
|||
|
|
|
|||
|
|
// Yesterday's quote — outside today window.
|
|||
|
|
addRow("quotes", { claimed_by: "rep-1", customer: "cust-a", status: "emailed", created: "2026-08-26T23:59:59.000Z" });
|
|||
|
|
// Today's quote.
|
|||
|
|
addRow("quotes", { claimed_by: "rep-1", customer: "cust-a", status: "emailed", created: "2026-08-27T00:00:00.000Z" });
|
|||
|
|
|
|||
|
|
const data = await getBossDashboardData({ window: "today", now: fixedNow });
|
|||
|
|
const rep = data.reps.find((r) => r.id === "rep-1");
|
|||
|
|
|
|||
|
|
expect(rep?.quotesLoaded).toBe(1);
|
|||
|
|
expect(rep?.quotesDoneBy).toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("returns drivers[] with stopsToday/stopsDone/stopsLeft", async () => {
|
|||
|
|
// Today driver + 3 stops for them (2 done, 1 left).
|
|||
|
|
addRow("order_items", { drv_id: "drv-1", item_status: "delivered", drv_status: "delivered", drv_collected_at: "2026-08-27T07:00:00.000Z" });
|
|||
|
|
addRow("order_items", { drv_id: "drv-1", item_status: "delivered", drv_status: "delivered", drv_collected_at: "2026-08-27T08:00:00.000Z" });
|
|||
|
|
addRow("order_items", { drv_id: "drv-1", item_status: "assigned", drv_status: "assigned" });
|
|||
|
|
// Future stop — should NOT count as today.
|
|||
|
|
addRow("order_items", { drv_id: "drv-1", item_status: "assigned", drv_status: "assigned", created: "2026-09-01T07:00:00.000Z" });
|
|||
|
|
|
|||
|
|
const data = await getBossDashboardData({ window: "today", now: fixedNow });
|
|||
|
|
expect(data.drivers.length).toBe(1);
|
|||
|
|
expect(data.drivers[0].stopsToday).toBe(3);
|
|||
|
|
expect(data.drivers[0].stopsDone).toBe(2);
|
|||
|
|
expect(data.drivers[0].stopsLeft).toBe(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("returns valid shape with zero activity", async () => {
|
|||
|
|
const data = await getBossDashboardData({ window: "today", now: fixedNow });
|
|||
|
|
expect(data.window).toBe("today");
|
|||
|
|
expect(data.reps).toEqual([]);
|
|||
|
|
expect(data.drivers).toEqual([]);
|
|||
|
|
expect(data.orderTotals).toEqual({ total: 0, delivered: 0, invoiced: 0 });
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|||
|
|
|
|||
|
|
Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts`
|
|||
|
|
Expected: FAIL — `getBossDashboardData` is not exported.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Implement `getBossDashboardData`**
|
|||
|
|
|
|||
|
|
Append to `src/lib/server/boss-dashboard-service.ts`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { getAdminPocketBase } from "./pocketbase-admin";
|
|||
|
|
|
|||
|
|
export interface BossRepRow {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
isUnknown: boolean;
|
|||
|
|
quotesLoaded: number;
|
|||
|
|
quotesDoneFor: number;
|
|||
|
|
quotesDoneBy: number;
|
|||
|
|
ordersPlaced: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface BossDriverRow {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
stopsToday: number;
|
|||
|
|
stopsDone: number;
|
|||
|
|
stopsLeft: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface BossDashboardData {
|
|||
|
|
window: BossWindow;
|
|||
|
|
rangeStart: string;
|
|||
|
|
rangeEnd: string;
|
|||
|
|
orderTotals: { total: number; delivered: number; invoiced: number };
|
|||
|
|
reps: BossRepRow[];
|
|||
|
|
drivers: BossDriverRow[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const DONE_STATUSES = ["emailed", "approved", "sent"];
|
|||
|
|
const ACTIVE_ORDER_STATUSES = [
|
|||
|
|
"pending", "po_generated", "assigned", "collection_pool",
|
|||
|
|
"collected", "delivered", "invoiced",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const POCKETBASE_RECORD_ID = /^[a-z0-9]{15}$/i;
|
|||
|
|
|
|||
|
|
export async function getBossDashboardData({
|
|||
|
|
window,
|
|||
|
|
now = new Date(),
|
|||
|
|
}: {
|
|||
|
|
window: BossWindow;
|
|||
|
|
now?: Date;
|
|||
|
|
}): Promise<BossDashboardData> {
|
|||
|
|
const { rangeStart, rangeEnd } = windowRange(window, now);
|
|||
|
|
const pb = await getAdminPocketBase();
|
|||
|
|
|
|||
|
|
// 1. Load users + customers in parallel — needed for rep joins.
|
|||
|
|
const [users, customers, orders, quotes, items] = await Promise.all([
|
|||
|
|
pb.collection("users").getFullList({ fields: "id,name" }),
|
|||
|
|
pb.collection("customers").getFullList({ fields: "id,rep" }),
|
|||
|
|
pb.collection("orders").getFullList({
|
|||
|
|
filter: `created >= "${rangeStart}" && created < "${rangeEnd}"`,
|
|||
|
|
fields: "id,rep,invoiced,item_status,created",
|
|||
|
|
}),
|
|||
|
|
pb.collection("quotes").getFullList({
|
|||
|
|
filter: `created >= "${rangeStart}" && created < "${rangeEnd}"`,
|
|||
|
|
fields: "id,claimed_by,customer,status,created",
|
|||
|
|
}),
|
|||
|
|
pb.collection("order_items").getFullList({
|
|||
|
|
filter: `created >= "${rangeStart}" && created < "${rangeEnd}"`,
|
|||
|
|
fields: "id,drv_id,item_status,drv_status,drv_collected_at,created",
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const userById = new Map<string, { id: string; name: string }>();
|
|||
|
|
for (const u of users) userById.set(u.id, { id: u.id, name: String((u as Record<string, unknown>).name ?? "") });
|
|||
|
|
|
|||
|
|
const customerRepById = new Map<string, string>();
|
|||
|
|
for (const c of customers) customerRepById.set(c.id, String((c as Record<string, unknown>).rep ?? ""));
|
|||
|
|
|
|||
|
|
// 2. Order totals.
|
|||
|
|
let total = 0, delivered = 0, invoiced = 0;
|
|||
|
|
const repOrders = new Map<string, number>();
|
|||
|
|
for (const o of orders) {
|
|||
|
|
const itemStatus = String((o as Record<string, unknown>).item_status ?? "");
|
|||
|
|
const isCancelled = itemStatus === "cancelled";
|
|||
|
|
if (!isCancelled) total += 1;
|
|||
|
|
if (itemStatus === "delivered") delivered += 1;
|
|||
|
|
if ((o as Record<string, unknown>).invoiced === true) invoiced += 1;
|
|||
|
|
const repId = resolveOrderRepId(o as Record<string, unknown>, userById);
|
|||
|
|
if (repId) repOrders.set(repId, (repOrders.get(repId) ?? 0) + (isCancelled ? 0 : 1));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Quote stats per rep.
|
|||
|
|
// - quotesLoaded: claimed_by resolves to rep
|
|||
|
|
// - quotesDoneFor: customer's rep = rep
|
|||
|
|
// - quotesDoneBy: claimed_by resolves to rep AND status in DONE_STATUSES
|
|||
|
|
const repRows = new Map<string, BossRepRow>();
|
|||
|
|
const ensureRep = (id: string, name: string): BossRepRow => {
|
|||
|
|
let row = repRows.get(id);
|
|||
|
|
if (!row) {
|
|||
|
|
row = { id, name, isUnknown: id === "", quotesLoaded: 0, quotesDoneFor: 0, quotesDoneBy: 0, ordersPlaced: 0 };
|
|||
|
|
repRows.set(id, row);
|
|||
|
|
}
|
|||
|
|
return row;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Seed every known rep so the UI always has a row, even if zero activity.
|
|||
|
|
for (const u of users) ensureRep(u.id, String((u as Record<string, unknown>).name ?? ""));
|
|||
|
|
|
|||
|
|
for (const q of quotes) {
|
|||
|
|
const rec = q as Record<string, unknown>;
|
|||
|
|
const status = String(rec.status ?? "");
|
|||
|
|
const customerRepId = customerRepById.get(String(rec.customer ?? "")) ?? "";
|
|||
|
|
const claimedBy = String(rec.claimed_by ?? "");
|
|||
|
|
|
|||
|
|
// doneFor: customer's rep
|
|||
|
|
if (customerRepId && userById.has(customerRepId)) {
|
|||
|
|
const u = userById.get(customerRepId)!;
|
|||
|
|
ensureRep(u.id, u.name).quotesDoneFor += 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// loaded + doneBy: claimed_by resolves to a user id
|
|||
|
|
if (POCKETBASE_RECORD_ID.test(claimedBy) && userById.has(claimedBy)) {
|
|||
|
|
const u = userById.get(claimedBy)!;
|
|||
|
|
const row = ensureRep(u.id, u.name);
|
|||
|
|
row.quotesLoaded += 1;
|
|||
|
|
if (DONE_STATUSES.includes(status)) row.quotesDoneBy += 1;
|
|||
|
|
} else if (claimedBy.trim() !== "") {
|
|||
|
|
// Unresolved claimed_by — bucket into (unknown)
|
|||
|
|
const row = ensureRep("", "(unknown)");
|
|||
|
|
row.quotesLoaded += 1;
|
|||
|
|
if (DONE_STATUSES.includes(status)) row.quotesDoneBy += 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Merge order counts into rep rows.
|
|||
|
|
for (const [repId, count] of repOrders) {
|
|||
|
|
const u = userById.get(repId);
|
|||
|
|
if (!u) continue; // skip orders whose rep can't be resolved
|
|||
|
|
const row = ensureRep(u.id, u.name);
|
|||
|
|
row.ordersPlaced = count;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Sort: ordersPlaced DESC, quotesLoaded DESC, name ASC.
|
|||
|
|
const reps = Array.from(repRows.values())
|
|||
|
|
.filter((r) => !r.isUnknown) // unknown goes last
|
|||
|
|
.sort((a, b) =>
|
|||
|
|
b.ordersPlaced - a.ordersPlaced ||
|
|||
|
|
b.quotesLoaded - a.quotesLoaded ||
|
|||
|
|
a.name.localeCompare(b.name),
|
|||
|
|
);
|
|||
|
|
const unknownRow = repRows.get("");
|
|||
|
|
if (unknownRow && (unknownRow.quotesLoaded > 0 || unknownRow.quotesDoneBy > 0)) {
|
|||
|
|
reps.push(unknownRow);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. Driver stops. Always "today" — drivers don't have a week/month tab.
|
|||
|
|
const driverStops = new Map<string, { id: string; stopsToday: number; stopsDone: number }>();
|
|||
|
|
for (const item of items) {
|
|||
|
|
const rec = item as Record<string, unknown>;
|
|||
|
|
const drvId = String(rec.drv_id ?? "");
|
|||
|
|
if (!drvId) continue;
|
|||
|
|
const drvStatus = String(rec.drv_status ?? "");
|
|||
|
|
const collected = drvStatus === "collected" || drvStatus === "delivered" || drvStatus === "invoiced";
|
|||
|
|
const entry = driverStops.get(drvId) ?? { id: drvId, stopsToday: 0, stopsDone: 0 };
|
|||
|
|
entry.stopsToday += 1;
|
|||
|
|
if (collected) entry.stopsDone += 1;
|
|||
|
|
driverStops.set(drvId, entry);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Resolve driver names from users (drivers are users in `users` collection).
|
|||
|
|
const drivers: BossDriverRow[] = Array.from(driverStops.values()).map((d) => {
|
|||
|
|
const u = userById.get(d.id);
|
|||
|
|
return {
|
|||
|
|
id: d.id,
|
|||
|
|
name: u?.name ?? "(unnamed driver)",
|
|||
|
|
stopsToday: d.stopsToday,
|
|||
|
|
stopsDone: d.stopsDone,
|
|||
|
|
stopsLeft: Math.max(0, d.stopsToday - d.stopsDone),
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
drivers.sort((a, b) => b.stopsLeft - a.stopsLeft || a.name.localeCompare(b.name));
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
window,
|
|||
|
|
rangeStart,
|
|||
|
|
rangeEnd,
|
|||
|
|
orderTotals: { total, delivered, invoiced },
|
|||
|
|
reps,
|
|||
|
|
drivers,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveOrderRepId(
|
|||
|
|
order: Record<string, unknown>,
|
|||
|
|
userById: Map<string, { id: string; name: string }>,
|
|||
|
|
): string {
|
|||
|
|
const rep = String(order.rep ?? "").trim();
|
|||
|
|
if (!rep) return "";
|
|||
|
|
// If the field is already a PB user id, use it directly.
|
|||
|
|
if (POCKETBASE_RECORD_ID.test(rep) && userById.has(rep)) return rep;
|
|||
|
|
// Otherwise try matching the free-text name against users.
|
|||
|
|
for (const u of userById.values()) {
|
|||
|
|
if (u.name.trim().toLowerCase() === rep.toLowerCase()) return u.id;
|
|||
|
|
}
|
|||
|
|
return "";
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
|||
|
|
|
|||
|
|
Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts`
|
|||
|
|
Expected: PASS — 6 tests green.
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/lib/server/boss-dashboard-service.ts src/lib/server/boss-dashboard-service.test.ts
|
|||
|
|
git commit -m "feat(boss-dashboard): aggregate per-rep stats and driver stops per window"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 3: `getBossRepActivity` pagination
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/lib/server/boss-dashboard-service.ts`
|
|||
|
|
- Modify: `src/lib/server/boss-dashboard-service.test.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the failing tests**
|
|||
|
|
|
|||
|
|
Append to the test file:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { getBossRepActivity } from "./boss-dashboard-service";
|
|||
|
|
|
|||
|
|
describe("getBossRepActivity", () => {
|
|||
|
|
it("returns paginated quotes for a rep, newest first", async () => {
|
|||
|
|
addRow("users", { id: "rep-1", name: "Gary" });
|
|||
|
|
addRow("customers", { id: "cust-a", rep: "rep-1", name: "Acme" });
|
|||
|
|
for (let i = 1; i <= 30; i++) {
|
|||
|
|
addRow("quotes", {
|
|||
|
|
quote_number: `Q-${i}`,
|
|||
|
|
claimed_by: "rep-1",
|
|||
|
|
customer: "cust-a",
|
|||
|
|
status: "emailed",
|
|||
|
|
created: new Date(Date.UTC(2026, 7, 27, 10, i)).toISOString(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const page1 = await getBossRepActivity({
|
|||
|
|
rep: "rep-1",
|
|||
|
|
window: "today",
|
|||
|
|
kind: "quotes",
|
|||
|
|
page: 1,
|
|||
|
|
now: fixedNow,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(page1.items.length).toBe(25);
|
|||
|
|
expect(page1.hasMore).toBe(true);
|
|||
|
|
expect(page1.items[0].number).toBe("Q-30"); // newest first
|
|||
|
|
|
|||
|
|
const page2 = await getBossRepActivity({
|
|||
|
|
rep: "rep-1",
|
|||
|
|
window: "today",
|
|||
|
|
kind: "quotes",
|
|||
|
|
page: 2,
|
|||
|
|
now: fixedNow,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(page2.items.length).toBe(5);
|
|||
|
|
expect(page2.hasMore).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("returns paginated orders for a rep, newest first", async () => {
|
|||
|
|
addRow("users", { id: "rep-1", name: "Gary" });
|
|||
|
|
addRow("orders", { order_number: "O-1", rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T08:00:00.000Z" });
|
|||
|
|
addRow("orders", { order_number: "O-2", rep: "rep-1", invoiced: false, item_status: "pending", created: "2026-08-27T09:00:00.000Z" });
|
|||
|
|
addRow("orders", { order_number: "O-3", rep: "rep-2", invoiced: false, item_status: "pending", created: "2026-08-27T10:00:00.000Z" });
|
|||
|
|
|
|||
|
|
const data = await getBossRepActivity({
|
|||
|
|
rep: "rep-1",
|
|||
|
|
window: "today",
|
|||
|
|
kind: "orders",
|
|||
|
|
page: 1,
|
|||
|
|
now: fixedNow,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(data.items.length).toBe(2);
|
|||
|
|
expect(data.items[0].number).toBe("O-2"); // newest first
|
|||
|
|
expect(data.items[1].number).toBe("O-1");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("returns empty items when no matches", async () => {
|
|||
|
|
const data = await getBossRepActivity({
|
|||
|
|
rep: "rep-1",
|
|||
|
|
window: "today",
|
|||
|
|
kind: "quotes",
|
|||
|
|
page: 1,
|
|||
|
|
now: fixedNow,
|
|||
|
|
});
|
|||
|
|
expect(data.items).toEqual([]);
|
|||
|
|
expect(data.hasMore).toBe(false);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|||
|
|
|
|||
|
|
Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts`
|
|||
|
|
Expected: FAIL — `getBossRepActivity` is not exported.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Implement `getBossRepActivity`**
|
|||
|
|
|
|||
|
|
Append to `src/lib/server/boss-dashboard-service.ts`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
export type RepActivityKind = "quotes" | "orders";
|
|||
|
|
|
|||
|
|
export interface RepActivityItem {
|
|||
|
|
id: string;
|
|||
|
|
number: string;
|
|||
|
|
customer: string;
|
|||
|
|
status: string;
|
|||
|
|
ageMs: number; // relative to `now`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface RepActivityPage {
|
|||
|
|
items: RepActivityItem[];
|
|||
|
|
hasMore: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PAGE_SIZE = 25;
|
|||
|
|
|
|||
|
|
export async function getBossRepActivity({
|
|||
|
|
rep,
|
|||
|
|
window,
|
|||
|
|
kind,
|
|||
|
|
page = 1,
|
|||
|
|
now = new Date(),
|
|||
|
|
}: {
|
|||
|
|
rep: string;
|
|||
|
|
window: BossWindow;
|
|||
|
|
kind: RepActivityKind;
|
|||
|
|
page?: number;
|
|||
|
|
now?: Date;
|
|||
|
|
}): Promise<RepActivityPage> {
|
|||
|
|
const { rangeStart, rangeEnd } = windowRange(window, now);
|
|||
|
|
const pb = await getAdminPocketBase();
|
|||
|
|
|
|||
|
|
if (kind === "quotes") {
|
|||
|
|
// Quotes owned by this rep = claimed_by = rep.id
|
|||
|
|
const result = await pb.collection("quotes").getList({
|
|||
|
|
filter: `claimed_by = "${rep}" && created >= "${rangeStart}" && created < "${rangeEnd}"`,
|
|||
|
|
sort: "-created",
|
|||
|
|
perPage: PAGE_SIZE,
|
|||
|
|
page,
|
|||
|
|
fields: "id,quote_number,customer,status,created",
|
|||
|
|
expand: "customer",
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const items: RepActivityItem[] = (result.items as Array<Record<string, unknown>>).map((q) => {
|
|||
|
|
const expanded = q.expand as { customer?: { customer?: string } } | undefined;
|
|||
|
|
const customerName = expanded?.customer?.customer ?? "";
|
|||
|
|
return {
|
|||
|
|
id: String(q.id),
|
|||
|
|
number: String(q.quote_number ?? ""),
|
|||
|
|
customer: customerName,
|
|||
|
|
status: String(q.status ?? ""),
|
|||
|
|
ageMs: now.getTime() - new Date(String(q.created)).getTime(),
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return { items, hasMore: items.length === PAGE_SIZE };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// kind === "orders"
|
|||
|
|
const result = await pb.collection("orders").getList({
|
|||
|
|
filter: `rep = "${rep}" && created >= "${rangeStart}" && created < "${rangeEnd}"`,
|
|||
|
|
sort: "-created",
|
|||
|
|
perPage: PAGE_SIZE,
|
|||
|
|
page,
|
|||
|
|
fields: "id,order_number,customer,item_status,created",
|
|||
|
|
expand: "customer",
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const items: RepActivityItem[] = (result.items as Array<Record<string, unknown>>).map((o) => {
|
|||
|
|
const expanded = o.expand as { customer?: { customer?: string } } | undefined;
|
|||
|
|
const customerName = expanded?.customer?.customer ?? "";
|
|||
|
|
return {
|
|||
|
|
id: String(o.id),
|
|||
|
|
number: String(o.order_number ?? ""),
|
|||
|
|
customer: customerName,
|
|||
|
|
status: String(o.item_status ?? ""),
|
|||
|
|
ageMs: now.getTime() - new Date(String(o.created)).getTime(),
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return { items, hasMore: items.length === PAGE_SIZE };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
|||
|
|
|
|||
|
|
Run: `pnpm vitest run src/lib/server/boss-dashboard-service.test.ts`
|
|||
|
|
Expected: PASS — 9 tests green.
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/lib/server/boss-dashboard-service.ts src/lib/server/boss-dashboard-service.test.ts
|
|||
|
|
git commit -m "feat(boss-dashboard): paginated rep activity feed for drill-down sheet"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 2 — API routes
|
|||
|
|
|
|||
|
|
### Task 4: `/api/v1/boss-dashboard` route handler
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `src/app/api/v1/boss-dashboard/route.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Implement the route handler**
|
|||
|
|
|
|||
|
|
Write `src/app/api/v1/boss-dashboard/route.ts`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { NextRequest, NextResponse } from "next/server";
|
|||
|
|
import { getBossDashboardData, type BossWindow } from "@/lib/server/boss-dashboard-service";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* GET /api/v1/boss-dashboard?window=today|week|month
|
|||
|
|
*
|
|||
|
|
* Read model for the boss's phone dashboard at `/m`. Returns a single
|
|||
|
|
* window's worth of aggregates (~3-5 KB). Auth via the PB browser
|
|||
|
|
* session — same pattern as /api/v1/dashboard.
|
|||
|
|
*/
|
|||
|
|
export const runtime = "nodejs";
|
|||
|
|
export const dynamic = "force-dynamic";
|
|||
|
|
|
|||
|
|
const VALID_WINDOWS: BossWindow[] = ["today", "week", "month"];
|
|||
|
|
|
|||
|
|
async function assertBrowserSession(req: NextRequest) {
|
|||
|
|
const auth = req.headers.get("authorization");
|
|||
|
|
if (!auth) {
|
|||
|
|
throw Object.assign(new Error("Not authenticated."), { status: 401 });
|
|||
|
|
}
|
|||
|
|
const pbUrl =
|
|||
|
|
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
|
|||
|
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth;
|
|||
|
|
const res = await fetch(
|
|||
|
|
`${pbUrl.replace(/\/$/, "")}/api/collections/users/auth-refresh`,
|
|||
|
|
{
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { Authorization: token },
|
|||
|
|
signal: AbortSignal.timeout(5000),
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
throw Object.assign(new Error("Invalid or expired session."), {
|
|||
|
|
status: 401,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return res.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function GET(req: NextRequest) {
|
|||
|
|
try {
|
|||
|
|
const user = await assertBrowserSession(req);
|
|||
|
|
|
|||
|
|
const url = new URL(req.url);
|
|||
|
|
const raw = url.searchParams.get("window") ?? "today";
|
|||
|
|
if (!VALID_WINDOWS.includes(raw as BossWindow)) {
|
|||
|
|
return NextResponse.json(
|
|||
|
|
{ error: `Invalid window: ${raw}` },
|
|||
|
|
{ status: 400 },
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const data = await getBossDashboardData({ window: raw as BossWindow });
|
|||
|
|
return NextResponse.json({ ...data, viewerName: user?.record?.name ?? null });
|
|||
|
|
} catch (error) {
|
|||
|
|
const message =
|
|||
|
|
error instanceof Error ? error.message : "Failed to load dashboard";
|
|||
|
|
const status = (error as Error & { status?: number }).status || 500;
|
|||
|
|
return NextResponse.json({ error: message }, { status });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Type-check**
|
|||
|
|
|
|||
|
|
Run: `pnpm tsc --noEmit`
|
|||
|
|
Expected: zero new errors.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/api/v1/boss-dashboard/route.ts
|
|||
|
|
git commit -m "feat(boss-dashboard): add /api/v1/boss-dashboard route handler"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 5: `/api/v1/boss-dashboard/rep-activity` route handler
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `src/app/api/v1/boss-dashboard/rep-activity/route.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Implement the route handler**
|
|||
|
|
|
|||
|
|
Write `src/app/api/v1/boss-dashboard/rep-activity/route.ts`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { NextRequest, NextResponse } from "next/server";
|
|||
|
|
import {
|
|||
|
|
getBossRepActivity,
|
|||
|
|
type BossWindow,
|
|||
|
|
type RepActivityKind,
|
|||
|
|
} from "@/lib/server/boss-dashboard-service";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* GET /api/v1/boss-dashboard/rep-activity?rep=<id>&window=<w>&kind=quotes|orders&page=<n>
|
|||
|
|
*
|
|||
|
|
* Paginated list of records behind a rep's per-window counts. Drives the
|
|||
|
|
* bottom sheet that opens when the boss taps a rep row on /m.
|
|||
|
|
*/
|
|||
|
|
export const runtime = "nodejs";
|
|||
|
|
export const dynamic = "force-dynamic";
|
|||
|
|
|
|||
|
|
const VALID_WINDOWS: BossWindow[] = ["today", "week", "month"];
|
|||
|
|
const VALID_KINDS: RepActivityKind[] = ["quotes", "orders"];
|
|||
|
|
|
|||
|
|
async function assertBrowserSession(req: NextRequest) {
|
|||
|
|
const auth = req.headers.get("authorization");
|
|||
|
|
if (!auth) {
|
|||
|
|
throw Object.assign(new Error("Not authenticated."), { status: 401 });
|
|||
|
|
}
|
|||
|
|
const pbUrl =
|
|||
|
|
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
|
|||
|
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth;
|
|||
|
|
const res = await fetch(
|
|||
|
|
`${pbUrl.replace(/\/$/, "")}/api/collections/users/auth-refresh`,
|
|||
|
|
{
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { Authorization: token },
|
|||
|
|
signal: AbortSignal.timeout(5000),
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
if (!res.ok) {
|
|||
|
|
throw Object.assign(new Error("Invalid or expired session."), {
|
|||
|
|
status: 401,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return res.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function GET(req: NextRequest) {
|
|||
|
|
try {
|
|||
|
|
await assertBrowserSession(req);
|
|||
|
|
|
|||
|
|
const url = new URL(req.url);
|
|||
|
|
const rep = url.searchParams.get("rep") ?? "";
|
|||
|
|
const window = url.searchParams.get("window") ?? "today";
|
|||
|
|
const kind = url.searchParams.get("kind") ?? "quotes";
|
|||
|
|
const pageParam = url.searchParams.get("page") ?? "1";
|
|||
|
|
|
|||
|
|
if (!rep) {
|
|||
|
|
return NextResponse.json({ error: "Missing rep" }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
if (!VALID_WINDOWS.includes(window as BossWindow)) {
|
|||
|
|
return NextResponse.json({ error: `Invalid window` }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
if (!VALID_KINDS.includes(kind as RepActivityKind)) {
|
|||
|
|
return NextResponse.json({ error: `Invalid kind` }, { status: 400 });
|
|||
|
|
}
|
|||
|
|
const page = Math.max(1, parseInt(pageParam, 10) || 1);
|
|||
|
|
|
|||
|
|
const data = await getBossRepActivity({
|
|||
|
|
rep,
|
|||
|
|
window: window as BossWindow,
|
|||
|
|
kind: kind as RepActivityKind,
|
|||
|
|
page,
|
|||
|
|
});
|
|||
|
|
return NextResponse.json(data);
|
|||
|
|
} catch (error) {
|
|||
|
|
const message =
|
|||
|
|
error instanceof Error ? error.message : "Failed to load rep activity";
|
|||
|
|
const status = (error as Error & { status?: number }).status || 500;
|
|||
|
|
return NextResponse.json({ error: message }, { status });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Type-check**
|
|||
|
|
|
|||
|
|
Run: `pnpm tsc --noEmit`
|
|||
|
|
Expected: zero new errors.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/api/v1/boss-dashboard/rep-activity/route.ts
|
|||
|
|
git commit -m "feat(boss-dashboard): add /api/v1/boss-dashboard/rep-activity route handler"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 6: Route tests
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `src/app/api/v1/boss-dashboard/route.test.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the failing tests**
|
|||
|
|
|
|||
|
|
Write `src/app/api/v1/boss-dashboard/route.test.ts`:
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
|
|
import { NextRequest } from "next/server";
|
|||
|
|
|
|||
|
|
const { serviceMock } = vi.hoisted(() => ({
|
|||
|
|
serviceMock: {
|
|||
|
|
getBossDashboardData: vi.fn(),
|
|||
|
|
getBossRepActivity: vi.fn(),
|
|||
|
|
},
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
vi.mock("@/lib/server/boss-dashboard-service", () => serviceMock);
|
|||
|
|
|
|||
|
|
const validToken = "Bearer pb-token";
|
|||
|
|
const authReq = (url: string) =>
|
|||
|
|
new NextRequest(url, { headers: { authorization: validToken } });
|
|||
|
|
|
|||
|
|
beforeEach(() => {
|
|||
|
|
vi.clearAllMocks();
|
|||
|
|
// Stub the auth-refresh call that assertBrowserSession makes.
|
|||
|
|
global.fetch = vi.fn(async () =>
|
|||
|
|
new Response(JSON.stringify({ record: { id: "u1", name: "Dave" } }), {
|
|||
|
|
status: 200,
|
|||
|
|
headers: { "content-type": "application/json" },
|
|||
|
|
}),
|
|||
|
|
) as unknown as typeof fetch;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("GET /api/v1/boss-dashboard", () => {
|
|||
|
|
it("401 when no Authorization header", async () => {
|
|||
|
|
const req = new NextRequest("http://localhost/api/v1/boss-dashboard");
|
|||
|
|
const { GET } = await import("./route");
|
|||
|
|
const res = await GET(req);
|
|||
|
|
expect(res.status).toBe(401);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("400 on invalid window", async () => {
|
|||
|
|
const { GET } = await import("./route");
|
|||
|
|
const res = await GET(authReq("http://localhost/api/v1/boss-dashboard?window=bogus"));
|
|||
|
|
expect(res.status).toBe(400);
|
|||
|
|
const body = await res.json();
|
|||
|
|
expect(body.error).toContain("Invalid window");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("200 with payload on default window=today", async () => {
|
|||
|
|
serviceMock.getBossDashboardData.mockResolvedValueOnce({
|
|||
|
|
window: "today",
|
|||
|
|
rangeStart: "2026-08-27T00:00:00.000Z",
|
|||
|
|
rangeEnd: "2026-08-28T00:00:00.000Z",
|
|||
|
|
orderTotals: { total: 0, delivered: 0, invoiced: 0 },
|
|||
|
|
reps: [],
|
|||
|
|
drivers: [],
|
|||
|
|
});
|
|||
|
|
const { GET } = await import("./route");
|
|||
|
|
const res = await GET(authReq("http://localhost/api/v1/boss-dashboard"));
|
|||
|
|
expect(res.status).toBe(200);
|
|||
|
|
const body = await res.json();
|
|||
|
|
expect(body.window).toBe("today");
|
|||
|
|
expect(body.viewerName).toBe("Dave");
|
|||
|
|
expect(serviceMock.getBossDashboardData).toHaveBeenCalledWith({ window: "today" });
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("GET /api/v1/boss-dashboard/rep-activity", () => {
|
|||
|
|
it("400 when rep is missing", async () => {
|
|||
|
|
const { GET } = await import("./rep-activity/route");
|
|||
|
|
const res = await GET(authReq("http://localhost/api/v1/boss-dashboard/rep-activity?window=today"));
|
|||
|
|
expect(res.status).toBe(400);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("200 with paginated payload", async () => {
|
|||
|
|
serviceMock.getBossRepActivity.mockResolvedValueOnce({
|
|||
|
|
items: [
|
|||
|
|
{ id: "q1", number: "Q-001", customer: "Acme", status: "emailed", ageMs: 0 },
|
|||
|
|
],
|
|||
|
|
hasMore: false,
|
|||
|
|
});
|
|||
|
|
const { GET } = await import("./rep-activity/route");
|
|||
|
|
const res = await GET(
|
|||
|
|
authReq("http://localhost/api/v1/boss-dashboard/rep-activity?rep=rep-1&window=today&kind=quotes&page=1"),
|
|||
|
|
);
|
|||
|
|
expect(res.status).toBe(200);
|
|||
|
|
const body = await res.json();
|
|||
|
|
expect(body.items.length).toBe(1);
|
|||
|
|
expect(serviceMock.getBossRepActivity).toHaveBeenCalledWith({
|
|||
|
|
rep: "rep-1",
|
|||
|
|
window: "today",
|
|||
|
|
kind: "quotes",
|
|||
|
|
page: 1,
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run the test to verify it passes**
|
|||
|
|
|
|||
|
|
Run: `pnpm vitest run src/app/api/v1/boss-dashboard/route.test.ts`
|
|||
|
|
Expected: PASS — 5 tests green.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/api/v1/boss-dashboard/route.test.ts
|
|||
|
|
git commit -m "test(boss-dashboard): auth gate, query parsing, payload shape"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 3 — PWA manifest + layout shell
|
|||
|
|
|
|||
|
|
### Task 7: Mobile manifest
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `public/manifest-mobile.json`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the manifest**
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"name": "WFDASH — Boss",
|
|||
|
|
"short_name": "Boss",
|
|||
|
|
"description": "Phone dashboard for the boss.",
|
|||
|
|
"start_url": "/m",
|
|||
|
|
"display": "standalone",
|
|||
|
|
"background_color": "#0b1020",
|
|||
|
|
"theme_color": "#0b1020",
|
|||
|
|
"orientation": "portrait",
|
|||
|
|
"icons": [
|
|||
|
|
{
|
|||
|
|
"src": "/icon-mobile-192.png",
|
|||
|
|
"sizes": "192x192",
|
|||
|
|
"type": "image/png"
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"src": "/icon-mobile-512.png",
|
|||
|
|
"sizes": "512x512",
|
|||
|
|
"type": "image/png"
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Note: The icon files don't exist yet — Task 8 generates placeholders or skips the icons entirely until the operator supplies real ones. The manifest references them so adding icons later requires no schema change.
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add public/manifest-mobile.json
|
|||
|
|
git commit -m "feat(boss-dashboard): add mobile PWA manifest"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 8: `(mobile)/m/layout.tsx`
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `src/app/(mobile)/m/layout.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Implement the layout**
|
|||
|
|
|
|||
|
|
Write `src/app/(mobile)/m/layout.tsx`:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import type { Metadata, Viewport } from "next";
|
|||
|
|
|
|||
|
|
export const metadata: Metadata = {
|
|||
|
|
title: "WFDASH — Boss",
|
|||
|
|
description: "Phone dashboard for the boss.",
|
|||
|
|
manifest: "/manifest-mobile.json",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const viewport: Viewport = {
|
|||
|
|
width: "device-width",
|
|||
|
|
initialScale: 1,
|
|||
|
|
viewportFit: "cover",
|
|||
|
|
themeColor: "#0b1020",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default function MobileLayout({
|
|||
|
|
children,
|
|||
|
|
}: {
|
|||
|
|
children: React.ReactNode;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<main className="min-h-screen bg-background text-foreground antialiased">
|
|||
|
|
{children}
|
|||
|
|
</main>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Type-check**
|
|||
|
|
|
|||
|
|
Run: `pnpm tsc --noEmit`
|
|||
|
|
Expected: zero new errors.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/(mobile)/m/layout.tsx
|
|||
|
|
git commit -m "feat(boss-dashboard): add (mobile)/m phone-first layout shell"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 4 — Page chrome (header, tabs, order totals)
|
|||
|
|
|
|||
|
|
### Task 9: `page.tsx` and `dashboard-view.tsx` skeleton
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `src/app/(mobile)/m/page.tsx`
|
|||
|
|
- Create: `src/app/(mobile)/m/dashboard-view.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write `page.tsx`**
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import BossDashboardView from "./dashboard-view";
|
|||
|
|
|
|||
|
|
export default function BossDashboardPage() {
|
|||
|
|
return <BossDashboardView />;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Write the dashboard-view skeleton**
|
|||
|
|
|
|||
|
|
Write `src/app/(mobile)/m/dashboard-view.tsx`:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|||
|
|
import { toast } from "sonner";
|
|||
|
|
import { RefreshCw } from "lucide-react";
|
|||
|
|
import getPocketBase from "@/lib/pocketbase";
|
|||
|
|
import type {
|
|||
|
|
BossDashboardData,
|
|||
|
|
BossWindow,
|
|||
|
|
} from "@/lib/server/boss-dashboard-service";
|
|||
|
|
|
|||
|
|
const REALTIME_DEBOUNCE_MS = 1_000;
|
|||
|
|
|
|||
|
|
export default function BossDashboardView() {
|
|||
|
|
const [data, setData] = useState<BossDashboardData | null>(null);
|
|||
|
|
const [viewerName, setViewerName] = useState<string | null>(null);
|
|||
|
|
const [window, setWindow] = useState<BossWindow>("today");
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [lastRefreshed, setLastRefreshed] = useState<Date | null>(null);
|
|||
|
|
|
|||
|
|
const fetchData = useCallback(async () => {
|
|||
|
|
try {
|
|||
|
|
const pb = getPocketBase();
|
|||
|
|
const pbToken = pb.authStore?.token;
|
|||
|
|
const headers: Record<string, string> = {};
|
|||
|
|
if (pbToken) headers.Authorization = pbToken;
|
|||
|
|
|
|||
|
|
const res = await fetch(`/api/v1/boss-dashboard?window=${window}`, {
|
|||
|
|
headers,
|
|||
|
|
cache: "no-store",
|
|||
|
|
});
|
|||
|
|
if (res.status === 401) {
|
|||
|
|
window.location.href = "/login";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|||
|
|
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|||
|
|
}
|
|||
|
|
const next = (await res.json()) as BossDashboardData & { viewerName?: string };
|
|||
|
|
setData({
|
|||
|
|
window: next.window,
|
|||
|
|
rangeStart: next.rangeStart,
|
|||
|
|
rangeEnd: next.rangeEnd,
|
|||
|
|
orderTotals: next.orderTotals,
|
|||
|
|
reps: next.reps,
|
|||
|
|
drivers: next.drivers,
|
|||
|
|
});
|
|||
|
|
setViewerName(next.viewerName ?? null);
|
|||
|
|
setLastRefreshed(new Date());
|
|||
|
|
} catch (err) {
|
|||
|
|
const message = err instanceof Error ? err.message : "Failed to load";
|
|||
|
|
toast.error(message);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [window]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
void fetchData();
|
|||
|
|
}, [fetchData]);
|
|||
|
|
|
|||
|
|
// Realtime subscription (added in Task 14).
|
|||
|
|
|
|||
|
|
const firstName = (viewerName ?? "").split(" ")[0] || "there";
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="mx-auto max-w-md px-4 py-4 space-y-4">
|
|||
|
|
{/* Header strip */}
|
|||
|
|
<header className="flex items-center justify-between gap-3">
|
|||
|
|
<div>
|
|||
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|||
|
|
Good {greeting()}, <span className="text-cyan-400">{firstName}</span>.
|
|||
|
|
</h1>
|
|||
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|||
|
|
{lastRefreshed
|
|||
|
|
? `Updated ${timeAgo(lastRefreshed)}`
|
|||
|
|
: "Loading…"}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => void fetchData()}
|
|||
|
|
disabled={loading}
|
|||
|
|
aria-label="Refresh"
|
|||
|
|
className="grid h-9 w-9 place-items-center rounded-full border border-cyan-500/30 text-cyan-400 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|||
|
|
</button>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
{/* Placeholder for tabs (Task 10) */}
|
|||
|
|
<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">
|
|||
|
|
Tabs land here in Task 10.
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Placeholder for order totals (Task 11) */}
|
|||
|
|
<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">
|
|||
|
|
Order totals land here in Task 11.
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Placeholder for reps (Task 12) */}
|
|||
|
|
<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">
|
|||
|
|
Reps list lands here in Task 12.
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Placeholder for drivers (Task 13) */}
|
|||
|
|
<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">
|
|||
|
|
Driver stops land here in Task 13.
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function greeting(): string {
|
|||
|
|
const h = new Date().getHours();
|
|||
|
|
if (h < 12) return "morning";
|
|||
|
|
if (h < 17) return "afternoon";
|
|||
|
|
return "evening";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function timeAgo(date: Date): string {
|
|||
|
|
const diff = Math.max(0, Date.now() - date.getTime());
|
|||
|
|
const sec = Math.floor(diff / 1000);
|
|||
|
|
if (sec < 5) return "just now";
|
|||
|
|
if (sec < 60) return `${sec}s ago`;
|
|||
|
|
const min = Math.floor(sec / 60);
|
|||
|
|
if (min < 60) return `${min}m ago`;
|
|||
|
|
const hr = Math.floor(min / 60);
|
|||
|
|
if (hr < 24) return `${hr}h ago`;
|
|||
|
|
return date.toLocaleDateString("en-ZA", { day: "numeric", month: "short" });
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Verify the page mounts**
|
|||
|
|
|
|||
|
|
Run: `pnpm dev` (background) then `curl -s http://localhost:4001/m | head -c 200`
|
|||
|
|
Expected: HTML response (no SSR data, since this is "use client"). Visit in a browser to confirm the "Good ..." greeting renders after the fetch resolves.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Type-check**
|
|||
|
|
|
|||
|
|
Run: `pnpm tsc --noEmit`
|
|||
|
|
Expected: zero new errors.
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/(mobile)/m/page.tsx src/app/(mobile)/m/dashboard-view.tsx
|
|||
|
|
git commit -m "feat(boss-dashboard): add page chrome with header and refresh"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 10: Time tab bar
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/app/(mobile)/m/dashboard-view.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Replace the tabs placeholder with the segmented control**
|
|||
|
|
|
|||
|
|
In `dashboard-view.tsx`, replace the `<div className="rounded-xl bg-muted/30 p-3 text-xs text-muted-foreground">Tabs land here in Task 10.</div>` block with:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
{/* Time tab bar */}
|
|||
|
|
<div className="flex gap-1 rounded-xl bg-muted/30 p-1" role="tablist">
|
|||
|
|
{(["today", "week", "month"] as const).map((w) => (
|
|||
|
|
<button
|
|||
|
|
key={w}
|
|||
|
|
type="button"
|
|||
|
|
role="tab"
|
|||
|
|
aria-selected={window === w}
|
|||
|
|
onClick={() => setWindow(w)}
|
|||
|
|
className={`flex-1 rounded-lg px-3 py-2 text-sm font-medium capitalize transition-colors ${
|
|||
|
|
window === w
|
|||
|
|
? "bg-cyan-500/15 text-cyan-300"
|
|||
|
|
: "text-muted-foreground hover:text-foreground"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{w}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify**
|
|||
|
|
|
|||
|
|
Open `http://localhost:4001/m` in a browser. Tap each tab — confirm `data` reloads (visible "Loading…" → "Updated 0s ago" cycle in the header).
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/(mobile)/m/dashboard-view.tsx
|
|||
|
|
git commit -m "feat(boss-dashboard): time tab bar with segmented control"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 11: Order totals strip
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/app/(mobile)/m/dashboard-view.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Replace the order-totals placeholder**
|
|||
|
|
|
|||
|
|
Replace the `Order totals land here in Task 11.` block with:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
{/* Order totals */}
|
|||
|
|
{data && (
|
|||
|
|
<div className="grid grid-cols-3 gap-2">
|
|||
|
|
<OrderTotal label="Total" value={data.orderTotals.total} color="text-foreground" />
|
|||
|
|
<OrderTotal label="Delivered" value={data.orderTotals.delivered} color="text-orange-400" />
|
|||
|
|
<OrderTotal label="Invoiced" value={data.orderTotals.invoiced} color="text-emerald-400" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
And add this helper at the bottom of the file (outside the default export):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
function OrderTotal({
|
|||
|
|
label,
|
|||
|
|
value,
|
|||
|
|
color,
|
|||
|
|
}: {
|
|||
|
|
label: string;
|
|||
|
|
value: number;
|
|||
|
|
color: string;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div className="rounded-xl bg-card border border-border/60 p-3">
|
|||
|
|
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|||
|
|
{label}
|
|||
|
|
</div>
|
|||
|
|
<div className={`mt-1 text-xl font-semibold tabular-nums ${color}`}>
|
|||
|
|
{value.toLocaleString()}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify**
|
|||
|
|
|
|||
|
|
Open `http://localhost:4001/m`. Confirm three tiles render with the totals from your live data.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/(mobile)/m/dashboard-view.tsx
|
|||
|
|
git commit -m "feat(boss-dashboard): order totals strip (total / delivered / invoiced)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 5 — Reps and drivers sections
|
|||
|
|
|
|||
|
|
### Task 12: Reps list
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/app/(mobile)/m/dashboard-view.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Replace the reps placeholder**
|
|||
|
|
|
|||
|
|
Replace the `Reps list lands here in Task 12.` block with:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
{/* Reps section */}
|
|||
|
|
<section>
|
|||
|
|
<div className="flex items-center justify-between mb-2">
|
|||
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
|||
|
|
Sales reps
|
|||
|
|
</h2>
|
|||
|
|
{data && (
|
|||
|
|
<span className="text-xs text-muted-foreground">
|
|||
|
|
{data.reps.length}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{data && data.reps.length === 0 ? (
|
|||
|
|
<div className="rounded-xl border border-border/60 bg-card/50 p-6 text-center text-sm text-muted-foreground">
|
|||
|
|
No rep activity in this window.
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<ul className="space-y-2">
|
|||
|
|
{data?.reps.map((rep) => (
|
|||
|
|
<li key={rep.id || "unknown"}>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
disabled={rep.isUnknown}
|
|||
|
|
onClick={() => openSheet(rep)}
|
|||
|
|
className="w-full rounded-xl border border-border/60 bg-card p-3 text-left transition-colors hover:bg-card/70 disabled:opacity-50 disabled:cursor-not-allowed"
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center justify-between gap-3">
|
|||
|
|
<div className="flex items-center gap-2 min-w-0">
|
|||
|
|
<div className="grid h-8 w-8 place-items-center rounded-full bg-cyan-500/15 text-cyan-300 text-xs font-semibold shrink-0">
|
|||
|
|
{(rep.name[0] ?? "?").toUpperCase()}
|
|||
|
|
</div>
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<div className="text-sm font-medium truncate">{rep.name}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="grid grid-cols-4 gap-2 text-center shrink-0">
|
|||
|
|
<Metric label="loaded" value={rep.quotesLoaded} color="text-cyan-400" />
|
|||
|
|
<Metric label="for" value={rep.quotesDoneFor} color="text-violet-400" />
|
|||
|
|
<Metric label="done" value={rep.quotesDoneBy} color="text-emerald-400" />
|
|||
|
|
<Metric label="orders" value={rep.ordersPlaced} color="text-orange-400" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{data.orderTotals.total > 0 && (
|
|||
|
|
<div className="mt-2 h-1 rounded-full bg-muted overflow-hidden">
|
|||
|
|
<div
|
|||
|
|
className="h-full bg-orange-400"
|
|||
|
|
style={{
|
|||
|
|
width: `${Math.min(100, Math.round((rep.ordersPlaced / data.orderTotals.total) * 100))}%`,
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</button>
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
)}
|
|||
|
|
</section>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
And add these helpers at the bottom of the file:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
function Metric({
|
|||
|
|
label,
|
|||
|
|
value,
|
|||
|
|
color,
|
|||
|
|
}: {
|
|||
|
|
label: string;
|
|||
|
|
value: number;
|
|||
|
|
color: string;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<div className={`text-sm font-semibold tabular-nums ${color}`}>
|
|||
|
|
{value.toLocaleString()}
|
|||
|
|
</div>
|
|||
|
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">
|
|||
|
|
{label}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openSheet(_rep: BossDashboardData["reps"][number]) {
|
|||
|
|
// Wired in Task 14.
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify**
|
|||
|
|
|
|||
|
|
Open `http://localhost:4001/m`. Confirm one card per rep, 4 metrics each, progress bar showing share of total orders.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/(mobile)/m/dashboard-view.tsx
|
|||
|
|
git commit -m "feat(boss-dashboard): per-rep cards with 4 metrics and progress bar"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 13: Drivers section
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/app/(mobile)/m/dashboard-view.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Replace the drivers placeholder**
|
|||
|
|
|
|||
|
|
Replace the `Driver stops land here in Task 13.` block with:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
{/* Drivers section */}
|
|||
|
|
<section>
|
|||
|
|
<div className="flex items-center justify-between mb-2">
|
|||
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
|||
|
|
Driver stops today
|
|||
|
|
</h2>
|
|||
|
|
{data && (
|
|||
|
|
<span className="text-xs text-muted-foreground">
|
|||
|
|
{data.drivers.reduce((s, d) => s + d.stopsToday, 0)}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{data && data.drivers.length === 0 ? (
|
|||
|
|
<div className="rounded-xl border border-border/60 bg-card/50 p-6 text-center text-sm text-muted-foreground">
|
|||
|
|
No driver stops scheduled today.
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<ul className="space-y-2">
|
|||
|
|
{data?.drivers.map((drv) => {
|
|||
|
|
const allDone = drv.stopsLeft === 0;
|
|||
|
|
const noneStarted = drv.stopsDone === 0 && drv.stopsLeft > 0;
|
|||
|
|
const badgeColor = allDone
|
|||
|
|
? "bg-emerald-500/15 text-emerald-400"
|
|||
|
|
: noneStarted
|
|||
|
|
? "bg-rose-500/15 text-rose-400"
|
|||
|
|
: "bg-amber-500/15 text-amber-400";
|
|||
|
|
const badgeLabel = allDone
|
|||
|
|
? "All done"
|
|||
|
|
: noneStarted
|
|||
|
|
? `${drv.stopsLeft} left`
|
|||
|
|
: `${drv.stopsLeft} left`;
|
|||
|
|
return (
|
|||
|
|
<li
|
|||
|
|
key={drv.id}
|
|||
|
|
className="flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-card p-3"
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2 min-w-0">
|
|||
|
|
<div className="grid h-8 w-8 place-items-center rounded-full bg-cyan-500/15 text-cyan-300 text-xs font-semibold shrink-0">
|
|||
|
|
{(drv.name[0] ?? "?").toUpperCase()}
|
|||
|
|
</div>
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<div className="text-sm font-medium truncate">{drv.name}</div>
|
|||
|
|
<div className="text-[11px] text-muted-foreground">
|
|||
|
|
{drv.stopsToday} stops · {drv.stopsDone} done
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<span className={`rounded-full px-2.5 py-1 text-[11px] font-medium ${badgeColor}`}>
|
|||
|
|
{badgeLabel}
|
|||
|
|
</span>
|
|||
|
|
</li>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</ul>
|
|||
|
|
)}
|
|||
|
|
</section>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify**
|
|||
|
|
|
|||
|
|
Open `http://localhost:4001/m`. Confirm driver cards render with the urgency-colored badge.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/(mobile)/m/dashboard-view.tsx
|
|||
|
|
git commit -m "feat(boss-dashboard): driver stops section with urgency-colored badges"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 6 — Drill-down bottom sheet
|
|||
|
|
|
|||
|
|
### Task 14: Rep-activity bottom sheet
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/app/(mobile)/m/dashboard-view.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add sheet state, fetch logic, and `<dialog>` render**
|
|||
|
|
|
|||
|
|
In `dashboard-view.tsx`:
|
|||
|
|
|
|||
|
|
- Add these state variables at the top of the component (next to the existing `useState` declarations):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
const [sheetRep, setSheetRep] = useState<BossDashboardData["reps"][number] | null>(null);
|
|||
|
|
const [sheetKind, setSheetKind] = useState<"quotes" | "orders">("quotes");
|
|||
|
|
const [sheetItems, setSheetItems] = useState<
|
|||
|
|
Array<{ id: string; number: string; customer: string; status: string; ageMs: number }>
|
|||
|
|
>([]);
|
|||
|
|
const [sheetPage, setSheetPage] = useState(1);
|
|||
|
|
const [sheetHasMore, setSheetHasMore] = useState(false);
|
|||
|
|
const [sheetLoading, setSheetLoading] = useState(false);
|
|||
|
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- Replace the placeholder `function openSheet` body with:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
async function openSheet(rep: BossDashboardData["reps"][number]) {
|
|||
|
|
if (rep.isUnknown) return;
|
|||
|
|
setSheetRep(rep);
|
|||
|
|
setSheetKind("quotes");
|
|||
|
|
setSheetPage(1);
|
|||
|
|
setSheetItems([]);
|
|||
|
|
dialogRef.current?.showModal();
|
|||
|
|
await loadSheetPage(rep.id, "quotes", 1, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadSheetPage(
|
|||
|
|
repId: string,
|
|||
|
|
kind: "quotes" | "orders",
|
|||
|
|
page: number,
|
|||
|
|
replace: boolean,
|
|||
|
|
) {
|
|||
|
|
setSheetLoading(true);
|
|||
|
|
try {
|
|||
|
|
const pb = getPocketBase();
|
|||
|
|
const headers: Record<string, string> = {};
|
|||
|
|
if (pb.authStore?.token) headers.Authorization = pb.authStore.token;
|
|||
|
|
|
|||
|
|
const res = await fetch(
|
|||
|
|
`/api/v1/boss-dashboard/rep-activity?rep=${encodeURIComponent(repId)}&window=${window}&kind=${kind}&page=${page}`,
|
|||
|
|
{ headers, cache: "no-store" },
|
|||
|
|
);
|
|||
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|||
|
|
const data = (await res.json()) as {
|
|||
|
|
items: typeof sheetItems;
|
|||
|
|
hasMore: boolean;
|
|||
|
|
};
|
|||
|
|
setSheetItems(replace ? data.items : [...sheetItems, ...data.items]);
|
|||
|
|
setSheetHasMore(data.hasMore);
|
|||
|
|
} catch (err) {
|
|||
|
|
toast.error(err instanceof Error ? err.message : "Failed to load");
|
|||
|
|
} finally {
|
|||
|
|
setSheetLoading(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeSheet() {
|
|||
|
|
dialogRef.current?.close();
|
|||
|
|
setSheetRep(null);
|
|||
|
|
setSheetItems([]);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- Add the `<dialog>` to the rendered output. Insert this **after the closing `</div>` of the outer `mx-auto max-w-md px-4 py-4 space-y-4` wrapper** (so the dialog is a sibling of the page content):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
<dialog
|
|||
|
|
ref={dialogRef}
|
|||
|
|
className="fixed inset-x-0 bottom-0 w-full max-w-md mx-auto rounded-t-2xl bg-card border border-border/60 p-0 backdrop:bg-black/60"
|
|||
|
|
onClose={() => setSheetRep(null)}
|
|||
|
|
>
|
|||
|
|
{sheetRep && (
|
|||
|
|
<div className="p-4 space-y-3">
|
|||
|
|
<header className="flex items-center justify-between gap-3">
|
|||
|
|
<div>
|
|||
|
|
<div className="text-sm font-semibold">{sheetRep.name}</div>
|
|||
|
|
<div className="text-xs text-muted-foreground">
|
|||
|
|
{sheetRep.quotesLoaded} loaded · {sheetRep.quotesDoneBy} done
|
|||
|
|
· {sheetRep.ordersPlaced} orders
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={closeSheet}
|
|||
|
|
aria-label="Close"
|
|||
|
|
className="grid h-8 w-8 place-items-center rounded-full border border-border/60"
|
|||
|
|
>
|
|||
|
|
×
|
|||
|
|
</button>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
{/* Tab bar */}
|
|||
|
|
<div className="flex gap-1 rounded-lg bg-muted/30 p-1" role="tablist">
|
|||
|
|
{(["quotes", "orders"] as const).map((k) => (
|
|||
|
|
<button
|
|||
|
|
key={k}
|
|||
|
|
type="button"
|
|||
|
|
role="tab"
|
|||
|
|
aria-selected={sheetKind === k}
|
|||
|
|
onClick={async () => {
|
|||
|
|
setSheetKind(k);
|
|||
|
|
setSheetPage(1);
|
|||
|
|
await loadSheetPage(sheetRep.id, k, 1, true);
|
|||
|
|
}}
|
|||
|
|
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium capitalize transition-colors ${
|
|||
|
|
sheetKind === k
|
|||
|
|
? "bg-cyan-500/15 text-cyan-300"
|
|||
|
|
: "text-muted-foreground hover:text-foreground"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{k}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* List */}
|
|||
|
|
<ul className="max-h-80 overflow-y-auto divide-y divide-border/40 -mx-1">
|
|||
|
|
{sheetItems.map((item) => (
|
|||
|
|
<li key={item.id}>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => {
|
|||
|
|
const path =
|
|||
|
|
sheetKind === "quotes"
|
|||
|
|
? `/quotes/${item.id}`
|
|||
|
|
: `/orders/${item.id}`;
|
|||
|
|
window.open(path, "_blank");
|
|||
|
|
}}
|
|||
|
|
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-muted/30"
|
|||
|
|
>
|
|||
|
|
<div className="min-w-0">
|
|||
|
|
<div className="text-sm font-medium truncate">{item.number}</div>
|
|||
|
|
<div className="text-xs text-muted-foreground truncate">
|
|||
|
|
{item.customer || "—"}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="shrink-0 text-right">
|
|||
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|||
|
|
{item.status}
|
|||
|
|
</div>
|
|||
|
|
<div className="text-[11px] text-muted-foreground">
|
|||
|
|
{formatAge(item.ageMs)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
|
|||
|
|
{sheetHasMore && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
disabled={sheetLoading}
|
|||
|
|
onClick={async () => {
|
|||
|
|
const nextPage = sheetPage + 1;
|
|||
|
|
setSheetPage(nextPage);
|
|||
|
|
await loadSheetPage(sheetRep.id, sheetKind, nextPage, false);
|
|||
|
|
}}
|
|||
|
|
className="w-full rounded-lg border border-border/60 py-2 text-sm text-muted-foreground hover:bg-muted/30 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{sheetLoading ? "Loading…" : "Load more"}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{sheetItems.length === 0 && !sheetLoading && (
|
|||
|
|
<div className="text-center text-sm text-muted-foreground py-6">
|
|||
|
|
No {sheetKind} in this window.
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</dialog>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- Add the helper at the bottom of the file:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
function formatAge(ms: number): string {
|
|||
|
|
const sec = Math.floor(ms / 1000);
|
|||
|
|
if (sec < 60) return `${sec}s`;
|
|||
|
|
const min = Math.floor(sec / 60);
|
|||
|
|
if (min < 60) return `${min}m ago`;
|
|||
|
|
const hr = Math.floor(min / 60);
|
|||
|
|
if (hr < 24) return `${hr}h ago`;
|
|||
|
|
const days = Math.floor(hr / 24);
|
|||
|
|
return `${days}d ago`;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify**
|
|||
|
|
|
|||
|
|
Open `http://localhost:4001/m`. Tap a rep row — confirm the bottom sheet slides up with two tabs (Quotes / Orders). Switch tabs — confirm the list reloads. Tap a row — confirm `/quotes/<id>` opens in a new tab.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/(mobile)/m/dashboard-view.tsx
|
|||
|
|
git commit -m "feat(boss-dashboard): rep-activity bottom sheet with quotes/orders tabs"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 7 — Realtime + polish
|
|||
|
|
|
|||
|
|
### Task 15: PB realtime subscription
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `src/app/(mobile)/m/dashboard-view.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add the subscription effect**
|
|||
|
|
|
|||
|
|
In `dashboard-view.tsx`, **after the existing `useEffect(() => { void fetchData(); }, [fetchData]);`** block, insert:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
useEffect(() => {
|
|||
|
|
const pb = getPocketBase();
|
|||
|
|
const debounceRef = { handle: 0 as number };
|
|||
|
|
const trigger = () => {
|
|||
|
|
if (debounceRef.handle) window.clearTimeout(debounceRef.handle);
|
|||
|
|
debounceRef.handle = window.setTimeout(() => {
|
|||
|
|
void fetchData();
|
|||
|
|
}, REALTIME_DEBOUNCE_MS);
|
|||
|
|
};
|
|||
|
|
const subscribe = async () => {
|
|||
|
|
await pb.collection("customers").subscribe("*", trigger);
|
|||
|
|
await pb.collection("quotes").subscribe("*", trigger);
|
|||
|
|
await pb.collection("orders").subscribe("*", trigger);
|
|||
|
|
};
|
|||
|
|
void subscribe();
|
|||
|
|
return () => {
|
|||
|
|
if (debounceRef.handle) window.clearTimeout(debounceRef.handle);
|
|||
|
|
void pb.collection("customers").unsubscribe("*");
|
|||
|
|
void pb.collection("quotes").unsubscribe("*");
|
|||
|
|
void pb.collection("orders").unsubscribe("*");
|
|||
|
|
};
|
|||
|
|
}, [fetchData]);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Also remove the comment "// Realtime subscription (added in Task 14)." that you wrote in Task 9.
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify**
|
|||
|
|
|
|||
|
|
Open `http://localhost:4001/m` in one browser tab. In a second tab, create a new quote or order in the operator dashboard. Within ~1 second, the boss's `/m` page should re-fetch and the numbers should update.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add src/app/(mobile)/m/dashboard-view.tsx
|
|||
|
|
git commit -m "feat(boss-dashboard): PB realtime subscription with 1s debounce"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 15b: Final verification
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Run the full test suite**
|
|||
|
|
|
|||
|
|
Run: `pnpm vitest run`
|
|||
|
|
Expected: All tests pass, including the 9 new boss-dashboard tests and 5 route tests.
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Type-check**
|
|||
|
|
|
|||
|
|
Run: `pnpm tsc --noEmit`
|
|||
|
|
Expected: Zero errors.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Lint**
|
|||
|
|
|
|||
|
|
Run: `pnpm lint`
|
|||
|
|
Expected: Zero new lint errors.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Manual smoke test**
|
|||
|
|
|
|||
|
|
Open `http://localhost:4001/m` in a phone-sized browser window (DevTools → toggle device toolbar → iPhone 12). Walk through the 10-step verification list in the spec (`docs/superpowers/specs/2026-08-27-boss-phone-dashboard-design.md`, "How to verify after implementation").
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Final commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add -A
|
|||
|
|
git status # review what's changed
|
|||
|
|
git commit -m "chore(boss-dashboard): post-implementation cleanup" --allow-empty
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Only commit if there are real changes; otherwise skip this step.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Self-Review Checklist
|
|||
|
|
|
|||
|
|
The plan was self-reviewed for:
|
|||
|
|
|
|||
|
|
- **Spec coverage:** every section in `docs/superpowers/specs/2026-08-27-boss-phone-dashboard-design.md` maps to a task:
|
|||
|
|
- Window boundaries → Task 1
|
|||
|
|
- Per-rep aggregations → Task 2
|
|||
|
|
- Unknown bucketing → Task 2 (test)
|
|||
|
|
- Rep-activity pagination → Task 3
|
|||
|
|
- Auth via `assertBrowserSession` → Tasks 4, 5, 6
|
|||
|
|
- PWA manifest → Task 7
|
|||
|
|
- Mobile layout → Task 8
|
|||
|
|
- First paint → Task 9 (page renders shell immediately)
|
|||
|
|
- Header strip → Task 9
|
|||
|
|
- Time tab bar → Task 10
|
|||
|
|
- Order totals → Task 11
|
|||
|
|
- Reps section → Task 12
|
|||
|
|
- Drivers section → Task 13
|
|||
|
|
- Bottom sheet drill-down → Task 14
|
|||
|
|
- Realtime debounce → Task 15
|
|||
|
|
- Empty / error / loading states → Tasks 9-13 (built-in), Task 14 (sheet), Task 15b (final pass)
|
|||
|
|
- Tests for service → Tasks 1, 2, 3
|
|||
|
|
- Tests for routes → Task 6
|
|||
|
|
- Verify checklist → Task 15b
|
|||
|
|
|
|||
|
|
- **Placeholder scan:** no "TBD", "TODO", "implement later", "similar to" hand-waves. Every step that touches code shows the full code block.
|
|||
|
|
|
|||
|
|
- **Type consistency:** `BossWindow` defined in Task 1, used in Tasks 2, 3, 4, 5. `BossDashboardData` defined in Task 2, used in Tasks 9-15. `RepActivityKind` defined in Task 3, used in Tasks 5, 14. `BossRepRow`, `BossDriverRow` defined in Task 2, used in Tasks 9-15.
|
|||
|
|
|
|||
|
|
- **Pattern consistency:** mirrors the existing `/api/v1/dashboard` route (local `assertBrowserSession`, `force-dynamic`, `runtime = "nodejs"`), the existing `/dashboard` page (PB realtime + 1s debounce + bearer token forward), the existing dashboard service tests (in-memory PB mock with `vi.hoisted`).
|
|||
|
|
|
|||
|
|
- **No big-bang rewrite:** no file outside the new `(mobile)/` route group, the new service, and the new API routes is modified.
|