Skip to content

Commit 42d1902

Browse files
committed
Dashboard Module
1 parent ae66a61 commit 42d1902

13 files changed

Lines changed: 1762 additions & 12 deletions

File tree

.kiro/specs/workwork-ledger-mvp/tasks.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -295,38 +295,38 @@
295295
- 列表、筛选、导出按钮
296296
- _需求: 8.2, 8.3_
297297

298-
- [ ] 19. 仪表盘模块
299-
- [ ] 19.1 实现仪表盘统计 API
298+
- [x] 19. 仪表盘模块
299+
- [x] 19.1 实现仪表盘统计 API
300300
- 总收入、按时间段聚合
301301
- _需求: 9.1, 9.2_
302-
- [ ] 19.2 编写属性测试:仪表盘总收入计算
302+
- [x] 19.2 编写属性测试:仪表盘总收入计算
303303
- **属性 20: 仪表盘总收入计算**
304304
- **验证: 需求 9.1**
305-
- [ ] 19.3 实现收入趋势聚合
305+
- [x] 19.3 实现收入趋势聚合
306306
- 按周/月聚合
307307
- _需求: 9.3_
308-
- [ ] 19.4 编写属性测试:时间聚合守恒
308+
- [x] 19.4 编写属性测试:时间聚合守恒
309309
- **属性 21: 时间聚合守恒**
310310
- **验证: 需求 9.3**
311-
- [ ] 19.5 实现客户排名统计
311+
- [x] 19.5 实现客户排名统计
312312
- Top 5 客户及百分比
313313
- _需求: 9.4_
314-
- [ ] 19.6 编写属性测试:客户排名正确性
314+
- [x] 19.6 编写属性测试:客户排名正确性
315315
- **属性 22: 客户排名正确性**
316316
- **验证: 需求 9.4**
317-
- [ ] 19.7 实现支付方式分布统计
317+
- [x] 19.7 实现支付方式分布统计
318318
- 法币 vs 稳定币比例
319319
- _需求: 9.5_
320-
- [ ] 19.8 编写属性测试:支付方式分布守恒
320+
- [x] 19.8 编写属性测试:支付方式分布守恒
321321
- **属性 23: 支付方式分布守恒**
322322
- **验证: 需求 9.5**
323-
- [ ] 19.9 实现预留税金计算
323+
- [x] 19.9 实现预留税金计算
324324
- totalIncome × taxRate
325325
- _需求: 9.6_
326-
- [ ] 19.10 编写属性测试:预留税金计算
326+
- [x] 19.10 编写属性测试:预留税金计算
327327
- **属性 24: 预留税金计算**
328328
- **验证: 需求 9.6**
329-
- [ ] 19.11 实现仪表盘前端页面
329+
- [x] 19.11 实现仪表盘前端页面
330330
- 时间选择器、统计卡片、图表
331331
- _需求: 9.1-9.6_
332332

src/app/dashboard/page.tsx

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import Link from 'next/link';
5+
import { Navbar } from '@/components/layout/navbar';
6+
import { StatCard } from '@/components/dashboard/stat-card';
7+
import { TimePeriodSelector, type TimePeriod } from '@/components/dashboard/time-period-selector';
8+
import { IncomeChart } from '@/components/dashboard/income-chart';
9+
import { TopClients } from '@/components/dashboard/top-clients';
10+
import { PaymentDistribution } from '@/components/dashboard/payment-distribution';
11+
import { trpc } from '@/trpc/client';
12+
13+
const DEMO_USER_ID = 'demo-user-id';
14+
15+
export default function DashboardPage() {
16+
const [period, setPeriod] = useState<TimePeriod>('this_month');
17+
const [customStartDate, setCustomStartDate] = useState('');
18+
const [customEndDate, setCustomEndDate] = useState('');
19+
20+
// Fetch dashboard stats
21+
const statsQuery = trpc.dashboard.getStats.useQuery({
22+
userId: DEMO_USER_ID,
23+
period,
24+
startDate: period === 'custom' && customStartDate ? new Date(customStartDate) : undefined,
25+
endDate: period === 'custom' && customEndDate ? new Date(customEndDate) : undefined,
26+
aggregationPeriod: 'month',
27+
});
28+
29+
const stats = statsQuery.data;
30+
const isLoading = statsQuery.isLoading;
31+
32+
// Format currency
33+
const formatCurrency = (value: string | undefined) => {
34+
if (!value) return '$0.00';
35+
return `$${parseFloat(value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
36+
};
37+
38+
return (
39+
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
40+
<Navbar />
41+
42+
<div className="container mx-auto px-4 py-8">
43+
{/* Header */}
44+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
45+
<div>
46+
<h1 className="text-3xl font-bold text-white mb-2">仪表盘</h1>
47+
<p className="text-gray-400">收入概览与统计分析</p>
48+
</div>
49+
<TimePeriodSelector
50+
value={period}
51+
onChange={setPeriod}
52+
customStartDate={customStartDate}
53+
customEndDate={customEndDate}
54+
onCustomStartDateChange={setCustomStartDate}
55+
onCustomEndDateChange={setCustomEndDate}
56+
/>
57+
</div>
58+
59+
{/* Stats Cards */}
60+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
61+
<StatCard
62+
title="总收入"
63+
value={formatCurrency(stats?.totalIncomeInDefaultCurrency)}
64+
subtitle="默认币种 (USD)"
65+
color="emerald"
66+
icon={
67+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
68+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
69+
</svg>
70+
}
71+
/>
72+
73+
<StatCard
74+
title="交易笔数"
75+
value={stats?.periodAggregations?.reduce((sum, p) => sum + 1, 0).toString() || '0'}
76+
subtitle="本期间"
77+
color="cyan"
78+
icon={
79+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
80+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
81+
</svg>
82+
}
83+
/>
84+
85+
<StatCard
86+
title="Top 客户"
87+
value={stats?.topClients?.[0]?.clientName || '-'}
88+
subtitle={stats?.topClients?.[0] ? `${stats.topClients[0].percentage.toFixed(1)}% 贡献` : '暂无数据'}
89+
color="purple"
90+
icon={
91+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
92+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
93+
</svg>
94+
}
95+
/>
96+
97+
<StatCard
98+
title="建议预留税金"
99+
value={stats?.suggestedTaxReserve ? formatCurrency(stats.suggestedTaxReserve) : '-'}
100+
subtitle="基于预估税率"
101+
color="yellow"
102+
icon={
103+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
104+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
105+
</svg>
106+
}
107+
/>
108+
</div>
109+
110+
{/* Charts Row */}
111+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
112+
<IncomeChart
113+
data={stats?.periodAggregations || []}
114+
isLoading={isLoading}
115+
/>
116+
<TopClients
117+
clients={stats?.topClients || []}
118+
isLoading={isLoading}
119+
/>
120+
</div>
121+
122+
{/* Payment Distribution */}
123+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
124+
<PaymentDistribution
125+
distribution={stats?.paymentMethodDistribution || []}
126+
isLoading={isLoading}
127+
/>
128+
129+
{/* Quick Actions */}
130+
<div className="bg-white/5 backdrop-blur-lg rounded-2xl p-6 border border-white/10">
131+
<h3 className="text-lg font-semibold text-white mb-4">快捷操作</h3>
132+
<div className="grid grid-cols-2 gap-4">
133+
<Link
134+
href="/invoices/new"
135+
className="flex flex-col items-center gap-2 p-4 bg-white/5 rounded-xl hover:bg-white/10 transition-colors border border-white/10"
136+
>
137+
<div className="w-10 h-10 bg-emerald-500/20 rounded-full flex items-center justify-center">
138+
<svg className="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
139+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
140+
</svg>
141+
</div>
142+
<span className="text-sm text-gray-300">创建发票</span>
143+
</Link>
144+
145+
<Link
146+
href="/clients"
147+
className="flex flex-col items-center gap-2 p-4 bg-white/5 rounded-xl hover:bg-white/10 transition-colors border border-white/10"
148+
>
149+
<div className="w-10 h-10 bg-cyan-500/20 rounded-full flex items-center justify-center">
150+
<svg className="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
151+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
152+
</svg>
153+
</div>
154+
<span className="text-sm text-gray-300">管理客户</span>
155+
</Link>
156+
157+
<Link
158+
href="/ledger"
159+
className="flex flex-col items-center gap-2 p-4 bg-white/5 rounded-xl hover:bg-white/10 transition-colors border border-white/10"
160+
>
161+
<div className="w-10 h-10 bg-purple-500/20 rounded-full flex items-center justify-center">
162+
<svg className="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
163+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
164+
</svg>
165+
</div>
166+
<span className="text-sm text-gray-300">查看账本</span>
167+
</Link>
168+
169+
<Link
170+
href="/settings"
171+
className="flex flex-col items-center gap-2 p-4 bg-white/5 rounded-xl hover:bg-white/10 transition-colors border border-white/10"
172+
>
173+
<div className="w-10 h-10 bg-yellow-500/20 rounded-full flex items-center justify-center">
174+
<svg className="w-5 h-5 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
175+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
176+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
177+
</svg>
178+
</div>
179+
<span className="text-sm text-gray-300">设置</span>
180+
</Link>
181+
</div>
182+
</div>
183+
</div>
184+
</div>
185+
</div>
186+
);
187+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use client';
2+
3+
interface PeriodData {
4+
period: string;
5+
amount: string;
6+
}
7+
8+
interface IncomeChartProps {
9+
data: PeriodData[];
10+
isLoading?: boolean;
11+
}
12+
13+
export function IncomeChart({ data, isLoading }: IncomeChartProps) {
14+
if (isLoading) {
15+
return (
16+
<div className="bg-white/5 backdrop-blur-lg rounded-2xl p-6 border border-white/10">
17+
<h3 className="text-lg font-semibold text-white mb-4">收入趋势</h3>
18+
<div className="h-64 flex items-center justify-center">
19+
<div className="animate-pulse text-gray-400">加载中...</div>
20+
</div>
21+
</div>
22+
);
23+
}
24+
25+
if (data.length === 0) {
26+
return (
27+
<div className="bg-white/5 backdrop-blur-lg rounded-2xl p-6 border border-white/10">
28+
<h3 className="text-lg font-semibold text-white mb-4">收入趋势</h3>
29+
<div className="h-64 flex items-center justify-center">
30+
<p className="text-gray-400">暂无数据</p>
31+
</div>
32+
</div>
33+
);
34+
}
35+
36+
// Find max value for scaling
37+
const maxAmount = Math.max(...data.map((d) => parseFloat(d.amount)));
38+
const scale = maxAmount > 0 ? 200 / maxAmount : 1;
39+
40+
return (
41+
<div className="bg-white/5 backdrop-blur-lg rounded-2xl p-6 border border-white/10">
42+
<h3 className="text-lg font-semibold text-white mb-4">收入趋势</h3>
43+
<div className="h-64 flex items-end gap-2 px-4">
44+
{data.map((item, index) => {
45+
const height = Math.max(parseFloat(item.amount) * scale, 4);
46+
return (
47+
<div key={index} className="flex-1 flex flex-col items-center gap-2">
48+
<div
49+
className="w-full bg-gradient-to-t from-emerald-600 to-emerald-400 rounded-t-lg transition-all hover:from-emerald-500 hover:to-emerald-300"
50+
style={{ height: `${height}px` }}
51+
title={`$${parseFloat(item.amount).toFixed(2)}`}
52+
/>
53+
<span className="text-xs text-gray-400 truncate max-w-full">
54+
{formatPeriodLabel(item.period)}
55+
</span>
56+
</div>
57+
);
58+
})}
59+
</div>
60+
</div>
61+
);
62+
}
63+
64+
function formatPeriodLabel(period: string): string {
65+
// Handle month format: 2024-01
66+
if (/^\d{4}-\d{2}$/.test(period)) {
67+
const [year, month] = period.split('-');
68+
return `${month}月`;
69+
}
70+
// Handle week format: 2024-W01
71+
if (/^\d{4}-W\d{2}$/.test(period)) {
72+
const week = period.split('-W')[1];
73+
return `W${week}`;
74+
}
75+
return period;
76+
}

0 commit comments

Comments
 (0)