Skip to content

Commit ae66a61

Browse files
committed
Ledger Module
1 parent 05ea4f4 commit ae66a61

12 files changed

Lines changed: 1284 additions & 15 deletions

File tree

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -265,33 +265,33 @@
265265
- 保存配置后自动生成地址
266266
- _需求: 2.4_
267267

268-
- [ ] 17. 检查点 - 确保所有测试通过
268+
- [x] 17. 检查点 - 确保所有测试通过
269269
- 确保所有测试通过,如有问题请询问用户
270270

271271
## 阶段 P4: 账本与仪表盘
272272

273-
- [ ] 18. 账本模块
274-
- [ ] 18.1 实现账本条目自动创建
273+
- [x] 18. 账本模块
274+
- [x] 18.1 实现账本条目自动创建
275275
- 支付成功时自动创建
276276
- 包含所有必要字段
277277
- _需求: 8.1_
278-
- [ ] 18.2 实现账本列表和筛选 API
278+
- [x] 18.2 实现账本列表和筛选 API
279279
- 时间范围、客户、项目、币种、支付方式筛选
280280
- _需求: 8.2_
281-
- [ ] 18.3 编写属性测试:账本筛选正确性
281+
- [x] 18.3 编写属性测试:账本筛选正确性
282282
- **属性 18: 账本筛选正确性**
283283
- **验证: 需求 8.2**
284-
- [ ] 18.4 实现汇率转换
284+
- [x] 18.4 实现汇率转换
285285
- 获取汇率(可先用固定汇率,后续接入 API)
286286
- 计算 amountInDefaultCurrency
287287
- _需求: 8.4_
288-
- [ ] 18.5 编写属性测试:汇率转换正确性
288+
- [x] 18.5 编写属性测试:汇率转换正确性
289289
- **属性 19: 汇率转换正确性**
290290
- **验证: 需求 8.4**
291-
- [ ] 18.6 实现 CSV 导出
291+
- [x] 18.6 实现 CSV 导出
292292
- 导出筛选后的账本条目
293293
- _需求: 8.3_
294-
- [ ] 18.7 实现账本前端页面
294+
- [x] 18.7 实现账本前端页面
295295
- 列表、筛选、导出按钮
296296
- _需求: 8.2, 8.3_
297297

src/app/ledger/page.tsx

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Button } from '@/components/ui/button';
5+
import { LedgerList } from '@/components/ledger/ledger-list';
6+
import { LedgerFilters } from '@/components/ledger/ledger-filters';
7+
import { Navbar } from '@/components/layout/navbar';
8+
import { trpc } from '@/trpc/client';
9+
10+
const DEMO_USER_ID = 'demo-user-id';
11+
12+
type Currency = 'USD' | 'EUR' | 'HKD' | 'GBP' | 'JPY';
13+
type PaymentMethod = 'card' | 'bank_transfer' | 'crypto_usdc' | 'crypto_usdt';
14+
15+
export default function LedgerPage() {
16+
const [startDate, setStartDate] = useState('');
17+
const [endDate, setEndDate] = useState('');
18+
const [currency, setCurrency] = useState('');
19+
const [paymentMethod, setPaymentMethod] = useState('');
20+
const [isExporting, setIsExporting] = useState(false);
21+
22+
const ledgerQuery = trpc.ledger.list.useQuery({
23+
userId: DEMO_USER_ID,
24+
startDate: startDate ? new Date(startDate) : undefined,
25+
endDate: endDate ? new Date(endDate) : undefined,
26+
currency: currency ? (currency as Currency) : undefined,
27+
paymentMethod: paymentMethod ? (paymentMethod as PaymentMethod) : undefined,
28+
});
29+
30+
const exportQuery = trpc.ledger.exportCSV.useQuery(
31+
{
32+
userId: DEMO_USER_ID,
33+
startDate: startDate ? new Date(startDate) : undefined,
34+
endDate: endDate ? new Date(endDate) : undefined,
35+
currency: currency ? (currency as Currency) : undefined,
36+
paymentMethod: paymentMethod ? (paymentMethod as PaymentMethod) : undefined,
37+
},
38+
{ enabled: false }
39+
);
40+
41+
const handleExport = async () => {
42+
setIsExporting(true);
43+
try {
44+
const result = await exportQuery.refetch();
45+
if (result.data?.csv) {
46+
// Create and download CSV file
47+
const blob = new Blob([result.data.csv], { type: 'text/csv;charset=utf-8;' });
48+
const link = document.createElement('a');
49+
const url = URL.createObjectURL(blob);
50+
link.setAttribute('href', url);
51+
link.setAttribute('download', `ledger-export-${new Date().toISOString().split('T')[0]}.csv`);
52+
link.style.visibility = 'hidden';
53+
document.body.appendChild(link);
54+
link.click();
55+
document.body.removeChild(link);
56+
}
57+
} catch (error) {
58+
console.error('Export failed:', error);
59+
alert('Failed to export ledger data');
60+
} finally {
61+
setIsExporting(false);
62+
}
63+
};
64+
65+
// Calculate totals
66+
const entries = ledgerQuery.data?.entries || [];
67+
const totalAmount = entries.reduce(
68+
(sum, entry) => sum + parseFloat(String(entry.amountInDefaultCurrency)),
69+
0
70+
);
71+
72+
return (
73+
<div className="min-h-screen bg-gray-50">
74+
<Navbar />
75+
<div className="container mx-auto px-4 py-8">
76+
<div className="flex justify-between items-center mb-6">
77+
<h1 className="text-2xl font-bold text-gray-900">收入账本</h1>
78+
<Button onClick={handleExport} disabled={isExporting}>
79+
{isExporting ? 'Exporting...' : 'Export CSV'}
80+
</Button>
81+
</div>
82+
83+
{/* Summary Card */}
84+
<div className="bg-white rounded-lg shadow p-6 mb-6">
85+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
86+
<div>
87+
<p className="text-sm text-gray-500">Total Entries</p>
88+
<p className="text-2xl font-bold text-gray-900">
89+
{ledgerQuery.data?.total || 0}
90+
</p>
91+
</div>
92+
<div>
93+
<p className="text-sm text-gray-500">Total Income (USD)</p>
94+
<p className="text-2xl font-bold text-green-600">
95+
${totalAmount.toFixed(2)}
96+
</p>
97+
</div>
98+
<div>
99+
<p className="text-sm text-gray-500">Current Page</p>
100+
<p className="text-2xl font-bold text-gray-900">
101+
{ledgerQuery.data?.page || 1} / {ledgerQuery.data?.totalPages || 1}
102+
</p>
103+
</div>
104+
</div>
105+
</div>
106+
107+
{/* Filters */}
108+
<LedgerFilters
109+
startDate={startDate}
110+
endDate={endDate}
111+
currency={currency}
112+
paymentMethod={paymentMethod}
113+
onStartDateChange={setStartDate}
114+
onEndDateChange={setEndDate}
115+
onCurrencyChange={setCurrency}
116+
onPaymentMethodChange={setPaymentMethod}
117+
/>
118+
119+
{/* Ledger List */}
120+
<div className="bg-white rounded-lg shadow">
121+
<LedgerList
122+
entries={entries.map((entry) => ({
123+
...entry,
124+
amount: String(entry.amount),
125+
amountInDefaultCurrency: String(entry.amountInDefaultCurrency),
126+
}))}
127+
isLoading={ledgerQuery.isLoading}
128+
/>
129+
</div>
130+
131+
{/* Pagination */}
132+
{ledgerQuery.data && ledgerQuery.data.totalPages > 1 && (
133+
<div className="mt-4 flex justify-center gap-2">
134+
<Button
135+
variant="secondary"
136+
disabled={ledgerQuery.data.page <= 1}
137+
onClick={() => {
138+
// TODO: Implement pagination
139+
}}
140+
>
141+
Previous
142+
</Button>
143+
<span className="px-4 py-2 text-gray-600">
144+
Page {ledgerQuery.data.page} of {ledgerQuery.data.totalPages}
145+
</span>
146+
<Button
147+
variant="secondary"
148+
disabled={ledgerQuery.data.page >= ledgerQuery.data.totalPages}
149+
onClick={() => {
150+
// TODO: Implement pagination
151+
}}
152+
>
153+
Next
154+
</Button>
155+
</div>
156+
)}
157+
</div>
158+
</div>
159+
);
160+
}

src/components/layout/navbar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export function Navbar() {
3535
<Link href="/invoices" className={linkClass('/invoices')}>
3636
发票
3737
</Link>
38+
<Link href="/ledger" className={linkClass('/ledger')}>
39+
账本
40+
</Link>
3841
<Link href="/settings" className={linkClass('/settings')}>
3942
设置
4043
</Link>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use client';
2+
3+
import { Input } from '@/components/ui/input';
4+
5+
interface LedgerFiltersProps {
6+
startDate: string;
7+
endDate: string;
8+
currency: string;
9+
paymentMethod: string;
10+
onStartDateChange: (value: string) => void;
11+
onEndDateChange: (value: string) => void;
12+
onCurrencyChange: (value: string) => void;
13+
onPaymentMethodChange: (value: string) => void;
14+
}
15+
16+
export function LedgerFilters({
17+
startDate,
18+
endDate,
19+
currency,
20+
paymentMethod,
21+
onStartDateChange,
22+
onEndDateChange,
23+
onCurrencyChange,
24+
onPaymentMethodChange,
25+
}: LedgerFiltersProps) {
26+
return (
27+
<div className="flex flex-wrap gap-4 mb-6">
28+
<div className="flex flex-col">
29+
<label className="text-sm text-gray-600 mb-1">Start Date</label>
30+
<Input
31+
type="date"
32+
value={startDate}
33+
onChange={(e) => onStartDateChange(e.target.value)}
34+
className="w-40"
35+
/>
36+
</div>
37+
<div className="flex flex-col">
38+
<label className="text-sm text-gray-600 mb-1">End Date</label>
39+
<Input
40+
type="date"
41+
value={endDate}
42+
onChange={(e) => onEndDateChange(e.target.value)}
43+
className="w-40"
44+
/>
45+
</div>
46+
<div className="flex flex-col">
47+
<label className="text-sm text-gray-600 mb-1">Currency</label>
48+
<select
49+
value={currency}
50+
onChange={(e) => onCurrencyChange(e.target.value)}
51+
className="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
52+
>
53+
<option value="">All Currencies</option>
54+
<option value="USD">USD</option>
55+
<option value="EUR">EUR</option>
56+
<option value="HKD">HKD</option>
57+
<option value="GBP">GBP</option>
58+
<option value="JPY">JPY</option>
59+
</select>
60+
</div>
61+
<div className="flex flex-col">
62+
<label className="text-sm text-gray-600 mb-1">Payment Method</label>
63+
<select
64+
value={paymentMethod}
65+
onChange={(e) => onPaymentMethodChange(e.target.value)}
66+
className="px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
67+
>
68+
<option value="">All Methods</option>
69+
<option value="card">Card</option>
70+
<option value="bank_transfer">Bank Transfer</option>
71+
<option value="crypto_usdc">USDC</option>
72+
<option value="crypto_usdt">USDT</option>
73+
</select>
74+
</div>
75+
</div>
76+
);
77+
}

0 commit comments

Comments
 (0)