feat(hq-web): reports page — dues aging, module revenue, profitability, aws cost chart

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 5495b42e30
commit 2acca18fc9

@ -7,6 +7,7 @@ const NAV = [
{ to: '/', label: 'Dashboard' }, { to: '/', label: 'Dashboard' },
{ to: '/clients', label: 'Clients' }, { to: '/clients', label: 'Clients' },
{ to: '/modules', label: 'Modules' }, { to: '/modules', label: 'Modules' },
{ to: '/reports', label: 'Reports' },
{ to: '/documents/new', label: 'New Document' }, { to: '/documents/new', label: 'New Document' },
] ]

@ -276,6 +276,55 @@ export const createInteraction = (clientId: string, body: Record<string, unknown
export const updateInteraction = (id: string, body: Record<string, unknown>): Promise<Interaction> => export const updateInteraction = (id: string, body: Record<string, unknown>): Promise<Interaction> =>
apiFetch<{ interaction: Interaction }>(`/interactions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.interaction) apiFetch<{ interaction: Interaction }>(`/interactions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.interaction)
// ---------- HQ-3a shapes ----------
export interface DuesAgingRow {
clientId: string; clientName: string
b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number
}
export interface ModuleRevenueRow {
moduleId: string; moduleCode: string; moduleName: string; billedPaise: number; settledPaise: number
}
export interface ProfitabilityRow {
clientId: string; clientName: string
billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number
}
export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number }
export interface AwsUsageRow {
id: string; clientId: string; month: string
storageGb: number; transferGb: number; costPaise: number; source: 'auto' | 'manual'; updatedAt: string
}
// ---------- HQ-3a calls ----------
const rangeQs = (from?: string, to?: string): string => {
const p = new URLSearchParams()
if (from !== undefined && from !== '') p.set('from', from)
if (to !== undefined && to !== '') p.set('to', to)
const q = p.toString()
return q === '' ? '' : `?${q}`
}
export const getDuesAging = (): Promise<DuesAgingRow[]> =>
apiFetch<{ rows: DuesAgingRow[] }>('/reports/dues-aging').then((r) => r.rows)
export const getModuleRevenue = (from?: string, to?: string): Promise<ModuleRevenueRow[]> =>
apiFetch<{ rows: ModuleRevenueRow[] }>(`/reports/module-revenue${rangeQs(from, to)}`).then((r) => r.rows)
export const getProfitability = (from?: string, to?: string): Promise<ProfitabilityRow[]> =>
apiFetch<{ rows: ProfitabilityRow[] }>(`/reports/profitability${rangeQs(from, to)}`).then((r) => r.rows)
export const getAwsRanking = (month?: string): Promise<{ month: string; total: number; rows: CostRankRow[] }> =>
apiFetch<{ month: string; total: number; rows: CostRankRow[] }>(`/aws/ranking${month !== undefined && month !== '' ? `?month=${month}` : ''}`)
export const getClientAwsUsage = (clientId: string): Promise<AwsUsageRow[]> =>
apiFetch<{ usage: AwsUsageRow[] }>(`/clients/${clientId}/aws-usage`).then((r) => r.usage)
export const upsertAwsUsage = (body: Record<string, unknown>): Promise<AwsUsageRow> =>
apiFetch<{ usage: AwsUsageRow }>('/aws/usage', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.usage)
export const pullAwsCosts = (month?: string): Promise<{ upserted: number; totalPaise: number; unknownTags: { tag: string; costPaise: number }[] }> =>
apiFetch('/aws/pull', { method: 'POST', body: JSON.stringify(month !== undefined ? { month } : {}) })
export const getCompanyProfile = (): Promise<Record<string, string>> =>
apiFetch<{ company: Record<string, string> }>('/settings/company').then((r) => r.company)
export const putCompanyProfile = (body: Record<string, string>): Promise<Record<string, string>> =>
apiFetch<{ company: Record<string, string> }>('/settings/company', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.company)
/** The PDF route needs the bearer header, which an <iframe src> can't carry — fetch a blob instead. */ /** The PDF route needs the bearer header, which an <iframe src> can't carry — fetch a blob instead. */
export async function fetchPdfBlob(documentId: string): Promise<Blob> { export async function fetchPdfBlob(documentId: string): Promise<Blob> {
const token = localStorage.getItem(TOKEN_KEY) ?? '' const token = localStorage.getItem(TOKEN_KEY) ?? ''

@ -11,6 +11,7 @@ import { Dashboard } from './pages/Dashboard'
import { Clients } from './pages/Clients' import { Clients } from './pages/Clients'
import { ClientDetail } from './pages/ClientDetail' import { ClientDetail } from './pages/ClientDetail'
import { Modules } from './pages/Modules' import { Modules } from './pages/Modules'
import { Reports } from './pages/Reports'
import { NewDocument } from './pages/NewDocument' import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView' import { DocumentView } from './pages/DocumentView'
@ -24,6 +25,7 @@ function App() {
<Route path="/clients" element={<Clients />} /> <Route path="/clients" element={<Clients />} />
<Route path="/clients/:id" element={<ClientDetail />} /> <Route path="/clients/:id" element={<ClientDetail />} />
<Route path="/modules" element={<Modules />} /> <Route path="/modules" element={<Modules />} />
<Route path="/reports" element={<Reports />} />
<Route path="/documents/new" element={<NewDocument />} /> <Route path="/documents/new" element={<NewDocument />} />
<Route path="/documents/:id" element={<DocumentView />} /> <Route path="/documents/:id" element={<DocumentView />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

@ -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: '030', numeric: true },
{ key: 'b2', label: '3160', numeric: true }, { key: 'b3', label: '6190', 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>
)
}
Loading…
Cancel
Save