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

114 lines
6.2 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 }, opts: { mine?: boolean } = {},
): DashboardView {
// 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 = db.prepare(
`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`,
).all(...(meId !== undefined ? [today, meId, meId] : [today])) as { id: string; doc_no: string | null; client_id: string; anchor: 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.anchor, 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 <= ?${mineClient} ORDER BY rp.next_run`,
).all(...(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 = 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 <= ?${mineClient} ORDER BY cm.next_renewal`,
).all(...(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 = 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 = 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
WHERE 1=1${meId !== undefined ? ' AND (p.created_by=? OR c.owner_id=?)' : ''}
ORDER BY p.id DESC LIMIT 10`,
).all(...(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
? listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role, ...(meId !== undefined ? { ownerId: meId } : {}) })
: listQueue(db)
const counts = 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 },
}
}