diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx index 8622382..765d8b8 100644 --- a/apps/hq-web/src/Layout.tsx +++ b/apps/hq-web/src/Layout.tsx @@ -35,7 +35,7 @@ export function Layout() { // their own queue, and pagination cannot under-count. useEffect(() => { if (!hasSession()) return - Promise.all([getReminders('queued'), getReminders('failed')]) + Promise.all([getReminders({ status: 'queued' }), getReminders({ status: 'failed' })]) .then(([q, f]) => setQueueCounts({ queued: q.total, failed: f.total })) .catch(() => { /* badge is best-effort */ }) }, [location.pathname]) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index c2ee8e6..58a883d 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -3,6 +3,7 @@ const TOKEN_KEY = 'hq.token' const NAME_KEY = 'hq.name' const ROLE_KEY = 'hq.role' +const ID_KEY = 'hq.id' export function hasSession(): boolean { return (localStorage.getItem(TOKEN_KEY) ?? '') !== '' @@ -12,6 +13,11 @@ export function displayName(): string { return localStorage.getItem(NAME_KEY) ?? '' } +/** The signed-in employee's id ('' for sessions minted before it was stored). */ +export function staffId(): string { + return localStorage.getItem(ID_KEY) ?? '' +} + /** 'owner' | 'manager' | 'staff' — pages gate write actions on this. */ export function role(): string { return localStorage.getItem(ROLE_KEY) ?? 'staff' @@ -27,6 +33,7 @@ export function clearSession(): void { localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(NAME_KEY) localStorage.removeItem(ROLE_KEY) + localStorage.removeItem(ID_KEY) } /** @@ -62,6 +69,7 @@ export async function login(email: string, password: string): Promise<{ displayN localStorage.setItem(TOKEN_KEY, out.token) localStorage.setItem(NAME_KEY, out.staff.displayName) localStorage.setItem(ROLE_KEY, out.staff.role) + localStorage.setItem(ID_KEY, out.staff.id) return { displayName: out.staff.displayName, role: out.staff.role } } @@ -338,6 +346,11 @@ export interface Reminder { error: string | null; createdAt: string; sentAt: string | null } +/** One queue row: the reminder + server-derived display fields (client name, doc no, owner, label). */ +export type QueueItem = Reminder & { + clientName: string; docNo: string | null; ownerId: string | null; label: string +} + export interface DashboardView { today: string overdue: { docId: string; docNo: string | null; clientId: string; clientName: string; outstandingPaise: number; daysOverdue: number }[] @@ -345,7 +358,7 @@ export interface DashboardView { renewalsThisMonth: { clientModuleId: string; clientId: string; clientName: string; nextRenewal: string }[] followUpsToday: { id: string; clientId: string; clientName: string; onDate: string; followUpOn: string; notes: string }[] recentPayments: { id: string; clientId: string; clientName: string; receivedOn: string; amountPaise: number; mode: string }[] - queue: (Reminder & { clientName: string; docNo: string | null; ownerId: string | null; label: string })[] + queue: QueueItem[] /** Honest queue size — `queue` is the first page only. */ queueTotal: number totals: { overduePaise: number; queued: number; failed: number } @@ -355,10 +368,22 @@ export interface DashboardView { export const getDashboard = (): Promise => apiFetch<{ view: DashboardView }>('/dashboard').then((r) => r.view) -/** Viewer-scoped, paginated queue slice + honest total (staff see only their rows). */ -export const getReminders = (status?: ReminderStatus): Promise<{ reminders: Reminder[]; total: number }> => - apiFetch<{ reminders: Reminder[]; total: number }>(`/reminders${status !== undefined ? `?status=${status}` : ''}`) - .then((r) => ({ reminders: r.reminders, total: r.total })) +export interface RemindersPage { reminders: QueueItem[]; total: number; page: number; pageSize: number } +/** + * Viewer-scoped, paginated queue slice + honest total (staff are server-forced to their + * own rows). No `status` = the working set (queued|failed); `owner` narrows — managerial only. + */ +export const getReminders = ( + opts: { status?: ReminderStatus; owner?: string; page?: number; pageSize?: number } = {}, +): Promise => { + const p = new URLSearchParams() + if (opts.status !== undefined) p.set('status', opts.status) + if (opts.owner !== undefined && opts.owner !== '') p.set('owner', opts.owner) + if (opts.page !== undefined) p.set('page', String(opts.page)) + if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize)) + const q = p.toString() + return apiFetch(`/reminders${q === '' ? '' : `?${q}`}`) +} export const sendReminder = (id: string): Promise => apiFetch<{ reminder: Reminder }>(`/reminders/${id}/send`, { method: 'POST', body: '{}' }).then((r) => r.reminder) export const dismissReminder = (id: string): Promise => diff --git a/apps/hq-web/src/components/ReminderQueue.tsx b/apps/hq-web/src/components/ReminderQueue.tsx new file mode 100644 index 0000000..1400c6f --- /dev/null +++ b/apps/hq-web/src/components/ReminderQueue.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react' +import { Button } from '@sims/ui' +import { dismissReminder, getReminderPreview, sendReminder, type Reminder } from '../api' + +/** Fallback kind labels for queue rows whose server `label` is empty. */ +export const RULE_LABEL: Record = { + invoice_overdue: 'Overdue invoice', renewal_due: 'Renewal due', amc_expiring: 'AMC expiring', + follow_up: 'Follow-up', recurring_generated: 'Recurring invoice', email_bounced: 'Email bounced', + quote_followup: 'Quote follow-up', +} + +/** Rule kinds with a client-facing email; the rest are internal work items (dismiss only). */ +export const SENDABLE = new Set(['invoice_overdue', 'renewal_due', 'amc_expiring', 'recurring_generated', 'quote_followup']) + +/** Badge tone for a reminder status: failed red, sent green, dismissed neutral, queued amber. */ +export const reminderTone = (status: string): 'ok' | 'warn' | 'err' | undefined => + status === 'failed' ? 'err' : status === 'sent' ? 'ok' : status === 'dismissed' ? undefined : 'warn' + +/** + * Send / Preview / Dismiss for one queue row — shared by the Dashboard queue card and + * the Reminders page (WS-C: one set of actions, two views). Reloads via `onDone` even + * on failure: the server has already parked the reminder as 'failed' with its error, + * and the row must flip live. + */ +export function QueueActions(props: { rem: Reminder; onDone: () => void; onError: (m: string) => void }) { + const [busy, setBusy] = useState(false) + const [mail, setMail] = useState<{ subject: string; body: string } | undefined>() + const [loading, setLoading] = useState(false) + const run = (start: () => Promise) => { + if (busy) return // Button has no disabled prop — guard re-entry here + setBusy(true); props.onError('') + start() + .catch((e: Error) => props.onError(e.message)) + .then(props.onDone) + .finally(() => setBusy(false)) + } + const preview = () => { + if (mail !== undefined) { setMail(undefined); return } // toggle closed + if (loading) return + setLoading(true); props.onError('') + getReminderPreview(props.rem.id) + .then(setMail) + .catch((e: Error) => props.onError(e.message)) + .finally(() => setLoading(false)) + } + return ( +
+
+ {SENDABLE.has(props.rem.ruleKind) && ( + <> + + + + )} + +
+ {mail !== undefined && ( +
+
Subject
+
{mail.subject}
+
Body
+
{mail.body}
+
+ )} +
+ ) +} diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index 89bace3..12e76c2 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -18,6 +18,7 @@ import { DocumentView } from './pages/DocumentView' import { Employees } from './pages/Employees' import { Pipeline } from './pages/Pipeline' import { Documents } from './pages/Documents' +import { Reminders } from './pages/Reminders' function App() { return ( @@ -27,6 +28,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index f396ef1..31fbd59 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -1,5 +1,5 @@ import { - BarChart3, Boxes, FilePlus2, Files, FileText, Filter, LayoutDashboard, UserCog, Users, + BarChart3, BellRing, Boxes, FilePlus2, Files, FileText, Filter, LayoutDashboard, UserCog, Users, type LucideIcon, } from 'lucide-react' @@ -17,6 +17,7 @@ export const NAV_GROUPS: NavGroup[] = [ items: [ { to: '/', label: 'Dashboard', icon: LayoutDashboard }, { to: '/pipeline', label: 'Pipeline', icon: Filter }, + { to: '/reminders', label: 'Reminders', icon: BellRing }, { to: '/clients', label: 'Clients', icon: Users }, { to: '/documents', label: 'Documents', icon: Files, end: true }, { to: '/documents/new', label: 'New Document', icon: FilePlus2 }, diff --git a/apps/hq-web/src/pages/Dashboard.tsx b/apps/hq-web/src/pages/Dashboard.tsx index f38b6a2..550a3a2 100644 --- a/apps/hq-web/src/pages/Dashboard.tsx +++ b/apps/hq-web/src/pages/Dashboard.tsx @@ -1,21 +1,13 @@ import { useState, type ReactNode } from 'react' -import { useNavigate } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { formatINR } from '@sims/domain' -import { Badge, Button, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui' -import { getDashboard, sendReminder, dismissReminder, getReminderPreview, type Reminder } from '../api' +import { Badge, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui' +import { getDashboard } from '../api' +import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue' import { useData } from './Clients' const inr = (p: number) => formatINR(p) -const RULE_LABEL: Record = { - invoice_overdue: 'Overdue invoice', renewal_due: 'Renewal due', amc_expiring: 'AMC expiring', - follow_up: 'Follow-up', recurring_generated: 'Recurring invoice', email_bounced: 'Email bounced', - quote_followup: 'Quote follow-up', -} -const SENDABLE = new Set(['invoice_overdue', 'renewal_due', 'amc_expiring', 'recurring_generated', 'quote_followup']) -const toneFor = (status: string): 'ok' | 'warn' | 'err' => - status === 'failed' ? 'err' : status === 'sent' ? 'ok' : 'warn' - /** Dashboard home — today's money + the manual reminder queue (spec §3). */ export function Dashboard() { const nav = useNavigate() @@ -37,7 +29,10 @@ export function Dashboard() { -

Reminder queue

+

+ Reminder queue + View all → +

{v.queue.length === 0 ? Nothing waiting — all clear. : ( {rem.status}, + status: {rem.status}, error: rem.error ?? '—', act: , }))} @@ -62,7 +57,7 @@ export function Dashboard() { {v.queueTotal > v.queue.length && ( // No silent caps (rule 8): the table is the first page only — say so.

- Showing {v.queue.length} of {v.queueTotal} — handle these to surface the rest. + Showing {v.queue.length} of {v.queueTotal} — see the full queue.

)} @@ -124,45 +119,3 @@ function Section(props: { ) } -function QueueActions(props: { rem: Reminder & { docNo: string | null }; onDone: () => void; onError: (m: string) => void }) { - const [busy, setBusy] = useState(false) - const [mail, setMail] = useState<{ subject: string; body: string } | undefined>() - const [loading, setLoading] = useState(false) - const run = (p: Promise) => { - setBusy(true); props.onError('') - p.catch((e: Error) => props.onError(e.message)) - // Reload even on failure: the server has already parked the reminder as - // 'failed' with its error, and the row must flip live. - .then(props.onDone) - .finally(() => setBusy(false)) - } - const preview = () => { - if (mail !== undefined) { setMail(undefined); return } // toggle closed - setLoading(true); props.onError('') - getReminderPreview(props.rem.id) - .then(setMail) - .catch((e: Error) => props.onError(e.message)) - .finally(() => setLoading(false)) - } - return ( -
-
- {SENDABLE.has(props.rem.ruleKind) && ( - <> - - - - )} - -
- {mail !== undefined && ( -
-
Subject
-
{mail.subject}
-
Body
-
{mail.body}
-
- )} -
- ) -} diff --git a/apps/hq-web/src/pages/Reminders.tsx b/apps/hq-web/src/pages/Reminders.tsx new file mode 100644 index 0000000..afb1fd1 --- /dev/null +++ b/apps/hq-web/src/pages/Reminders.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { Badge, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui' +import { getReminders, isManagerial, staffId, type ReminderStatus } from '../api' +import { QueueActions, RULE_LABEL, reminderTone } from '../components/ReminderQueue' +import { useData } from './Clients' + +const PAGE_SIZE = 50 + +const STATUS_CHIPS: { key: ReminderStatus; label: string }[] = [ + { key: 'queued', label: 'Queued' }, { key: 'sent', label: 'Sent' }, + { key: 'failed', label: 'Failed' }, { key: 'dismissed', label: 'Dismissed' }, +] + +/** + * Reminders page (go-live cluster WS-C): the full queue the dashboard card is a + * slice of. Status chips map onto `?status=`; the Mine/All toggle (`?owner=`) only + * renders for owner/manager — staff are server-forced to their own rows anyway. + * Server-paginated with an honest total (rule 8). + */ +export function Reminders() { + const [status, setStatus] = useState('queued') + const [scope, setScope] = useState<'all' | 'mine'>('all') + const [page, setPage] = useState(1) + const [err, setErr] = useState('') + const me = staffId() + // Mine narrows via ?owner=; hidden for pre-existing sessions that never stored the id. + const showScope = isManagerial() && me !== '' + + const list = useData( + () => getReminders({ + status, + ...(showScope && scope === 'mine' ? { owner: me } : {}), + page, pageSize: PAGE_SIZE, + }), + [status, scope, page], + ) + + const data = list.data + const rows = data?.reminders + const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1 + const from = data !== undefined && data.total > 0 ? (data.page - 1) * data.pageSize + 1 : 0 + const to = data !== undefined ? Math.min(data.page * data.pageSize, data.total) : 0 + + return ( +
+ + + { setStatus(k as ReminderStatus); setPage(1) }} + /> + {showScope && ( + { setScope(k as 'all' | 'mine'); setPage(1) }} + /> + )} + + {data !== undefined && {data.total} total} + + {err !== '' && {err}} + {list.error !== undefined && } + {list.error !== undefined ? null + : rows === undefined ? + : rows.length === 0 ? No {status} reminders{scope === 'mine' ? ' of yours' : ''}. : ( + <> + (rows[i]!.status === 'failed' ? 'err' : undefined)} + rows={rows.map((rem) => ({ + kind: rem.label !== '' ? rem.label : RULE_LABEL[rem.ruleKind] ?? rem.ruleKind, + client: {rem.clientName}, + doc: rem.docId !== null + ? {rem.docNo ?? '— draft —'} + : '—', + due: rem.duePeriod, + status: {rem.status}, + error: rem.error ?? '—', + // Sent/dismissed rows are history — actions only while there is work to do. + act: rem.status === 'queued' || rem.status === 'failed' + ? + : null, + }))} + /> + + Showing {from}–{to} of {data!.total} + + + Page {data!.page} / {totalPages} + + + + )} +
+ ) +} diff --git a/apps/hq/test/reminders-page.test.ts b/apps/hq/test/reminders-page.test.ts new file mode 100644 index 0000000..9acc70a --- /dev/null +++ b/apps/hq/test/reminders-page.test.ts @@ -0,0 +1,104 @@ +// apps/hq/test/reminders-page.test.ts — go-live cluster WS-C: the Reminders page contract. +// The page's four status chips map straight onto GET /reminders?status=; the managerial +// Mine/All toggle onto ?owner=. Queued/failed were already covered (quote-followup.test.ts); +// this pins the sent/dismissed chips, ?owner= at HTTP level, and staff scoping across chips. +import express from 'express' +import { describe, it, expect, afterAll } from 'vitest' +import { openDb, type DB } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff } from '../src/auth' +import { apiRouter } from '../src/api' +import { createClient, type Client } from '../src/repos-clients' +import { createModule, setPrice, type Module } from '../src/repos-modules' +import { createDraft, markStatus, type Doc } from '../src/repos-documents' +import { saveAccount } from '../src/repos-email' +import { encrypt } from '../src/crypto' +import { dismissReminder, listReminders, setSetting, upsertReminder } from '../src/repos-reminders' +import { sendReminder, type SendReminderDeps } from '../src/send-reminder' +import { runDailyScan, type ScanDeps } from '../src/scheduler' + +const KEY = '11'.repeat(32) +const okFetch = (async (url: string) => + new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch +const deps: ScanDeps & SendReminderDeps = { + gmail: { f: okFetch, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, + renderPdf: async () => Buffer.from('%PDF-fake'), + company: () => ({ 'company.name': 'Tecnostac' }), + now: () => '2026-07-10T09:00:00Z', +} + +function world() { + const db = openDb(':memory:'); seedIfEmpty(db) + saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) + setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in') + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] }) + const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + return { db, c, m } +} + +/** A QUOTATION marked sent, with the first-sent event pinned to `sentAt` for age math. */ +function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Doc { + const q = createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + markStatus(db, by, q.id, 'sent') + db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`).run(sentAt, q.id) + return q +} + +describe('GET /reminders — Reminders page contract (status chips, Mine/All, staff scope)', () => { + const { db, c, m } = world() + createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) + const staff = createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' }) + const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) + const server = app.listen(0) + const base = `http://localhost:${(server.address() as { port: number }).port}/api` + afterAll(() => server.close()) + + const tokenOf = async (email: string, password: string) => + (await (await fetch(`${base}/auth/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + })).json() as { token: string }).token + const get = async (token: string, qs: string) => { + const res = await fetch(`${base}/reminders${qs}`, { headers: { authorization: `Bearer ${token}` } }) + return { status: res.status, json: await res.json() as any } + } + + it('every chip maps to ?status= and Mine (?owner=) composes with it; staff scope holds on every chip', async () => { + const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) + const mine = sentQuote(db, c, m, '2026-07-01T09:00:00Z', staff.id) // → queued nudge owned by staff + const theirs = sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'someone-else') // → queued nudge owned by someone else + await runDailyScan(db, deps, '2026-07-04') + // Flip someone-else's nudge to sent; queue a doc-less shared renewal and dismiss it. + const nudges = listReminders(db, { ruleKind: 'quote_followup' }) + const theirsRem = nudges.find((r) => r.docId === theirs.id)! + expect((await sendReminder(db, deps, theirsRem.id, 'u1')).ok).toBe(true) + const renewal = upsertReminder(db, { + ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01', + clientId: c.id, now: '2026-07-04T00:00:00Z', + }) + dismissReminder(db, 'u1', renewal.id) + + const ownerTok = await tokenOf('owner@test.in', 'owner-password') + // Each chip narrows to exactly its status. + expect((await get(ownerTok, '?status=queued')).json.total).toBe(1) + const sent = await get(ownerTok, '?status=sent') + expect(sent.json.total).toBe(1) + expect(sent.json.reminders[0].id).toBe(theirsRem.id) + expect(sent.json.reminders[0].status).toBe('sent') + const dismissed = await get(ownerTok, '?status=dismissed') + expect(dismissed.json.total).toBe(1) + expect(dismissed.json.reminders[0].status).toBe('dismissed') + expect((await get(ownerTok, '?status=failed')).json.total).toBe(0) + // Mine (?owner=) composes with the chip: staff's queued nudge yes, someone-else's sent one no. + expect((await get(ownerTok, `?status=queued&owner=${staff.id}`)).json.total).toBe(1) + expect((await get(ownerTok, `?status=sent&owner=${staff.id}`)).json.total).toBe(0) + // Staff scope holds on EVERY chip: not their sent row; doc-less dismissed row is shared work. + const staffTok = await tokenOf('staff@test.in', 'staff-password') + const staffQueued = await get(staffTok, '?status=queued') + expect(staffQueued.json.total).toBe(1) + expect(staffQueued.json.reminders[0].docId).toBe(mine.id) + expect((await get(staffTok, '?status=sent')).json.total).toBe(0) + expect((await get(staffTok, '?status=dismissed')).json.total).toBe(1) + }) +})