feat(hq): dashboard money view and 6-hourly scheduler wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
7e1dcc1ad8
commit
06b5eb532c
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in New Issue