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

+90
View File
@@ -0,0 +1,90 @@
import { useId, useRef, useState } from 'react';
import { type Position, money, positionAmount } from './api';
import { t as tr } from './i18n';
import { iconUrl } from './IconLibrary';
export function AccountPicker({
name,
accounts,
value,
onChange,
excludeId,
}: {
name: string;
accounts: Position[];
value: string;
onChange: (id: string) => void;
excludeId?: string;
}) {
const dialog = useRef<HTMLDialogElement>(null),
title = useId();
const [grouped, setGrouped] = useState(true),
[query, setQuery] = useState('');
const selected = accounts.find((p) => p.id === value);
const rows = accounts.filter(
(p) =>
p.id !== excludeId &&
`${p.name} ${p.groupName || ''} ${p.currency}`.toLowerCase().includes(query.toLowerCase()),
);
const groups = grouped ? [...new Set(rows.map((p) => p.groupName || '未分组'))] : ['全部账户'];
return (
<div className="account-picker">
<input name={name} type="hidden" value={value} />
<button
className="secondary picker-trigger"
type="button"
onClick={() => dialog.current?.showModal()}
>
{selected
? `${selected.name} · ${money(positionAmount(selected), selected.currency)}`
: tr('选择账户')}
</button>
<dialog ref={dialog} className="picker-dialog" aria-labelledby={title}>
<div className="panel-title">
<h2 id={title}>{tr('选择账户')}</h2>
<button type="button" onClick={() => dialog.current?.close()} aria-label={tr('关闭')}>
×
</button>
</div>
<input
placeholder={tr('搜索账户、分组或币种')}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="actions">
<button type="button" aria-pressed={!grouped} onClick={() => setGrouped(false)}>
{tr('全部账户')}
</button>
<button type="button" aria-pressed={grouped} onClick={() => setGrouped(true)}>
{tr('按分组显示')}
</button>
</div>
{groups.map((group) => (
<section key={group}>
<h3>{tr(group)}</h3>
{rows
.filter((p) => !grouped || (p.groupName || '未分组') === group)
.map((p) => (
<button
type="button"
className="picker-option"
key={p.id}
onClick={() => {
onChange(p.id);
dialog.current?.close();
}}
>
{p.iconId && <img src={iconUrl(p.iconId)} alt="" />}
<span>
{p.name}
<small>{money(positionAmount(p), p.currency)}</small>
</span>
{p.id === value && <span>✓</span>}
</button>
))}
</section>
))}
{!rows.length && <p>{tr('没有符合条件的账户')}</p>}
</dialog>
</div>
);
}
+276 -119
View File
@@ -2,6 +2,7 @@ import { t as tr, useLocale, LanguagePicker } from './i18n';
import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react';
import {
LayoutDashboard,
CalendarDays,
Wallet,
Landmark,
HandCoins,
@@ -23,6 +24,7 @@ import {
ApiError,
currencies,
money,
positionAmount,
today,
nowMinute,
displayTime,
@@ -35,6 +37,8 @@ import {
type PageResult,
} from './api';
import './style.css';
import { Calendar } from './Calendar';
import { SchedulePanel } from './SchedulePanel';
import { IconPicker } from './IconPicker';
import { TransferForm } from './TransferForm';
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
@@ -46,6 +50,7 @@ const labels: Record<string, string> = {
asset: '独立资产',
debt: '债务',
history: '变化记录',
calendar: '收支日历',
settings: '设置与备份',
};
const categories: Record<string, [string, string][]> = {
@@ -154,7 +159,7 @@ function Chart({ rows, base }: { rows: Total[]; base: string }) {
<>
<div className="legend">
<span className="green">{tr('总资产')}</span>
<span className="orange">{tr('总负债')}</span>
<span className="danger-text">{tr('总负债')}</span>
<span className="blue">{tr('净资产')}</span>
<small>{base}</small>
</div>
@@ -176,7 +181,7 @@ function Chart({ rows, base }: { rows: Total[]; base: string }) {
)
.join(' ')}
fill="none"
stroke={['#199b76', '#dc9463', '#457bca'][i]}
stroke={['#199b76', '#c73542', '#457bca'][i]}
strokeWidth="3"
/>
))}
@@ -222,6 +227,9 @@ export default function App() {
[loading, setLoading] = useState(false),
[error, setError] = useState(''),
[success, setSuccess] = useState('');
const [calendarRefresh, setCalendarRefresh] = useState(0);
const [quickMode, setQuickMode] = useState(false),
[accountGroup, setAccountGroup] = useState('');
const [fxStatus, setFxStatus] = useState<{
state: string;
message: string;
@@ -255,6 +263,8 @@ export default function App() {
userRef.current = user;
const activityAt = useRef(0);
function clearAccount() {
setQuickMode(false);
setAccountGroup('');
setClearStep(0);
setClearConfirmation(0);
activityAt.current = 0;
@@ -331,6 +341,16 @@ export default function App() {
}
setOverview({ ...o, trend: t.items, recent: recent.items });
} else if (['account', 'asset', 'debt'].includes(page)) {
if (page === 'account' && !rangeOnly) {
const result = await api<{
executed: number;
errors: { message: string }[];
hasMore: boolean;
}>('/schedules/run', 'POST', {});
if (!active()) return;
if (result.errors.length) setError(result.errors[0].message);
if (result.executed) setSuccess(tr('到期计划已记账') + ` (${result.executed})`);
}
const [p, h] = await Promise.all([
api<Position[]>('/positions?kind=' + page),
selected
@@ -359,6 +379,8 @@ export default function App() {
setHistoryCursor(h.nextCursor);
setTransfers(t.items);
setTransferCursor(t.nextCursor);
} else if (page === 'calendar') {
setCalendarRefresh((value) => value + 1);
} else if (page === 'settings') {
const settings = await api<User & { rates: Rate[]; fxStatus: typeof fxStatus }>(
'/settings' + (settingsSection === 'rates' ? '?rates=true' : ''),
@@ -725,6 +747,7 @@ export default function App() {
['asset', Landmark],
['debt', HandCoins],
['history', HistoryIcon],
['calendar', CalendarDays],
['settings', Settings],
] as const;
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
@@ -921,7 +944,13 @@ export default function App() {
</span>
<ArrowUpRight size={18} />
</div>
<strong>{money(value, user.baseCurrency)}</strong>
<strong
className={
cls === 'debt' || value.startsWith('-') ? 'danger-text' : undefined
}
>
{money(value, user.baseCurrency)}
</strong>
<small>
{title === tr('净资产')
? tr('总资产 − 总负债')
@@ -1055,18 +1084,26 @@ export default function App() {
<span className="badge">
{categoryLabel(p)} ·{' '}
{p.kind === 'debt'
? tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)')
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
: p.side === 'asset'
? tr('资产')
: tr('负债')}
{p.archived ? tr(' · 已归档') : ''}
</span>
<h2>{money(p.amount, p.currency)}</h2>
<h2
className={
p.side === 'liability' || p.amount.startsWith('-')
? 'danger-text'
: undefined
}
>
{money(positionAmount(p), p.currency)}
</h2>
{p.hidden && <span className="badge">{tr('隐藏项目')}</span>}
<p className="muted">
{tr('本位币:')}
{p.converted !== null
? money(p.converted || '0', user.baseCurrency)
? money(positionAmount(p, p.converted || '0'), user.baseCurrency)
: tr('缺少汇率')}
</p>
<p className="notes-display">{p.notes || tr('暂无备注')}</p>
@@ -1085,7 +1122,7 @@ export default function App() {
className="secondary"
onClick={() => setModal({ kind: 'debt-payment', p })}
>
{tr('债务收款 / 还款')}
{tr('收款 / 还款')}
</button>
</>
)}
@@ -1103,18 +1140,6 @@ export default function App() {
>
{operationLabel(p.side === 'asset' ? 'collect' : 'repay')}
</button>
<button
className="secondary"
onClick={() =>
setModal({
kind: 'debt-payment',
p,
operation: p.side === 'asset' ? 'lend' : 'borrow',
})
}
>
{operationLabel(p.side === 'asset' ? 'lend' : 'borrow')}
</button>
</>
)}
<button className="secondary" onClick={() => setModal({ kind: 'edit', p })}>
@@ -1129,6 +1154,7 @@ export default function App() {
api('/positions/' + p.id, 'PATCH', {
name: p.name,
category: p.category,
groupName: p.groupName,
notes: p.notes,
archived: !p.archived,
hidden: p.hidden,
@@ -1196,15 +1222,63 @@ export default function App() {
</>
) : (
<section className="panel">
{page === 'account' && (
<>
<div className="actions account-tools">
<button
className={quickMode ? 'primary' : 'secondary'}
aria-pressed={quickMode}
onClick={() => setQuickMode(!quickMode)}
>
{tr(quickMode ? '退出快速记账' : '快速记账')}
</button>
<select
aria-label={tr('账户分组')}
value={accountGroup}
onChange={(e) => setAccountGroup(e.target.value)}
>
<option value="">{tr('全部账户')}</option>
{[
...new Set(
positions
.filter((p) => p.kind === 'account')
.map((p) => p.groupName || '未分组'),
),
].map((g) => (
<option key={g} value={g}>
{tr(g)}
</option>
))}
</select>
</div>
{quickMode && <p className="muted">{tr('点击账户卡片,直接填写当前余额。')}</p>}
<SchedulePanel
accounts={positions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
)}
refresh={() => load(false, true)}
/>
</>
)}
{positions.some((p) => p.kind === page) ? (
<div className="position-grid">
{positions
.filter((p) => p.kind === page)
.filter(
(p) =>
p.kind === page &&
(page !== 'account' ||
!accountGroup ||
(p.groupName || '未分组') === accountGroup),
)
.map((p) => (
<button
className="position-card"
key={p.id}
onClick={() => setSelected(p.id)}
onClick={() =>
page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p })
: setSelected(p.id)
}
>
<div>
<span className="position-icon">
@@ -1221,21 +1295,30 @@ export default function App() {
<ArrowUpRight size={18} />
</div>
<h3>{p.name}</h3>
{p.groupName && <span className="badge">{p.groupName}</span>}
<p>
{categoryLabel(p)} ·{' '}
{p.kind === 'debt'
? tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)')
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
: p.side === 'asset'
? tr('资产')
: tr('负债')}
{p.archived ? tr(' · 已归档') : ''}
</p>
<strong>{money(p.amount, p.currency)}</strong>
<strong
className={
p.side === 'liability' || p.amount.startsWith('-')
? 'danger-text'
: undefined
}
>
{money(positionAmount(p), p.currency)}
</strong>
<small>
{tr('折合')}{' '}
{p.converted === null
? tr('缺少汇率')
: money(p.converted || '0', user.baseCurrency)}
: money(positionAmount(p, p.converted || '0'), user.baseCurrency)}
</small>
</button>
))}
@@ -1250,92 +1333,97 @@ export default function App() {
</section>
))}
{page === 'history' && (
<section className="panel">
<div className="actions">
<label>
{tr('开始')}
<input
type="date"
value={rangeFrom}
max={rangeTo}
onChange={(e) => setRangeFrom(e.target.value)}
/>
</label>
<label>
{tr('结束')}
<input
type="date"
value={rangeTo}
min={rangeFrom}
max={today()}
onChange={(e) => setRangeTo(e.target.value)}
/>
</label>
</div>
<h2>{tr('资金往来')}</h2>
{transfers.length ? (
<div className="transfer-list">
{transfers.map((t) => (
<article key={t.id}>
<div>
<strong>
{operationLabel(t.operation || 'transfer')} ·
<button
className="text"
onClick={() => {
setSelected(t.sourceId);
setPage(t.source.kind);
}}
>
{t.source.name}
</button>
{' ↔ '}
<button
className="text"
onClick={() => {
setSelected(t.targetId);
setPage(t.target.kind);
}}
>
{t.target.name}
</button>
</strong>
<small>{displayTime(t.date)}</small>
</div>
<p>
{money(t.amount, t.sourceCurrency)} → {money(t.received, t.targetCurrency)}{' '}
{tr('· 手续费')}
{money(t.fee, t.sourceCurrency)}
</p>
{t.notes && <p className="muted notes-display">{t.notes}</p>}
</article>
))}
<>
<section className="panel">
<div className="actions">
<label>
{tr('开始')}
<input
type="date"
value={rangeFrom}
max={rangeTo}
onChange={(e) => setRangeFrom(e.target.value)}
/>
</label>
<label>
{tr('结束')}
<input
type="date"
value={rangeTo}
min={rangeFrom}
max={today()}
onChange={(e) => setRangeTo(e.target.value)}
/>
</label>
</div>
) : (
<p className="muted">{tr('暂无账户间转账记录。')}</p>
)}
{transferCursor && (
<button disabled={loading} onClick={() => void more('transfers')}>
{tr('加载更早转账')}
</button>
)}
<h2>{tr('全部金额变化')}</h2>
<p className="muted">
{tr('原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。')}
</p>
<HistoryTable
rows={historyRows}
open={(id) => {
setSelected(id);
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
}}
/>
{historyCursor && (
<button disabled={loading} onClick={() => void more('history')}>
{tr('加载更早记录')}
</button>
)}
</section>
<h2>{tr('资金往来')}</h2>
{transfers.length ? (
<div className="transfer-list">
{transfers.map((t) => (
<article key={t.id}>
<div>
<strong>
{operationLabel(t.operation || 'transfer')} ·
<button
className="text"
onClick={() => {
setSelected(t.sourceId);
setPage(t.source.kind);
}}
>
{t.source.name}
</button>
{' ↔ '}
<button
className="text"
onClick={() => {
setSelected(t.targetId);
setPage(t.target.kind);
}}
>
{t.target.name}
</button>
</strong>
<small>{displayTime(t.date)}</small>
</div>
<p>
{money(t.amount, t.sourceCurrency)} →{' '}
{money(t.received, t.targetCurrency)} {tr('· 手续费')}
{money(t.fee, t.sourceCurrency)}
</p>
{t.notes && <p className="muted notes-display">{t.notes}</p>}
</article>
))}
</div>
) : (
<p className="muted">{tr('暂无账户间转账记录。')}</p>
)}
{transferCursor && (
<button disabled={loading} onClick={() => void more('transfers')}>
{tr('加载更早转账')}
</button>
)}
<h2>{tr('全部金额变化')}</h2>
<p className="muted">
{tr('原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。')}
</p>
<HistoryTable
rows={historyRows}
open={(id) => {
setSelected(id);
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
}}
/>
{historyCursor && (
<button disabled={loading} onClick={() => void more('history')}>
{tr('加载更早记录')}
</button>
)}
</section>
</>
)}
{page === 'calendar' && (
<Calendar revealed={!!user.revealed} refreshVersion={calendarRefresh} />
)}
{page === 'settings' && (
<>
@@ -1781,7 +1869,9 @@ export default function App() {
<Modal
title={
modal.kind === 'debt-payment'
? tr('债务收款 / 还款')
? modal.p?.kind === 'debt'
? operationLabel(modal.p.side === 'asset' ? 'collect' : 'repay')
: tr('收款 / 还款')
: modal.kind === 'transfer'
? tr('账户间转账')
: modal.kind === 'reveal'
@@ -1923,7 +2013,25 @@ export default function App() {
{tr('隐藏此项目(密码验证后可查看和编辑)')}
</label>
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
<IconPicker key={modal.p?.id || 'new-account'} initialId={modal.p?.iconId} />
<>
<IconPicker key={modal.p?.id || 'new-account'} initialId={modal.p?.iconId} />
<Field label={tr('账户分组')}>
<input
name="groupName"
maxLength={60}
defaultValue={modal.p?.groupName || ''}
list="account-groups"
placeholder={tr('例如:日常、消费、理财')}
/>
<datalist id="account-groups">
{[...new Set(positions.map((p) => p.groupName).filter(Boolean))].map(
(g) => (
<option key={g} value={g} />
),
)}
</datalist>
</Field>
</>
)}
<Field label={tr('名称')}>
<input
@@ -1957,8 +2065,8 @@ export default function App() {
<>
<Field label={tr('债务方向')}>
<select name="side" defaultValue="liability">
<option value="liability">{tr('借入(应付)')}</option>
<option value="asset">{tr('借出(应收)')}</option>
<option value="liability">{tr('欠款(待还)')}</option>
<option value="asset">{tr('借款(待收)')}</option>
</select>
</Field>
<p className="muted">
@@ -1979,13 +2087,26 @@ export default function App() {
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field
label={tr('变更后的绝对金额') + (modal.p ? '(' + modal.p.currency + ')' : '')}
label={
tr(
modal.p?.kind === 'debt'
? '剩余本金'
: modal.p?.kind === 'asset'
? '当前估值'
: '当前余额',
) + (modal.p ? '(' + modal.p.currency + ')' : '')
}
>
<input
name="amount"
inputMode="decimal"
required
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
pattern={
(modal.p?.side === 'asset' && modal.p?.kind === 'account') ||
modal.kind === 'account'
? '-?(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
: '(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
}
defaultValue={
modal.h?.after || modal.p?.amount || (modal.kind === 'debt' ? '0' : '')
}
@@ -1994,7 +2115,7 @@ export default function App() {
</Field>
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={tr('业务时间(北京时间,精确到分钟)')}>
<Field label={tr('记录时间(UTC+8)')}>
<input
type={'datetime-local'}
name="date"
@@ -2129,6 +2250,7 @@ function HistoryTable({
(
{
initial: tr('初始记录'),
scheduled_expense: tr('计划支出'),
balance: tr('余额更新'),
valuation: tr('估值更新'),
repayment: tr('还款'),
@@ -2144,9 +2266,44 @@ function HistoryTable({
}
</small>
</td>
<td>{money(h.before, h.currency)}</td>
<td>{money(h.after, h.currency)}</td>
<td>{money(h.delta, h.currency)}</td>
<td
className={h.kind === 'debt' && h.side === 'liability' ? 'danger-text' : undefined}
>
{money(
h.kind === 'debt' && h.side === 'liability' && Number(h.before) !== 0
? '-' + h.before
: h.before,
h.currency,
)}
</td>
<td
className={h.kind === 'debt' && h.side === 'liability' ? 'danger-text' : undefined}
>
{money(
h.kind === 'debt' && h.side === 'liability' && Number(h.after) !== 0
? '-' + h.after
: h.after,
h.currency,
)}
</td>
<td
className={
(h.kind === 'debt' && h.side === 'liability') || h.delta.startsWith('-')
? 'danger-text'
: undefined
}
>
{money(
h.kind === 'debt' && h.side === 'liability'
? h.delta.startsWith('-')
? h.delta.slice(1)
: Number(h.delta) !== 0
? '-' + h.delta
: h.delta
: h.delta,
h.currency,
)}
</td>
<td className="notes-cell">{h.notes || '—'}</td>
{correct && (
<td>
+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>
);
}
+11 -15
View File
@@ -1,5 +1,6 @@
import { AccountPicker } from './AccountPicker';
import { useState, type FormEvent } from 'react';
import { type Position, nowMinute, money, requestUuid } from './api';
import { type Position, nowMinute, money, requestUuid, positionAmount } from './api';
import { t as tr } from './i18n';
export type DebtOperation = 'borrow' | 'lend' | 'collect' | 'repay';
export const operationLabel = (operation: string) =>
@@ -8,8 +9,8 @@ export const operationLabel = (operation: string) =>
{
borrow: '借入到账',
lend: '借出付款',
collect: '收回借款',
repay: '偿还债务',
collect: '收款',
repay: '还款',
transfer: '账户转账',
} as Record<string, string>
)[operation] || operation,
@@ -63,8 +64,8 @@ export function DebtPaymentForm({
>
{debts.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)')} ·{' '}
{money(p.amount, p.currency)}
{p.name} · {tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')} ·{' '}
{money(positionAmount(p), p.currency)}
</option>
))}
</select>
@@ -85,17 +86,12 @@ export function DebtPaymentForm({
</label>
<label>
{tr(operation === 'borrow' || operation === 'collect' ? '收款账户' : '付款账户')}
<select
<AccountPicker
name="sourceId"
accounts={accounts}
value={accountId}
onChange={(event) => setAccount(event.target.value)}
>
{accounts.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {money(p.amount, p.currency)}
</option>
))}
</select>
onChange={setAccount}
/>
</label>
<p className="muted">
{tr('账户与债务同时记账并自动建立关联;这里填写本次金额,不是操作后的余额。')}
@@ -139,7 +135,7 @@ export function DebtPaymentForm({
{tr('手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。')}
</p>
<label>
{tr('业务时间(北京时间,精确到分钟)')}
{tr('记录时间(UTC+8)')}
<input
name="date"
type="datetime-local"
+232
View File
@@ -0,0 +1,232 @@
import { useEffect, useState } from 'react';
import { api, money, nowMinute, type Position } from './api';
import { AccountPicker } from './AccountPicker';
import { t as tr } from './i18n';
type Plan = {
id: string;
name: string;
operation: string;
amount: string;
nextAt: string;
intervalDays: number;
enabled: boolean;
completed: boolean;
sourceId: string;
};
export function SchedulePanel({
accounts,
refresh,
}: {
accounts: Position[];
refresh: () => Promise<void>;
}) {
const [rows, setRows] = useState<Plan[]>([]),
[open, setOpen] = useState(false),
[busy, setBusy] = useState(false),
[error, setError] = useState(''),
[status, setStatus] = useState('');
const [operation, setOperation] = useState('expense'),
[source, setSource] = useState(''),
[target, setTarget] = useState(''),
[amount, setAmount] = useState('');
const sourceId = accounts.some((p) => p.id === source) ? source : accounts[0]?.id || '';
const sourceAccount = accounts.find((p) => p.id === sourceId),
targetAccount = accounts.find((p) => p.id === target);
const same = sourceAccount?.currency === targetAccount?.currency;
useEffect(() => {
let active = true;
void api<Plan[]>('/schedules')
.then((v) => {
if (active) setRows(v);
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [accounts]);
async function act(work: () => Promise<unknown>) {
setBusy(true);
setError('');
setStatus('');
try {
await work();
await refresh();
setRows(await api<Plan[]>('/schedules'));
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<details className="schedule-panel">
<summary>
{tr('定时计划')} ({rows.length})
</summary>
<p className="muted">
{tr('打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')}
</p>
<div className="actions">
<button type="button" disabled={busy || !accounts.length} onClick={() => setOpen(!open)}>
{tr(open ? '取消' : '添加计划')}
</button>
<button
disabled={busy}
onClick={() =>
void act(async () => {
const result = await api<{
executed: number;
errors: { message: string }[];
hasMore: boolean;
}>('/schedules/run', 'POST', {});
setStatus(
tr('已执行') +
` ${result.executed}` +
(result.hasMore ? ' · ' + tr('仍有到期计划,请再次执行') : ''),
);
if (result.errors.length) setError(result.errors[0].message);
})
}
>
{tr('执行到期计划')}
</button>
</div>
{open && (
<form
onSubmit={(e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
void act(async () => {
await api('/schedules', 'POST', {
...data,
operation,
sourceId,
targetId: operation === 'transfer' ? target : null,
received: operation === 'transfer' ? (same ? amount : data.received) : '0',
intervalDays: Number(data.intervalDays),
});
setOpen(false);
});
}}
>
<label>
{tr('计划名称')}
<input name="name" required maxLength={100} />
</label>
<label>
{tr('操作')}
<select value={operation} onChange={(e) => setOperation(e.target.value)}>
<option value="expense">{tr('支出')}</option>
<option value="transfer">{tr('转账')}</option>
</select>
</label>
<label>
{tr('付款账户')}
<AccountPicker
name="sourceId"
accounts={accounts}
value={sourceId}
onChange={(id) => {
setSource(id);
if (target === id) setTarget('');
}}
/>
</label>
{operation === 'transfer' && (
<label>
{tr('转入账户')}
<AccountPicker
name="targetId"
accounts={accounts}
value={target}
onChange={setTarget}
excludeId={sourceId}
/>
</label>
)}
<label>
{tr('金额')} ({sourceAccount?.currency})
<input
name="amount"
required
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
/>
</label>
{operation === 'transfer' && !same && (
<label>
{tr('实际到账')} ({targetAccount?.currency})
<input
name="received"
required
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
/>
</label>
)}
<label>
{tr('首次执行时间')}
<input name="nextAt" type="datetime-local" required defaultValue={nowMinute()} />
</label>
<label>
{tr('重复间隔(天,0为仅一次)')}
<input
name="intervalDays"
type="number"
min="0"
max="3650"
required
defaultValue="30"
/>
</label>
<label>
{tr('备注')}
<textarea name="notes" maxLength={1800} />
</label>
<button className="primary" disabled={busy || (operation === 'transfer' && !target)}>
{tr('保存计划')}
</button>
</form>
)}
{rows.map((p) => (
<div className="schedule-row" key={p.id}>
<span>
{p.name} ·{' '}
<strong className={p.operation === 'expense' ? 'danger-text' : undefined}>
{money(p.amount, accounts.find((a) => a.id === p.sourceId)?.currency || '')}
</strong>
<small>
{p.nextAt.replace('T', ' ')} ·{' '}
{p.intervalDays ? tr('每') + ` ${p.intervalDays} ` + tr('天') : tr('仅一次')} ·{' '}
{tr(p.completed ? '已完成' : p.enabled ? '启用' : '已暂停')}
</small>
</span>
<button
disabled={busy || p.completed}
onClick={() =>
void act(() => api('/schedules/' + p.id, 'PATCH', { enabled: !p.enabled }))
}
>
{tr(p.enabled ? '暂停' : '启用')}
</button>
<button
disabled={busy}
onClick={() => void act(() => api('/schedules/' + p.id, 'DELETE'))}
>
{tr('删除')}
</button>
</div>
))}
{error && (
<p role="alert" className="notice error">
{error}
</p>
)}
{status && <p role="status">{status}</p>}
</details>
);
}
+18 -17
View File
@@ -1,3 +1,4 @@
import { AccountPicker } from './AccountPicker';
import { t as tr } from './i18n';
import { useState, type FormEvent } from 'react';
import { nowMinute, money, requestUuid, type Position } from './api';
@@ -31,29 +32,29 @@ export function TransferForm({
<form onSubmit={save} className="transfer-form">
<input type="hidden" name="requestId" value={requestId} />
<p className="muted">
{tr(
'双方余额同时更新。正数手续费额外扣除,负数表示优惠抵扣(不超过转出金额);转账时间不能早于双方最新余额记录。',
)}
{tr('转账会同时更新双方余额。余额不足时允许透支;手续费为负数时抵扣转出金额。')}
</p>
<label>
{tr('转出账户')}
<select name="sourceId" value={sourceId} onChange={(e) => setSource(e.target.value)}>
{accounts.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {money(p.amount, p.currency)}
</option>
))}
</select>
<AccountPicker
name="sourceId"
accounts={accounts}
value={sourceId}
onChange={(id) => {
setSource(id);
if (targetId === id) setTarget(accounts.find((p) => p.id !== id)?.id || '');
}}
/>
</label>
<label>
{tr('转入账户')}
<select name="targetId" value={targetId} onChange={(e) => setTarget(e.target.value)}>
{accounts.map((p) => (
<option key={p.id} value={p.id} disabled={p.id === sourceId}>
{p.name} · {p.currency}
</option>
))}
</select>
<AccountPicker
name="targetId"
accounts={accounts}
value={targetId}
onChange={setTarget}
excludeId={sourceId}
/>
</label>
<div className="form-columns">
<label>
+13 -1
View File
@@ -35,6 +35,7 @@ export type History = {
positionId: string;
name: string;
kind: string;
side: string;
before: string;
after: string;
delta: string;
@@ -44,6 +45,7 @@ export type History = {
reason: string;
};
export type Position = {
groupName?: string;
iconId?: string | null;
id: string;
kind: string;
@@ -104,7 +106,11 @@ export const nowMinute = () => new Date(Date.now() + 8 * 3600000).toISOString().
export const displayTime = (s: string) => s.replace('T', ' ');
export function money(v: string, currency: string) {
const [whole, frac] = v.split('.');
return `${currency} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac !== undefined ? '.' + frac : ''}`;
const symbol =
(
{ CNY: '¥', USD: 'US$', HKD: 'HK$', EUR: '€', GBP: '£', JPY: 'JP¥' } as Record<string, string>
)[currency] || currency;
return `${symbol} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac !== undefined ? '.' + frac : ''}`;
}
export type Transfer = {
@@ -133,3 +139,9 @@ export function requestUuid() {
}
export type PageResult<T> = { items: T[]; nextCursor: string | null; revealed: boolean };
export function positionAmount(p: Position, value = p.amount) {
return p.kind === 'debt' && p.side === 'liability' && !/^[-]?0(?:\.0+)?$/.test(value)
? '-' + value.replace(/^-/, '')
: value;
}
+72 -2
View File
@@ -234,7 +234,7 @@
"变化额": "Change",
"操作": "Actions",
"初始记录": "Initial record",
"还款": "Repayment",
"还款": "Repay",
"更正": "Correction",
"转账转出": "Transfer out",
"转账到账": "Transfer in",
@@ -356,5 +356,75 @@
"转账历史与双方金额不一致": "Paired payment history does not match its balances",
"缺少配对转账记录": "Missing paired payment records",
"需要先下载备份": "Download a backup first",
"资金往来": "Money movements"
"资金往来": "Money movements",
"选择账户": "Choose an account",
"搜索账户、分组或币种": "Search accounts, groups or currencies",
"全部账户": "All accounts",
"按分组显示": "Group accounts",
"未分组": "Ungrouped",
"没有符合条件的账户": "No matching accounts",
"账户分组": "Account group",
"例如:日常、消费、理财": "For example: Daily, Spending, Investments",
"快速记账": "Quick balance entry",
"退出快速记账": "Exit quick entry",
"点击账户卡片,直接填写当前余额。": "Select an account to enter its current balance.",
"定时计划": "Scheduled payments",
"打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "Due plans run when you open Accounts. Up to 20 payments are processed per batch; missed payments are posted in sequence.",
"添加计划": "Add a plan",
"执行到期计划": "Run due plans",
"计划名称": "Plan name",
"支出": "Expense",
"转账": "Transfer",
"金额": "Amount",
"实际到账": "Amount received",
"首次执行时间": "First payment time",
"重复间隔(天,0为仅一次)": "Repeat every (days, 0 for once)",
"保存计划": "Save plan",
"每": "Every",
"天": "days",
"仅一次": "Once",
"启用": "Enable",
"已暂停": "Paused",
"暂停": "Pause",
"删除": "Delete",
"已执行": "Processed",
"仍有到期计划,请再次执行": "More payments are due. Run again.",
"到期计划已记账": "Due payments recorded",
"计划支出": "Scheduled expense",
"剩余本金": "Outstanding principal",
"当前估值": "Current value",
"当前余额": "Current balance",
"收款": "Collect payment",
"收款 / 还款": "Collect / repay",
"记录时间(UTC+8)": "Record time (UTC+8)",
"欠款(待还)": "Payable",
"借款(待收)": "Receivable",
"转账会同时更新双方余额。余额不足时允许透支;手续费为负数时抵扣转出金额。": "Both balances update together. Overdrafts are allowed; a negative fee reduces the payment.",
"仅资产账户支持负余额": "Only asset accounts allow negative balances.",
"计划账户关联无效": "Invalid plan accounts.",
"计划时间无效": "Invalid scheduled time.",
"金额必须大于零": "Amount must be greater than zero.",
"请选择启用的资产账户": "Choose active asset accounts.",
"支出计划无需转入账户": "An expense plan does not need a receiving account.",
"计划不存在": "Plan not found.",
"计划账户已归档或不可用": "The account is archived or unavailable.",
"计划未执行,请刷新或检查账户状态后重试": "Payment was not processed. Refresh or check the account before retrying.",
"转账计划须选择不同账户并填写到账金额": "Choose different accounts and enter the received amount.",
"金额最多 16 位整数和 8 位小数": "Use at most 16 integer digits and 8 decimal places.",
"收支日历": "Cash flow calendar",
"月份": "Month",
"按账户余额变动估算;转账、借贷本金、初始余额和估值不计入。负债账户按相反方向计算。": "Estimated from account balance changes. Transfers, loan principal, opening balances and valuations are excluded. Liability account changes are reversed.",
"当日收支": "Daily cash flow",
"今天": "Today",
"当天没有收支记录": "No cash flow recorded on this day",
"收入": "Income",
"一": "Mon",
"二": "Tue",
"三": "Wed",
"四": "Thu",
"五": "Fri",
"六": "Sat",
"日": "Sun",
"已完成": "Completed",
"一次性计划已完成,请新建计划": "This one-time plan is complete. Create a new plan."
}
+71 -1
View File
@@ -356,5 +356,75 @@
"转账历史与双方金额不一致": "轉賬歷史與雙方金額不一致",
"缺少配对转账记录": "缺少配對轉賬記錄",
"需要先下载备份": "需要先下載備份",
"资金往来": "資金往來"
"资金往来": "資金往來",
"选择账户": "選擇賬戶",
"搜索账户、分组或币种": "搜索賬戶、分組或幣種",
"全部账户": "全部賬戶",
"按分组显示": "按分組顯示",
"未分组": "未分組",
"没有符合条件的账户": "沒有符合條件的賬戶",
"账户分组": "賬戶分組",
"例如:日常、消费、理财": "例如:日常、消費、理財",
"快速记账": "快速記賬",
"退出快速记账": "退出快速記賬",
"点击账户卡片,直接填写当前余额。": "點擊賬戶卡片,直接填寫當前餘額。",
"定时计划": "定時計劃",
"打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "打開賬戶頁時處理到期計劃。每次最多執行20項,離線期間的計劃會逐次補記。",
"添加计划": "添加計劃",
"执行到期计划": "執行到期計劃",
"计划名称": "計劃名稱",
"支出": "支出",
"转账": "轉賬",
"金额": "金額",
"实际到账": "實際到賬",
"首次执行时间": "首次執行時間",
"重复间隔(天,0为仅一次)": "重複間隔(天,0為僅一次)",
"保存计划": "保存計劃",
"每": "每",
"天": "天",
"仅一次": "僅一次",
"启用": "啟用",
"已暂停": "已暫停",
"暂停": "暫停",
"删除": "刪除",
"已执行": "已執行",
"仍有到期计划,请再次执行": "仍有到期計劃,請再次執行",
"到期计划已记账": "到期計劃已記賬",
"计划支出": "計劃支出",
"剩余本金": "剩餘本金",
"当前估值": "當前估值",
"当前余额": "當前餘額",
"收款": "收款",
"收款 / 还款": "收款 / 還款",
"记录时间(UTC+8)": "記錄時間(UTC+8)",
"欠款(待还)": "欠款(待還)",
"借款(待收)": "借款(待收)",
"转账会同时更新双方余额。余额不足时允许透支;手续费为负数时抵扣转出金额。": "轉賬會同時更新雙方餘額。餘額不足時允許透支;手續費為負數時抵扣轉出金額。",
"仅资产账户支持负余额": "僅資產賬戶支持負餘額",
"计划账户关联无效": "計劃賬戶關聯無效",
"计划时间无效": "計劃時間無效",
"金额必须大于零": "金額必須大於零",
"请选择启用的资产账户": "請選擇啟用的資產賬戶",
"支出计划无需转入账户": "支出計劃無需轉入賬戶",
"计划不存在": "計劃不存在",
"计划账户已归档或不可用": "計劃賬戶已歸檔或不可用",
"计划未执行,请刷新或检查账户状态后重试": "計劃未執行,請刷新或檢查賬戶狀態後重試",
"转账计划须选择不同账户并填写到账金额": "轉賬計劃須選擇不同賬戶並填寫到賬金額",
"金额最多 16 位整数和 8 位小数": "金額最多 16 位整數和 8 位小數",
"收支日历": "收支日曆",
"月份": "月份",
"按账户余额变动估算;转账、借贷本金、初始余额和估值不计入。负债账户按相反方向计算。": "按賬戶餘額變動估算;轉賬、借貸本金、初始餘額和估值不計入。負債賬戶按相反方向計算。",
"当日收支": "當日收支",
"今天": "今天",
"当天没有收支记录": "當天沒有收支記錄",
"收入": "收入",
"一": "一",
"二": "二",
"三": "三",
"四": "四",
"五": "五",
"六": "六",
"日": "日",
"已完成": "已完成",
"一次性计划已完成,请新建计划": "一次性計劃已完成,請新建計劃"
}
+132
View File
@@ -1203,3 +1203,135 @@ footer {
gap: 0.5rem;
}
}
.account-tools {
margin-bottom: 16px;
}
.picker-trigger {
width: 100%;
text-align: left;
}
.picker-dialog {
width: min(480px, calc(100vw - 32px));
max-height: 80vh;
overflow-y: auto;
border: 1px solid #dce1e5;
border-radius: 18px;
padding: 24px;
color: #1d2939;
background: #fff;
}
.picker-dialog::backdrop {
background: #10203080;
}
.picker-dialog input {
width: 100%;
}
.picker-dialog .actions {
margin: 12px 0;
}
.picker-option {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
margin: 8px 0;
}
.picker-option img {
width: 32px;
height: 32px;
object-fit: contain;
}
.picker-option small {
display: block;
opacity: 0.65;
}
.schedule-panel {
margin: 20px 0;
padding: 16px;
border: 1px solid #dce1e5;
border-radius: 12px;
}
.schedule-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 12px 0;
border-top: 1px solid #edf0f2;
}
.schedule-row > span {
flex: 1;
min-width: 180px;
}
.schedule-row small {
display: block;
}
.schedule-panel form {
margin: 16px 0;
}
.danger-text {
color: #c73542 !important;
}
.income-text {
color: #1b7855;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.weekday {
text-align: center;
padding: 8px;
color: #67727e;
}
.calendar-day {
min-height: 88px;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
overflow: hidden;
}
.calendar-day.selected {
outline: 2px solid #255b4e;
background: #edf7f1;
}
.calendar-day small {
font-size: 11px;
overflow-wrap: anywhere;
}
.calendar-detail {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid #e0e5e9;
}
@media (max-width: 600px) {
.calendar-grid {
gap: 3px;
}
.calendar-day {
padding: 5px;
min-height: 80px;
}
.calendar-day small {
font-size: 10px;
}
}
.calendar-panel > .panel-title {
flex-wrap: wrap;
gap: 12px;
}
.calendar-panel input[type='month'] {
width: auto;
max-width: 100%;
flex: 0 1 210px;
}
.calendar-panel h2 {
white-space: nowrap;
}