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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 30c2e3b60b
commit b5d252b7dc

@ -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<boolean | undefined>()
const [menuOpen, setMenuOpen] = useState(false)
@ -131,7 +137,7 @@ export function Layout() {
const badgeTitle = `${queueCounts.queued} queued${queueCounts.failed > 0 ? `, ${queueCounts.failed} failed` : ''}`
return (
<div className="hq-shell">
<div className={`hq-shell${collapsed ? ' side-collapsed' : ''}`}>
{menuOpen && <div className="hq-scrim" onClick={() => setMenuOpen(false)} />}
<nav className={`hq-side${menuOpen ? ' open' : ''}`}>
<div className="hq-brand">
@ -142,7 +148,7 @@ export function Layout() {
<div key={g.label} className="hq-group">
<div className="hq-eyebrow">{g.label}</div>
{g.items.map((n) => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}>
<NavLink key={n.to} to={n.to} title={n.label} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}>
<n.icon size={15} strokeWidth={1.8} aria-hidden />
<span className="lbl">{n.label}</span>
{n.to === '/' && badgeCount > 0 && (
@ -157,10 +163,17 @@ export function Layout() {
</div>
))}
<div className="hq-side-foot">
{APP_VERSION}
<span className="lbl">{APP_VERSION}</span>
{healthy !== undefined && (
<span className={`hq-dot${healthy ? ' ok' : ' err'}`} title={healthy ? 'Server healthy' : 'Server unreachable'} />
)}
<button
type="button" className="hq-collapse" onClick={toggleCollapsed}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? '' : ''}
</button>
</div>
</nav>
<div className="hq-main">

@ -395,8 +395,9 @@ export interface DashboardView {
// ---------- HQ-2 calls ----------
export const getDashboard = (): Promise<DashboardView> =>
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<DashboardView> =>
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

@ -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;

@ -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 (
<div className="wf-page">
<PageHeader title="Dashboard" desc="Todays money — dues, renewals, follow-ups, and the reminder queue." />
<PageHeader
title={managerial && mine ? 'My Day' : 'Dashboard'}
desc="Todays money — dues, renewals, follow-ups, and the reminder queue."
{...(managerial ? {
actions: (
<span style={{ display: 'flex', gap: 4 }}>
<button type="button" className={`wf${!mine ? ' active' : ''}`} onClick={() => setMine(false)}>Everyone</button>
<button type="button" className={`wf${mine ? ' active' : ''}`} onClick={() => setMine(true)}>Mine</button>
</span>
),
} : {})}
/>
{dash.error !== undefined && <ErrorState message={dash.error} onRetry={dash.reload} />}
{err !== '' && <Notice tone="err">{err}</Notice>}
{v === undefined && dash.error === undefined ? <Skeleton rows={5} /> : v === undefined ? null : (

@ -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 ----------

@ -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,

@ -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)
})
})

Loading…
Cancel
Save