feat(hq): dashboard money view and 6-hourly scheduler wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 7e1dcc1ad8
commit 06b5eb532c

@ -36,6 +36,7 @@ import {
type ReminderStatus,
} from './repos-reminders'
import { sendReminder, type SendReminderDeps } from './send-reminder'
import { dashboardView } from './repos-dashboard'
import { documentHtml } from './templates'
import { renderPdf } from './pdf'
import { emailStatus } from './repos-email'
@ -455,6 +456,12 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
}
})
// ---------- dashboard ----------
r.get('/dashboard', requireAuth, (_req, res) => {
const today = new Date().toISOString().slice(0, 10)
res.json({ ok: true, view: dashboardView(db, today) })
})
// ---------- payments ----------
r.post('/payments', requireAuth, (req, res) => {
try {

@ -0,0 +1,97 @@
import type { DB } from './db'
import { outstandingPaise } from './repos-payments'
import { listOpenFollowUps } from './repos-interactions'
import { listQueue, type Reminder } from './repos-reminders'
import { addDaysIso } from './scheduler'
/** Read-only money view for the dashboard home (spec §3 "today's money"). */
export interface DashboardView {
today: string
overdue: { docId: string; docNo: string | null; clientId: string; clientName: string; outstandingPaise: number; daysOverdue: number }[]
dueThisWeek: { planId: string; clientId: string; clientName: string; nextRun: string; amountPaise: number | null }[]
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 })[]
totals: { overduePaise: number; queued: number; failed: number }
}
function daysBetween(fromIso: string, toIso: string): number {
return Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000)
}
function endOfMonth(today: string): string {
const [y, m] = today.split('-').map(Number)
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): DashboardView {
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 <= ?
ORDER BY d.doc_date`,
).all(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) {
const outstanding = outstandingPaise(db, r.id)
if (outstanding <= 0) continue
overduePaise += outstanding
overdue.push({
docId: r.id, docNo: r.doc_no, clientId: r.client_id, clientName: r.client_name,
outstandingPaise: outstanding, daysOverdue: Math.max(0, daysBetween(r.doc_date, today)),
})
}
const weekEnd = addDaysIso(today, 7)
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) => {
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 }
})
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) => {
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 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) => {
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 }
})
const queue = listQueue(db).map((rem) => {
const client = db.prepare(`SELECT name FROM client WHERE id=?`).get(rem.clientId) as { name: string } | undefined
const doc = rem.docId !== null
? db.prepare(`SELECT doc_no FROM document WHERE id=?`).get(rem.docId) as { doc_no: string | null } | undefined
: undefined
return { ...rem, clientName: client?.name ?? '', docNo: doc?.doc_no ?? null }
})
return {
today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments, queue,
totals: {
overduePaise,
queued: queue.filter((q) => q.status === 'queued').length,
failed: queue.filter((q) => q.status === 'failed').length,
},
}
}

@ -4,7 +4,10 @@ import type http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { apiRouter } from './api'
import { openDb } from './db'
import { pollBounces } from './bounces'
import { openDb, type DB } from './db'
import { renderPdf } from './pdf'
import { runDailyScan } from './scheduler'
import { seedIfEmpty } from './seed'
// dist/server.cjs (esbuild CJS bundle) has __dirname; vitest/tsx run this file as ESM.
@ -13,7 +16,34 @@ const moduleDir = typeof __dirname === 'undefined'
? path.dirname(fileURLToPath(import.meta.url))
: __dirname
export function startServer(port: number): http.Server {
function schedulerDeps(db: DB) {
const company = (): Record<string, string> =>
Object.fromEntries((db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`)
.all() as { key: string; value: string }[]).map((r) => [r.key, r.value]))
const gmail = {
f: fetch,
clientId: process.env['GOOGLE_CLIENT_ID'] ?? '',
clientSecret: process.env['GOOGLE_CLIENT_SECRET'] ?? '',
keyHex: process.env['HQ_SECRET_KEY'] ?? '',
}
return { gmail, renderPdf, company }
}
/** Boot the scan + bounce poll now and every 6h. unref() so it never blocks exit. */
export function startScheduler(db: DB): NodeJS.Timeout {
const deps = schedulerDeps(db)
const today = (): string => new Date().toISOString().slice(0, 10)
const tick = (): void => {
void runDailyScan(db, deps, today()).catch((e: unknown) => console.error('[scheduler] scan failed', e))
void pollBounces(db, deps).catch((e: unknown) => console.error('[scheduler] bounce poll failed', e))
}
tick()
const handle = setInterval(tick, 6 * 60 * 60 * 1000)
handle.unref()
return handle
}
export function startServer(port: number, opts: { scheduler?: boolean } = {}): http.Server {
const app = express()
const db = openDb(process.env['HQ_DATA_DIR'])
seedIfEmpty(db) // first boot prints the owner password once
@ -22,12 +52,13 @@ export function startServer(port: number): http.Server {
app.use('/api', apiRouter(db))
const webDist = path.resolve(moduleDir, '../../hq-web/dist')
if (fs.existsSync(webDist)) app.use('/', express.static(webDist))
if (opts.scheduler === true) startScheduler(db)
return app.listen(port)
}
// Direct launch (bundled dist/server.cjs); vitest imports startServer instead.
if (process.argv[1]?.endsWith('server.cjs') || process.argv[1]?.endsWith('server.ts')) {
const PORT = Number(process.env['HQ_PORT'] ?? 5182)
startServer(PORT)
startServer(PORT, { scheduler: true })
console.log(`SiMS HQ console on http://localhost:${PORT}`)
}

@ -0,0 +1,70 @@
// apps/hq/test/dashboard.test.ts
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { apiRouter } from '../src/api'
import { createClient } from '../src/repos-clients'
import { assignModule, createModule, setPrice, updateClientModule } from '../src/repos-modules'
import { createDraft, issueDocument } from '../src/repos-documents'
import { createInteraction } from '../src/repos-interactions'
import { dashboardView } from '../src/repos-dashboard'
import { runDailyScan, type ScanDeps } from '../src/scheduler'
const scanDeps: ScanDeps = {
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z',
}
function seeded() {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
db.prepare(`UPDATE document SET doc_date='2026-06-01' WHERE id=?`).run(inv.id)
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' })
createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' })
return { db, c, inv }
}
describe('dashboardView', () => {
it('aggregates overdue, renewals, follow-ups and the reminder queue', async () => {
const { db, inv } = seeded()
await runDailyScan(db, scanDeps, '2026-07-10') // populates the queue
const view = dashboardView(db, '2026-07-10')
expect(view.overdue.map((o) => o.docId)).toContain(inv.id)
expect(view.overdue[0]!.outstandingPaise).toBe(11_800_00)
expect(view.renewalsThisMonth).toHaveLength(1)
expect(view.followUpsToday).toHaveLength(1)
expect(view.queue.length).toBeGreaterThanOrEqual(3) // overdue + renewal + follow_up
expect(view.totals.overduePaise).toBe(11_800_00)
})
})
describe('GET /api/dashboard + reminder queue routes', () => {
const { db } = seeded()
createStaff(db, { email: 'e2e@test.in', displayName: 'E2E', role: 'owner', password: 'e2e-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`
let token = ''
const call = async (method: string, path: string, body?: unknown) => {
const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) })
return { status: res.status, json: await res.json() as any }
}
afterAll(() => server.close())
it('serves the dashboard, lists the queue and dismisses a reminder', async () => {
token = (await call('POST', '/auth/login', { email: 'e2e@test.in', password: 'e2e-password' })).json.token
await runDailyScan(db, scanDeps, '2026-07-10')
const dash = (await call('GET', '/dashboard')).json
expect(dash.ok).toBe(true)
expect(dash.view.overdue.length).toBeGreaterThanOrEqual(1)
const queue = (await call('GET', '/reminders')).json.reminders
expect(queue.length).toBeGreaterThanOrEqual(1)
const dismissed = (await call('POST', `/reminders/${queue[0].id}/dismiss`)).json
expect(dismissed.reminder.status).toBe('dismissed')
})
})
Loading…
Cancel
Save