diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 53d1d46..38e0692 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -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 => 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 => + apiFetch('/reports/sms-balances') + // D27: audit log viewer (owner-only). export interface AuditRow { id: string; at_wall: string; user_id: string; action: string diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index 2a0b62f..17178c9 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index 744f922..42435e9 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -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 }, diff --git a/apps/hq-web/src/pages/Mis.tsx b/apps/hq-web/src/pages/Mis.tsx index ae30d1d..70e723b 100644 --- a/apps/hq-web/src/pages/Mis.tsx +++ b/apps/hq-web/src/pages/Mis.tsx @@ -50,6 +50,9 @@ export function Mis() { sub={{data.renewals30Count} due} to="/renewals" tone={data.renewals30Count > 0 ? 'var(--warn)' : undefined} /> + below {data.smsThreshold.toLocaleString('en-IN')} SMS} + to="/sms-balances" tone={data.smsLow > 0 ? 'var(--warn)' : undefined} /> {data.tickets.open} open · {data.tickets.inProgress} in progress · {data.tickets.waiting} waiting} to="/tickets" /> diff --git a/apps/hq-web/src/pages/SmsBalances.tsx b/apps/hq-web/src/pages/SmsBalances.tsx new file mode 100644 index 0000000..ae13f97 --- /dev/null +++ b/apps/hq-web/src/pages/SmsBalances.tsx @@ -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('low') + const [busy, setBusy] = useState() + 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 ( +
+ + {data !== undefined && data.lowCount > 0 && scope !== 'low' && ( + + {data.lowCount} client{data.lowCount === 1 ? '' : 's'} below {data.threshold.toLocaleString('en-IN')} SMS — needs a top-up. + + )} + + setScope(k as Scope)} + /> + + {data !== undefined && threshold {data.threshold.toLocaleString('en-IN')}} + + {list.error !== undefined ? + : data === undefined ? + : shown.length === 0 ? ( + + {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.'} + + ) : ( + (shown[i]!.low ? 'err' : undefined)} + rows={shown.map((r) => ({ + client: {r.clientCode} · {r.clientName}, + balance: r.balance === null + ? not recorded + : ( + + {r.balance.toLocaleString('en-IN')} + + ), + reseller: r.reseller ?? '—', + actions: ( + e.stopPropagation()}> + + + ), + }))} + /> + )} +
+ ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 2d7fbc0..8d7a925 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -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 { diff --git a/apps/hq/src/repos-mis.ts b/apps/hq/src/repos-mis.ts index 8e99f44..ee2a20a 100644 --- a/apps/hq/src/repos-mis.ts +++ b/apps/hq/src/repos-mis.ts @@ -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 => @@ -75,6 +77,7 @@ export async function misCockpit(db: DB, today: string): Promise { 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 { 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, } } diff --git a/apps/hq/src/repos-sms.ts b/apps/hq/src/repos-sms.ts new file mode 100644 index 0000000..16829e2 --- /dev/null +++ b/apps/hq/src/repos-sms.ts @@ -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 { + 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 = {} + try { fv = JSON.parse(r.field_values) as Record } 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, + } +} diff --git a/apps/hq/test/sms-balances.test.ts b/apps/hq/test/sms-balances.test.ts new file mode 100644 index 0000000..7d43b76 --- /dev/null +++ b/apps/hq/test/sms-balances.test.ts @@ -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 + }) +})