feat(d27): #5 owner MIS cockpit

repos-mis misCockpit(today): this-month vs last for billed (issued invoices) +
collections (payments), total outstanding (dues-aging), open pipeline value,
renewals due in 30d, open tickets, reminder queue — composed from the existing
repo aggregates so figures reconcile with Reports/Dashboard. Owner-only GET
/reports/mis. New /mis 'Cockpit' page: KPI tiles with MoM deltas + click-through
to the source pages. Money in tests. typecheck + web build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent bd793d390f
commit 7f6e2ce904

@ -901,3 +901,17 @@ export const getRenewals = (from?: string, to?: string): Promise<RenewalRow[]> =
}
export const generateModuleRenewalQuote = (clientModuleId: string): Promise<Doc> =>
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<MisCockpit> =>
apiFetch<{ mis: MisCockpit }>('/reports/mis').then((r) => r.mis)

@ -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() {
<Route path="/reminders" element={<Reminders />} />
<Route path="/projects" element={<Projects />} />
<Route path="/renewals" element={<Renewals />} />
<Route path="/mis" element={<Mis />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />

@ -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: [

@ -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 <span style={{ fontSize: 12, opacity: 0.6 }}></span>
const pct = Math.round(((now - prev) / prev) * 100)
const up = pct >= 0
return (
<span style={{ fontSize: 12, fontWeight: 600, color: up ? 'var(--ok)' : 'var(--err)' }}>
{up ? '▲' : '▼'} {Math.abs(pct)}% vs last month
</span>
)
}
function Tile(props: { label: string; value: string; sub?: React.ReactNode; to?: string; tone?: string }) {
const inner = (
<div className="wf-card" style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 4, height: '100%' }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-dim)' }}>{props.label}</span>
<span style={{ fontSize: 24, fontWeight: 700, color: props.tone }}>{props.value}</span>
{props.sub !== undefined && <span>{props.sub}</span>}
</div>
)
return props.to !== undefined ? <Link to={props.to} style={{ textDecoration: 'none', color: 'inherit' }}>{inner}</Link> : inner
}
/** Owner MIS cockpit (D27): the numbers the owner checks, on one screen. */
export function Mis() {
const { data, error, reload } = useData<MisCockpit | undefined>(getMisCockpit, [])
return (
<div className="wf-page">
<PageHeader title="Cockpit" desc="This month at a glance — billing, collections, outstanding, pipeline, renewals and support." />
{error !== undefined ? <ErrorState message={error} onRetry={reload} />
: data === undefined ? <Skeleton rows={6} />
: (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
<Tile label="Billed this month" value={formatINR(data.invoicedThisPaise)}
sub={<Delta now={data.invoicedThisPaise} prev={data.invoicedLastPaise} />} to="/documents" />
<Tile label="Collected this month" value={formatINR(data.collectedThisPaise)}
sub={<Delta now={data.collectedThisPaise} prev={data.collectedLastPaise} />} />
<Tile label="Outstanding" value={formatINR(data.outstandingPaise)}
sub={<span style={{ fontSize: 12, opacity: 0.7 }}>{data.outstandingClients} client(s)</span>}
to="/reports" tone="var(--err)" />
<Tile label="Open pipeline" value={formatINR(data.pipelineValuePaise)}
sub={<span style={{ fontSize: 12, opacity: 0.7 }}>{data.pipelineCount} quote(s)/proforma(s)</span>} to="/pipeline" />
<Tile label="Renewals · next 30d" value={formatINR(data.renewals30Paise)}
sub={<span style={{ fontSize: 12, opacity: 0.7 }}>{data.renewals30Count} due</span>} to="/renewals"
tone={data.renewals30Count > 0 ? 'var(--warn)' : undefined} />
<Tile label="Credit notes this month" value={formatINR(data.creditNoteThisPaise)} />
<Tile label="Open tickets" value={String(data.tickets.open + data.tickets.inProgress + data.tickets.waiting)}
sub={<span style={{ fontSize: 12, opacity: 0.7 }}>{data.tickets.open} open · {data.tickets.inProgress} in progress · {data.tickets.waiting} waiting</span>}
to="/tickets" />
<Tile label="Reminder queue" value={String(data.reminders.queued)}
sub={data.reminders.failed > 0
? <span style={{ fontSize: 12, color: 'var(--err)', fontWeight: 600 }}>{data.reminders.failed} failed</span>
: <span style={{ fontSize: 12, opacity: 0.7 }}>none failed</span>}
to="/reminders" tone={data.reminders.failed > 0 ? 'var(--err)' : undefined} />
</div>
)}
</div>
)
}

@ -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 {

@ -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<number> =>
((await db.get<{ n: number }>(sql, ...a))!).n
export async function misCockpit(db: DB, today: string): Promise<MisCockpit> {
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<?`,
thisMonth, nextMonth)
const invoicedLast = 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<?`,
lastMonth, thisMonth)
const cnThis = await sum(db,
`SELECT COALESCE(SUM(payable_paise),0) AS n FROM document
WHERE doc_type='CREDIT_NOTE' AND doc_no IS NOT NULL AND status<>'cancelled' AND doc_date>=? AND doc_date<?`,
thisMonth, nextMonth)
// Cash received by received_on month.
const collectedThis = await sum(db, `SELECT COALESCE(SUM(amount_paise),0) AS n FROM payment WHERE received_on>=? AND received_on<?`, thisMonth, nextMonth)
const collectedLast = await sum(db, `SELECT COALESCE(SUM(amount_paise),0) AS n FROM payment WHERE received_on>=? AND received_on<?`, lastMonth, thisMonth)
// Outstanding = the dues-aging total (same computation the Reports page shows).
const dues = await duesAging(db, today)
const outstandingPaise = dues.reduce((s, r) => 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 },
}
}

@ -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) })
})
})
Loading…
Cancel
Save