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 style={{ marginTop: 14 }}><Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save template'}</Button></div>
</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' }}> <div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview <LivePreview
body={previewBody} body={previewBody}

@ -1,5 +1,5 @@
import { useState } from 'react' 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 { import {
createEmployee, deactivateEmployee, getEmployees, patchEmployee, createEmployee, deactivateEmployee, getEmployees, patchEmployee,
reactivateEmployee, resetEmployeePassword, reactivateEmployee, resetEmployeePassword,
@ -60,16 +60,15 @@ export function Employees() {
/> />
)} )}
{error !== undefined && <Notice tone="err">{error}</Notice>} {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, {/* Branch on the fetch error first `employees` stays undefined on failure,
so falling through would show "Loading…" forever under the error notice. */} so falling through would show the skeleton forever under the error state. */}
{list.error !== undefined ? null {list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: employees === undefined ? <EmptyState>Loading</EmptyState> : employees === undefined ? <Skeleton rows={4} />
: employees.length === 0 ? <EmptyState>No employees yet.</EmptyState> : ( : employees.length === 0 ? <EmptyState>No employees yet.</EmptyState> : (
<> <>
<DataTable <DataTable
columns={[ 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: 'role', label: 'Role' }, { key: 'active', label: 'Active' },
{ key: 'actions', label: 'Actions' }, { key: 'actions', label: 'Actions' },
]} ]}
@ -116,7 +115,7 @@ function AddEmployeeForm(props: { onDone: () => void; onCancel: () => void }) {
} }
return ( 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' }}> <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="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> <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 ( 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' }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
{/* Email is the login id — immutable after creation. */} {/* Email is the login id — immutable after creation. */}
<Field label="Email (immutable)"><input className="wf" value={props.employee.email} disabled /></Field> <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 ( 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' }}> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<Field label={`New password for ${props.employee.email} (min 8)`}> <Field label={`New password for ${props.employee.email} (min 8)`}>
<input className="wf" type="password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} /> <input className="wf" type="password" autoFocus value={password} onChange={(e) => setPassword(e.target.value)} />

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { formatINR, fromRupees } from '@sims/domain' 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 { import {
addPrice, createModule, getModules, getPrices, patchModule, previewSample, role, addPrice, createModule, getModules, getPrices, patchModule, previewSample, role,
KIND_LABEL, type Kind, type Module, KIND_LABEL, type Kind, type Module,
@ -33,12 +33,12 @@ export function Modules() {
{creating && isOwner && ( {creating && isOwner && (
<NewModuleForm onCreated={() => { setCreating(false); modules.reload() }} /> <NewModuleForm onCreated={() => { setCreating(false); modules.reload() }} />
)} )}
{modules.error !== undefined && <Notice tone="err">{modules.error}</Notice>} {modules.error !== undefined ? <ErrorState message={modules.error} onRetry={modules.reload} />
{modules.data === undefined ? <EmptyState>Loading</EmptyState> : modules.data === undefined ? <Skeleton rows={5} />
: modules.data.length === 0 ? <EmptyState>No modules yet.</EmptyState> : ( : modules.data.length === 0 ? <EmptyState>No modules yet.</EmptyState> : (
<DataTable <DataTable
columns={[ 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: 'sac', label: 'SAC' }, { key: 'kinds', label: 'Billing kinds' },
{ key: 'multi', label: 'Multi-sub' }, { key: 'active', label: 'Active' }, { key: 'multi', label: 'Multi-sub' }, { key: 'active', label: 'Active' },
]} ]}
@ -90,7 +90,7 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
</Toolbar> </Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>} {error !== undefined && <Notice tone="err">{error}</Notice>}
</div> </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' }}> <div style={{ width: '100%', height: '100%', background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>
<LivePreview <LivePreview
body={previewBody} body={previewBody}
@ -133,7 +133,7 @@ function NewModuleForm(props: { onCreated: () => void }) {
} }
return ( 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' }}> <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="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> <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> </Toolbar>
)} )}
{error !== undefined && <Notice tone="err">{error}</Notice>} {error !== undefined && <Notice tone="err">{error}</Notice>}
{prices.error !== undefined && <Notice tone="err">{prices.error}</Notice>} {prices.error !== undefined ? <ErrorState message={prices.error} onRetry={prices.reload} />
{prices.data === undefined ? <EmptyState>Loading</EmptyState> : prices.data === undefined ? <Skeleton rows={5} />
: prices.data.length === 0 ? <EmptyState>No prices yet{props.isOwner ? ' — add the first row above' : ''}.</EmptyState> : ( : prices.data.length === 0 ? <EmptyState>No prices yet{props.isOwner ? ' — add the first row above' : ''}.</EmptyState> : (
<DataTable <DataTable
columns={[ columns={[

@ -283,7 +283,7 @@ export function NewDocument() {
<Button tone="primary" onClick={() => setSheetOpen(true)}>Preview</Button> <Button tone="primary" onClick={() => setSheetOpen(true)}>Preview</Button>
</div> </div>
{sheetOpen && ( {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={{ 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> <div style={{ flex: 1, margin: 12, marginTop: 0, background: '#fff', borderRadius: 4, overflow: 'hidden' }}>{paper}</div>
{totalsStrip} {totalsStrip}
@ -296,7 +296,7 @@ export function NewDocument() {
return ( return (
<div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}> <div style={{ display: 'flex', gap: 16, alignItems: 'stretch' }}>
<div style={{ flex: '0 0 600px', minWidth: 0 }}>{form}</div> <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> <div style={{ flex: 1, minHeight: 0, background: '#fff', borderRadius: 4, boxShadow: '0 1px 6px rgba(0,0,0,.15)', overflow: 'hidden' }}>{paper}</div>
{totalsStrip} {totalsStrip}
</div> </div>

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { formatINR } from '@sims/domain' 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 { getDuesAging, getModuleRevenue, getProfitability, getAwsRanking } from '../api'
import { useData } from './Clients' import { useData } from './Clients'
@ -15,6 +15,7 @@ const prevMonth = (): string => {
export function Reports() { export function Reports() {
const nav = useNavigate() const nav = useNavigate()
const [month, setMonth] = useState(prevMonth()) const [month, setMonth] = useState(prevMonth())
const [report, setReport] = useState<'dues' | 'revenue' | 'profit' | 'aws'>('dues')
const dues = useData(getDuesAging, []) const dues = useData(getDuesAging, [])
const revenue = useData(() => getModuleRevenue(), []) const revenue = useData(() => getModuleRevenue(), [])
const profit = useData(() => getProfitability(), []) const profit = useData(() => getProfitability(), [])
@ -24,10 +25,21 @@ export function Reports() {
<div className="wf-page"> <div className="wf-page">
<PageHeader title="Reports" desc="Dues aging, module revenue, client profitability, and the AWS cost chart." /> <PageHeader title="Reports" desc="Dues aging, module revenue, client profitability, and the AWS cost chart." />
<h3>Dues aging</h3> <FilterChips
{dues.error !== undefined && <Notice tone="err">{dues.error}</Notice>} active={report}
{dues.data === undefined || dues.data.length === 0 onChange={(k) => setReport(k as typeof report)}
? <EmptyState>No outstanding dues.</EmptyState> chips={[
{ key: 'dues', label: 'Dues aging' },
{ key: 'revenue', label: 'Module revenue' },
{ key: 'profit', label: 'Profitability' },
{ key: 'aws', label: 'AWS costs' },
]}
/>
{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 <DataTable
columns={[ columns={[
@ -36,16 +48,18 @@ export function Reports() {
{ key: 'b4', label: '90+', numeric: true }, { key: 'total', label: 'Total', numeric: true }, { key: 'b4', label: '90+', numeric: true }, { key: 'total', label: 'Total', numeric: true },
]} ]}
onRowClick={(_r, i) => nav(`/clients/${dues.data![i]!.clientId}`)} 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) => ({ 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), 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> {report === 'revenue' && (
{revenue.error !== undefined && <Notice tone="err">{revenue.error}</Notice>} revenue.error !== undefined ? <ErrorState message={revenue.error} onRetry={revenue.reload} />
{revenue.data === undefined || revenue.data.length === 0 : revenue.data === undefined ? <Skeleton rows={5} />
? <EmptyState>No invoiced revenue yet.</EmptyState> : revenue.data.length === 0 ? <EmptyState>No invoiced revenue yet.</EmptyState>
: ( : (
<DataTable <DataTable
columns={[ columns={[
@ -54,12 +68,13 @@ export function Reports() {
]} ]}
rows={revenue.data.map((m) => ({ module: `${m.moduleName} (${m.moduleCode})`, billed: inr(m.billedPaise), settled: inr(m.settledPaise) }))} 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> {report === 'profit' && (
{profit.error !== undefined && <Notice tone="err">{profit.error}</Notice>} profit.error !== undefined ? <ErrorState message={profit.error} onRetry={profit.reload} />
{profit.data === undefined || profit.data.length === 0 : profit.data === undefined ? <Skeleton rows={5} />
? <EmptyState>No client activity yet.</EmptyState> : profit.data.length === 0 ? <EmptyState>No client activity yet.</EmptyState>
: ( : (
<DataTable <DataTable
columns={[ columns={[
@ -73,13 +88,15 @@ export function Reports() {
aws: inr(p.awsCostPaise), margin: inr(p.marginPaise), aws: inr(p.awsCostPaise), margin: inr(p.marginPaise),
}))} }))}
/> />
)
)} )}
<h3 style={{ marginTop: 20 }}>AWS cost chart</h3> {report === 'aws' && (
<>
<Field label="Month"><input type="month" className="wf" value={month} onChange={(e) => setMonth(e.target.value)} /></Field> <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.error !== undefined ? <ErrorState message={ranking.error} onRetry={ranking.reload} />
{ranking.data === undefined || ranking.data.rows.length === 0 : ranking.data === undefined ? <Skeleton rows={5} />
? <EmptyState>No AWS cost recorded for {month}.</EmptyState> : ranking.data.rows.length === 0 ? <EmptyState>No AWS cost recorded for {month}.</EmptyState>
: ( : (
<div style={{ margin: '10px 0' }}> <div style={{ margin: '10px 0' }}>
{ranking.data.rows.map((row) => ( {ranking.data.rows.map((row) => (
@ -94,6 +111,8 @@ export function Reports() {
))} ))}
</div> </div>
)} )}
</>
)}
</div> </div>
) )
} }

Loading…
Cancel
Save