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
parent
bd793d390f
commit
7f6e2ce904
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 },
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue