feat: add account groups, scheduled payments and cash flow calendar

This commit is contained in:
陈煜 committed 2026-10-02 17:42:57 +08:00
1 parent a7e0a0fe58
commit 86c6daf695
46 files changed
+2548 -290

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react';
import { api, money, today } from './api';
import { t as tr } from './i18n';
type Day = { date: string; income: string; expense: string; complete: boolean };
type Entry = {
id: string;
name: string;
direction: string;
amount: string;
currency: string;
converted: string | null;
notes: string;
};
export function Calendar({
revealed,
refreshVersion,
}: {
revealed: boolean;
refreshVersion: number;
}) {
const [month, setMonth] = useState(today().slice(0, 7)),
[selected, setSelected] = useState(today());
const [days, setDays] = useState<Day[]>([]),
[rows, setRows] = useState<Entry[]>([]),
[base, setBase] = useState('CNY'),
[error, setError] = useState(''),
[loading, setLoading] = useState(false);
useEffect(() => {
let active = true;
setDays([]);
setError('');
void api<{ items: Day[]; baseCurrency: string }>('/calendar?month=' + month)
.then((v) => {
if (active) {
setDays(v.items);
setBase(v.baseCurrency);
}
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [month, revealed, refreshVersion]);
useEffect(() => {
let active = true;
setRows([]);
setLoading(true);
void api<{ items: Entry[] }>('/calendar/day?date=' + selected)
.then((v) => {
if (active) setRows(v.items);
})
.catch((e) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [selected, revealed, refreshVersion]);
const offset = (new Date(month + '-01T00:00:00Z').getUTCDay() + 6) % 7;
const day = days.find((v) => v.date === selected);
return (
<section className="panel calendar-panel">
<div className="panel-title">
<h2>{tr('收支日历')}</h2>
<input
aria-label={tr('月份')}
type="month"
min="1900-01"
max={today().slice(0, 7)}
value={month}
onChange={(e) => {
if (e.target.value) {
setMonth(e.target.value);
setSelected(
e.target.value === today().slice(0, 7) ? today() : e.target.value + '-01',
);
}
}}
/>
</div>
<p className="muted">
{tr('按账户余额变动估算;转账、借贷本金、初始余额和估值不计入。负债账户按相反方向计算。')}
</p>
<div className="calendar-grid">
{['一', '二', '三', '四', '五', '六', '日'].map((v) => (
<span key={v} className="weekday">
{tr(v)}
</span>
))}
{Array.from({ length: offset }, (_, i) => (
<span key={'blank-' + i} />
))}
{days.map((d) => (
<button
key={d.date}
disabled={d.date > today()}
aria-label={d.date}
aria-pressed={d.date === selected}
className={'calendar-day ' + (d.date === selected ? 'selected' : '')}
onClick={() => setSelected(d.date)}
>
<strong>{Number(d.date.slice(-2))}</strong>
{Number(d.income) > 0 && (
<small className="income-text">+{money(d.income, base)}</small>
)}
{Number(d.expense) > 0 && (
<small className="danger-text">−{money(d.expense, base)}</small>
)}
{!d.complete && <small>{tr('缺少汇率')}</small>}
</button>
))}
</div>
<div className="calendar-detail">
<div className="panel-title">
<h3>
{selected} · {tr('当日收支')}
</h3>
<button
className="text"
onClick={() => {
setMonth(today().slice(0, 7));
setSelected(today());
}}
>
{tr('今天')}
</button>
</div>
{day && (
<p>
{tr('收入')}:{day.complete ? money(day.income, base) : tr('缺少汇率')} ·{' '}
<span className="danger-text">
{tr('支出')}:{day.complete ? money(day.expense, base) : tr('缺少汇率')}
</span>
</p>
)}
{loading ? (
<p>{tr('正在刷新数据…')}</p>
) : rows.length ? (
rows.map((v) => (
<article className="schedule-row" key={v.id}>
<span>
{v.name}
<small className="notes-display">{v.notes}</small>
</span>
<strong className={v.direction === 'expense' ? 'danger-text' : 'income-text'}>
{v.direction === 'expense' ? '−' : '+'}
{money(v.amount, v.currency)}
</strong>
{v.converted === null && <small>{tr('缺少汇率')}</small>}
</article>
))
) : (
<p>{tr('当天没有收支记录')}</p>
)}
</div>
{error && (
<p role="alert" className="notice error">
{error}
</p>
)}
</section>
);
}