feat(hq-web): reports chips + skeleton/error states across catalog pages

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 94019980bb
commit a244ddb2b0

@ -110,7 +110,7 @@ export function DocumentTemplate() {
<div style={{ marginTop: 14 }}><Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save template'}</Button></div>
</div>
<div style={{ flex: 1, minWidth: 0, position: 'sticky', top: 12, height: 'calc(100vh - 140px)', background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
<div style={{ flex: 1, minWidth: 0, position: 'sticky', top: 12, height: 'calc(100vh - 140px)', background: 'var(--bg-inset)', borderRadius: 8, padding: 12 }}>
<div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview
body={previewBody}

@ -1,5 +1,5 @@
import { useState } from 'react'
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import {
createEmployee, deactivateEmployee, getEmployees, patchEmployee,
reactivateEmployee, resetEmployeePassword,
@ -60,16 +60,15 @@ export function Employees() {
/>
)}
{error !== undefined && <Notice tone="err">{error}</Notice>}
{list.error !== undefined && <Notice tone="err">{list.error}</Notice>}
{/* Branch on the fetch error first `employees` stays undefined on failure,
so falling through would show "Loading…" forever under the error notice. */}
{list.error !== undefined ? null
: employees === undefined ? <EmptyState>Loading</EmptyState>
so falling through would show the skeleton forever under the error state. */}
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: employees === undefined ? <Skeleton rows={4} />
: employees.length === 0 ? <EmptyState>No employees yet.</EmptyState> : (
<>
<DataTable
columns={[
{ key: 'name', label: 'Name' }, { key: 'email', label: 'Email' },
{ key: 'name', label: 'Name' }, { key: 'email', label: 'Email', mono: true },
{ key: 'role', label: 'Role' }, { key: 'active', label: 'Active' },
{ key: 'actions', label: 'Actions' },
]}
@ -116,7 +115,7 @@ function AddEmployeeForm(props: { onDone: () => void; onCancel: () => void }) {
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Email (login id)"><input className="wf" type="email" autoFocus value={f.email} onChange={set('email')} /></Field>
<Field label="Name"><input className="wf" value={f.displayName} onChange={set('displayName')} /></Field>
@ -147,7 +146,7 @@ function EditEmployeeForm(props: { employee: Employee; onDone: () => void; onCan
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{/* Email is the login id — immutable after creation. */}
<Field label="Email (immutable)"><input className="wf" value={props.employee.email} disabled /></Field>
@ -180,7 +179,7 @@ function ResetPasswordForm(props: { employee: Employee; onDone: () => void; onCa
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label={`New password for ${props.employee.email} (min 8)`}>
<input className="wf" type="password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} />

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import {
addPrice, createModule, getModules, getPrices, patchModule, previewSample, role,
KIND_LABEL, type Kind, type Module,
@ -33,12 +33,12 @@ export function Modules() {
{creating && isOwner && (
<NewModuleForm onCreated={() => { setCreating(false); modules.reload() }} />
)}
{modules.error !== undefined && <Notice tone="err">{modules.error}</Notice>}
{modules.data === undefined ? <EmptyState>Loading</EmptyState>
{modules.error !== undefined ? <ErrorState message={modules.error} onRetry={modules.reload} />
: modules.data === undefined ? <Skeleton rows={5} />
: modules.data.length === 0 ? <EmptyState>No modules yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code' }, { key: 'name', label: 'Name' },
{ key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' },
{ key: 'sac', label: 'SAC' }, { key: 'kinds', label: 'Billing kinds' },
{ key: 'multi', label: 'Multi-sub' }, { key: 'active', label: 'Active' },
]}
@ -90,7 +90,7 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
<div style={{ flex: 1, minWidth: 320, height: 420, background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
<div style={{ flex: 1, minWidth: 320, height: 420, background: 'var(--bg-inset)', borderRadius: 8, padding: 12 }}>
<div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview
body={previewBody}
@ -133,7 +133,7 @@ function NewModuleForm(props: { onCreated: () => void }) {
}
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<div className="wf-card">
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label="Code"><input className="wf" style={{ width: 100 }} value={f.code} autoFocus onChange={set('code')} /></Field>
<Field label="Name"><input className="wf" value={f.name} onChange={set('name')} /></Field>
@ -198,8 +198,8 @@ function PriceBook(props: { module: Module; isOwner: boolean }) {
</Toolbar>
)}
{error !== undefined && <Notice tone="err">{error}</Notice>}
{prices.error !== undefined && <Notice tone="err">{prices.error}</Notice>}
{prices.data === undefined ? <EmptyState>Loading</EmptyState>
{prices.error !== undefined ? <ErrorState message={prices.error} onRetry={prices.reload} />
: prices.data === undefined ? <Skeleton rows={5} />
: prices.data.length === 0 ? <EmptyState>No prices yet{props.isOwner ? ' — add the first row above' : ''}.</EmptyState> : (
<DataTable
columns={[

@ -283,7 +283,7 @@ export function NewDocument() {
<Button tone="primary" onClick={() => setSheetOpen(true)}>Preview</Button>
</div>
{sheetOpen && (
<div style={{ position: 'fixed', inset: 0, zIndex: 30, background: 'var(--bg-sunken, #e9e9ee)', display: 'flex', flexDirection: 'column' }}>
<div style={{ position: 'fixed', inset: 0, zIndex: 30, background: 'var(--bg-inset)', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: 8 }}><Button onClick={() => setSheetOpen(false)}>Close</Button></div>
<div style={{ flex: 1, margin: 12, marginTop: 0, background: '#fff', borderRadius: 4, overflow: 'hidden' }}>{paper}</div>
{totalsStrip}
@ -296,7 +296,7 @@ export function NewDocument() {
return (
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}>
<div style={{ flex: '0 0 600px', minWidth: 0 }}>{form}</div>
<div style={{ flex: 1, minWidth: 0, position: 'sticky', top: 12, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column', background: 'var(--bg-sunken, #e9e9ee)', borderRadius: 8, padding: 12 }}>
<div style={{ flex: 1, minWidth: 0, position: 'sticky', top: 12, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column', background: 'var(--bg-inset)', borderRadius: 8, padding: 12 }}>
<div style={{ flex: 1, minHeight: 0, background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>{paper}</div>
{totalsStrip}
</div>

@ -1,7 +1,7 @@
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 { DataTable, EmptyState, ErrorState, Field, FilterChips, Skeleton, PageHeader } from '@sims/ui'
import { getDuesAging, getModuleRevenue, getProfitability, getAwsRanking } from '../api'
import { useData } from './Clients'
@ -15,6 +15,7 @@ const prevMonth = (): string => {
export function Reports() {
const nav = useNavigate()
const [month, setMonth] = useState(prevMonth())
const [report, setReport] = useState<'dues' | 'revenue' | 'profit' | 'aws'>('dues')
const dues = useData(getDuesAging, [])
const revenue = useData(() => getModuleRevenue(), [])
const profit = useData(() => getProfitability(), [])
@ -24,76 +25,94 @@ export function Reports() {
<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),
}))}
/>
)}
<FilterChips
active={report}
onChange={(k) => setReport(k as typeof report)}
chips={[
{ key: 'dues', label: 'Dues aging' },
{ key: 'revenue', label: 'Module revenue' },
{ key: 'profit', label: 'Profitability' },
{ key: 'aws', label: 'AWS costs' },
]}
/>
<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) }))}
/>
)}
{report === 'dues' && (
dues.error !== undefined ? <ErrorState message={dues.error} onRetry={dues.reload} />
: dues.data === undefined ? <Skeleton rows={5} />
: 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}`)}
rowTone={(i) => ((dues.data![i]!.b90p > 0) ? 'err' : dues.data![i]!.b61_90 > 0 ? 'warn' : undefined)}
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 }}>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),
}))}
/>
)}
{report === 'revenue' && (
revenue.error !== undefined ? <ErrorState message={revenue.error} onRetry={revenue.reload} />
: revenue.data === undefined ? <Skeleton rows={5} />
: 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 }}>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>
{report === 'profit' && (
profit.error !== undefined ? <ErrorState message={profit.error} onRetry={profit.reload} />
: profit.data === undefined ? <Skeleton rows={5} />
: 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),
}))}
/>
)
)}
{report === 'aws' && (
<>
<Field label="Month"><input type="month" className="wf" value={month} onChange={(e) => setMonth(e.target.value)} /></Field>
{ranking.error !== undefined ? <ErrorState message={ranking.error} onRetry={ranking.reload} />
: ranking.data === undefined ? <Skeleton rows={5} />
: 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>
)}
)}
</>
)}
</div>
)
}

Loading…
Cancel
Save