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.
118 lines
6.2 KiB
TypeScript
118 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 async function dashboardView(
|
|
db: DB, today: string, viewer?: { id: string; role: string }, opts: { mine?: boolean } = {},
|
|
): Promise<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 = 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 },
|
|
}
|
|
}
|