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
parent
5761544c74
commit
e83d70a4ff
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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…
Reference in New Issue