diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 2951f8d..97b42cc 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -259,9 +259,12 @@ export function ClientDetail() { {modules.data !== undefined && ( { + onAssign={(moduleId, kind, creds) => { 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)) }} /> diff --git a/apps/hq-web/src/pages/Modules.tsx b/apps/hq-web/src/pages/Modules.tsx index f5c0907..631e8b2 100644 --- a/apps/hq-web/src/pages/Modules.tsx +++ b/apps/hq-web/src/pages/Modules.tsx @@ -153,6 +153,9 @@ function ModuleClients(props: { module: Module }) { const [assignPick, setAssignPick] = useState() const [assignKind, setAssignKind] = useState(props.module.allowedKinds[0] ?? 'yearly') 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 = () => { if (edit === undefined || busy) return @@ -172,9 +175,15 @@ function ModuleClients(props: { module: Module }) { } const doAssign = () => { if (assignPick === undefined || busy) return + if (needsCreds && (assignUser.trim() === '' || assignPass.trim() === '')) { + setErr('SMS needs the gateway username and password'); return + } setBusy(true); setErr(undefined) 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)) .finally(() => setBusy(false)) } @@ -233,6 +242,13 @@ function ModuleClients(props: { module: Module }) { setAssignEdition(e.target.value)} /> + {needsCreds && ( +
+ setAssignUser(e.target.value)} /> + setAssignPass(e.target.value)} /> + Required — the gateway login the balance pull uses. +
+ )} diff --git a/apps/hq-web/src/pages/SmsBalances.tsx b/apps/hq-web/src/pages/SmsBalances.tsx index 883317a..de09cce 100644 --- a/apps/hq-web/src/pages/SmsBalances.tsx +++ b/apps/hq-web/src/pages/SmsBalances.tsx @@ -141,7 +141,6 @@ export function SmsBalances() { { key: 'checked', label: 'Checked' }, { key: 'actions', label: '' }, ]} - rowTone={(i) => (shown[i]!.low ? 'err' : undefined)} rows={shown.map((r) => ({ client: {r.clientCode} · {r.clientName}, balance: r.balance === null diff --git a/apps/hq-web/src/pages/client-forms.tsx b/apps/hq-web/src/pages/client-forms.tsx index cc14d25..a092b05 100644 --- a/apps/hq-web/src/pages/client-forms.tsx +++ b/apps/hq-web/src/pages/client-forms.tsx @@ -282,32 +282,60 @@ 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 mod = props.modules.find((m) => m.id === moduleId) const [kind, setKind] = useState('') + 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 ( - - - {mod !== undefined && ( - { setModuleId(e.target.value); setKind(''); setUsername(''); setPassword('') }} + > + + {props.modules.filter((m) => m.active).map((m) => )} + {mod !== undefined && ( + + )} + {needsCreds && ( + <> + setUsername(e.target.value)} /> + setPassword(e.target.value)} /> + + )} + + + {needsCreds && ( + + SMS needs the provider gateway login — username and password are required so the balance can be pulled. + )} - - + ) } diff --git a/apps/hq/src/repos-sms.ts b/apps/hq/src/repos-sms.ts index 50d78c4..01bc450 100644 --- a/apps/hq/src/repos-sms.ts +++ b/apps/hq/src/repos-sms.ts @@ -66,7 +66,7 @@ export async function smsBalances(db: DB, includeSecrets = false): Promise { @@ -191,7 +191,7 @@ export async function refreshAllSmsBalances(db: DB, userId: string, keyHex: stri if (mod === undefined) return summary const url = await smsBalanceApiUrl(db) 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) { if (r.username === null || r.username === '') { summary.skipped++; continue } diff --git a/apps/hq/test/sms-balances.test.ts b/apps/hq/test/sms-balances.test.ts index 7d43b76..145b733 100644 --- a/apps/hq/test/sms-balances.test.ts +++ b/apps/hq/test/sms-balances.test.ts @@ -20,9 +20,10 @@ describe('smsBalances (D28)', () => { 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' }) + // Only LIVE SMS shows on the balance screen (D28) — assign these live. + const cmLow = await assignModule(db, 'u1', { clientId: low.id, moduleId: sms.id, kind: 'yearly', status: 'live' }) + 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', cmHigh.id, { sms_balance: '82000' }) // above threshold