feat(sms): live-only balance list, no full-row red, mandatory creds on assign

- SMS balances now lists ONLY live SMS subscriptions (status='live'); quoted/installing
  ones are excluded (105 -> 79 real). The daily/manual gateway pull is likewise live-only.
- Removed the full-row red tint on low rows — the red balance badge alone signals it.
- Assigning the SMS module now REQUIRES the gateway username + password (both the Client
  360 'Assign module' form and the Modules-page '+ Add client' panel): Assign is blocked
  until they're filled, and they're set on the new subscription in the same flow. So a
  live SMS client always has the credentials the balance pull needs.
Test updated (assign live); typecheck + suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent 1bf0e4ee45
commit da67b8b393

@ -259,9 +259,12 @@ export function ClientDetail() {
{modules.data !== undefined && ( {modules.data !== undefined && (
<AssignModuleForm <AssignModuleForm
modules={modules.data} modules={modules.data}
onAssign={(moduleId, kind) => { onAssign={(moduleId, kind, creds) => {
assignClientModule(id, { moduleId, kind }) assignClientModule(id, { moduleId, kind })
.then(() => { cms.reload(); ledger.reload() }) .then((cm) => creds !== undefined
? patchClientModule(cm.id, { username: creds.username, password: creds.password })
: cm)
.then(() => { toast.ok('Module assigned'); cms.reload(); ledger.reload() })
.catch((err: Error) => toast.err(err.message)) .catch((err: Error) => toast.err(err.message))
}} }}
/> />

@ -153,6 +153,9 @@ function ModuleClients(props: { module: Module }) {
const [assignPick, setAssignPick] = useState<Client | undefined>() const [assignPick, setAssignPick] = useState<Client | undefined>()
const [assignKind, setAssignKind] = useState<Kind>(props.module.allowedKinds[0] ?? 'yearly') const [assignKind, setAssignKind] = useState<Kind>(props.module.allowedKinds[0] ?? 'yearly')
const [assignEdition, setAssignEdition] = useState('standard') const [assignEdition, setAssignEdition] = useState('standard')
const [assignUser, setAssignUser] = useState('')
const [assignPass, setAssignPass] = useState('')
const needsCreds = props.module.code === 'SMS' // SMS gateway login required at assign
const saveEdit = () => { const saveEdit = () => {
if (edit === undefined || busy) return if (edit === undefined || busy) return
@ -172,9 +175,15 @@ function ModuleClients(props: { module: Module }) {
} }
const doAssign = () => { const doAssign = () => {
if (assignPick === undefined || busy) return if (assignPick === undefined || busy) return
if (needsCreds && (assignUser.trim() === '' || assignPass.trim() === '')) {
setErr('SMS needs the gateway username and password'); return
}
setBusy(true); setErr(undefined) setBusy(true); setErr(undefined)
assignClientModule(assignPick.id, { moduleId: props.module.id, kind: assignKind, edition: assignEdition }) assignClientModule(assignPick.id, { moduleId: props.module.id, kind: assignKind, edition: assignEdition })
.then(() => { setAssigning(false); setAssignQ(''); setAssignPick(undefined); roster.reload() }) .then((cm) => needsCreds
? patchClientModule(cm.id, { username: assignUser.trim(), password: assignPass.trim() })
: cm)
.then(() => { setAssigning(false); setAssignQ(''); setAssignPick(undefined); setAssignUser(''); setAssignPass(''); roster.reload() })
.catch((e: Error) => setErr(e.message)) .catch((e: Error) => setErr(e.message))
.finally(() => setBusy(false)) .finally(() => setBusy(false))
} }
@ -233,6 +242,13 @@ function ModuleClients(props: { module: Module }) {
<Field label="Edition / pack"> <Field label="Edition / pack">
<input value={assignEdition} onChange={(e) => setAssignEdition(e.target.value)} /> <input value={assignEdition} onChange={(e) => setAssignEdition(e.target.value)} />
</Field> </Field>
{needsCreds && (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<Field label="SMS username *"><input className="wf mono" style={{ width: 160 }} value={assignUser} onChange={(e) => setAssignUser(e.target.value)} /></Field>
<Field label="SMS password *"><input className="wf mono" style={{ width: 160 }} value={assignPass} onChange={(e) => setAssignPass(e.target.value)} /></Field>
<span style={{ fontSize: 12, color: 'var(--text-dim)', alignSelf: 'center' }}>Required the gateway login the balance pull uses.</span>
</div>
)}
<Button tone="primary" onClick={() => { if (assignPick !== undefined && !busy) doAssign() }}> <Button tone="primary" onClick={() => { if (assignPick !== undefined && !busy) doAssign() }}>
{busy ? 'Assigning…' : 'Assign to module'} {busy ? 'Assigning…' : 'Assign to module'}
</Button> </Button>

@ -141,7 +141,6 @@ export function SmsBalances() {
{ key: 'checked', label: 'Checked' }, { key: 'checked', label: 'Checked' },
{ key: 'actions', label: '' }, { key: 'actions', label: '' },
]} ]}
rowTone={(i) => (shown[i]!.low ? 'err' : undefined)}
rows={shown.map((r) => ({ rows={shown.map((r) => ({
client: <Link to={`/clients/${r.clientId}?tab=modules`}>{r.clientCode} · {r.clientName}</Link>, client: <Link to={`/clients/${r.clientId}?tab=modules`}>{r.clientCode} · {r.clientName}</Link>,
balance: r.balance === null balance: r.balance === null

@ -282,15 +282,25 @@ export function InteractionDialog(props: {
) )
} }
export function AssignModuleForm(props: { modules: Module[]; onAssign: (moduleId: string, kind: Kind) => void }) { export function AssignModuleForm(props: {
modules: Module[]
onAssign: (moduleId: string, kind: Kind, creds?: { username: string; password: string }) => void
}) {
const [moduleId, setModuleId] = useState('') const [moduleId, setModuleId] = useState('')
const mod = props.modules.find((m) => m.id === moduleId) const mod = props.modules.find((m) => m.id === moduleId)
const [kind, setKind] = useState<Kind | ''>('') const [kind, setKind] = useState<Kind | ''>('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
// SMS is a gateway service — its login is required at assign time so the balance pull works.
const needsCreds = mod?.code === 'SMS'
const ready = moduleId !== '' && kind !== '' && (!needsCreds || (username.trim() !== '' && password.trim() !== ''))
const reset = () => { setModuleId(''); setKind(''); setUsername(''); setPassword('') }
return ( return (
<div className="wf-card" style={{ padding: '10px 14px', marginBottom: 10 }}>
<Toolbar> <Toolbar>
<select <select
className="wf" value={moduleId} className="wf" value={moduleId}
onChange={(e) => { setModuleId(e.target.value); setKind('') }} onChange={(e) => { setModuleId(e.target.value); setKind(''); setUsername(''); setPassword('') }}
> >
<option value="">Assign module</option> <option value="">Assign module</option>
{props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)} {props.modules.filter((m) => m.active).map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
@ -301,13 +311,31 @@ export function AssignModuleForm(props: { modules: Module[]; onAssign: (moduleId
{mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)} {mod.allowedKinds.map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select> </select>
)} )}
{needsCreds && (
<>
<input className="wf mono" style={{ width: 150 }} placeholder="SMS username *" aria-label="SMS username"
value={username} onChange={(e) => setUsername(e.target.value)} />
<input className="wf mono" style={{ width: 150 }} placeholder="SMS password *" aria-label="SMS password"
value={password} onChange={(e) => setPassword(e.target.value)} />
</>
)}
<Button <Button
tone="primary" tone="primary" disabled={!ready}
onClick={() => { if (moduleId !== '' && kind !== '') { props.onAssign(moduleId, kind); setModuleId(''); setKind('') } }} onClick={() => {
if (!ready) return
props.onAssign(moduleId, kind as Kind, needsCreds ? { username: username.trim(), password: password.trim() } : undefined)
reset()
}}
> >
Assign Assign
</Button> </Button>
</Toolbar> </Toolbar>
{needsCreds && (
<span style={{ fontSize: 12, color: 'var(--text-dim)' }}>
SMS needs the provider gateway login username and password are required so the balance can be pulled.
</span>
)}
</div>
) )
} }

@ -66,7 +66,7 @@ export async function smsBalances(db: DB, includeSecrets = false): Promise<SmsBa
FROM client_module cm FROM client_module cm
JOIN client c ON c.id = cm.client_id JOIN client c ON c.id = cm.client_id
LEFT JOIN sms_balance_check s ON s.client_module_id = cm.id LEFT JOIN sms_balance_check s ON s.client_module_id = cm.id
WHERE cm.module_id = ? AND cm.active = 1`, WHERE cm.module_id = ? AND cm.active = 1 AND cm.status = 'live'`,
mod.id, mod.id,
) )
const rows: SmsBalanceRow[] = raw.map((r) => { const rows: SmsBalanceRow[] = raw.map((r) => {
@ -191,7 +191,7 @@ export async function refreshAllSmsBalances(db: DB, userId: string, keyHex: stri
if (mod === undefined) return summary if (mod === undefined) return summary
const url = await smsBalanceApiUrl(db) const url = await smsBalanceApiUrl(db)
const rows = await db.all<{ id: string; username: string | null }>( const rows = await db.all<{ id: string; username: string | null }>(
`SELECT id, username FROM client_module WHERE module_id=? AND active=1`, mod.id, `SELECT id, username FROM client_module WHERE module_id=? AND active=1 AND status='live'`, mod.id,
) )
for (const r of rows) { for (const r of rows) {
if (r.username === null || r.username === '') { summary.skipped++; continue } if (r.username === null || r.username === '') { summary.skipped++; continue }

@ -20,9 +20,10 @@ describe('smsBalances (D28)', () => {
const low = await createClient(db, 'u1', { name: 'Low Bank', stateCode: '32' }) const low = await createClient(db, 'u1', { name: 'Low Bank', stateCode: '32' })
const high = await createClient(db, 'u1', { name: 'High 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 blank = await createClient(db, 'u1', { name: 'Blank Bank', stateCode: '32' })
const cmLow = await assignModule(db, 'u1', { clientId: low.id, moduleId: sms.id, kind: 'yearly' }) // Only LIVE SMS shows on the balance screen (D28) — assign these live.
const cmHigh = await assignModule(db, 'u1', { clientId: high.id, moduleId: sms.id, kind: 'yearly' }) const cmLow = await assignModule(db, 'u1', { clientId: low.id, moduleId: sms.id, kind: 'yearly', status: 'live' })
await assignModule(db, 'u1', { clientId: blank.id, moduleId: sms.id, kind: 'yearly' }) const cmHigh = await assignModule(db, 'u1', { clientId: high.id, moduleId: sms.id, kind: 'yearly', status: 'live' })
await assignModule(db, 'u1', { clientId: blank.id, moduleId: sms.id, kind: 'yearly', status: 'live' })
await setModuleFieldValues(db, 'u1', cmLow.id, { sms_balance: '2,500' }) // comma-formatted, below threshold 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 await setModuleFieldValues(db, 'u1', cmHigh.id, { sms_balance: '82000' }) // above threshold

Loading…
Cancel
Save