feat(hq-web): standalone Reminders page (go-live WS-C, phase 3)
- /reminders page: Queued/Sent/Failed/Dismissed chips mapping to ?status=,
managerial-only Mine/All toggle via ?owner= (staff are server-forced to
their own rows), kind/client/doc/due/status table with Send/Preview/
Dismiss on open rows, honest server-driven pager (rule 8).
- Extract the Dashboard queue actions into shared
components/ReminderQueue.tsx (QueueActions, RULE_LABEL, reminderTone)
with a re-entry guard on Send/Dismiss; Dashboard queue card links
"View all" to /reminders.
- api.ts: getReminders now takes { status, owner, page, pageSize } and
returns the full QueueItem rows + page metadata; login stores the
staff id (hq.id) so Mine can narrow by owner.
- Nav item under WORK + route; no backend changes. A contract test pins
the sent/dismissed chips, ?owner= narrowing and cross-chip staff scope
at HTTP level (apps/hq/test/reminders-page.test.ts).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
parent
5d81550e34
commit
e612c5cf81
@ -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<string, string> = {
|
||||
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<unknown>) => {
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{SENDABLE.has(props.rem.ruleKind) && (
|
||||
<>
|
||||
<Button onClick={preview}>{loading ? '…' : mail !== undefined ? 'Hide' : 'Preview'}</Button>
|
||||
<Button tone="primary" onClick={() => run(() => sendReminder(props.rem.id))}>{busy ? '…' : 'Send'}</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => run(() => dismissReminder(props.rem.id))}>Dismiss</Button>
|
||||
</div>
|
||||
{mail !== undefined && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '6px 8px', background: 'var(--bg-raised)', maxWidth: 420 }}>
|
||||
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Subject</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{mail.subject}</div>
|
||||
<div style={{ fontSize: 11, textTransform: 'uppercase', color: 'var(--text-muted, #666)' }}>Body</div>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0, fontFamily: 'inherit', fontSize: 13 }}>{mail.body}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue