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