You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq-web/src/pages/SmsBalances.tsx

177 lines
7.5 KiB
TypeScript

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, refreshSmsBalances, isManagerial, type SmsBalanceRow,
} from '../api'
import { useData } from './Clients'
type Scope = 'low' | 'all' | 'unknown'
/** "3h ago" / "2d ago" from an ISO timestamp — compact freshness for the Checked column. */
function ago(iso: string | null): string {
if (iso === null) return 'never'
const ms = Date.now() - Date.parse(iso)
if (Number.isNaN(ms)) return '—'
const mins = Math.floor(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return `${Math.floor(hrs / 24)}d ago`
}
/**
* 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 [refreshing, setRefreshing] = useState(false)
const [showCreds, setShowCreds] = useState(true) // managerial: show username/password columns
const list = useData(getSmsBalances, [])
const managerial = isManagerial()
/** Download the SMS logins as a CSV (managerial): client, code, username, password, provider, balance. */
const downloadCreds = () => {
const rows = data?.rows ?? []
const esc = (v: string | number | null) => {
const s = v === null ? '' : String(v)
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
const header = ['Code', 'Client', 'Username', 'Password', 'Provider', 'Balance', 'Installation date', 'Phone']
const lines = [header.join(',')]
for (const r of rows) {
lines.push([esc(r.clientCode), esc(r.clientName), esc(r.username), esc(r.password),
esc(r.provider), esc(r.balance), esc(r.installationDate), esc(r.phone)].join(','))
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'sms-credentials.csv'; a.click()
URL.revokeObjectURL(url)
}
const refreshAll = () => {
if (refreshing) return
setRefreshing(true)
refreshSmsBalances()
.then((s) => {
toast.ok(`Checked ${s.attempted}: ${s.ok} ok, ${s.changed} updated${s.failed > 0 ? `, ${s.failed} failed` : ''}${s.skipped > 0 ? `, ${s.skipped} no login` : ''}`)
list.reload()
})
.catch((e: Error) => toast.err(e.message))
.finally(() => setRefreshing(false))
}
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."
actions={managerial
? (
<span style={{ display: 'inline-flex', gap: 8 }}>
<Button onClick={() => setShowCreds((v) => !v)}>{showCreds ? 'Hide logins' : 'Show logins'}</Button>
<Button onClick={downloadCreds}>Download logins (CSV)</Button>
<Button tone="primary" disabled={refreshing} onClick={refreshAll}>{refreshing ? 'Checking…' : 'Refresh from gateway'}</Button>
</span>
)
: undefined}
/>
{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 },
...(managerial && showCreds
? [{ key: 'username', label: 'Username', mono: true }, { key: 'password', label: 'Password', mono: true }]
: []),
{ key: 'provider', label: 'Provider' },
{ key: 'checked', label: 'Checked' },
{ 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>
),
username: r.username ?? <span style={{ opacity: 0.4 }}></span>,
password: r.password !== null && r.password !== ''
? r.password
: <span style={{ opacity: 0.4 }}>{r.hasCreds ? '(set)' : '—'}</span>,
provider: r.provider ?? '—',
checked: r.checkStatus === 'error'
? <span title={r.checkMessage ?? ''} style={{ color: 'var(--err)', fontSize: 12.5 }}> {ago(r.checkedAt)}</span>
: !r.hasCreds
? <span style={{ opacity: 0.5, fontSize: 12.5 }}>no login</span>
: <span style={{ opacity: 0.7, fontSize: 12.5 }}>{ago(r.checkedAt)}</span>,
actions: (
<span onClick={(e) => e.stopPropagation()}>
<Button tone="primary" disabled={busy !== undefined} onClick={() => raiseTopup(r)}>
{busy === r.cmId ? '…' : 'Top-up quote'}
</Button>
</span>
),
}))}
/>
)}
</div>
)
}