Skip to content

Commit 7d44bdc

Browse files
Add interactive Reports page with charts
Replace the placeholder Reports page with a full interactive reporting UI. Fetches report summaries from api.reports.summary (default 7-day range) and adds loading/error handling, date-range filters, Refresh/Apply controls, and a StatCard component. Renders a status PieChart and stacked daily BarChart (using recharts), plus a daily table with totals and avg scores. Includes status color mapping, number/date formatting, and empty/no-data states.
1 parent d90089b commit 7d44bdc

1 file changed

Lines changed: 221 additions & 13 deletions

File tree

web/src/pages/Reports.tsx

Lines changed: 221 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,229 @@
1-
// Reports page — Phase 3.6
2-
// Will show date-range reports with PDF/CSV export.
3-
// Placeholder until the /api/v1/reports endpoint is implemented.
1+
import { useEffect, useState } from 'react'
2+
import {
3+
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer,
4+
PieChart, Pie, Cell,
5+
} from 'recharts'
6+
import { api } from '../api/client'
7+
import type { ReportSummary } from '../types'
8+
9+
const STATUS_COLORS: Record<string, string> = {
10+
pending: '#a78bfa',
11+
sent: '#34d399',
12+
failed: '#f87171',
13+
dead_lettered: '#fb923c',
14+
}
15+
16+
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
17+
return (
18+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5">
19+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">{label}</p>
20+
<p className="mt-1 text-3xl font-bold text-gray-900">{value}</p>
21+
{sub && <p className="mt-1 text-sm text-gray-400">{sub}</p>}
22+
</div>
23+
)
24+
}
25+
26+
function defaultRange(): { from: string; to: string } {
27+
const now = new Date()
28+
const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
29+
return { from: from.toISOString(), to: now.toISOString() }
30+
}
431

532
export default function Reports() {
33+
const [summary, setSummary] = useState<ReportSummary | null>(null)
34+
const [loading, setLoading] = useState(true)
35+
const [error, setError] = useState<string | null>(null)
36+
const [from, setFrom] = useState(defaultRange().from)
37+
const [to, setTo] = useState(defaultRange().to)
38+
39+
function load() {
40+
setLoading(true)
41+
setError(null)
42+
api.reports
43+
.summary({ from: from || undefined, to: to || undefined })
44+
.then(setSummary)
45+
.catch((e: Error) => setError(e.message))
46+
.finally(() => setLoading(false))
47+
}
48+
49+
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
50+
51+
const statusData = summary
52+
? Object.entries(summary.by_status).map(([name, value]) => ({ name, value }))
53+
: []
54+
55+
const dailyData = (summary?.by_day ?? []).map(d => ({
56+
date: d.date,
57+
sent: d.sent,
58+
failed: d.failed,
59+
dead_lettered: d.dead_lettered,
60+
pending: d.pending,
61+
avg_score: d.avg_score,
62+
}))
63+
664
return (
765
<div className="p-6 space-y-4">
8-
<h1 className="text-xl font-semibold text-gray-800">Reports</h1>
9-
10-
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center space-y-3">
11-
<p className="text-4xl"></p>
12-
<p className="font-medium text-gray-700">Reports coming soon</p>
13-
<p className="text-sm text-gray-500 max-w-sm mx-auto">
14-
Date-range reports with total messages, average quality score,
15-
findings per rule, and PDF / CSV export will be available in
16-
Phase 3.6.
17-
</p>
66+
{/* header */}
67+
<div className="flex items-center justify-between flex-wrap gap-3">
68+
<h1 className="text-xl font-semibold text-gray-800">Reports</h1>
69+
<button
70+
onClick={load}
71+
className="px-3 py-1.5 rounded-md border border-gray-300 text-sm hover:bg-gray-50"
72+
>
73+
Refresh
74+
</button>
75+
</div>
76+
77+
{/* filters */}
78+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 flex flex-wrap gap-3 items-end">
79+
<label className="flex flex-col gap-1 text-xs text-gray-500">
80+
From
81+
<input
82+
type="datetime-local"
83+
value={from ? new Date(from).toISOString().slice(0, 16) : ''}
84+
onChange={e => setFrom(e.target.value ? new Date(e.target.value).toISOString() : '')}
85+
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
86+
/>
87+
</label>
88+
89+
<label className="flex flex-col gap-1 text-xs text-gray-500">
90+
To
91+
<input
92+
type="datetime-local"
93+
value={to ? new Date(to).toISOString().slice(0, 16) : ''}
94+
onChange={e => setTo(e.target.value ? new Date(e.target.value).toISOString() : '')}
95+
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
96+
/>
97+
</label>
98+
99+
<button
100+
onClick={load}
101+
className="px-4 py-1.5 rounded-md bg-brand-600 text-white text-sm hover:bg-brand-700"
102+
>
103+
Apply
104+
</button>
18105
</div>
106+
107+
{/* loading / error / empty */}
108+
{loading && (
109+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center text-gray-400 text-sm">
110+
Loading…
111+
</div>
112+
)}
113+
{!loading && error && (
114+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center text-red-500 text-sm">
115+
{error}
116+
</div>
117+
)}
118+
{!loading && !error && summary && summary.total === 0 && (
119+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center text-gray-400 text-sm">
120+
No messages in the selected range.
121+
</div>
122+
)}
123+
124+
{/* content */}
125+
{!loading && !error && summary && summary.total > 0 && (
126+
<>
127+
{/* stat cards */}
128+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
129+
<StatCard label="Total Messages" value={summary.total} />
130+
<StatCard label="Avg Quality Score" value={summary.avg_score.toFixed(3)} />
131+
<StatCard
132+
label="Date Range"
133+
value={`${dailyData.length}d`}
134+
sub={`${new Date(summary.from).toLocaleDateString()}${new Date(summary.to).toLocaleDateString()}`}
135+
/>
136+
<StatCard
137+
label="Sent"
138+
value={summary.by_status.sent ?? 0}
139+
sub={`${(((summary.by_status.sent ?? 0) / summary.total) * 100).toFixed(0)}% success`}
140+
/>
141+
</div>
142+
143+
{/* status pie + daily bar */}
144+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
145+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5">
146+
<p className="text-sm font-medium text-gray-600 mb-3">Status Breakdown</p>
147+
{statusData.length === 0 ? (
148+
<div className="flex items-center justify-center h-[220px] text-gray-300 text-sm">No data</div>
149+
) : (
150+
<ResponsiveContainer width="100%" height={220}>
151+
<PieChart>
152+
<Pie
153+
data={statusData}
154+
dataKey="value"
155+
nameKey="name"
156+
cx="50%"
157+
cy="50%"
158+
outerRadius={80}
159+
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
160+
>
161+
{statusData.map(entry => (
162+
<Cell key={entry.name} fill={STATUS_COLORS[entry.name] ?? '#94a3b8'} />
163+
))}
164+
</Pie>
165+
<Legend />
166+
</PieChart>
167+
</ResponsiveContainer>
168+
)}
169+
</div>
170+
171+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5">
172+
<p className="text-sm font-medium text-gray-600 mb-3">Daily Volume</p>
173+
{dailyData.length === 0 ? (
174+
<div className="flex items-center justify-center h-[220px] text-gray-300 text-sm">No data</div>
175+
) : (
176+
<ResponsiveContainer width="100%" height={220}>
177+
<BarChart data={dailyData}>
178+
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
179+
<YAxis tick={{ fontSize: 11 }} />
180+
<Tooltip />
181+
<Legend />
182+
<Bar dataKey="sent" stackId="a" fill={STATUS_COLORS.sent} />
183+
<Bar dataKey="failed" stackId="a" fill={STATUS_COLORS.failed} />
184+
<Bar dataKey="dead_lettered" stackId="a" fill={STATUS_COLORS.dead_lettered} />
185+
<Bar dataKey="pending" stackId="a" fill={STATUS_COLORS.pending} />
186+
</BarChart>
187+
</ResponsiveContainer>
188+
)}
189+
</div>
190+
</div>
191+
192+
{/* daily table */}
193+
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-x-auto">
194+
<table className="min-w-full text-sm">
195+
<thead className="bg-gray-50 border-b border-gray-200">
196+
<tr>
197+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Date</th>
198+
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">Total</th>
199+
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">Avg Score</th>
200+
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">Sent</th>
201+
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">Failed</th>
202+
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">Dead-Letter</th>
203+
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">Pending</th>
204+
</tr>
205+
</thead>
206+
<tbody className="divide-y divide-gray-100">
207+
{summary.by_day.map(d => (
208+
<tr key={d.date} className="hover:bg-gray-50">
209+
<td className="px-4 py-2 font-mono text-xs text-gray-600">{d.date}</td>
210+
<td className="px-4 py-2 text-right">{d.total}</td>
211+
<td className="px-4 py-2 text-right font-mono text-xs">{d.avg_score.toFixed(3)}</td>
212+
<td className="px-4 py-2 text-right text-emerald-600">{d.sent}</td>
213+
<td className="px-4 py-2 text-right text-red-600">{d.failed}</td>
214+
<td className="px-4 py-2 text-right text-orange-600">{d.dead_lettered}</td>
215+
<td className="px-4 py-2 text-right text-violet-600">{d.pending}</td>
216+
</tr>
217+
))}
218+
</tbody>
219+
</table>
220+
</div>
221+
222+
<p className="text-xs text-gray-400">
223+
Showing {summary.by_day.length} day(s) · {summary.total} total messages
224+
</p>
225+
</>
226+
)}
19227
</div>
20228
)
21229
}

0 commit comments

Comments
 (0)