import { ownerScope } from './auth' import type { DB } from './db' import { outstandingPaise } from './repos-payments' import { listOpenFollowUps } from './repos-interactions' import { listQueue, queueCounts, type QueueItem } 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: QueueItem[] /** Honest queue size — `queue` is the first page only (rule 8: no silent caps). */ queueTotal: number 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 async function dashboardView( db: DB, today: string, viewer?: { id: string; role: string }, opts: { mine?: boolean } = {}, ): Promise { // My Day (D18 WS-E): staff are ALWAYS scoped to their own book (ownerScope forces // it); owner/manager opt in via mine=true. "Mine" = clients I own, documents and // payments I created, my follow-ups. const meId = viewer !== undefined && (opts.mine === true || ownerScope(viewer) !== undefined) ? viewer.id : undefined const mineDoc = meId !== undefined ? ` AND (d.created_by=? OR c.owner_id=?)` : '' const mineClient = meId !== undefined ? ` AND c.owner_id=?` : '' // Overdue anchors on the stamped due date when present (D18 WS-A) — an unpaid // invoice whose due date is still in the future is NOT overdue. const overdueRows = await db.all<{ id: string; doc_no: string | null; client_id: string; anchor: string; client_name: string }>( `SELECT d.id, d.doc_no, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, 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 COALESCE(d.due_date, d.doc_date) <= ?${mineDoc} ORDER BY anchor`, ...(meId !== undefined ? [today, meId, meId] : [today]), ) const overdue: DashboardView['overdue'] = [] let overduePaise = 0 for (const r of overdueRows) { const outstanding = await 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.anchor, today)), }) } const weekEnd = addDaysIso(today, 7) const dueThisWeek = (await db.all( `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 <= ?${mineClient} ORDER BY rp.next_run`, ...(meId !== undefined ? [today, weekEnd, meId] : [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 = (await db.all( `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 <= ?${mineClient} ORDER BY cm.next_renewal`, ...(meId !== undefined ? [today, endOfMonth(today), meId] : [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 = (await listOpenFollowUps(db, today)) .filter((f) => meId === undefined || f.staffId === meId) // my follow-ups only under My Day .map((f) => ({ id: f.id, clientId: f.clientId, clientName: f.clientName, onDate: f.onDate, followUpOn: f.followUpOn!, notes: f.notes, })) const recentPayments = (await db.all( `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 WHERE 1=1${meId !== undefined ? ' AND (p.created_by=? OR c.owner_id=?)' : ''} ORDER BY p.id DESC LIMIT 10`, ...(meId !== undefined ? [meId, meId] : []), )).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 } }) // Same owner scope as GET /reminders: staff see only their own queue (spec §6e); // a managerial My Day narrows the queue to their own rows via ownerId. const queuePage = viewer !== undefined ? await listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role, ...(meId !== undefined ? { ownerId: meId } : {}) }) : await listQueue(db) const counts = await queueCounts(db, viewer !== undefined ? (meId ?? ownerScope(viewer)) : undefined) return { today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments, queue: queuePage.rows, queueTotal: queuePage.total, totals: { overduePaise, queued: counts.queued, failed: counts.failed }, } }