feat(d28): #6 SMS credit review + low-balance upsell

New 'SMS balances' screen: every SMS client with its credit balance (the module's
own sms_balance service field), lowest first so the ones about to run dry sit on
top. Low = below sms.low_balance_threshold (dated setting, config over code);
'Low' is the default view = the upsell list. One click raises a top-up proforma
(reuses the module renewal-quote path); client name links to the Modules tab where
the balance is kept. Owner cockpit gains an 'SMS balance low' tile as the at-a-
glance alert. Read-only report, no audit. Test locks parse/flag/sort. typecheck +
web build clean.

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

@ -912,10 +912,24 @@ export interface MisCockpit {
renewals30Count: number; renewals30Paise: number
tickets: { open: number; inProgress: number; waiting: number }
reminders: { queued: number; failed: number }
smsLow: number; smsThreshold: number
}
export const getMisCockpit = (): Promise<MisCockpit> =>
apiFetch<{ mis: MisCockpit }>('/reports/mis').then((r) => r.mis)
// D28: SMS credit review — who is running low, top them up.
export interface SmsBalanceRow {
cmId: string; clientId: string; clientName: string; clientCode: string
balanceText: string | null; balance: number | null; low: boolean
reseller: string | null; status: string
}
export interface SmsBalances {
rows: SmsBalanceRow[]; threshold: number
total: number; lowCount: number; unknownCount: number
}
export const getSmsBalances = (): Promise<SmsBalances> =>
apiFetch<SmsBalances>('/reports/sms-balances')
// D27: audit log viewer (owner-only).
export interface AuditRow {
id: string; at_wall: string; user_id: string; action: string

@ -27,6 +27,7 @@ import { Projects } from './pages/Projects'
import { Renewals } from './pages/Renewals'
import { Mis } from './pages/Mis'
import { Audit } from './pages/Audit'
import { SmsBalances } from './pages/SmsBalances'
function App() {
return (
@ -41,6 +42,7 @@ function App() {
<Route path="/renewals" element={<Renewals />} />
<Route path="/mis" element={<Mis />} />
<Route path="/audit" element={<Audit />} />
<Route path="/sms-balances" element={<SmsBalances />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} />

@ -1,6 +1,6 @@
import {
BarChart3, BellRing, CalendarClock, Gauge, Boxes, FilePlus2, Files, Filter, LayoutDashboard, ListChecks,
ScrollText, Settings as SettingsIcon, Upload, UserCog, Users, Wrench,
MessageSquare, ScrollText, Settings as SettingsIcon, Upload, UserCog, Users, Wrench,
type LucideIcon,
} from 'lucide-react'
@ -22,6 +22,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ to: '/projects', label: 'Onboarding', icon: ListChecks },
{ to: '/tickets', label: 'Tickets', icon: Wrench },
{ to: '/renewals', label: 'Renewals', icon: CalendarClock },
{ to: '/sms-balances', label: 'SMS balances', icon: MessageSquare },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/documents', label: 'Documents', icon: Files, end: true },
{ to: '/documents/new', label: 'New Document', icon: FilePlus2 },

@ -50,6 +50,9 @@ export function Mis() {
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="SMS balance low" value={String(data.smsLow)}
sub={<span style={{ fontSize: 12, opacity: 0.7 }}>below {data.smsThreshold.toLocaleString('en-IN')} SMS</span>}
to="/sms-balances" tone={data.smsLow > 0 ? 'var(--warn)' : undefined} />
<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" />

@ -0,0 +1,104 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar, useToast,
} from '@sims/ui'
import { getSmsBalances, generateModuleRenewalQuote, type SmsBalanceRow } from '../api'
import { useData } from './Clients'
type Scope = 'low' | 'all' | 'unknown'
/**
* SMS credit review (D28): every SMS client with its balance, lowest first, so the
* ones about to run dry are at the top. "Low" (below the threshold) is the default
* view that's the upsell list. One click raises a top-up proforma; the client name
* links to the Modules tab where the balance is recorded. Balances are entered by the
* team (there is no SMS-gateway sync deliberately kept simple).
*/
export function SmsBalances() {
const nav = useNavigate()
const toast = useToast()
const [scope, setScope] = useState<Scope>('low')
const [busy, setBusy] = useState<string | undefined>()
const list = useData(getSmsBalances, [])
const raiseTopup = (r: SmsBalanceRow) => {
if (busy !== undefined) return
setBusy(r.cmId)
generateModuleRenewalQuote(r.cmId)
.then((doc) => { toast.ok('Top-up proforma created'); nav(`/documents/${doc.id}`) })
.catch((e: Error) => { toast.err(e.message); setBusy(undefined) })
}
const data = list.data
const shown = useMemo(() => {
const all = data?.rows ?? []
if (scope === 'low') return all.filter((r) => r.low)
if (scope === 'unknown') return all.filter((r) => r.balance === null)
return all
}, [data, scope])
return (
<div className="wf-page">
<PageHeader
title="SMS balances"
desc="SMS credits across every client, lowest first — spot who is about to run dry and raise a top-up before they do."
/>
{data !== undefined && data.lowCount > 0 && scope !== 'low' && (
<Notice tone="warn">
{data.lowCount} client{data.lowCount === 1 ? '' : 's'} below {data.threshold.toLocaleString('en-IN')} SMS needs a top-up.
</Notice>
)}
<Toolbar>
<FilterChips
chips={[
{ key: 'low', label: 'Low', count: data?.lowCount ?? 0 },
{ key: 'all', label: 'All', count: data?.total ?? 0 },
{ key: 'unknown', label: 'Not recorded', count: data?.unknownCount ?? 0 },
]}
active={scope}
onChange={(k) => setScope(k as Scope)}
/>
<span style={{ flex: 1 }} />
{data !== undefined && <Badge>threshold {data.threshold.toLocaleString('en-IN')}</Badge>}
</Toolbar>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: data === undefined ? <Skeleton rows={6} />
: shown.length === 0 ? (
<EmptyState>
{scope === 'low' ? 'No SMS client is below the low-balance threshold — all topped up.'
: scope === 'unknown' ? 'Every SMS client has a balance recorded.'
: 'No SMS clients yet.'}
</EmptyState>
) : (
<DataTable
columns={[
{ key: 'client', label: 'Client' },
{ key: 'balance', label: 'Balance', numeric: true },
{ key: 'reseller', label: 'Reseller' },
{ key: 'actions', label: '' },
]}
rowTone={(i) => (shown[i]!.low ? 'err' : undefined)}
rows={shown.map((r) => ({
client: <Link to={`/clients/${r.clientId}?tab=modules`}>{r.clientCode} · {r.clientName}</Link>,
balance: r.balance === null
? <span style={{ opacity: 0.55 }}>not recorded</span>
: (
<Badge tone={r.low ? 'red' : 'green'}>
{r.balance.toLocaleString('en-IN')}
</Badge>
),
reseller: r.reseller ?? '—',
actions: (
<span onClick={(e) => e.stopPropagation()}>
<Button tone="primary" disabled={busy !== undefined} onClick={() => raiseTopup(r)}>
{busy === r.cmId ? '…' : 'Top-up quote'}
</Button>
</span>
),
}))}
/>
)}
</div>
)
}

@ -62,6 +62,7 @@ import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRan
import { listUsageRateCards, setUsageRateCard } from './repos-rate-card'
import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals'
import { misCockpit } from './repos-mis'
import { smsBalances } from './repos-sms'
import { makeRateLimiter } from './rate-limit'
import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard,
@ -1513,6 +1514,15 @@ export function apiRouter(
}
})
// ---------- SMS credit review (D28) ----------
r.get('/reports/sms-balances', requireAuth, async (_req, res) => {
try {
res.json({ ok: true, ...await smsBalances(db) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- audit log viewer (D27, owner-only) ----------
r.get('/audit', requireAuth, requireOwner, async (req, res) => {
try {

@ -3,6 +3,7 @@ import { duesAging } from './repos-reports'
import { ticketCounts } from './repos-tickets'
import { queueCounts } from './repos-reminders'
import { renewalsDue } from './repos-renewals'
import { smsLowCount } from './repos-sms'
/**
* Owner MIS cockpit (D27): one read-only screen of the numbers the owner checks
@ -28,6 +29,7 @@ export interface MisCockpit {
renewals30Count: number; renewals30Paise: number
tickets: { open: number; inProgress: number; waiting: number }
reminders: { queued: number; failed: number }
smsLow: number; smsThreshold: number
}
const sum = async (db: DB, sql: string, ...a: unknown[]): Promise<number> =>
@ -75,6 +77,7 @@ export async function misCockpit(db: DB, today: string): Promise<MisCockpit> {
const tc = await ticketCounts(db)
const qc = await queueCounts(db)
const sms = await smsLowCount(db)
return {
today, thisMonth, lastMonth,
@ -85,5 +88,6 @@ export async function misCockpit(db: DB, today: string): Promise<MisCockpit> {
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 },
smsLow: sms.low, smsThreshold: sms.threshold,
}
}

@ -0,0 +1,72 @@
import type { DB } from './db'
import { getNumberSetting } from './repos-reminders'
/**
* SMS credit review (D28) the "who is running low, who to top up" screen and its
* count for the cockpit. Balance is the SMS module's OWN service field `sms_balance`
* (config over code: the module declares the field; D26). We never invent a value
* blank or unparseable text shows as "unknown", not zero. Read-only: writes no audit.
*/
export interface SmsBalanceRow {
cmId: string; clientId: string; clientName: string; clientCode: string
balanceText: string | null; balance: number | null; low: boolean
reseller: string | null; status: string
}
export interface SmsBalances {
rows: SmsBalanceRow[]; threshold: number
total: number; lowCount: number; unknownCount: number
}
/** Leading integer of a free-text balance ("82,000", "82000 sms" → 82000); null if none. */
function parseBalance(text: string | null): number | null {
if (text === null) return null
const m = text.replace(/,/g, '').match(/-?\d+/)
return m === null ? null : parseInt(m[0], 10)
}
/** Low-balance count only — cheap enough for the cockpit tile without the full list. */
export async function smsLowCount(db: DB): Promise<{ low: number; threshold: number }> {
const b = await smsBalances(db)
return { low: b.lowCount, threshold: b.threshold }
}
export async function smsBalances(db: DB): Promise<SmsBalances> {
const threshold = await getNumberSetting(db, 'sms.low_balance_threshold', 10000)
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code='SMS'`)
if (mod === undefined) return { rows: [], threshold, total: 0, lowCount: 0, unknownCount: 0 }
const raw = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string
field_values: string; status: string
}>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.field_values, cm.status
FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.module_id = ? AND cm.active = 1`,
mod.id,
)
const rows: SmsBalanceRow[] = raw.map((r) => {
let fv: Record<string, unknown> = {}
try { fv = JSON.parse(r.field_values) as Record<string, unknown> } catch { fv = {} }
const balanceText = typeof fv.sms_balance === 'string' && fv.sms_balance.trim() !== '' ? fv.sms_balance.trim() : null
const balance = parseBalance(balanceText)
return {
cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
balanceText, balance, low: balance !== null && balance < threshold,
reseller: typeof fv.reseller === 'string' && fv.reseller.trim() !== '' ? fv.reseller.trim() : null,
status: r.status,
}
})
// Lowest known balance first (the ones to act on); unknown balances sink to the bottom.
rows.sort((a, b) => {
if (a.balance === null && b.balance === null) return a.clientName.localeCompare(b.clientName)
if (a.balance === null) return 1
if (b.balance === null) return -1
return a.balance - b.balance
})
return {
rows, threshold, total: rows.length,
lowCount: rows.filter((r) => r.low).length,
unknownCount: rows.filter((r) => r.balance === null).length,
}
}

@ -0,0 +1,42 @@
// apps/hq/test/sms-balances.test.ts — D28: SMS credit review + low-balance flag.
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule, setModuleFieldValues } from '../src/repos-modules'
import { setSetting } from '../src/repos-reminders'
import { smsBalances } from '../src/repos-sms'
describe('smsBalances (D28)', () => {
it('parses the sms_balance field, flags low, sorts lowest-first, unknown sinks', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
// SMS module (with its sms_balance service field) comes from APEX import in prod; make it here.
const sms = await createModule(db, 'u1', {
code: 'SMS', name: 'SMS Alerts', sac: '998319', allowedKinds: ['yearly'],
fieldSpec: [{ key: 'sms_balance', label: 'SMS balance', type: 'text' }],
})
await setSetting(db, 'u1', 'sms.low_balance_threshold', '10000')
const low = await createClient(db, 'u1', { name: 'Low Bank', stateCode: '32' })
const high = await createClient(db, 'u1', { name: 'High Bank', stateCode: '32' })
const blank = await createClient(db, 'u1', { name: 'Blank Bank', stateCode: '32' })
const cmLow = await assignModule(db, 'u1', { clientId: low.id, moduleId: sms.id, kind: 'yearly' })
const cmHigh = await assignModule(db, 'u1', { clientId: high.id, moduleId: sms.id, kind: 'yearly' })
await assignModule(db, 'u1', { clientId: blank.id, moduleId: sms.id, kind: 'yearly' })
await setModuleFieldValues(db, 'u1', cmLow.id, { sms_balance: '2,500' }) // comma-formatted, below threshold
await setModuleFieldValues(db, 'u1', cmHigh.id, { sms_balance: '82000' }) // above threshold
const b = await smsBalances(db)
expect(b.threshold).toBe(10000)
expect(b.total).toBe(3)
expect(b.lowCount).toBe(1)
expect(b.unknownCount).toBe(1)
// lowest known first, unknown last
expect(b.rows[0]!.clientName).toBe('Low Bank')
expect(b.rows[0]!.balance).toBe(2500)
expect(b.rows[0]!.low).toBe(true)
expect(b.rows[1]!.clientName).toBe('High Bank')
expect(b.rows[1]!.low).toBe(false)
expect(b.rows[2]!.balance).toBeNull() // blank → unknown, sunk to bottom
})
})
Loading…
Cancel
Save