You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-dashboard.ts

96 lines
4.9 KiB
TypeScript

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 function dashboardView(db: DB, today: string, viewer?: { id: string; role: 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 }
})
// Same owner scope as GET /reminders: staff see only their own queue (spec §6e).
const queuePage = viewer !== undefined
? listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role })
: listQueue(db)
const counts = queueCounts(db, viewer !== undefined ? ownerScope(viewer) : undefined)
return {
today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments,
queue: queuePage.rows, queueTotal: queuePage.total,
totals: { overduePaise, queued: counts.queued, failed: counts.failed },
}
}