|
|
|
@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
import { useState } from 'react'
|
|
|
|
|
|
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
|
|
|
|
import { formatINR } from '@sims/domain'
|
|
|
|
|
|
|
|
import { DataTable, EmptyState, Field, Notice, PageHeader } from '@sims/ui'
|
|
|
|
|
|
|
|
import { getDuesAging, getModuleRevenue, getProfitability, getAwsRanking } from '../api'
|
|
|
|
|
|
|
|
import { useData } from './Clients'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const inr = (p: number) => formatINR(p, { symbol: false })
|
|
|
|
|
|
|
|
const prevMonth = (): string => {
|
|
|
|
|
|
|
|
const d = new Date()
|
|
|
|
|
|
|
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, 1)).toISOString().slice(0, 7)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Reports — dues aging, module revenue, client profitability, and the AWS cost chart (spec §5–§6). */
|
|
|
|
|
|
|
|
export function Reports() {
|
|
|
|
|
|
|
|
const nav = useNavigate()
|
|
|
|
|
|
|
|
const [month, setMonth] = useState(prevMonth())
|
|
|
|
|
|
|
|
const dues = useData(getDuesAging, [])
|
|
|
|
|
|
|
|
const revenue = useData(() => getModuleRevenue(), [])
|
|
|
|
|
|
|
|
const profit = useData(() => getProfitability(), [])
|
|
|
|
|
|
|
|
const ranking = useData(() => getAwsRanking(month), [month])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
<div className="wf-page">
|
|
|
|
|
|
|
|
<PageHeader title="Reports" desc="Dues aging, module revenue, client profitability, and the AWS cost chart." />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<h3>Dues aging</h3>
|
|
|
|
|
|
|
|
{dues.error !== undefined && <Notice tone="err">{dues.error}</Notice>}
|
|
|
|
|
|
|
|
{dues.data === undefined || dues.data.length === 0
|
|
|
|
|
|
|
|
? <EmptyState>No outstanding dues.</EmptyState>
|
|
|
|
|
|
|
|
: (
|
|
|
|
|
|
|
|
<DataTable
|
|
|
|
|
|
|
|
columns={[
|
|
|
|
|
|
|
|
{ key: 'client', label: 'Client' }, { key: 'b1', label: '0–30', numeric: true },
|
|
|
|
|
|
|
|
{ key: 'b2', label: '31–60', numeric: true }, { key: 'b3', label: '61–90', numeric: true },
|
|
|
|
|
|
|
|
{ key: 'b4', label: '90+', numeric: true }, { key: 'total', label: 'Total', numeric: true },
|
|
|
|
|
|
|
|
]}
|
|
|
|
|
|
|
|
onRowClick={(_r, i) => nav(`/clients/${dues.data![i]!.clientId}`)}
|
|
|
|
|
|
|
|
rows={dues.data.map((d) => ({
|
|
|
|
|
|
|
|
client: d.clientName, b1: inr(d.b0_30), b2: inr(d.b31_60), b3: inr(d.b61_90), b4: inr(d.b90p), total: inr(d.totalPaise),
|
|
|
|
|
|
|
|
}))}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<h3 style={{ marginTop: 20 }}>Module-wise revenue</h3>
|
|
|
|
|
|
|
|
{revenue.error !== undefined && <Notice tone="err">{revenue.error}</Notice>}
|
|
|
|
|
|
|
|
{revenue.data === undefined || revenue.data.length === 0
|
|
|
|
|
|
|
|
? <EmptyState>No invoiced revenue yet.</EmptyState>
|
|
|
|
|
|
|
|
: (
|
|
|
|
|
|
|
|
<DataTable
|
|
|
|
|
|
|
|
columns={[
|
|
|
|
|
|
|
|
{ key: 'module', label: 'Module' }, { key: 'billed', label: 'Billed', numeric: true },
|
|
|
|
|
|
|
|
{ key: 'settled', label: 'Settled', numeric: true },
|
|
|
|
|
|
|
|
]}
|
|
|
|
|
|
|
|
rows={revenue.data.map((m) => ({ module: `${m.moduleName} (${m.moduleCode})`, billed: inr(m.billedPaise), settled: inr(m.settledPaise) }))}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<h3 style={{ marginTop: 20 }}>Client profitability</h3>
|
|
|
|
|
|
|
|
{profit.error !== undefined && <Notice tone="err">{profit.error}</Notice>}
|
|
|
|
|
|
|
|
{profit.data === undefined || profit.data.length === 0
|
|
|
|
|
|
|
|
? <EmptyState>No client activity yet.</EmptyState>
|
|
|
|
|
|
|
|
: (
|
|
|
|
|
|
|
|
<DataTable
|
|
|
|
|
|
|
|
columns={[
|
|
|
|
|
|
|
|
{ key: 'client', label: 'Client' }, { key: 'billed', label: 'Billed', numeric: true },
|
|
|
|
|
|
|
|
{ key: 'settled', label: 'Settled', numeric: true }, { key: 'aws', label: 'AWS cost', numeric: true },
|
|
|
|
|
|
|
|
{ key: 'margin', label: 'Margin', numeric: true },
|
|
|
|
|
|
|
|
]}
|
|
|
|
|
|
|
|
onRowClick={(_r, i) => nav(`/clients/${profit.data![i]!.clientId}`)}
|
|
|
|
|
|
|
|
rows={profit.data.map((p) => ({
|
|
|
|
|
|
|
|
client: p.clientName, billed: inr(p.billedPaise), settled: inr(p.settledPaise),
|
|
|
|
|
|
|
|
aws: inr(p.awsCostPaise), margin: inr(p.marginPaise),
|
|
|
|
|
|
|
|
}))}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<h3 style={{ marginTop: 20 }}>AWS cost chart</h3>
|
|
|
|
|
|
|
|
<Field label="Month"><input type="month" className="wf" value={month} onChange={(e) => setMonth(e.target.value)} /></Field>
|
|
|
|
|
|
|
|
{ranking.error !== undefined && <Notice tone="err">{ranking.error}</Notice>}
|
|
|
|
|
|
|
|
{ranking.data === undefined || ranking.data.rows.length === 0
|
|
|
|
|
|
|
|
? <EmptyState>No AWS cost recorded for {month}.</EmptyState>
|
|
|
|
|
|
|
|
: (
|
|
|
|
|
|
|
|
<div style={{ margin: '10px 0' }}>
|
|
|
|
|
|
|
|
{ranking.data.rows.map((row) => (
|
|
|
|
|
|
|
|
<div key={row.clientId} style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '4px 0' }}>
|
|
|
|
|
|
|
|
<div style={{ width: 160, cursor: 'pointer' }} onClick={() => nav(`/clients/${row.clientId}`)}>{row.clientName}</div>
|
|
|
|
|
|
|
|
<div style={{ flex: 1, background: 'var(--border)', borderRadius: 4, height: 16 }}>
|
|
|
|
|
|
|
|
<div style={{ width: `${row.sharePctBp / 100}%`, background: 'var(--accent, #4a7)', height: '100%', borderRadius: 4 }} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div style={{ width: 120, textAlign: 'right' }}>{inr(row.costPaise)}</div>
|
|
|
|
|
|
|
|
<div style={{ width: 56, textAlign: 'right', color: 'var(--muted, #888)' }}>{(row.sharePctBp / 100).toFixed(1)}%</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
}
|