|
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 | +} |
4 | 31 |
|
5 | 32 | 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 | + |
6 | 64 | return ( |
7 | 65 | <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> |
18 | 105 | </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 | + )} |
19 | 227 | </div> |
20 | 228 | ) |
21 | 229 | } |
0 commit comments