diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx index 48efcd6..9380438 100644 --- a/apps/hq-web/src/Layout.tsx +++ b/apps/hq-web/src/Layout.tsx @@ -4,7 +4,10 @@ import { LogOut, Menu, Search, UserRound } from 'lucide-react' import { avatarColors, Badge, CommandPalette, initials, ModeToggle, type PaletteItem, } 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' const APP_VERSION = 'v0.1.0' @@ -21,6 +24,8 @@ export function Layout() { return !v }) 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() const [menuOpen, setMenuOpen] = useState(false) const [userOpen, setUserOpen] = useState(false) @@ -48,6 +53,15 @@ export function Layout() { .catch(() => { /* badge is best-effort */ }) }, [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. useEffect(() => { setMenuOpen(false); setUserOpen(false) }, [location.pathname]) @@ -142,6 +156,16 @@ export function Layout() { const badgeCount = queueCounts.queued + queueCounts.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 = {} + 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 (
Skip to content @@ -154,22 +178,25 @@ export function Layout() { {groups.map((g) => (
{g.label}
- {g.items.map((n) => ( - /* Collapsed rail hides the label text (display:none → off the a11y tree), - so name the link explicitly then. Expanded, the visible label + count - badge form the name — an aria-label there would mute the badge. */ - (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}> - - {n.label} - {n.to === '/' && badgeCount > 0 && ( - 0 ? ' err' : ''}`} - title={badgeTitle} - aria-label={badgeTitle} - >{badgeCount} - )} - - ))} + {g.items.map((n) => { + const c = counts[n.to] + return ( + /* Collapsed rail hides the label text (display:none → off the a11y tree), + so name the link explicitly then. Expanded, the visible label + count + badge form the name — an aria-label there would mute the badge. */ + (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}> + + {n.label} + {c !== undefined && ( + {c.n} + )} + + ) + })}
))}
diff --git a/apps/hq-web/src/app.css b/apps/hq-web/src/app.css index 468e1eb..26ac478 100644 --- a/apps/hq-web/src/app.css +++ b/apps/hq-web/src/app.css @@ -41,9 +41,10 @@ } .hq-count { 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; } +.hq-count.warn { background: var(--warn-soft); color: var(--warn); } .hq-count.err { background: var(--err-soft); color: var(--err); } .hq-side-foot { 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. 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 p { margin: 0; color: var(--text-dim); } .hub-section { margin-top: 22px; } diff --git a/apps/hq-web/src/pages/Home.tsx b/apps/hq-web/src/pages/Home.tsx index 468b0dd..37daf93 100644 --- a/apps/hq-web/src/pages/Home.tsx +++ b/apps/hq-web/src/pages/Home.tsx @@ -1,25 +1,22 @@ import { Link } from 'react-router-dom' import { - BarChart3, BellRing, Boxes, CalendarClock, Files, Filter, Gauge, KeyRound, LayoutDashboard, - ListChecks, MessageSquare, ScrollText, Settings as SettingsIcon, Table2, Upload, UserCog, Users, Wrench, + BellRing, Boxes, CalendarClock, Files, Filter, LayoutDashboard, MessageSquare, Users, Wrench, type LucideIcon, } from 'lucide-react' -import { displayName, getDashboard, getPipeline, getSmsBalances, getTickets, role } from '../api' +import { displayName, getDashboard, getPipeline, getSmsBalances, getTickets } from '../api' import { useData } from './Clients' type Tone = 'warn' | 'err' 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 - * sidebar. Each card carries a one-line purpose and, where cheap to fetch, a live - * count so the landing doubles as an at-a-glance status board. Owner-only areas are - * hidden for other roles (the routes are gated server-side regardless). + * Home hub (D30) — a quick-access launcher for the sections used day to day. Not + * every screen (the sidebar has those); just the main ones, each a big target with + * a live count so the landing doubles as a status board. */ export function Home() { const name = displayName() - const isOwner = role() === 'owner' const dash = useData(() => getDashboard(false), []) const sms = useData(getSmsBalances, []) const tickets = useData(() => getTickets({ status: 'open' }), []) @@ -38,82 +35,40 @@ export function Home() { const pipeStat: Stat | undefined = pipeline.data !== undefined && pipeline.data.total > 0 ? { text: `${pipeline.data.total} open` } : undefined - const groups: { label: string; cards: HubCard[] }[] = [ - { - label: 'Overview', - cards: [ - { to: '/', label: 'Dashboard', desc: 'Today’s money — dues, renewals, follow-ups, the send queue.', icon: LayoutDashboard, stat: { text: 'My Day' } }, - ], - }, - { - label: 'Work', - cards: [ - { to: '/pipeline', label: 'Pipeline', desc: 'Leads and quotes in flight, with next-action nudges.', icon: Filter, ...(pipeStat !== undefined ? { stat: pipeStat } : {}) }, - { 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 }, - ], - }, + // Main, look-at-it-all-the-time sections only — the rest live in the sidebar. + const cards: HubCard[] = [ + { to: '/', label: 'Dashboard', desc: 'Today’s money — dues, renewals, follow-ups, the send queue.', icon: LayoutDashboard, stat: { text: 'My Day' } }, + { 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: '/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 } : {}) }, + { to: '/tickets', label: 'Tickets', desc: 'Support desk with SLA aging and overdue flags.', icon: Wrench, ...(ticketStat !== undefined ? { stat: ticketStat } : {}) }, + { to: '/sms-balances', label: 'SMS credits', desc: 'Client SMS balances and top-ups.', icon: MessageSquare, ...(smsStat !== undefined ? { stat: smsStat } : {}) }, + { to: '/modules', label: 'Modules', desc: 'Sellable modules with the dated price book.', icon: Boxes }, ] return (

Welcome back{name !== '' ? `, ${name}` : ''}

-

Jump straight to any part of the console.

+

Your most-used areas — everything else is in the sidebar.

+
+
+ {cards.map((c) => ( + + + + {c.label} + {c.desc} + {c.stat !== undefined && ( + {c.stat.text} + )} + + + + ))}
- {groups.map((g) => { - const cards = g.cards.filter((c) => c.ownerOnly !== true || isOwner) - if (cards.length === 0) return null - return ( -
-
{g.label}
-
- {cards.map((c) => ( - - - - {c.label} - {c.desc} - {c.stat !== undefined && ( - {c.stat.text} - )} - - - - ))} -
-
- ) - })}
) }