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.
177 lines
7.5 KiB
TypeScript
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>
|
|
)
|
|
}
|