feat: add account groups, scheduled payments and cash flow calendar
This commit is contained in:
1 parent
a7e0a0fe58
commit
86c6daf695
46 files changed
+2548
-290
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -356,5 +356,75 @@
|
||||
"转账历史与双方金额不一致": "轉賬歷史與雙方金額不一致",
|
||||
"缺少配对转账记录": "缺少配對轉賬記錄",
|
||||
"需要先下载备份": "需要先下載備份",
|
||||
"资金往来": "資金往來"
|
||||
"资金往来": "資金往來",
|
||||
"选择账户": "選擇賬戶",
|
||||
"搜索账户、分组或币种": "搜索賬戶、分組或幣種",
|
||||
"全部账户": "全部賬戶",
|
||||
"按分组显示": "按分組顯示",
|
||||
"未分组": "未分組",
|
||||
"没有符合条件的账户": "沒有符合條件的賬戶",
|
||||
"账户分组": "賬戶分組",
|
||||
"例如:日常、消费、理财": "例如:日常、消費、理財",
|
||||
"快速记账": "快速記賬",
|
||||
"退出快速记账": "退出快速記賬",
|
||||
"点击账户卡片,直接填写当前余额。": "點擊賬戶卡片,直接填寫當前餘額。",
|
||||
"定时计划": "定時計劃",
|
||||
"打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "打開賬戶頁時處理到期計劃。每次最多執行20項,離線期間的計劃會逐次補記。",
|
||||
"添加计划": "添加計劃",
|
||||
"执行到期计划": "執行到期計劃",
|
||||
"计划名称": "計劃名稱",
|
||||
"支出": "支出",
|
||||
"转账": "轉賬",
|
||||
"金额": "金額",
|
||||
"实际到账": "實際到賬",
|
||||
"首次执行时间": "首次執行時間",
|
||||
"重复间隔(天,0为仅一次)": "重複間隔(天,0為僅一次)",
|
||||
"保存计划": "保存計劃",
|
||||
"每": "每",
|
||||
"天": "天",
|
||||
"仅一次": "僅一次",
|
||||
"启用": "啟用",
|
||||
"已暂停": "已暫停",
|
||||
"暂停": "暫停",
|
||||
"删除": "刪除",
|
||||
"已执行": "已執行",
|
||||
"仍有到期计划,请再次执行": "仍有到期計劃,請再次執行",
|
||||
"到期计划已记账": "到期計劃已記賬",
|
||||
"计划支出": "計劃支出",
|
||||
"剩余本金": "剩餘本金",
|
||||
"当前估值": "當前估值",
|
||||
"当前余额": "當前餘額",
|
||||
"收款": "收款",
|
||||
"收款 / 还款": "收款 / 還款",
|
||||
"记录时间(UTC+8)": "記錄時間(UTC+8)",
|
||||
"欠款(待还)": "欠款(待還)",
|
||||
"借款(待收)": "借款(待收)",
|
||||
"转账会同时更新双方余额。余额不足时允许透支;手续费为负数时抵扣转出金额。": "轉賬會同時更新雙方餘額。餘額不足時允許透支;手續費為負數時抵扣轉出金額。",
|
||||
"仅资产账户支持负余额": "僅資產賬戶支持負餘額",
|
||||
"计划账户关联无效": "計劃賬戶關聯無效",
|
||||
"计划时间无效": "計劃時間無效",
|
||||
"金额必须大于零": "金額必須大於零",
|
||||
"请选择启用的资产账户": "請選擇啟用的資產賬戶",
|
||||
"支出计划无需转入账户": "支出計劃無需轉入賬戶",
|
||||
"计划不存在": "計劃不存在",
|
||||
"计划账户已归档或不可用": "計劃賬戶已歸檔或不可用",
|
||||
"计划未执行,请刷新或检查账户状态后重试": "計劃未執行,請刷新或檢查賬戶狀態後重試",
|
||||
"转账计划须选择不同账户并填写到账金额": "轉賬計劃須選擇不同賬戶並填寫到賬金額",
|
||||
"金额最多 16 位整数和 8 位小数": "金額最多 16 位整數和 8 位小數",
|
||||
"收支日历": "收支日曆",
|
||||
"月份": "月份",
|
||||
"按账户余额变动估算;转账、借贷本金、初始余额和估值不计入。负债账户按相反方向计算。": "按賬戶餘額變動估算;轉賬、借貸本金、初始餘額和估值不計入。負債賬戶按相反方向計算。",
|
||||
"当日收支": "當日收支",
|
||||
"今天": "今天",
|
||||
"当天没有收支记录": "當天沒有收支記錄",
|
||||
"收入": "收入",
|
||||
"一": "一",
|
||||
"二": "二",
|
||||
"三": "三",
|
||||
"四": "四",
|
||||
"五": "五",
|
||||
"六": "六",
|
||||
"日": "日",
|
||||
"已完成": "已完成",
|
||||
"一次性计划已完成,请新建计划": "一次性計劃已完成,請新建計劃"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user