feat(d30): trim Home hub to main sections + sidebar count badges

- Home hub (/home) now shows only the day-to-day sections — Dashboard,
  Clients, Documents, Reminders, Renewals, Pipeline, Tickets, SMS credits,
  Modules — as a flat card grid; everything else stays in the sidebar.
- Sidebar section count badges (Layout.tsx): Reminders, Pipeline, Tickets,
  Renewals and SMS now show a live count, fetched best-effort. Badges are
  neutral by default, amber for SMS low balance, red for failed sends; the
  reminder-queue badge moves from Dashboard to Reminders. Hidden when the
  rail is collapsed.
- Neutral count-badge base + hub spacing (app.css).

Typecheck clean; web build + 410 tests green. The server.ts prod DB
password is intentionally left uncommitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent 90864b7067
commit b8e2be628f

@ -4,7 +4,10 @@ import { LogOut, Menu, Search, UserRound } from 'lucide-react'
import { import {
avatarColors, Badge, CommandPalette, initials, ModeToggle, type PaletteItem, avatarColors, Badge, CommandPalette, initials, ModeToggle, type PaletteItem,
} from '@sims/ui' } from '@sims/ui'
import { clearSession, displayName, getSearch, getEmailStatus, getReminders, hasSession, role } from './api' import {
clearSession, displayName, getEmailStatus, getPipeline, getReminders, getRenewals,
getSearch, getSmsBalances, getTickets, hasSession, role,
} from './api'
import { NAV_GROUPS } from './nav' import { NAV_GROUPS } from './nav'
const APP_VERSION = 'v0.1.0' const APP_VERSION = 'v0.1.0'
@ -21,6 +24,8 @@ export function Layout() {
return !v return !v
}) })
const [queueCounts, setQueueCounts] = useState({ queued: 0, failed: 0 }) const [queueCounts, setQueueCounts] = useState({ queued: 0, failed: 0 })
// Section-badge counts (Pipeline / Tickets / Renewals / SMS) — fetched once on mount.
const [sectionCounts, setSectionCounts] = useState({ pipeline: 0, tickets: 0, renewals: 0, smsLow: 0 })
const [healthy, setHealthy] = useState<boolean | undefined>() const [healthy, setHealthy] = useState<boolean | undefined>()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [userOpen, setUserOpen] = useState(false) const [userOpen, setUserOpen] = useState(false)
@ -48,6 +53,15 @@ export function Layout() {
.catch(() => { /* badge is best-effort */ }) .catch(() => { /* badge is best-effort */ })
}, [location.pathname]) }, [location.pathname])
// Section count badges — fetched once; each is best-effort (badge stays 0 on failure).
useEffect(() => {
if (!hasSession()) return
getPipeline({ filter: 'all' }).then((r) => setSectionCounts((c) => ({ ...c, pipeline: r.total }))).catch(() => { /* best-effort */ })
getTickets({ status: 'open' }).then((r) => setSectionCounts((c) => ({ ...c, tickets: r.total }))).catch(() => { /* best-effort */ })
getRenewals().then((rows) => setSectionCounts((c) => ({ ...c, renewals: rows.length }))).catch(() => { /* best-effort */ })
getSmsBalances().then((r) => setSectionCounts((c) => ({ ...c, smsLow: r.lowCount }))).catch(() => { /* best-effort */ })
}, [])
// Close the mobile drawer and the user menu when the route changes. // Close the mobile drawer and the user menu when the route changes.
useEffect(() => { setMenuOpen(false); setUserOpen(false) }, [location.pathname]) useEffect(() => { setMenuOpen(false); setUserOpen(false) }, [location.pathname])
@ -142,6 +156,16 @@ export function Layout() {
const badgeCount = queueCounts.queued + queueCounts.failed const badgeCount = queueCounts.queued + queueCounts.failed
const badgeTitle = `${queueCounts.queued} queued${queueCounts.failed > 0 ? `, ${queueCounts.failed} failed` : ''}` const badgeTitle = `${queueCounts.queued} queued${queueCounts.failed > 0 ? `, ${queueCounts.failed} failed` : ''}`
// Per-section count badges, keyed by route. Neutral by default; amber for SMS low,
// red for failed reminder sends. Only shown for a count > 0, and hidden when the rail
// is collapsed (CSS). The reminder queue sits on Reminders now, not Dashboard.
const counts: Record<string, { n: number; tone?: 'warn' | 'err'; title: string }> = {}
if (badgeCount > 0) counts['/reminders'] = { n: badgeCount, title: badgeTitle, ...(queueCounts.failed > 0 ? { tone: 'err' as const } : {}) }
if (sectionCounts.pipeline > 0) counts['/pipeline'] = { n: sectionCounts.pipeline, title: `${sectionCounts.pipeline} in pipeline` }
if (sectionCounts.tickets > 0) counts['/tickets'] = { n: sectionCounts.tickets, title: `${sectionCounts.tickets} open` }
if (sectionCounts.renewals > 0) counts['/renewals'] = { n: sectionCounts.renewals, title: `${sectionCounts.renewals} upcoming` }
if (sectionCounts.smsLow > 0) counts['/sms-balances'] = { n: sectionCounts.smsLow, tone: 'warn', title: `${sectionCounts.smsLow} low on balance` }
return ( return (
<div className={`hq-shell${collapsed ? ' side-collapsed' : ''}`}> <div className={`hq-shell${collapsed ? ' side-collapsed' : ''}`}>
<a href="#main" className="skip-link">Skip to content</a> <a href="#main" className="skip-link">Skip to content</a>
@ -154,22 +178,25 @@ export function Layout() {
{groups.map((g) => ( {groups.map((g) => (
<div key={g.label} className="hq-group"> <div key={g.label} className="hq-group">
<div className="hq-eyebrow">{g.label}</div> <div className="hq-eyebrow">{g.label}</div>
{g.items.map((n) => ( {g.items.map((n) => {
/* Collapsed rail hides the label text (display:none off the a11y tree), const c = counts[n.to]
so name the link explicitly then. Expanded, the visible label + count return (
badge form the name an aria-label there would mute the badge. */ /* Collapsed rail hides the label text (display:none off the a11y tree),
<NavLink key={n.to} to={n.to} title={n.label} aria-label={collapsed ? n.label : undefined} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}> so name the link explicitly then. Expanded, the visible label + count
<n.icon size={15} strokeWidth={1.8} aria-hidden /> badge form the name an aria-label there would mute the badge. */
<span className="lbl">{n.label}</span> <NavLink key={n.to} to={n.to} title={n.label} aria-label={collapsed ? n.label : undefined} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}>
{n.to === '/' && badgeCount > 0 && ( <n.icon size={15} strokeWidth={1.8} aria-hidden />
<span <span className="lbl">{n.label}</span>
className={`hq-count${queueCounts.failed > 0 ? ' err' : ''}`} {c !== undefined && (
title={badgeTitle} <span
aria-label={badgeTitle} className={`hq-count${c.tone !== undefined ? ` ${c.tone}` : ''}`}
>{badgeCount}</span> title={c.title}
)} aria-label={c.title}
</NavLink> >{c.n}</span>
))} )}
</NavLink>
)
})}
</div> </div>
))} ))}
<div className="hq-side-foot"> <div className="hq-side-foot">

@ -41,9 +41,10 @@
} }
.hq-count { .hq-count {
font-family: var(--mono); font-size: 10.5px; font-weight: 600; font-family: var(--mono); font-size: 10.5px; font-weight: 600;
background: var(--warn-soft); color: var(--warn); background: var(--bg-inset); color: var(--text-dim);
border-radius: 999px; padding: 1px 7px; border-radius: 999px; padding: 1px 7px;
} }
.hq-count.warn { background: var(--warn-soft); color: var(--warn); }
.hq-count.err { background: var(--err-soft); color: var(--err); } .hq-count.err { background: var(--err-soft); color: var(--err); }
.hq-side-foot { .hq-side-foot {
margin-top: auto; padding: 10px 16px; border-top: 1px solid var(--border); margin-top: auto; padding: 10px 16px; border-top: 1px solid var(--border);
@ -183,7 +184,7 @@ button.dash-row:hover { background: var(--accent-soft); }
Home hub (D30) a card launcher for every screen, grouped like the sidebar. Home hub (D30) a card launcher for every screen, grouped like the sidebar.
Cards theme through the tokens (so they follow the palette + light/dark). Cards theme through the tokens (so they follow the palette + light/dark).
============================================================================ */ ============================================================================ */
.hub-head { margin-bottom: 6px; } .hub-head { margin-bottom: 20px; }
.hub-head h1 { font-size: 26px; margin: 2px 0 4px; } .hub-head h1 { font-size: 26px; margin: 2px 0 4px; }
.hub-head p { margin: 0; color: var(--text-dim); } .hub-head p { margin: 0; color: var(--text-dim); }
.hub-section { margin-top: 22px; } .hub-section { margin-top: 22px; }

@ -1,25 +1,22 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
BarChart3, BellRing, Boxes, CalendarClock, Files, Filter, Gauge, KeyRound, LayoutDashboard, BellRing, Boxes, CalendarClock, Files, Filter, LayoutDashboard, MessageSquare, Users, Wrench,
ListChecks, MessageSquare, ScrollText, Settings as SettingsIcon, Table2, Upload, UserCog, Users, Wrench,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { displayName, getDashboard, getPipeline, getSmsBalances, getTickets, role } from '../api' import { displayName, getDashboard, getPipeline, getSmsBalances, getTickets } from '../api'
import { useData } from './Clients' import { useData } from './Clients'
type Tone = 'warn' | 'err' type Tone = 'warn' | 'err'
interface Stat { text: string; tone?: Tone } interface Stat { text: string; tone?: Tone }
interface HubCard { to: string; label: string; desc: string; icon: LucideIcon; ownerOnly?: boolean; stat?: Stat } interface HubCard { to: string; label: string; desc: string; icon: LucideIcon; stat?: Stat }
/** /**
* Home hub (D30) a card launcher for every part of the console, grouped like the * Home hub (D30) a quick-access launcher for the sections used day to day. Not
* sidebar. Each card carries a one-line purpose and, where cheap to fetch, a live * every screen (the sidebar has those); just the main ones, each a big target with
* count so the landing doubles as an at-a-glance status board. Owner-only areas are * a live count so the landing doubles as a status board.
* hidden for other roles (the routes are gated server-side regardless).
*/ */
export function Home() { export function Home() {
const name = displayName() const name = displayName()
const isOwner = role() === 'owner'
const dash = useData(() => getDashboard(false), []) const dash = useData(() => getDashboard(false), [])
const sms = useData(getSmsBalances, []) const sms = useData(getSmsBalances, [])
const tickets = useData(() => getTickets({ status: 'open' }), []) const tickets = useData(() => getTickets({ status: 'open' }), [])
@ -38,82 +35,40 @@ export function Home() {
const pipeStat: Stat | undefined = pipeline.data !== undefined && pipeline.data.total > 0 const pipeStat: Stat | undefined = pipeline.data !== undefined && pipeline.data.total > 0
? { text: `${pipeline.data.total} open` } : undefined ? { text: `${pipeline.data.total} open` } : undefined
const groups: { label: string; cards: HubCard[] }[] = [ // Main, look-at-it-all-the-time sections only — the rest live in the sidebar.
{ const cards: HubCard[] = [
label: 'Overview', { to: '/', label: 'Dashboard', desc: 'Todays money — dues, renewals, follow-ups, the send queue.', icon: LayoutDashboard, stat: { text: 'My Day' } },
cards: [ { to: '/clients', label: 'Clients', desc: 'The client book — profiles, modules, ledger, support.', icon: Users },
{ to: '/', label: 'Dashboard', desc: 'Todays money — dues, renewals, follow-ups, the send queue.', icon: LayoutDashboard, stat: { text: 'My Day' } }, { to: '/documents', label: 'Documents', desc: 'Quotations, proformas, invoices, credit notes.', icon: Files },
], { to: '/reminders', label: 'Reminders', desc: 'The email send queue — preview, send, dismiss.', icon: BellRing, ...(remStat !== undefined ? { stat: remStat } : {}) },
}, { to: '/renewals', label: 'Renewals', desc: 'AMC and module renewals — one-click renewal quotes.', icon: CalendarClock, ...(renewStat !== undefined ? { stat: renewStat } : {}) },
{ { to: '/pipeline', label: 'Pipeline', desc: 'Leads and quotes in flight, with next-action nudges.', icon: Filter, ...(pipeStat !== undefined ? { stat: pipeStat } : {}) },
label: 'Work', { to: '/tickets', label: 'Tickets', desc: 'Support desk with SLA aging and overdue flags.', icon: Wrench, ...(ticketStat !== undefined ? { stat: ticketStat } : {}) },
cards: [ { to: '/sms-balances', label: 'SMS credits', desc: 'Client SMS balances and top-ups.', icon: MessageSquare, ...(smsStat !== undefined ? { stat: smsStat } : {}) },
{ to: '/pipeline', label: 'Pipeline', desc: 'Leads and quotes in flight, with next-action nudges.', icon: Filter, ...(pipeStat !== undefined ? { stat: pipeStat } : {}) }, { to: '/modules', label: 'Modules', desc: 'Sellable modules with the dated price book.', icon: Boxes },
{ to: '/reminders', label: 'Reminders', desc: 'The email send queue — preview, send, dismiss.', icon: BellRing, ...(remStat !== undefined ? { stat: remStat } : {}) },
{ to: '/tickets', label: 'Tickets', desc: 'Support desk with SLA aging and overdue flags.', icon: Wrench, ...(ticketStat !== undefined ? { stat: ticketStat } : {}) },
{ to: '/projects', label: 'Onboarding', desc: 'Install and training checklists across active projects.', icon: ListChecks },
],
},
{
label: 'Clients & billing',
cards: [
{ to: '/clients', label: 'Clients', desc: 'The client book — profiles, modules, ledger, support.', icon: Users },
{ to: '/documents', label: 'Documents', desc: 'Quotations, proformas, invoices, credit notes.', icon: Files },
{ to: '/renewals', label: 'Renewals', desc: 'AMC and module renewals — one-click renewal quotes.', icon: CalendarClock, ...(renewStat !== undefined ? { stat: renewStat } : {}) },
{ to: '/sms-balances', label: 'SMS credits', desc: 'Client SMS balances and top-ups.', icon: MessageSquare, ...(smsStat !== undefined ? { stat: smsStat } : {}) },
],
},
{
label: 'Catalog & insight',
cards: [
{ to: '/modules', label: 'Modules', desc: 'Sellable modules with the dated price book.', icon: Boxes },
{ to: '/module-data', label: 'Module data', desc: 'Operational service data across all client modules.', icon: Table2 },
{ to: '/portals', label: 'Portals', desc: 'Client portal logins and credentials.', icon: KeyRound },
{ to: '/mis', label: 'MIS cockpit', desc: 'Owner overview — invoiced, collected, outstanding.', icon: Gauge, ownerOnly: true },
{ to: '/reports', label: 'Reports', desc: 'Dues aging, GST summary, module revenue, profitability.', icon: BarChart3 },
],
},
{
label: 'Admin',
cards: [
{ to: '/employees', label: 'Employees', desc: 'Staff accounts, roles and access.', icon: UserCog, ownerOnly: true },
{ to: '/import', label: 'APEX import', desc: 'Bring in legacy clients and invoices from APEX.', icon: Upload, ownerOnly: true },
{ to: '/audit', label: 'Audit log', desc: 'Every change, append-only.', icon: ScrollText, ownerOnly: true },
{ to: '/settings', label: 'Settings', desc: 'Company, letterhead, reminders, appearance.', icon: SettingsIcon },
],
},
] ]
return ( return (
<div className="wf-page"> <div className="wf-page">
<div className="hub-head"> <div className="hub-head">
<h1>Welcome back{name !== '' ? `, ${name}` : ''}</h1> <h1>Welcome back{name !== '' ? `, ${name}` : ''}</h1>
<p>Jump straight to any part of the console.</p> <p>Your most-used areas everything else is in the sidebar.</p>
</div>
<div className="hub-cards">
{cards.map((c) => (
<Link key={c.to} to={c.to} className="hub-card">
<span className="hub-ic"><c.icon size={18} strokeWidth={1.9} aria-hidden /></span>
<span className="hub-body">
<span className="hub-t">{c.label}</span>
<span className="hub-d">{c.desc}</span>
{c.stat !== undefined && (
<span className={`hub-stat${c.stat.tone !== undefined ? ` ${c.stat.tone}` : ''}`}>{c.stat.text}</span>
)}
</span>
<span className="hub-arrow" aria-hidden></span>
</Link>
))}
</div> </div>
{groups.map((g) => {
const cards = g.cards.filter((c) => c.ownerOnly !== true || isOwner)
if (cards.length === 0) return null
return (
<section className="hub-section" key={g.label}>
<div className="hub-eyebrow">{g.label}</div>
<div className="hub-cards">
{cards.map((c) => (
<Link key={c.to} to={c.to} className="hub-card">
<span className="hub-ic"><c.icon size={18} strokeWidth={1.9} aria-hidden /></span>
<span className="hub-body">
<span className="hub-t">{c.label}</span>
<span className="hub-d">{c.desc}</span>
{c.stat !== undefined && (
<span className={`hub-stat${c.stat.tone !== undefined ? ` ${c.stat.tone}` : ''}`}>{c.stat.text}</span>
)}
</span>
<span className="hub-arrow" aria-hidden></span>
</Link>
))}
</div>
</section>
)
})}
</div> </div>
) )
} }

Loading…
Cancel
Save