From b5d252b7dcfcefa321588e33b49de6971692a66b Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 14:30:34 +0530 Subject: [PATCH] feat(shell): sidebar collapse + My Day dashboard (Phase 5, D18 WS-E) - sidebar collapses to an icon rail (footer toggle, localStorage-persisted; nav tooltips via title; <900px hamburger untouched) - dashboardView gains mine-scoping: staff are ALWAYS scoped to their book (clients they own, docs/payments they created, their follow-ups, their queue); owner/manager get a Mine/Everyone toggle (?mine=1) - 1 test; suite green Co-Authored-By: Claude Fable 5 --- apps/hq-web/src/Layout.tsx | 19 ++++++++++-- apps/hq-web/src/api.ts | 5 +-- apps/hq-web/src/app.css | 18 +++++++++++ apps/hq-web/src/pages/Dashboard.tsx | 21 +++++++++++-- apps/hq/src/api.ts | 10 ++++-- apps/hq/src/repos-dashboard.ts | 48 +++++++++++++++++++---------- apps/hq/test/dashboard.test.ts | 35 +++++++++++++++++++++ 7 files changed, 130 insertions(+), 26 deletions(-) diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx index a0c883d..bf7a036 100644 --- a/apps/hq-web/src/Layout.tsx +++ b/apps/hq-web/src/Layout.tsx @@ -14,6 +14,12 @@ export function Layout() { const nav = useNavigate() const location = useLocation() const [gmail, setGmail] = useState<'ok' | 'down' | 'unknown'>('unknown') + // Sidebar collapse (D18 WS-E): icon rail on demand, remembered across reloads. + const [collapsed, setCollapsed] = useState(() => localStorage.getItem('hq.sideCollapsed') === '1') + const toggleCollapsed = () => setCollapsed((v) => { + localStorage.setItem('hq.sideCollapsed', v ? '0' : '1') + return !v + }) const [queueCounts, setQueueCounts] = useState({ queued: 0, failed: 0 }) const [healthy, setHealthy] = useState() const [menuOpen, setMenuOpen] = useState(false) @@ -131,7 +137,7 @@ export function Layout() { const badgeTitle = `${queueCounts.queued} queued${queueCounts.failed > 0 ? `, ${queueCounts.failed} failed` : ''}` return ( -
+
{menuOpen &&
setMenuOpen(false)} />}
diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 0d6c606..f74a632 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -395,8 +395,9 @@ export interface DashboardView { // ---------- HQ-2 calls ---------- -export const getDashboard = (): Promise => - apiFetch<{ view: DashboardView }>('/dashboard').then((r) => r.view) +/** mine=true = My Day (owner/manager narrow to their own book; staff are always scoped). */ +export const getDashboard = (mine = false): Promise => + apiFetch<{ view: DashboardView }>(`/dashboard${mine ? '?mine=1' : ''}`).then((r) => r.view) export interface RemindersPage { reminders: QueueItem[]; total: number; page: number; pageSize: number } /** * Viewer-scoped, paginated queue slice + honest total (staff are server-forced to their diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index e6dfd22..1eea423 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -1,5 +1,23 @@ /* HQ shell — warm ops console (spec 2026-07-17 §3). */ .hq-shell { display: grid; grid-template-columns: 224px 1fr; height: 100vh; } + +/* Sidebar collapse (D18 WS-E): icon rail — labels, eyebrows, brand text and count + badges hide; nav icons + tooltips (title=) remain. State lives in localStorage. */ +.hq-shell.side-collapsed { grid-template-columns: 52px 1fr; } +.hq-shell.side-collapsed .hq-side .lbl, +.hq-shell.side-collapsed .hq-eyebrow, +.hq-shell.side-collapsed .hq-brand small, +.hq-shell.side-collapsed .hq-count, +.hq-shell.side-collapsed .hq-side-foot .lbl { display: none; } +.hq-shell.side-collapsed .hq-brand { font-size: 12px; padding: 4px 8px 14px; text-align: center; } +.hq-shell.side-collapsed .hq-side a { justify-content: center; padding: 8px 0; } +.hq-collapse { + margin-left: auto; border: 1px solid var(--border); background: var(--bg-raised); + color: var(--text-dim); border-radius: 4px; width: 20px; height: 20px; line-height: 1; + cursor: pointer; font-size: 12px; padding: 0; +} +.hq-collapse:hover { background: var(--bg-hover); color: var(--text); } +.hq-shell.side-collapsed .hq-side-foot { display: flex; justify-content: center; padding: 10px 4px; } .hq-side { background: var(--bg-raised); border-right: 1px solid var(--border); overflow-y: auto; padding: 12px 0 0; display: flex; flex-direction: column; diff --git a/apps/hq-web/src/pages/Dashboard.tsx b/apps/hq-web/src/pages/Dashboard.tsx index 550a3a2..74b8cc5 100644 --- a/apps/hq-web/src/pages/Dashboard.tsx +++ b/apps/hq-web/src/pages/Dashboard.tsx @@ -2,7 +2,7 @@ import { useState, type ReactNode } from 'react' import { Link, useNavigate } from 'react-router-dom' import { formatINR } from '@sims/domain' import { Badge, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui' -import { getDashboard } from '../api' +import { getDashboard, isManagerial } from '../api' import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue' import { useData } from './Clients' @@ -11,13 +11,28 @@ const inr = (p: number) => formatINR(p) /** Dashboard home — today's money + the manual reminder queue (spec §3). */ export function Dashboard() { const nav = useNavigate() - const dash = useData(getDashboard, []) + // My Day (D18 WS-E): owner/manager toggle Mine ↔ Everyone; staff are always + // scoped to their own book server-side, so they get no toggle. + const managerial = isManagerial() + const [mine, setMine] = useState(false) + const dash = useData(() => getDashboard(mine), [mine]) const [err, setErr] = useState('') const v = dash.data return (
- + + + + + ), + } : {})} + /> {dash.error !== undefined && } {err !== '' && {err}} {v === undefined && dash.error === undefined ? : v === undefined ? null : ( diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 19acc00..99b729f 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -939,9 +939,15 @@ export function apiRouter( }) // ---------- dashboard ---------- - r.get('/dashboard', requireAuth, (_req, res) => { + // ?mine=1 = My Day (D18 WS-E): owner/manager narrow to their own book; staff + // are always scoped server-side regardless of the param. + r.get('/dashboard', requireAuth, (req, res) => { const today = new Date().toISOString().slice(0, 10) - res.json({ ok: true, view: dashboardView(db, today, res.locals['staff'] as { id: string; role: string }) }) + res.json({ + ok: true, + view: dashboardView(db, today, res.locals['staff'] as { id: string; role: string }, + { mine: req.query['mine'] === '1' }), + }) }) // ---------- payments ---------- diff --git a/apps/hq/src/repos-dashboard.ts b/apps/hq/src/repos-dashboard.ts index 342c0a9..45d497a 100644 --- a/apps/hq/src/repos-dashboard.ts +++ b/apps/hq/src/repos-dashboard.ts @@ -29,14 +29,25 @@ function endOfMonth(today: string): string { return new Date(Date.UTC(y!, m!, 0)).toISOString().slice(0, 10) // day 0 of next month = last day of this } -export function dashboardView(db: DB, today: string, viewer?: { id: string; role: string }): DashboardView { +export function dashboardView( + db: DB, today: string, viewer?: { id: string; role: string }, opts: { mine?: boolean } = {}, +): DashboardView { + // My Day (D18 WS-E): staff are ALWAYS scoped to their own book (ownerScope forces + // it); owner/manager opt in via mine=true. "Mine" = clients I own, documents and + // payments I created, my follow-ups. + const meId = viewer !== undefined && (opts.mine === true || ownerScope(viewer) !== undefined) + ? viewer.id + : undefined + const mineDoc = meId !== undefined ? ` AND (d.created_by=? OR c.owner_id=?)` : '' + const mineClient = meId !== undefined ? ` AND c.owner_id=?` : '' + const overdueRows = db.prepare( `SELECT d.id, d.doc_no, d.client_id, d.doc_date, c.name AS client_name FROM document d JOIN client c ON c.id = d.client_id WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL - AND d.status NOT IN ('paid','cancelled','lost') AND d.doc_date <= ? + AND d.status NOT IN ('paid','cancelled','lost') AND d.doc_date <= ?${mineDoc} ORDER BY d.doc_date`, - ).all(today) as { id: string; doc_no: string | null; client_id: string; doc_date: string; client_name: string }[] + ).all(...(meId !== undefined ? [today, meId, meId] : [today])) as { id: string; doc_no: string | null; client_id: string; doc_date: string; client_name: string }[] const overdue: DashboardView['overdue'] = [] let overduePaise = 0 for (const r of overdueRows) { @@ -53,8 +64,8 @@ export function dashboardView(db: DB, today: string, viewer?: { id: string; role const dueThisWeek = db.prepare( `SELECT rp.id AS plan_id, rp.client_id, rp.next_run, rp.amount_paise, c.name AS client_name FROM recurring_plan rp JOIN client c ON c.id = rp.client_id - WHERE rp.active=1 AND rp.next_run >= ? AND rp.next_run <= ? ORDER BY rp.next_run`, - ).all(today, weekEnd).map((r) => { + WHERE rp.active=1 AND rp.next_run >= ? AND rp.next_run <= ?${mineClient} ORDER BY rp.next_run`, + ).all(...(meId !== undefined ? [today, weekEnd, meId] : [today, weekEnd])).map((r) => { const row = r as { plan_id: string; client_id: string; next_run: string; amount_paise: number | null; client_name: string } return { planId: row.plan_id, clientId: row.client_id, clientName: row.client_name, nextRun: row.next_run, amountPaise: row.amount_paise } }) @@ -62,30 +73,35 @@ export function dashboardView(db: DB, today: string, viewer?: { id: string; role const renewalsThisMonth = db.prepare( `SELECT cm.id AS cm_id, cm.client_id, cm.next_renewal, c.name AS client_name FROM client_module cm JOIN client c ON c.id = cm.client_id - WHERE cm.active=1 AND cm.next_renewal >= ? AND cm.next_renewal <= ? ORDER BY cm.next_renewal`, - ).all(today, endOfMonth(today)).map((r) => { + WHERE cm.active=1 AND cm.next_renewal >= ? AND cm.next_renewal <= ?${mineClient} ORDER BY cm.next_renewal`, + ).all(...(meId !== undefined ? [today, endOfMonth(today), meId] : [today, endOfMonth(today)])).map((r) => { const row = r as { cm_id: string; client_id: string; next_renewal: string; client_name: string } return { clientModuleId: row.cm_id, clientId: row.client_id, clientName: row.client_name, nextRenewal: row.next_renewal } }) - const followUpsToday = listOpenFollowUps(db, today).map((f) => ({ - id: f.id, clientId: f.clientId, clientName: f.clientName, onDate: f.onDate, - followUpOn: f.followUpOn!, notes: f.notes, - })) + const followUpsToday = listOpenFollowUps(db, today) + .filter((f) => meId === undefined || f.staffId === meId) // my follow-ups only under My Day + .map((f) => ({ + id: f.id, clientId: f.clientId, clientName: f.clientName, onDate: f.onDate, + followUpOn: f.followUpOn!, notes: f.notes, + })) const recentPayments = db.prepare( `SELECT p.id, p.client_id, p.received_on, p.amount_paise, p.mode, c.name AS client_name - FROM payment p JOIN client c ON c.id = p.client_id ORDER BY p.id DESC LIMIT 10`, - ).all().map((r) => { + FROM payment p JOIN client c ON c.id = p.client_id + WHERE 1=1${meId !== undefined ? ' AND (p.created_by=? OR c.owner_id=?)' : ''} + ORDER BY p.id DESC LIMIT 10`, + ).all(...(meId !== undefined ? [meId, meId] : [])).map((r) => { const row = r as { id: string; client_id: string; received_on: string; amount_paise: number; mode: string; client_name: string } return { id: row.id, clientId: row.client_id, clientName: row.client_name, receivedOn: row.received_on, amountPaise: row.amount_paise, mode: row.mode } }) - // Same owner scope as GET /reminders: staff see only their own queue (spec §6e). + // Same owner scope as GET /reminders: staff see only their own queue (spec §6e); + // a managerial My Day narrows the queue to their own rows via ownerId. const queuePage = viewer !== undefined - ? listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role }) + ? listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role, ...(meId !== undefined ? { ownerId: meId } : {}) }) : listQueue(db) - const counts = queueCounts(db, viewer !== undefined ? ownerScope(viewer) : undefined) + const counts = queueCounts(db, viewer !== undefined ? (meId ?? ownerScope(viewer)) : undefined) return { today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments, diff --git a/apps/hq/test/dashboard.test.ts b/apps/hq/test/dashboard.test.ts index 9e1809a..d89bb7a 100644 --- a/apps/hq/test/dashboard.test.ts +++ b/apps/hq/test/dashboard.test.ts @@ -68,3 +68,38 @@ describe('GET /api/dashboard + reminder queue routes', () => { expect(dismissed.reminder.status).toBe('dismissed') }) }) + +describe('My Day scoping (D18 WS-E)', () => { + it('staff are forced to their own book; managerial mine=true narrows; default managerial view is everyone', async () => { + const { openDb } = await import('../src/db') + const { seedIfEmpty } = await import('../src/seed') + const { createStaff } = await import('../src/auth') + const { createClient } = await import('../src/repos-clients') + const { setClientOwner } = await import('../src/repos-clients') + const { createModule, setPrice, assignModule, updateClientModule } = await import('../src/repos-modules') + const { dashboardView } = await import('../src/repos-dashboard') + + const db = openDb(':memory:'); seedIfEmpty(db) + const { id: staffId } = createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'password-9' }) + const { id: mgrId } = createStaff(db, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password-9' }) + const mine = createClient(db, 'u1', { name: 'Mine Bank', stateCode: '32' }) + const other = createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' }) + setClientOwner(db, mgrId, mine.id, staffId) + const m = createModule(db, 'u1', { code: 'POS2', name: 'POS2' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_000_00, effectiveFrom: '2026-01-01' }) + const today = '2026-07-17' + for (const c of [mine, other]) { + const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-25' }) + } + // Staff: only the client they own shows in renewals. + const staffView = dashboardView(db, today, { id: staffId, role: 'staff' }) + expect(staffView.renewalsThisMonth.map((r) => r.clientName)).toEqual(['Mine Bank']) + // Managerial default: everyone. + const mgrAll = dashboardView(db, today, { id: mgrId, role: 'manager' }) + expect(mgrAll.renewalsThisMonth).toHaveLength(2) + // Managerial My Day: narrowed to their own book (they own nothing → empty). + const mgrMine = dashboardView(db, today, { id: mgrId, role: 'manager' }, { mine: true }) + expect(mgrMine.renewalsThisMonth).toHaveLength(0) + }) +})