diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 349393f..89e9801 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -901,3 +901,17 @@ export const getRenewals = (from?: string, to?: string): Promise = } export const generateModuleRenewalQuote = (clientModuleId: string): Promise => apiFetch<{ document: Doc }>(`/client-modules/${clientModuleId}/renewal-quote`, { method: 'POST', body: '{}' }).then((r) => r.document) + +// D27: owner MIS cockpit. +export interface MisCockpit { + today: string; thisMonth: string; lastMonth: string + invoicedThisPaise: number; invoicedLastPaise: number; creditNoteThisPaise: number + collectedThisPaise: number; collectedLastPaise: number + outstandingPaise: number; outstandingClients: number + pipelineCount: number; pipelineValuePaise: number + renewals30Count: number; renewals30Paise: number + tickets: { open: number; inProgress: number; waiting: number } + reminders: { queued: number; failed: number } +} +export const getMisCockpit = (): Promise => + apiFetch<{ mis: MisCockpit }>('/reports/mis').then((r) => r.mis) diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index cff57a5..3e46573 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -25,6 +25,7 @@ import { Reminders } from './pages/Reminders' import { Tickets } from './pages/Tickets' import { Projects } from './pages/Projects' import { Renewals } from './pages/Renewals' +import { Mis } from './pages/Mis' function App() { return ( @@ -37,6 +38,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index 51affad..0b41f12 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -1,5 +1,5 @@ import { - BarChart3, BellRing, CalendarClock, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks, + BarChart3, BellRing, CalendarClock, Gauge, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks, Settings as SettingsIcon, Upload, UserCog, Users, Wrench, type LucideIcon, } from 'lucide-react' @@ -28,7 +28,7 @@ export const NAV_GROUPS: NavGroup[] = [ ], }, { label: 'Catalog', items: [{ to: '/modules', label: 'Modules', icon: Boxes }] }, - { label: 'Insight', items: [{ to: '/reports', label: 'Reports', icon: BarChart3 }] }, + { label: 'Insight', items: [{ to: '/mis', label: 'Cockpit', icon: Gauge, ownerOnly: true }, { to: '/reports', label: 'Reports', icon: BarChart3 }] }, { label: 'Admin', items: [ diff --git a/apps/hq-web/src/pages/Mis.tsx b/apps/hq-web/src/pages/Mis.tsx new file mode 100644 index 0000000..ae30d1d --- /dev/null +++ b/apps/hq-web/src/pages/Mis.tsx @@ -0,0 +1,65 @@ +import { Link } from 'react-router-dom' +import { formatINR } from '@sims/domain' +import { ErrorState, PageHeader, Skeleton } from '@sims/ui' +import { getMisCockpit, type MisCockpit } from '../api' +import { useData } from './Clients' + +/** Percentage change this month vs last, as a small coloured delta. */ +function Delta({ now, prev }: { now: number; prev: number }) { + if (prev === 0) return + const pct = Math.round(((now - prev) / prev) * 100) + const up = pct >= 0 + return ( + + {up ? '▲' : '▼'} {Math.abs(pct)}% vs last month + + ) +} + +function Tile(props: { label: string; value: string; sub?: React.ReactNode; to?: string; tone?: string }) { + const inner = ( +
+ {props.label} + {props.value} + {props.sub !== undefined && {props.sub}} +
+ ) + return props.to !== undefined ? {inner} : inner +} + +/** Owner MIS cockpit (D27): the numbers the owner checks, on one screen. */ +export function Mis() { + const { data, error, reload } = useData(getMisCockpit, []) + return ( +
+ + {error !== undefined ? + : data === undefined ? + : ( +
+ } to="/documents" /> + } /> + {data.outstandingClients} client(s)} + to="/reports" tone="var(--err)" /> + {data.pipelineCount} quote(s)/proforma(s)} to="/pipeline" /> + {data.renewals30Count} due} to="/renewals" + tone={data.renewals30Count > 0 ? 'var(--warn)' : undefined} /> + + {data.tickets.open} open · {data.tickets.inProgress} in progress · {data.tickets.waiting} waiting} + to="/tickets" /> + 0 + ? {data.reminders.failed} failed + : none failed} + to="/reminders" tone={data.reminders.failed > 0 ? 'var(--err)' : undefined} /> +
+ )} +
+ ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index eef53c3..d05fc59 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -61,6 +61,7 @@ import { pullMonthlyCosts } from './aws-costs' import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRange } from './repos-reports' import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals' +import { misCockpit } from './repos-mis' import { makeRateLimiter } from './rate-limit' import { addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, @@ -1512,6 +1513,15 @@ export function apiRouter( } }) + // ---------- owner MIS cockpit (D27) ---------- + r.get('/reports/mis', requireAuth, requireOwner, async (_req, res) => { + try { + res.json({ ok: true, mis: await misCockpit(db, new Date().toISOString().slice(0, 10)) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- renewals command center (D27) ---------- r.get('/renewals', requireAuth, async (req, res) => { try { diff --git a/apps/hq/src/repos-mis.ts b/apps/hq/src/repos-mis.ts new file mode 100644 index 0000000..8e99f44 --- /dev/null +++ b/apps/hq/src/repos-mis.ts @@ -0,0 +1,89 @@ +import type { DB } from './db' +import { duesAging } from './repos-reports' +import { ticketCounts } from './repos-tickets' +import { queueCounts } from './repos-reminders' +import { renewalsDue } from './repos-renewals' + +/** + * Owner MIS cockpit (D27): one read-only screen of the numbers the owner checks — + * this month vs last for billing + collections, total outstanding, open pipeline value, + * renewals due, open tickets, reminder queue. Composes the SAME repo aggregates the + * Reports/Dashboard pages use, so the figures reconcile exactly. Integer paise throughout. + */ + +function monthStart(iso: string): string { return iso.slice(0, 7) + '-01' } +function addMonths(ymd: string, n: number): string { + const [y, m] = ymd.split('-').map(Number) + const d = new Date(Date.UTC(y!, (m! - 1) + n, 1)) + return d.toISOString().slice(0, 10) +} + +export interface MisCockpit { + today: string; thisMonth: string; lastMonth: string + invoicedThisPaise: number; invoicedLastPaise: number + creditNoteThisPaise: number + collectedThisPaise: number; collectedLastPaise: number + outstandingPaise: number; outstandingClients: number + pipelineCount: number; pipelineValuePaise: number + renewals30Count: number; renewals30Paise: number + tickets: { open: number; inProgress: number; waiting: number } + reminders: { queued: number; failed: number } +} + +const sum = async (db: DB, sql: string, ...a: unknown[]): Promise => + ((await db.get<{ n: number }>(sql, ...a))!).n + +export async function misCockpit(db: DB, today: string): Promise { + const thisMonth = monthStart(today) + const lastMonth = addMonths(thisMonth, -1) + const nextMonth = addMonths(thisMonth, 1) + + // Billed (issued invoices, non-cancelled) by doc_date month. + const invoicedThis = await sum(db, + `SELECT COALESCE(SUM(payable_paise),0) AS n FROM document + WHERE doc_type='INVOICE' AND doc_no IS NOT NULL AND status<>'cancelled' AND doc_date>=? AND doc_date'cancelled' AND doc_date>=? AND doc_date'cancelled' AND doc_date>=? AND doc_date=? AND received_on=? AND received_on s + r.totalPaise, 0) + + // Open pipeline = issued/draft quotes+proformas not yet won/lost. + const pipelineCount = await sum(db, + `SELECT COUNT(*) AS n FROM document + WHERE doc_type IN ('QUOTATION','PROFORMA') AND status IN ('draft','sent','accepted')`) + const pipelineValuePaise = await sum(db, + `SELECT COALESCE(SUM(payable_paise),0) AS n FROM document + WHERE doc_type IN ('QUOTATION','PROFORMA') AND status IN ('draft','sent','accepted')`) + + // Renewals in the next 30 days (modules + AMC), reusing the renewals repo. + const to30 = new Date(Date.parse(today) + 30 * 86_400_000).toISOString().slice(0, 10) + const renewals = await renewalsDue(db, today, to30, today) + const renewals30Paise = renewals.reduce((s, r) => s + (r.amountPaise ?? 0), 0) + + const tc = await ticketCounts(db) + const qc = await queueCounts(db) + + return { + today, thisMonth, lastMonth, + invoicedThisPaise: invoicedThis, invoicedLastPaise: invoicedLast, creditNoteThisPaise: cnThis, + collectedThisPaise: collectedThis, collectedLastPaise: collectedLast, + outstandingPaise, outstandingClients: dues.length, + pipelineCount, pipelineValuePaise, + renewals30Count: renewals.length, renewals30Paise, + tickets: { open: tc['open'] ?? 0, inProgress: tc['in_progress'] ?? 0, waiting: tc['waiting'] ?? 0 }, + reminders: { queued: qc.queued, failed: qc.failed }, + } +} diff --git a/apps/hq/test/mis.test.ts b/apps/hq/test/mis.test.ts new file mode 100644 index 0000000..7b48c37 --- /dev/null +++ b/apps/hq/test/mis.test.ts @@ -0,0 +1,43 @@ +// apps/hq/test/mis.test.ts — D27: owner MIS cockpit composes existing aggregates. +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft, issueDocument } from '../src/repos-documents' +import { recordPayment } from '../src/repos-payments' +import { misCockpit } from '../src/repos-mis' + +describe('misCockpit (D27)', () => { + it('sums this-month billing, collections, outstanding and pipeline', async () => { + const db = openDb(':memory:'); await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Bank', stateCode: '32' }) + const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core', allowedKinds: ['yearly'] }) + await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + const today = new Date().toISOString().slice(0, 10) + const month = today.slice(0, 7) + + // An issued invoice dated this month → billed + outstanding. + const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { + docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], + })).id) + await db.run(`UPDATE document SET doc_date=? WHERE id=?`, `${month}-05`, inv.id) + // An open quote → pipeline. + await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + // A part payment this month → collections + reduces outstanding. + await recordPayment(db, 'u1', { + clientId: c.id, receivedOn: `${month}-10`, mode: 'bank', amountPaise: 5_000_00, + allocations: [{ documentId: inv.id, amountPaise: 5_000_00 }], + }) + + const mis = await misCockpit(db, today) + expect(mis.invoicedThisPaise).toBe(11_800_00) // one invoice incl. GST + expect(mis.collectedThisPaise).toBe(5_000_00) // the part payment + expect(mis.outstandingPaise).toBe(6_800_00) // 11,800 − 5,000 + expect(mis.outstandingClients).toBe(1) + expect(mis.pipelineCount).toBe(1) // the open quote + expect(mis.pipelineValuePaise).toBe(11_800_00) + expect(mis.tickets).toMatchObject({ open: 0 }) + expect(mis.reminders).toMatchObject({ queued: expect.any(Number), failed: expect.any(Number) }) + }) +})