feat: improve quick bookkeeping and asset inclusion
Add commemorative assets, session undo, paired repayment, month/day markers and filters. Compact account cards, move transfer entry, persist backup metadata, and update Chinese documentation.
This commit is contained in:
1 parent
ad6466cb6b
commit
096f1577c2
22 files changed
+1465
-73
No files matched your search
@@ -5,11 +5,11 @@
|
||||
"dev": "node scripts/dev.cjs",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts",
|
||||
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "node scripts/database.cjs deploy",
|
||||
"db:status": "node scripts/database.cjs status",
|
||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts test/account-deletion-integration.test.ts",
|
||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts test/account-deletion-integration.test.ts test/quick-entry-integration.test.ts",
|
||||
"test:performance": "tsx scripts/performance.ts after",
|
||||
"test:mcp": "tsx --test --test-concurrency=1 test/mcp.test.ts test/oauth-duration.test.ts",
|
||||
"mcp:probe": "tsx scripts/mcp-probe.ts",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `Position` ADD COLUMN `lastRepaymentMonth` CHAR(7) NULL COMMENT '最后手动标记已完成还款的业务月份,UTC+8 YYYY-MM;空表示未标记';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `Position` ADD COLUMN `lastBookedDate` CHAR(10) NULL COMMENT '最后手动标记已完成记账的业务日期,UTC+8 YYYY-MM-DD;空表示未标记';
|
||||
@@ -218,6 +218,10 @@ model Position {
|
||||
hidden Boolean @default(false)
|
||||
/// 是否参与资产负债总额及净资产轨迹
|
||||
included Boolean @default(true)
|
||||
/// 最后手动标记已完成还款的业务月份,UTC+8 YYYY-MM;空表示未标记
|
||||
lastRepaymentMonth String? @db.Char(7)
|
||||
/// 最后手动标记已完成记账的业务日期,UTC+8 YYYY-MM-DD;空表示未标记
|
||||
lastBookedDate String? @db.Char(10)
|
||||
/// 贵金属品种:gold 黄金或 silver 白银
|
||||
metalType String? @db.VarChar(12)
|
||||
/// 贵金属总重量,单位克
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { repaymentMonth, date } from './validation';
|
||||
import { BACKUP_ZIP_VERSION } from './backup-format';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { metalConfig, metalPriceInput, storedMetalPurity } from './metals';
|
||||
@@ -63,6 +64,8 @@ const record = positionMeta
|
||||
notes: z.string().max(2000),
|
||||
archived: z.boolean(),
|
||||
hidden: z.boolean(),
|
||||
lastRepaymentMonth: repaymentMonth.optional(),
|
||||
lastBookedDate: date.nullable().optional(),
|
||||
metalType: z.enum(['gold', 'silver']).nullable(),
|
||||
metalGrams: amount.nullable(),
|
||||
metalCostPerGram: metalConfig.shape.metalCostPerGram.nonoptional(),
|
||||
@@ -172,6 +175,10 @@ export function validateBackup(raw: unknown) {
|
||||
throw new BadRequestException('图标关联无效');
|
||||
const revisionIds = new Set<string>();
|
||||
for (const p of b.positions) {
|
||||
if (p.lastBookedDate && p.kind !== 'account')
|
||||
throw new BadRequestException('仅账户支持当天记账标记');
|
||||
if (p.lastRepaymentMonth && (p.kind !== 'account' || p.side !== 'liability'))
|
||||
throw new BadRequestException('还款月份仅适用于欠款账户');
|
||||
if (p.metalType || p.metalGrams || p.autoValuation || p.metalCostPerGram != null) {
|
||||
if (p.kind !== 'asset' || p.category !== 'gold')
|
||||
throw new BadRequestException('贵金属估价关联无效');
|
||||
@@ -393,6 +400,8 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
||||
archived: p.archived,
|
||||
hidden: p.hidden,
|
||||
included: p.included,
|
||||
lastRepaymentMonth: p.lastRepaymentMonth,
|
||||
lastBookedDate: p.lastBookedDate,
|
||||
metalType: p.metalType,
|
||||
metalGrams: p.metalGrams?.toString() ?? null,
|
||||
metalCostPerGram: p.metalCostPerGram?.toString() ?? null,
|
||||
@@ -700,6 +709,8 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
||||
archived: p.archived,
|
||||
hidden: p.hidden,
|
||||
included: p.included,
|
||||
lastRepaymentMonth: p.lastRepaymentMonth ?? null,
|
||||
lastBookedDate: p.lastBookedDate ?? null,
|
||||
metalType: p.metalType,
|
||||
metalGrams: p.metalGrams,
|
||||
metalCostPerGram: p.metalCostPerGram,
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
credentials,
|
||||
credentialChange,
|
||||
positionInput,
|
||||
repaymentMarkInput,
|
||||
bookedMarkInput,
|
||||
inclusionInput,
|
||||
positionMeta,
|
||||
revisionInput,
|
||||
transferInput,
|
||||
@@ -35,6 +38,9 @@ export function setupOpenApi(app: INestApplication) {
|
||||
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
|
||||
'POST /api/positions': positionInput,
|
||||
'PATCH /api/positions/{id}': positionMeta,
|
||||
'PATCH /api/positions/{id}/repayment-mark': repaymentMarkInput,
|
||||
'PATCH /api/positions/{id}/booked-mark': bookedMarkInput,
|
||||
'PATCH /api/positions/{id}/inclusion': inclusionInput,
|
||||
'POST /api/positions/{id}/revisions': revisionInput,
|
||||
'PUT /api/positions/{id}/revisions/{revisionId}': revisionInput,
|
||||
'PUT /api/positions/{id}/links': z
|
||||
|
||||
@@ -18,6 +18,9 @@ import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import {
|
||||
positionInput,
|
||||
repaymentMarkInput,
|
||||
bookedMarkInput,
|
||||
inclusionInput,
|
||||
positionMeta,
|
||||
revisionInput,
|
||||
today,
|
||||
@@ -158,6 +161,68 @@ export class PortfolioBusinessService {
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async quickMeta(r: UserRequest, id: string, raw: unknown, operation: 'booked' | 'included') {
|
||||
const v = operation === 'booked' ? bookedMarkInput.parse(raw) : inclusionInput.parse(raw);
|
||||
return this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(
|
||||
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
|
||||
);
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (operation === 'booked' && (p.kind !== 'account' || p.archived))
|
||||
throw new BadRequestException('仅启用账户支持当天记账标记');
|
||||
if (operation === 'included' && p.kind !== 'asset')
|
||||
throw new BadRequestException('仅独立资产支持此快速统计开关');
|
||||
if (v.expectedUpdatedAt && p.updatedAt.toISOString() !== v.expectedUpdatedAt)
|
||||
throw new ConflictException('项目已变化,请刷新后重试');
|
||||
if (
|
||||
('expectedDate' in v && p.lastBookedDate !== v.expectedDate) ||
|
||||
('expectedIncluded' in v && p.included !== v.expectedIncluded)
|
||||
)
|
||||
throw new ConflictException('标记已变化,请刷新后重试');
|
||||
const updated = await tx.position.update({
|
||||
where: { id },
|
||||
data: 'date' in v ? { lastBookedDate: v.date } : { included: v.included },
|
||||
});
|
||||
return {
|
||||
lastBookedDate: updated.lastBookedDate,
|
||||
included: updated.included,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
async repaymentMark(r: UserRequest, id: string, raw: unknown) {
|
||||
const input = repaymentMarkInput.parse(raw);
|
||||
return this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(
|
||||
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
|
||||
);
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('账户不存在');
|
||||
if (p.kind !== 'account' || p.side !== 'liability')
|
||||
throw new BadRequestException('仅欠款账户支持还款月份标记');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档账户');
|
||||
if (
|
||||
p.lastRepaymentMonth !== input.expectedMonth ||
|
||||
(input.expectedUpdatedAt && p.updatedAt.toISOString() !== input.expectedUpdatedAt)
|
||||
)
|
||||
throw new ConflictException('还款标记已变化,请刷新后重试');
|
||||
const updated = await tx.position.update({
|
||||
where: { id },
|
||||
data: { lastRepaymentMonth: input.month },
|
||||
});
|
||||
return {
|
||||
lastRepaymentMonth: input.month,
|
||||
previousMonth: p.lastRepaymentMonth,
|
||||
updatedAt: updated.updatedAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
async deletion(r: UserRequest, id: string) {
|
||||
const p = await this.own(r.userId, id, r.revealed);
|
||||
const [historyCount, movementCount, scheduleCount, linkCount] = await Promise.all([
|
||||
@@ -408,6 +473,27 @@ export class PortfolioController {
|
||||
) {
|
||||
return this.service.edit(r, id, b);
|
||||
}
|
||||
@Patch('positions/:id/booked-mark') async bookedMark(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
return this.service.quickMeta(r, id, b, 'booked');
|
||||
}
|
||||
@Patch('positions/:id/inclusion') async inclusion(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
return this.service.quickMeta(r, id, b, 'included');
|
||||
}
|
||||
@Patch('positions/:id/repayment-mark') async repaymentMark(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
return this.service.repaymentMark(r, id, b);
|
||||
}
|
||||
@Get('positions/:id/deletion') async deletion(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
return this.service.deletion(r, id);
|
||||
}
|
||||
|
||||
@@ -217,3 +217,31 @@ export const settingsInput = z
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => Object.keys(v).length > 0);
|
||||
|
||||
export const repaymentMonth = z
|
||||
.string()
|
||||
.regex(/^(19|[2-9]\d)\d{2}-(0[1-9]|1[0-2])$/)
|
||||
.refine((v) => v <= today().slice(0, 7), '不能标记未来还款月份')
|
||||
.nullable();
|
||||
export const repaymentMarkInput = z
|
||||
.object({
|
||||
month: repaymentMonth,
|
||||
expectedMonth: repaymentMonth,
|
||||
expectedUpdatedAt: z.iso.datetime().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const bookedMarkInput = z
|
||||
.object({
|
||||
date: date.nullable(),
|
||||
expectedDate: date.nullable(),
|
||||
expectedUpdatedAt: z.iso.datetime().optional(),
|
||||
})
|
||||
.strict();
|
||||
export const inclusionInput = z
|
||||
.object({
|
||||
included: z.boolean(),
|
||||
expectedIncluded: z.boolean(),
|
||||
expectedUpdatedAt: z.iso.datetime().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,269 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { today } from '../src/validation';
|
||||
import { readBackupZip } from '../src/zip';
|
||||
import { fixtureFetch } from './backup-fixture';
|
||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||
test('quick entries undo paired repayment and balance, persist monthly marks, protect privacy and survive ZIP restore', async () => {
|
||||
const db = new PrismaClient(),
|
||||
users: string[] = [];
|
||||
async function user() {
|
||||
const u = await db.user.create({
|
||||
data: { username: 'quick_' + randomUUID(), passwordHash: 'unused', idleMinutes: 0 },
|
||||
});
|
||||
users.push(u.id);
|
||||
const token = randomBytes(32).toString('hex'),
|
||||
sid = createHash('sha256').update(token).digest('hex');
|
||||
await db.session.create({
|
||||
data: { id: sid, userId: u.id, expiresAt: new Date(Date.now() + 3600000) },
|
||||
});
|
||||
return { id: u.id, cookie: 'wp_session=' + token, sid };
|
||||
}
|
||||
async function call(u: any, path: string, method = 'GET', data?: unknown) {
|
||||
const r = await fixtureFetch(base + path, {
|
||||
method,
|
||||
headers: {
|
||||
Cookie: u.cookie,
|
||||
Origin: origin,
|
||||
...(data ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
return {
|
||||
status: r.status,
|
||||
data: r.headers.get('content-type')?.includes('application/zip')
|
||||
? await readBackupZip(Buffer.from(await r.arrayBuffer()))
|
||||
: await r.json(),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const a = await user(),
|
||||
b = await user();
|
||||
const create = async (
|
||||
name: string,
|
||||
kind: string,
|
||||
side: string,
|
||||
category: string,
|
||||
amount: string,
|
||||
hidden = false,
|
||||
) => {
|
||||
const r = await call(a, '/positions', 'POST', {
|
||||
name,
|
||||
kind,
|
||||
side,
|
||||
category,
|
||||
amount,
|
||||
hidden,
|
||||
currency: 'CNY',
|
||||
date: '2026-09-01T10:00',
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
return r.data.id as string;
|
||||
};
|
||||
const cash = await create('付款账户', 'account', 'asset', 'bank', '1000');
|
||||
const card = await create('欠款账户', 'account', 'liability', 'credit_card', '200');
|
||||
for (const category of ['commemorative_coin', 'commemorative_note']) {
|
||||
const id = await create(category, 'asset', 'asset', category, '50');
|
||||
assert.equal((await call(a, '/positions/' + id)).data.category, category);
|
||||
}
|
||||
|
||||
const coin = (await db.position.findFirst({
|
||||
where: { userId: a.id, category: 'commemorative_coin' },
|
||||
}))!;
|
||||
const inclusionPath = '/positions/' + coin.id + '/inclusion';
|
||||
assert.equal(
|
||||
(await call(b, inclusionPath, 'PATCH', { included: false, expectedIncluded: true })).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, inclusionPath, 'PATCH', {
|
||||
included: false,
|
||||
expectedIncluded: true,
|
||||
expectedUpdatedAt: coin.updatedAt.toISOString(),
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await call(a, '/positions/' + coin.id)).data.included, false);
|
||||
assert.equal(
|
||||
(await call(a, inclusionPath, 'PATCH', { included: true, expectedIncluded: true })).status,
|
||||
409,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, '/positions/' + cash + '/inclusion', 'PATCH', {
|
||||
included: false,
|
||||
expectedIncluded: true,
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const bookedPath = '/positions/' + cash + '/booked-mark',
|
||||
day = today();
|
||||
const booked = await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null });
|
||||
assert.equal(booked.status, 200);
|
||||
assert.equal((await call(a, '/positions/' + cash)).data.lastBookedDate, day);
|
||||
assert.equal(
|
||||
(await call(a, bookedPath, 'PATCH', { date: null, expectedDate: null })).status,
|
||||
409,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(b, bookedPath, 'PATCH', { date: null, expectedDate: day })).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(a, bookedPath, 'PATCH', { date: '2099-01-01', expectedDate: day })).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, bookedPath, 'PATCH', {
|
||||
date: null,
|
||||
expectedDate: day,
|
||||
expectedUpdatedAt: booked.data.updatedAt,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null })).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, '/positions/' + coin.id + '/booked-mark', 'PATCH', {
|
||||
date: day,
|
||||
expectedDate: null,
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const revision = await call(a, '/positions/' + cash + '/revisions', 'POST', {
|
||||
amount: '1050.87654321',
|
||||
date: '2026-09-02T10:00',
|
||||
});
|
||||
assert.equal(revision.status, 201);
|
||||
assert.equal(
|
||||
(await call(a, '/positions/' + cash + '/revisions/' + revision.data.id, 'DELETE')).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000');
|
||||
const payment = await call(a, '/transfers', 'POST', {
|
||||
sourceId: cash,
|
||||
targetId: card,
|
||||
amount: '30',
|
||||
received: '30',
|
||||
fee: '0',
|
||||
date: '2026-09-03T10:00',
|
||||
requestId: randomUUID(),
|
||||
});
|
||||
assert.equal(payment.status, 201);
|
||||
assert.equal((await call(a, '/positions/' + cash)).data.amount, '970');
|
||||
assert.equal((await call(a, '/positions/' + card)).data.amount, '170');
|
||||
assert.equal((await call(a, '/transfers/' + payment.data.id, 'DELETE')).status, 200);
|
||||
assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000');
|
||||
assert.equal((await call(a, '/positions/' + card)).data.amount, '200');
|
||||
const month = today().slice(0, 7),
|
||||
path = '/positions/' + card + '/repayment-mark';
|
||||
assert.equal((await call(b, path, 'PATCH', { month, expectedMonth: null })).status, 404);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, '/positions/' + cash + '/repayment-mark', 'PATCH', {
|
||||
month,
|
||||
expectedMonth: null,
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(a, path, 'PATCH', { month: '2099-01', expectedMonth: null })).status,
|
||||
400,
|
||||
);
|
||||
const before = await call(a, '/positions/' + card);
|
||||
const marked = await call(a, path, 'PATCH', {
|
||||
month,
|
||||
expectedMonth: null,
|
||||
expectedUpdatedAt: before.data.updatedAt,
|
||||
});
|
||||
assert.equal(marked.status, 200);
|
||||
assert.equal((await call(a, '/positions/' + card)).data.lastRepaymentMonth, month);
|
||||
assert.equal((await call(a, '/positions/' + card)).data.amount, '200');
|
||||
assert.equal((await call(a, path, 'PATCH', { month: null, expectedMonth: null })).status, 409);
|
||||
const restored = await call(a, path, 'PATCH', {
|
||||
month: null,
|
||||
expectedMonth: month,
|
||||
expectedUpdatedAt: marked.data.updatedAt,
|
||||
});
|
||||
assert.equal(restored.status, 200);
|
||||
assert.equal((await call(a, '/positions/' + card)).data.lastRepaymentMonth, null);
|
||||
await call(a, path, 'PATCH', { month, expectedMonth: null });
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, path, 'PATCH', {
|
||||
month: null,
|
||||
expectedMonth: month,
|
||||
expectedUpdatedAt: marked.data.updatedAt,
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
const old = '2026-09';
|
||||
const changed = await call(a, path, 'PATCH', { month: old, expectedMonth: month });
|
||||
assert.equal(changed.status, 200);
|
||||
assert.equal((await call(a, '/positions/' + card)).data.lastRepaymentMonth, old);
|
||||
const hidden = await create('隐藏欠款', 'account', 'liability', 'loan', '5', true);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, '/positions/' + hidden + '/repayment-mark', 'PATCH', {
|
||||
month,
|
||||
expectedMonth: null,
|
||||
})
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
await db.session.update({
|
||||
where: { id: a.sid },
|
||||
data: { revealUntil: new Date(Date.now() + 600000) },
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, '/positions/' + hidden + '/repayment-mark', 'PATCH', {
|
||||
month,
|
||||
expectedMonth: null,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
await db.position.update({ where: { id: hidden }, data: { archived: true } });
|
||||
assert.equal(
|
||||
(
|
||||
await call(a, '/positions/' + hidden + '/repayment-mark', 'PATCH', {
|
||||
month: null,
|
||||
expectedMonth: month,
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
const backup = await call(a, '/backup');
|
||||
assert.equal(backup.status, 200);
|
||||
assert.equal(backup.data.positions.find((p: any) => p.id === card).lastRepaymentMonth, old);
|
||||
const imported = await call(b, '/backup/restore-fixture', 'POST', {
|
||||
confirmed: true,
|
||||
backup: backup.data,
|
||||
});
|
||||
assert.equal(imported.status, 201);
|
||||
const rows = await db.position.findMany({ where: { userId: b.id } });
|
||||
assert.equal(rows.find((p) => p.name === '欠款账户')?.lastRepaymentMonth, old);
|
||||
assert.equal(rows.find((p) => p.name === '隐藏欠款')?.lastRepaymentMonth, month);
|
||||
assert.equal(rows.find((p) => p.name === '付款账户')?.lastBookedDate, day);
|
||||
assert.equal(rows.find((p) => p.category === 'commemorative_coin')?.included, false);
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: users } } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
+457
-53
@@ -59,6 +59,15 @@ import { PositionDeletion, deletionLabel } from './PositionDeletion';
|
||||
import { IconPicker } from './IconPicker';
|
||||
import { MetalPanel } from './MetalPanel';
|
||||
import { QuickTransfer } from './QuickTransfer';
|
||||
import {
|
||||
QuickJournal,
|
||||
QuickRepayment,
|
||||
isPaidForMonth,
|
||||
isBookedToday,
|
||||
quickAccountVisible,
|
||||
canQuickRepay,
|
||||
type QuickEntry,
|
||||
} from './QuickJournal';
|
||||
import { TransferForm } from './TransferForm';
|
||||
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
|
||||
import type { Transfer } from './api';
|
||||
@@ -86,6 +95,8 @@ const categories: Record<string, [string, string][]> = {
|
||||
['property', '房产'],
|
||||
['vehicle', '车辆'],
|
||||
['gold', '贵金属'],
|
||||
['commemorative_coin', '纪念币'],
|
||||
['commemorative_note', '纪念钞'],
|
||||
['other', '其他资产'],
|
||||
],
|
||||
debt: [
|
||||
@@ -248,6 +259,7 @@ export default function App() {
|
||||
h?: History;
|
||||
transfer?: Transfer;
|
||||
operation?: DebtOperation;
|
||||
quick?: boolean;
|
||||
groups?: string[];
|
||||
} | null>(null),
|
||||
[busy, setBusy] = useState(false),
|
||||
@@ -263,6 +275,36 @@ export default function App() {
|
||||
sourceId?: string;
|
||||
targetId?: string;
|
||||
} | null>(null);
|
||||
const [quickEntries, setQuickEntries] = useState<QuickEntry[]>([]);
|
||||
const [quickMarkMode, setQuickMarkMode] = useState(false);
|
||||
const [quickBookedMode, setQuickBookedMode] = useState(false);
|
||||
const [hideBooked, setHideBooked] = useState(false);
|
||||
const [unpaidOnly, setUnpaidOnly] = useState(false);
|
||||
const [assetQuickMode, setAssetQuickMode] = useState(false);
|
||||
const [businessDay, setBusinessDay] = useState(today());
|
||||
const [repaymentMonth, setRepaymentMonth] = useState(today().slice(0, 7));
|
||||
const quickGeneration = useRef(0);
|
||||
useEffect(() => {
|
||||
const updateDay = () => {
|
||||
setBusinessDay(today());
|
||||
setRepaymentMonth(today().slice(0, 7));
|
||||
};
|
||||
const timer = setInterval(updateDay, 60000);
|
||||
window.addEventListener('focus', updateDay);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('focus', updateDay);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
quickGeneration.current++;
|
||||
setQuickEntries([]);
|
||||
setQuickMarkMode(false);
|
||||
setQuickBookedMode(false);
|
||||
setHideBooked(false);
|
||||
setUnpaidOnly(false);
|
||||
setAssetQuickMode(false);
|
||||
}, [user?.username, user?.revealed]);
|
||||
const [quickMode, setQuickMode] = useState(false),
|
||||
[accountGroup, setAccountGroup] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -278,6 +320,166 @@ export default function App() {
|
||||
: current,
|
||||
);
|
||||
}, [positions]);
|
||||
function toggleQuickMode() {
|
||||
quickGeneration.current++;
|
||||
if (!quickMode) setQuickEntries([]);
|
||||
setQuickMode(!quickMode);
|
||||
setQuickMarkMode(false);
|
||||
setQuickBookedMode(false);
|
||||
setHideBooked(false);
|
||||
setUnpaidOnly(false);
|
||||
setQuickTransfer(null);
|
||||
}
|
||||
function rememberQuick(entry: QuickEntry, generation: number, session: number) {
|
||||
if (generation === quickGeneration.current && session === sessionGeneration.current)
|
||||
setQuickEntries((rows) => [entry, ...rows]);
|
||||
}
|
||||
async function saveQuickBalance(
|
||||
position: Position,
|
||||
data: Record<string, unknown>,
|
||||
track: boolean,
|
||||
) {
|
||||
const generation = quickGeneration.current,
|
||||
session = sessionGeneration.current;
|
||||
const result = await api<{ id: string }>(
|
||||
'/positions/' + position.id + '/revisions',
|
||||
'POST',
|
||||
data,
|
||||
);
|
||||
if (track)
|
||||
rememberQuick(
|
||||
{
|
||||
key: 'balance:' + result.id,
|
||||
kind: 'balance',
|
||||
id: result.id,
|
||||
positionId: position.id,
|
||||
title: position.name,
|
||||
currency: position.currency,
|
||||
before: positionAmount(position),
|
||||
after: positionAmount(position, String(data.amount)),
|
||||
date: String(data.date),
|
||||
},
|
||||
generation,
|
||||
session,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
async function saveQuickMovement(data: unknown, label: string) {
|
||||
const value = data as Record<string, string>,
|
||||
generation = quickGeneration.current,
|
||||
session = sessionGeneration.current;
|
||||
const result = await api<{ id: string }>('/transfers', 'POST', data);
|
||||
const source = positions.find((p) => p.id === value.sourceId),
|
||||
target = positions.find((p) => p.id === value.targetId);
|
||||
rememberQuick(
|
||||
{
|
||||
key: 'transfer:' + result.id,
|
||||
kind: 'transfer',
|
||||
id: result.id,
|
||||
positionId: value.sourceId,
|
||||
title: label + ' · ' + (source?.name || '') + ' → ' + (target?.name || ''),
|
||||
currency: source?.currency || '',
|
||||
amount: value.amount,
|
||||
received: value.received,
|
||||
targetCurrency: target?.currency,
|
||||
date: value.date,
|
||||
},
|
||||
generation,
|
||||
session,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
async function markQuickRepayment(position: Position) {
|
||||
const month = today().slice(0, 7),
|
||||
previousMonth = position.lastRepaymentMonth ?? null;
|
||||
const nextMonth = isPaidForMonth(position, month) ? null : month;
|
||||
const generation = quickGeneration.current,
|
||||
session = sessionGeneration.current;
|
||||
const result = await api<{ updatedAt: string }>(
|
||||
'/positions/' + position.id + '/repayment-mark',
|
||||
'PATCH',
|
||||
{ month: nextMonth, expectedMonth: previousMonth, expectedUpdatedAt: position.updatedAt },
|
||||
);
|
||||
rememberQuick(
|
||||
{
|
||||
key: 'mark:' + position.id + ':' + Date.now(),
|
||||
kind: 'mark',
|
||||
id: position.id,
|
||||
positionId: position.id,
|
||||
title: position.name,
|
||||
currency: position.currency,
|
||||
date: nowMinute(),
|
||||
previousMonth,
|
||||
month: nextMonth,
|
||||
markUpdatedAt: result.updatedAt,
|
||||
},
|
||||
generation,
|
||||
session,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
async function markQuickBooked(position: Position) {
|
||||
const previousDate = position.lastBookedDate ?? null,
|
||||
day = today();
|
||||
const nextDate = isBookedToday(position, day) ? null : day;
|
||||
const generation = quickGeneration.current,
|
||||
session = sessionGeneration.current;
|
||||
const result = await api<{ updatedAt: string }>(
|
||||
'/positions/' + position.id + '/booked-mark',
|
||||
'PATCH',
|
||||
{
|
||||
date: nextDate,
|
||||
expectedDate: previousDate,
|
||||
expectedUpdatedAt: position.updatedAt,
|
||||
},
|
||||
);
|
||||
rememberQuick(
|
||||
{
|
||||
key: 'booked:' + position.id + ':' + Date.now(),
|
||||
kind: 'booked',
|
||||
id: position.id,
|
||||
positionId: position.id,
|
||||
title: position.name,
|
||||
currency: position.currency,
|
||||
date: nowMinute(),
|
||||
previousDate,
|
||||
bookedDate: nextDate,
|
||||
markUpdatedAt: result.updatedAt,
|
||||
},
|
||||
generation,
|
||||
session,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
async function toggleAssetInclusion(position: Position) {
|
||||
return api('/positions/' + position.id + '/inclusion', 'PATCH', {
|
||||
included: position.included === false,
|
||||
expectedIncluded: position.included !== false,
|
||||
expectedUpdatedAt: position.updatedAt,
|
||||
});
|
||||
}
|
||||
function undoQuick(entry: QuickEntry) {
|
||||
void act(async () => {
|
||||
if (entry.kind === 'balance')
|
||||
await api('/positions/' + entry.positionId + '/revisions/' + entry.id, 'DELETE');
|
||||
else if (entry.kind === 'transfer') await api('/transfers/' + entry.id, 'DELETE');
|
||||
else if (entry.kind === 'booked')
|
||||
await api('/positions/' + entry.positionId + '/booked-mark', 'PATCH', {
|
||||
date: entry.previousDate ?? null,
|
||||
expectedDate: entry.bookedDate ?? null,
|
||||
expectedUpdatedAt: entry.markUpdatedAt,
|
||||
});
|
||||
else
|
||||
await api('/positions/' + entry.positionId + '/repayment-mark', 'PATCH', {
|
||||
month: entry.previousMonth ?? null,
|
||||
expectedMonth: entry.month ?? null,
|
||||
expectedUpdatedAt: entry.markUpdatedAt,
|
||||
});
|
||||
setQuickEntries((rows) =>
|
||||
rows.map((row) => (row.key === entry.key ? { ...row, undone: true } : row)),
|
||||
);
|
||||
}, tr('记录已撤回,相关余额已更新'));
|
||||
}
|
||||
function chooseQuickAccount(p: Position) {
|
||||
if (busy || loading) return;
|
||||
if (p.kind !== 'account' || p.archived) {
|
||||
@@ -335,6 +537,9 @@ export default function App() {
|
||||
const activityAt = useRef(0);
|
||||
function clearAccount() {
|
||||
setQuickMode(false);
|
||||
quickGeneration.current++;
|
||||
setQuickEntries([]);
|
||||
setQuickMarkMode(false);
|
||||
setAccountGroup('');
|
||||
setClearStep(0);
|
||||
setClearConfirmation(0);
|
||||
@@ -987,7 +1192,11 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content">
|
||||
<div
|
||||
className={
|
||||
page === 'account' && quickMode && !selected ? 'content quick-content' : 'content'
|
||||
}
|
||||
>
|
||||
<div className="privacy-toolbar">
|
||||
<span>
|
||||
{user.revealed
|
||||
@@ -1030,12 +1239,15 @@ export default function App() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{page === 'account' && !p && (
|
||||
{page === 'account' && !p && !quickMode && (
|
||||
<button className="secondary" onClick={() => setModal({ kind: 'transfer' })}>
|
||||
{tr('转账')}
|
||||
</button>
|
||||
)}
|
||||
{['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
|
||||
{['overview', 'account', 'asset', 'debt'].includes(page) &&
|
||||
!p &&
|
||||
!(page === 'account' && quickMode) &&
|
||||
newAction}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1458,17 +1670,90 @@ export default function App() {
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<section className="panel">
|
||||
<section
|
||||
className={page === 'account' && quickMode ? 'panel quick-account-panel' : 'panel'}
|
||||
>
|
||||
{page === 'account' && (
|
||||
<>
|
||||
<div className="actions account-tools">
|
||||
<button
|
||||
className={quickMode ? 'primary' : 'secondary'}
|
||||
aria-pressed={quickMode}
|
||||
onClick={() => setQuickMode(!quickMode)}
|
||||
disabled={busy}
|
||||
onClick={toggleQuickMode}
|
||||
>
|
||||
{tr(quickMode ? '退出快速记账' : '快速记账')}
|
||||
</button>
|
||||
{quickMode && (
|
||||
<button
|
||||
className={quickMarkMode ? 'primary' : 'secondary'}
|
||||
disabled={busy}
|
||||
aria-pressed={quickMarkMode}
|
||||
onClick={() => {
|
||||
setQuickMarkMode(!quickMarkMode);
|
||||
setQuickBookedMode(false);
|
||||
setQuickTransfer(null);
|
||||
}}
|
||||
>
|
||||
{tr(quickMarkMode ? '结束还款标记' : '标记本月还款')}
|
||||
</button>
|
||||
)}
|
||||
{quickMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={
|
||||
busy ||
|
||||
positions.filter((p) => p.kind === 'account' && !p.archived).length <
|
||||
2
|
||||
}
|
||||
onClick={() => {
|
||||
setQuickMarkMode(false);
|
||||
setQuickBookedMode(false);
|
||||
setHideBooked(false);
|
||||
setUnpaidOnly(false);
|
||||
setQuickTransfer({});
|
||||
}}
|
||||
>
|
||||
{tr('转账')}
|
||||
</button>
|
||||
<button
|
||||
className={quickBookedMode ? 'primary' : 'secondary'}
|
||||
disabled={busy}
|
||||
aria-pressed={quickBookedMode}
|
||||
onClick={() => {
|
||||
setQuickBookedMode(!quickBookedMode);
|
||||
setQuickMarkMode(false);
|
||||
setQuickTransfer(null);
|
||||
}}
|
||||
>
|
||||
{tr(quickBookedMode ? '结束记账标记' : '标记当天已记账')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
aria-pressed={unpaidOnly}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setUnpaidOnly(!unpaidOnly);
|
||||
setQuickTransfer(null);
|
||||
}}
|
||||
>
|
||||
{tr(unpaidOnly ? '显示全部账户' : '仅看本月未还款')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
aria-pressed={hideBooked}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setHideBooked(!hideBooked);
|
||||
setQuickTransfer(null);
|
||||
}}
|
||||
>
|
||||
{tr(hideBooked ? '显示当天已记账' : '隐藏当天已记账')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<select
|
||||
aria-label={tr('账户分组')}
|
||||
value={accountGroup === null ? 'all' : JSON.stringify(accountGroup)}
|
||||
@@ -1487,6 +1772,7 @@ export default function App() {
|
||||
</select>
|
||||
<button
|
||||
className="secondary"
|
||||
hidden={quickMode}
|
||||
disabled={busy || groups.length < 2}
|
||||
onClick={() => setModal({ kind: 'group-order', groups: [...groups] })}
|
||||
>
|
||||
@@ -1496,18 +1782,58 @@ export default function App() {
|
||||
{quickMode && (
|
||||
<p className="muted">
|
||||
{tr(
|
||||
quickTransfer !== null
|
||||
? '快速转账中:依次点击转出和转入账户卡片。'
|
||||
: '点击账户卡片,直接填写当前余额。',
|
||||
quickBookedMode
|
||||
? '点击账户卡片,标记或取消当天已记账。'
|
||||
: quickMarkMode
|
||||
? '点击欠款账户卡片,标记或取消本月已还款。'
|
||||
: quickTransfer !== null
|
||||
? '快速转账中:依次点击转出和转入账户卡片。'
|
||||
: '点击账户卡片,直接填写当前余额。',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{page === 'asset' && (
|
||||
<>
|
||||
<div className="actions account-tools">
|
||||
<button
|
||||
className={assetQuickMode ? 'primary' : 'secondary'}
|
||||
disabled={busy}
|
||||
aria-pressed={assetQuickMode}
|
||||
onClick={() => setAssetQuickMode(!assetQuickMode)}
|
||||
>
|
||||
{tr(assetQuickMode ? '结束统计设置' : '快速设置计入总资产')}
|
||||
</button>
|
||||
</div>
|
||||
{assetQuickMode && (
|
||||
<p className="muted">{tr('点击资产卡片,切换是否计入总资产。')}</p>
|
||||
)}
|
||||
{assetQuickMode && user.includeIndependentAssets === false && (
|
||||
<p className="muted">
|
||||
{tr('独立资产总开关已关闭;单项设置已保存,开启总开关后生效。')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{page === 'account' &&
|
||||
quickMode &&
|
||||
positions.some((p) => p.kind === 'account') &&
|
||||
!positions.some(
|
||||
(p) =>
|
||||
p.kind === 'account' &&
|
||||
(accountGroup === null || (p.groupName || '') === accountGroup) &&
|
||||
quickAccountVisible(p, repaymentMonth, businessDay, unpaidOnly, hideBooked),
|
||||
) && (
|
||||
<p className="muted" role="status">
|
||||
{tr('当前筛选下没有账户,可切换筛选查看其他账户。')}
|
||||
</p>
|
||||
)}
|
||||
{positions.some((p) => p.kind === page) ? (
|
||||
<div
|
||||
className={
|
||||
'position-grid' +
|
||||
(page === 'account' && quickMode ? ' quick-dense-grid' : '') +
|
||||
(quickTransfer !== null && page === 'account' ? ' quick-transfer-select' : '')
|
||||
}
|
||||
>
|
||||
@@ -1517,7 +1843,16 @@ export default function App() {
|
||||
p.kind === page &&
|
||||
(page !== 'account' ||
|
||||
accountGroup === null ||
|
||||
(p.groupName || '') === accountGroup),
|
||||
(p.groupName || '') === accountGroup) &&
|
||||
(page !== 'account' ||
|
||||
!quickMode ||
|
||||
quickAccountVisible(
|
||||
p,
|
||||
repaymentMonth,
|
||||
businessDay,
|
||||
unpaidOnly,
|
||||
hideBooked,
|
||||
)),
|
||||
)
|
||||
.sort((a, b) =>
|
||||
page === 'account'
|
||||
@@ -1547,14 +1882,29 @@ export default function App() {
|
||||
? quickTransfer.sourceId === p.id || quickTransfer.targetId === p.id
|
||||
: undefined
|
||||
}
|
||||
disabled={busy || (!!quickTransfer && p.archived)}
|
||||
disabled={
|
||||
busy ||
|
||||
(!!quickTransfer && p.archived) ||
|
||||
(quickMode &&
|
||||
quickMarkMode &&
|
||||
(p.side !== 'liability' || p.archived)) ||
|
||||
(quickMode && quickBookedMode && p.archived)
|
||||
}
|
||||
key={p.id}
|
||||
onClick={() =>
|
||||
quickTransfer !== null && page === 'account' && quickMode
|
||||
? chooseQuickAccount(p)
|
||||
: page === 'account' && quickMode && !p.archived
|
||||
? setModal({ kind: 'revision', p })
|
||||
: setSelected(p.id)
|
||||
page === 'asset' && assetQuickMode
|
||||
? void act(() => toggleAssetInclusion(p), tr('统计设置已保存'))
|
||||
: quickMode && quickBookedMode && page === 'account'
|
||||
? void act(() => markQuickBooked(p), tr('记账标记已保存'))
|
||||
: quickMode && quickMarkMode && page === 'account'
|
||||
? p.kind === 'account' && p.side === 'liability' && !p.archived
|
||||
? void act(() => markQuickRepayment(p), tr('还款标记已保存'))
|
||||
: undefined
|
||||
: quickTransfer !== null && page === 'account' && quickMode
|
||||
? chooseQuickAccount(p)
|
||||
: page === 'account' && quickMode && !p.archived
|
||||
? setModal({ kind: 'revision', p, quick: true })
|
||||
: setSelected(p.id)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
@@ -1572,11 +1922,20 @@ export default function App() {
|
||||
<ArrowUpRight size={18} />
|
||||
</div>
|
||||
<h3>{p.name}</h3>
|
||||
{isPaidForMonth(p, repaymentMonth) && (
|
||||
<span className="badge repayment-paid">{tr('本月已还款')}</span>
|
||||
)}
|
||||
{quickMode && page === 'account' && isBookedToday(p, businessDay) && (
|
||||
<span className="badge repayment-paid">{tr('当天已记账')}</span>
|
||||
)}
|
||||
{assetQuickMode && page === 'asset' && p.included !== false && (
|
||||
<span className="badge repayment-paid">{tr('计入总资产')}</span>
|
||||
)}
|
||||
{(p.included === false ||
|
||||
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
|
||||
<span className="badge">{tr('不计入总览')}</span>
|
||||
)}
|
||||
<p>
|
||||
<p className="position-secondary">
|
||||
{categoryLabel(p)} ·{' '}
|
||||
{p.kind === 'debt'
|
||||
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
|
||||
@@ -1599,7 +1958,10 @@ export default function App() {
|
||||
: money(positionAmount(p), p.currency)}
|
||||
{p.metalGrams && <small> · {p.metalGrams} g</small>}
|
||||
</strong>
|
||||
<small hidden={p.currency === user.baseCurrency}>
|
||||
<small
|
||||
className="position-secondary"
|
||||
hidden={p.currency === user.baseCurrency}
|
||||
>
|
||||
{tr('折合')}{' '}
|
||||
{p.converted === null
|
||||
? tr(p.valuationAvailable === false ? '待估值' : '缺少汇率')
|
||||
@@ -1619,21 +1981,8 @@ export default function App() {
|
||||
</section>
|
||||
))}
|
||||
{page === 'account' && quickMode && !selected && (
|
||||
<aside className="quick-transfer-widget" aria-label={tr('快速转账')}>
|
||||
{quickTransfer === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary quick-transfer-toggle"
|
||||
disabled={
|
||||
busy ||
|
||||
loading ||
|
||||
positions.filter((p) => p.kind === 'account' && !p.archived).length < 2
|
||||
}
|
||||
onClick={() => setQuickTransfer({})}
|
||||
>
|
||||
{tr('转账')}
|
||||
</button>
|
||||
) : (
|
||||
<aside className="quick-session-panel panel" aria-label={tr('快速记账操作')}>
|
||||
{quickTransfer !== null && (
|
||||
<section className="quick-transfer-card">
|
||||
<div className="panel-title">
|
||||
<h2>{tr('快速转账')}</h2>
|
||||
@@ -1672,7 +2021,7 @@ export default function App() {
|
||||
changeTarget={() => setQuickTransfer({ sourceId: quickTransfer.sourceId })}
|
||||
submit={(data) =>
|
||||
void act(async () => {
|
||||
const result = await api('/transfers', 'POST', data);
|
||||
const result = await saveQuickMovement(data, tr('转账'));
|
||||
setQuickTransfer(null);
|
||||
return result;
|
||||
}, tr('转账已完成'))
|
||||
@@ -1682,6 +2031,7 @@ export default function App() {
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<QuickJournal entries={quickEntries} busy={busy} undo={undoQuick} />
|
||||
</aside>
|
||||
)}
|
||||
{page === 'history' && (
|
||||
@@ -2605,21 +2955,23 @@ export default function App() {
|
||||
? modal.p?.kind === 'debt'
|
||||
? operationLabel(modal.p.side === 'asset' ? 'collect' : 'repay')
|
||||
: tr('收款 / 还款')
|
||||
: modal.kind === 'delete-position'
|
||||
? '删除' + deletionLabel(modal.p!)
|
||||
: modal.kind === 'transfer'
|
||||
? tr('转账')
|
||||
: modal.kind === 'reveal'
|
||||
? tr('验证密码以显示隐藏资产')
|
||||
: modal.kind === 'edit'
|
||||
? tr('编辑项目')
|
||||
: modal.kind === 'revision'
|
||||
? tr('更新余额 / 估值')
|
||||
: modal.kind === 'correction'
|
||||
? tr('更正历史记录')
|
||||
: modal.kind === 'links'
|
||||
? tr('管理债务关联')
|
||||
: tr('新增') + tr(labels[modal.kind])
|
||||
: modal.kind === 'quick-repayment'
|
||||
? tr('账户还款')
|
||||
: modal.kind === 'delete-position'
|
||||
? '删除' + deletionLabel(modal.p!)
|
||||
: modal.kind === 'transfer'
|
||||
? tr('转账')
|
||||
: modal.kind === 'reveal'
|
||||
? tr('验证密码以显示隐藏资产')
|
||||
: modal.kind === 'edit'
|
||||
? tr('编辑项目')
|
||||
: modal.kind === 'revision'
|
||||
? tr('更新余额 / 估值')
|
||||
: modal.kind === 'correction'
|
||||
? tr('更正历史记录')
|
||||
: modal.kind === 'links'
|
||||
? tr('管理债务关联')
|
||||
: tr('新增') + tr(labels[modal.kind])
|
||||
}
|
||||
close={() => {
|
||||
if (!busy) setModal(null);
|
||||
@@ -2640,6 +2992,18 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : modal.kind === 'quick-repayment' && modal.p ? (
|
||||
<QuickRepayment
|
||||
target={modal.p}
|
||||
accounts={positions}
|
||||
busy={busy}
|
||||
submit={(data) =>
|
||||
void act(
|
||||
() => saveQuickMovement(data, tr('还款')),
|
||||
tr('还款已保存,双方余额已更新'),
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : ['transfer', 'debt-payment'].includes(modal.kind) ? (
|
||||
movementLoading ? (
|
||||
<p>{tr('正在刷新数据…')}</p>
|
||||
@@ -2703,11 +3067,16 @@ export default function App() {
|
||||
} else if (kind === 'revision' || kind === 'correction') {
|
||||
void act(
|
||||
() =>
|
||||
api(
|
||||
'/positions/' + mp!.id + '/revisions' + (modal.h ? '/' + modal.h.id : ''),
|
||||
modal.h ? 'PUT' : 'POST',
|
||||
{ ...v, amount: accountInputAmount(mp!, String(v.amount)) },
|
||||
),
|
||||
modal.h
|
||||
? api('/positions/' + mp!.id + '/revisions/' + modal.h.id, 'PUT', {
|
||||
...v,
|
||||
amount: accountInputAmount(mp!, String(v.amount)),
|
||||
})
|
||||
: saveQuickBalance(
|
||||
mp!,
|
||||
{ ...v, amount: accountInputAmount(mp!, String(v.amount)) },
|
||||
!!modal.quick,
|
||||
),
|
||||
tr('金额历史已保存,总览已重新计算'),
|
||||
);
|
||||
} else if (kind === 'edit') {
|
||||
@@ -2943,6 +3312,41 @@ export default function App() {
|
||||
{tr('负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。')}
|
||||
</p>
|
||||
)}
|
||||
{modal.kind === 'revision' && modal.quick && modal.p?.side === 'liability' && (
|
||||
<>
|
||||
<div className="actions quick-repayment-actions">
|
||||
{canQuickRepay(modal.p) && (
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
setModal({ kind: 'quick-repayment', p: modal.p, quick: true })
|
||||
}
|
||||
>
|
||||
{tr('还款')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(() => markQuickRepayment(modal.p!), tr('还款标记已保存'))
|
||||
}
|
||||
>
|
||||
{tr(
|
||||
isPaidForMonth(modal.p, repaymentMonth)
|
||||
? '取消本月已还款'
|
||||
: '标记本月已还款',
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{tr('还款会更新双方余额;月份标记只记录本月已处理,不改变金额。')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{modal.kind === 'revision' && (
|
||||
<>
|
||||
<Field label={tr('变更原因')}>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AccountPicker } from './AccountPicker';
|
||||
import { QuickTransfer } from './QuickTransfer';
|
||||
import { money, positionAmount, type Position } from './api';
|
||||
import { t as tr } from './i18n';
|
||||
export type QuickEntry = {
|
||||
key: string;
|
||||
kind: 'balance' | 'transfer' | 'mark' | 'booked';
|
||||
id: string;
|
||||
positionId: string;
|
||||
title: string;
|
||||
currency: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
amount?: string;
|
||||
received?: string;
|
||||
targetCurrency?: string;
|
||||
date: string;
|
||||
previousMonth?: string | null;
|
||||
month?: string | null;
|
||||
markUpdatedAt?: string;
|
||||
previousDate?: string | null;
|
||||
bookedDate?: string | null;
|
||||
undone?: boolean;
|
||||
};
|
||||
export const isPaidForMonth = (
|
||||
position: Pick<Position, 'kind' | 'side' | 'lastRepaymentMonth'>,
|
||||
month: string,
|
||||
) =>
|
||||
position.kind === 'account' &&
|
||||
position.side === 'liability' &&
|
||||
position.lastRepaymentMonth === month;
|
||||
export const isBookedToday = (position: Pick<Position, 'kind' | 'lastBookedDate'>, day: string) =>
|
||||
position.kind === 'account' && position.lastBookedDate === day;
|
||||
export const quickAccountVisible = (
|
||||
position: Position,
|
||||
month: string,
|
||||
day: string,
|
||||
unpaidOnly: boolean,
|
||||
hideBooked: boolean,
|
||||
) =>
|
||||
(!unpaidOnly ||
|
||||
(position.side === 'liability' && !position.archived && !isPaidForMonth(position, month))) &&
|
||||
(!hideBooked || !isBookedToday(position, day));
|
||||
export const canQuickRepay = (position: Pick<Position, 'kind' | 'side' | 'amount' | 'archived'>) =>
|
||||
position.kind === 'account' &&
|
||||
position.side === 'liability' &&
|
||||
!position.archived &&
|
||||
!position.amount.startsWith('-') &&
|
||||
!/^0(?:\.0+)?$/.test(position.amount);
|
||||
export function QuickJournal({
|
||||
entries,
|
||||
busy,
|
||||
undo,
|
||||
}: {
|
||||
entries: QuickEntry[];
|
||||
busy: boolean;
|
||||
undo: (entry: QuickEntry) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="quick-journal" aria-label={tr('本次快速记账')}>
|
||||
<h2>{tr('本次快速记账')}</h2>
|
||||
<p className="muted">{tr('本次保存成功的记录,可逐项撤回。')}</p>
|
||||
{!entries.length ? (
|
||||
<p className="muted">{tr('还没有保存记录')}</p>
|
||||
) : (
|
||||
<ol>
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.key} className={entry.undone ? 'undone' : ''}>
|
||||
<strong>{entry.title}</strong>
|
||||
{entry.kind === 'balance' && (
|
||||
<p>
|
||||
{money(entry.before || '0', entry.currency)} →{' '}
|
||||
{money(entry.after || '0', entry.currency)}
|
||||
</p>
|
||||
)}
|
||||
{entry.kind === 'transfer' && (
|
||||
<p>
|
||||
{money(entry.amount || '0', entry.currency)}
|
||||
{entry.targetCurrency !== entry.currency && (
|
||||
<> → {money(entry.received || '0', entry.targetCurrency || entry.currency)}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{entry.kind === 'mark' && (
|
||||
<p>{tr(entry.month ? '已标记本月还款' : '已取消本月标记')}</p>
|
||||
)}
|
||||
{entry.kind === 'booked' && (
|
||||
<p>{tr(entry.bookedDate ? '已标记当天记账' : '已取消当天标记')}</p>
|
||||
)}
|
||||
<small>{entry.date.replace('T', ' ')} · UTC+8</small>
|
||||
{entry.undone ? (
|
||||
<span className="badge">{tr('已撤回')}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => undo(entry)}
|
||||
>
|
||||
{tr('撤回')}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function QuickRepayment({
|
||||
target,
|
||||
accounts,
|
||||
busy,
|
||||
submit,
|
||||
}: {
|
||||
target: Position;
|
||||
accounts: Position[];
|
||||
busy: boolean;
|
||||
submit: (data: unknown) => void;
|
||||
}) {
|
||||
const choices = accounts.filter(
|
||||
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived && p.id !== target.id,
|
||||
);
|
||||
const [sourceId, setSourceId] = useState(choices[0]?.id || '');
|
||||
const source = choices.find((p) => p.id === sourceId);
|
||||
return (
|
||||
<section>
|
||||
<p>
|
||||
{tr('还款账户')}:{target.name} · {money(positionAmount(target), target.currency)}
|
||||
</p>
|
||||
<p className="muted">
|
||||
{tr('从付款账户转入欠款账户,双方余额同步更新;这里填写本次还款金额。')}
|
||||
</p>
|
||||
{!choices.length ? (
|
||||
<p>{tr('请先添加启用的资产账户作为付款账户。')}</p>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
{tr('付款账户')}
|
||||
<AccountPicker
|
||||
name="quick-payment-source"
|
||||
accounts={choices}
|
||||
value={sourceId}
|
||||
onChange={setSourceId}
|
||||
/>
|
||||
</label>
|
||||
{source && (
|
||||
<QuickTransfer
|
||||
key={source.id + target.id}
|
||||
source={source}
|
||||
target={target}
|
||||
busy={busy}
|
||||
changeSource={() => setSourceId('')}
|
||||
changeTarget={() => {}}
|
||||
fixedTarget
|
||||
actionLabel={tr('确认还款')}
|
||||
submit={submit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export function QuickTransfer({
|
||||
changeSource,
|
||||
changeTarget,
|
||||
submit,
|
||||
actionLabel,
|
||||
fixedTarget,
|
||||
}: {
|
||||
source: Position;
|
||||
target: Position;
|
||||
@@ -15,6 +17,8 @@ export function QuickTransfer({
|
||||
changeSource: () => void;
|
||||
changeTarget: () => void;
|
||||
submit: (data: unknown) => void;
|
||||
actionLabel?: string;
|
||||
fixedTarget?: boolean;
|
||||
}) {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [requestId] = useState(requestUuid);
|
||||
@@ -50,9 +54,11 @@ export function QuickTransfer({
|
||||
<small>{tr('转入账户')}</small>
|
||||
<strong>{target.name}</strong>
|
||||
<span>{money(positionAmount(target), target.currency)}</span>
|
||||
<button type="button" className="text" disabled={busy} onClick={changeTarget}>
|
||||
{tr('重选')}
|
||||
</button>
|
||||
{!fixedTarget && (
|
||||
<button type="button" className="text" disabled={busy} onClick={changeTarget}>
|
||||
{tr('重选')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
@@ -84,7 +90,7 @@ export function QuickTransfer({
|
||||
)}
|
||||
<p className="muted">{tr('按当前时间记录,手续费为 0。跨币种请填写实际到账金额。')}</p>
|
||||
<button className="primary" disabled={busy}>
|
||||
{tr(busy ? '正在转账…' : '确认转账')}
|
||||
{busy ? tr('正在保存…') : actionLabel || tr('确认转账')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -51,6 +51,9 @@ export type History = {
|
||||
reason: string;
|
||||
};
|
||||
export type Position = {
|
||||
lastRepaymentMonth?: string | null;
|
||||
lastBookedDate?: string | null;
|
||||
updatedAt?: string;
|
||||
included?: boolean;
|
||||
metalType?: string | null;
|
||||
metalCostPerGram?: string | null;
|
||||
|
||||
@@ -560,5 +560,52 @@
|
||||
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "Add by grams with automatic market valuation. Missing quotes mean awaiting valuation, not a known zero value.",
|
||||
"缺少报价,当前总额不完整。": "Quotes are missing; current totals are incomplete.",
|
||||
"黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "Gold and silver are valued by weight and the reference price per gram. Daily updates retain prior prices on failure; fees and resale discounts are excluded.",
|
||||
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "Purchase cost uses total grams. New holdings use grams and reference prices for valuation. Clearing purchase price hides profit and loss."
|
||||
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "Purchase cost uses total grams. New holdings use grams and reference prices for valuation. Clearing purchase price hides profit and loss.",
|
||||
"纪念币": "Commemorative coins",
|
||||
"纪念钞": "Commemorative banknotes",
|
||||
"本次快速记账": "Current quick entry session",
|
||||
"本次保存成功的记录,可逐项撤回。": "Successfully saved entries from this session can be undone individually.",
|
||||
"还没有保存记录": "No saved entries yet",
|
||||
"已标记本月还款": "Marked as repaid this month",
|
||||
"已取消本月标记": "Monthly repayment mark removed",
|
||||
"已撤回": "Undone",
|
||||
"撤回": "Undo",
|
||||
"还款账户": "Repayment account",
|
||||
"确认还款": "Confirm repayment",
|
||||
"从付款账户转入欠款账户,双方余额同步更新;这里填写本次还款金额。": "Transfer from the paying account to the liability account. Both balances update; enter the amount paid.",
|
||||
"请先添加启用的资产账户作为付款账户。": "Add an active asset account to pay from.",
|
||||
"记录已撤回,相关余额已更新": "Entry undone; related balances updated.",
|
||||
"还款标记已保存": "Repayment mark saved",
|
||||
"结束还款标记": "Finish marking repayments",
|
||||
"标记本月还款": "Mark monthly repayments",
|
||||
"点击欠款账户卡片,标记或取消本月已还款。": "Click a liability account to mark or unmark repayment for this month.",
|
||||
"本月已还款": "Repaid this month",
|
||||
"快速记账操作": "Quick entry actions",
|
||||
"账户还款": "Account repayment",
|
||||
"还款已保存,双方余额已更新": "Repayment saved; both balances updated.",
|
||||
"取消本月已还款": "Unmark repayment this month",
|
||||
"标记本月已还款": "Mark as repaid this month",
|
||||
"还款会更新双方余额;月份标记只记录本月已处理,不改变金额。": "Repayment updates both balances. A monthly mark only records completion and does not change amounts.",
|
||||
"不能标记未来还款月份": "Cannot mark a future repayment month",
|
||||
"仅欠款账户支持还款月份标记": "Only liability accounts support monthly repayment marks",
|
||||
"请先恢复归档账户": "Restore the archived account first",
|
||||
"还款标记已变化,请刷新后重试": "The repayment mark changed. Refresh and try again.",
|
||||
"还款月份仅适用于欠款账户": "Repayment months only apply to liability accounts",
|
||||
"结束记账标记": "Finish bookkeeping marks",
|
||||
"标记当天已记账": "Mark booked today",
|
||||
"仅看本月未还款": "Only unpaid this month",
|
||||
"显示当天已记账": "Show booked today",
|
||||
"隐藏当天已记账": "Hide booked today",
|
||||
"点击账户卡片,标记或取消当天已记账。": "Click an account to mark or unmark it as booked today.",
|
||||
"结束统计设置": "Finish inclusion settings",
|
||||
"快速设置计入总资产": "Quick asset inclusion",
|
||||
"点击资产卡片,切换是否计入总资产。": "Click an asset to toggle its inclusion in total assets.",
|
||||
"独立资产总开关已关闭;单项设置已保存,开启总开关后生效。": "Independent assets are globally excluded. Item settings are saved and apply when the global setting is enabled.",
|
||||
"记账标记已保存": "Bookkeeping mark saved",
|
||||
"统计设置已保存": "Inclusion settings saved",
|
||||
"当天已记账": "Booked today",
|
||||
"计入总资产": "Included in total assets",
|
||||
"已标记当天记账": "Marked booked today",
|
||||
"已取消当天标记": "Removed today’s mark",
|
||||
"当前筛选下没有账户,可切换筛选查看其他账户。": "No accounts match these filters. Change the filters to view other accounts."
|
||||
}
|
||||
@@ -560,5 +560,52 @@
|
||||
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "按克數添加,參考價自動估值;缺少報價時顯示待估值,不計為已知零價值。",
|
||||
"缺少报价,当前总额不完整。": "缺少報價,當前總額不完整。",
|
||||
"黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "黃金與白銀按重量及每克參考價估值。每日嘗試更新,失敗保留舊價;參考價不含工費與回收折價。",
|
||||
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "買入成本按總克數計算;新持倉按克數和參考價估值。清空買入價後不顯示盈虧。"
|
||||
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "買入成本按總克數計算;新持倉按克數和參考價估值。清空買入價後不顯示盈虧。",
|
||||
"纪念币": "紀念幣",
|
||||
"纪念钞": "紀念鈔",
|
||||
"本次快速记账": "本次快速記賬",
|
||||
"本次保存成功的记录,可逐项撤回。": "本次保存成功的記錄,可逐項撤回。",
|
||||
"还没有保存记录": "還沒有保存記錄",
|
||||
"已标记本月还款": "已標記本月還款",
|
||||
"已取消本月标记": "已取消本月標記",
|
||||
"已撤回": "已撤回",
|
||||
"撤回": "撤回",
|
||||
"还款账户": "還款賬戶",
|
||||
"确认还款": "確認還款",
|
||||
"从付款账户转入欠款账户,双方余额同步更新;这里填写本次还款金额。": "從付款賬戶轉入欠款賬戶,雙方餘額同步更新;這裡填寫本次還款金額。",
|
||||
"请先添加启用的资产账户作为付款账户。": "請先添加啟用的資產賬戶作為付款賬戶。",
|
||||
"记录已撤回,相关余额已更新": "記錄已撤回,相關餘額已更新",
|
||||
"还款标记已保存": "還款標記已保存",
|
||||
"结束还款标记": "結束還款標記",
|
||||
"标记本月还款": "標記本月還款",
|
||||
"点击欠款账户卡片,标记或取消本月已还款。": "點擊欠款賬戶卡片,標記或取消本月已還款。",
|
||||
"本月已还款": "本月已還款",
|
||||
"快速记账操作": "快速記賬操作",
|
||||
"账户还款": "賬戶還款",
|
||||
"还款已保存,双方余额已更新": "還款已保存,雙方餘額已更新",
|
||||
"取消本月已还款": "取消本月已還款",
|
||||
"标记本月已还款": "標記本月已還款",
|
||||
"还款会更新双方余额;月份标记只记录本月已处理,不改变金额。": "還款會更新雙方餘額;月份標記只記錄本月已處理,不改變金額。",
|
||||
"不能标记未来还款月份": "不能標記未來還款月份",
|
||||
"仅欠款账户支持还款月份标记": "僅欠款賬戶支持還款月份標記",
|
||||
"请先恢复归档账户": "請先恢復歸檔賬戶",
|
||||
"还款标记已变化,请刷新后重试": "還款標記已變化,請刷新後重試",
|
||||
"还款月份仅适用于欠款账户": "還款月份僅適用於欠款賬戶",
|
||||
"结束记账标记": "結束記賬標記",
|
||||
"标记当天已记账": "標記當天已記賬",
|
||||
"仅看本月未还款": "僅看本月未還款",
|
||||
"显示当天已记账": "顯示當天已記賬",
|
||||
"隐藏当天已记账": "隱藏當天已記賬",
|
||||
"点击账户卡片,标记或取消当天已记账。": "點擊賬戶卡片,標記或取消當天已記賬。",
|
||||
"结束统计设置": "結束統計設置",
|
||||
"快速设置计入总资产": "快速設置計入總資產",
|
||||
"点击资产卡片,切换是否计入总资产。": "點擊資產卡片,切換是否計入總資產。",
|
||||
"独立资产总开关已关闭;单项设置已保存,开启总开关后生效。": "獨立資產總開關已關閉;單項設置已保存,開啟總開關後生效。",
|
||||
"记账标记已保存": "記賬標記已保存",
|
||||
"统计设置已保存": "統計設置已保存",
|
||||
"当天已记账": "當天已記賬",
|
||||
"计入总资产": "計入總資產",
|
||||
"已标记当天记账": "已標記當天記賬",
|
||||
"已取消当天标记": "已取消當天標記",
|
||||
"当前筛选下没有账户,可切换筛选查看其他账户。": "當前篩選下沒有賬戶,可切換篩選查看其他賬戶。"
|
||||
}
|
||||
@@ -2268,3 +2268,157 @@ textarea,
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
}
|
||||
|
||||
.quick-account-panel {
|
||||
margin-right: 364px;
|
||||
}
|
||||
.quick-account-panel .quick-transfer-select {
|
||||
padding-right: 0;
|
||||
}
|
||||
.quick-session-panel {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
top: 114px;
|
||||
bottom: 24px;
|
||||
width: 340px;
|
||||
overflow: auto;
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
box-shadow: 0 12px 40px #17374718;
|
||||
}
|
||||
.quick-session-panel .quick-transfer-card {
|
||||
width: auto;
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--line, #dce5df);
|
||||
padding: 14px;
|
||||
}
|
||||
.quick-session-panel h2 {
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
.quick-journal ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.quick-journal li {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
border: 1px solid var(--line, #dce5df);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.quick-journal li p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.quick-journal li small {
|
||||
color: var(--text-muted, #617068);
|
||||
}
|
||||
.quick-journal li .secondary {
|
||||
justify-self: end;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
.quick-journal .undone {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.repayment-paid {
|
||||
background: #e6f6ef;
|
||||
color: #137852;
|
||||
}
|
||||
.quick-repayment-actions {
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.quick-account-panel {
|
||||
margin-right: 0;
|
||||
}
|
||||
.quick-session-panel {
|
||||
position: static;
|
||||
width: auto;
|
||||
max-height: none;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-content .page-heading {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.quick-content .page-heading .eyebrow,
|
||||
.quick-content .page-heading .muted {
|
||||
display: none;
|
||||
}
|
||||
.quick-content .privacy-toolbar > span {
|
||||
font-size: 12px;
|
||||
}
|
||||
.quick-content .account-tools {
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.quick-content .account-tools button,
|
||||
.quick-content .account-tools select {
|
||||
font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
min-height: 34px;
|
||||
}
|
||||
.quick-dense-grid {
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(155px, 1fr));
|
||||
}
|
||||
.quick-dense-grid .position-card {
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
.quick-dense-grid .position-card > div {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.quick-dense-grid .position-card .position-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.quick-dense-grid .position-card .position-icon img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.quick-dense-grid .position-card h3 {
|
||||
margin: 2px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.quick-dense-grid .position-card strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
.quick-dense-grid .position-secondary,
|
||||
.quick-dense-grid .position-card > div > svg {
|
||||
display: none;
|
||||
}
|
||||
.quick-dense-grid .account-group-title {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.quick-dense-grid .badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.quick-session-panel > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.quick-session-panel .quick-transfer-card {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.quick-account-panel .quick-transfer-select {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
@media (min-width: 1101px) {
|
||||
.quick-session-panel {
|
||||
top: 225px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isPaidForMonth,
|
||||
isBookedToday,
|
||||
quickAccountVisible,
|
||||
canQuickRepay,
|
||||
QuickJournal,
|
||||
} from '../src/QuickJournal';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
test('repayment badges expire by business month without clearing stored data', () => {
|
||||
const p = { kind: 'account', side: 'liability', lastRepaymentMonth: '2026-10' };
|
||||
assert.equal(isPaidForMonth(p, '2026-10'), true);
|
||||
assert.equal(isPaidForMonth(p, '2026-11'), false);
|
||||
assert.equal(p.lastRepaymentMonth, '2026-10');
|
||||
assert.equal(isPaidForMonth({ ...p, kind: 'debt' }, '2026-10'), false);
|
||||
});
|
||||
test('quick repayment only applies to active liability accounts with an outstanding balance', () => {
|
||||
const p = { kind: 'account', side: 'liability', amount: '200.00000001', archived: false };
|
||||
assert.equal(canQuickRepay(p), true);
|
||||
for (const change of [
|
||||
{ amount: '-5' },
|
||||
{ amount: '0.00000000' },
|
||||
{ archived: true },
|
||||
{ kind: 'debt' },
|
||||
{ side: 'asset' },
|
||||
])
|
||||
assert.equal(canQuickRepay({ ...p, ...change }), false);
|
||||
});
|
||||
test('session journal displays exact balances and distinguishes saved versus undone entries', () => {
|
||||
const entry = {
|
||||
key: 'one',
|
||||
kind: 'balance' as const,
|
||||
id: 'test',
|
||||
positionId: 'account',
|
||||
title: '测试',
|
||||
currency: 'CNY',
|
||||
before: '100',
|
||||
after: '125.87654321',
|
||||
date: '2026-10-04T12:00',
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(QuickJournal, { entries: [entry], busy: false, undo: () => {} }),
|
||||
);
|
||||
assert.match(html, /125\.87654321/);
|
||||
assert.match(html, /撤回/);
|
||||
const undone = renderToStaticMarkup(
|
||||
createElement(QuickJournal, {
|
||||
entries: [{ ...entry, undone: true }],
|
||||
busy: false,
|
||||
undo: () => {},
|
||||
}),
|
||||
);
|
||||
assert.match(undone, /已撤回/);
|
||||
assert.doesNotMatch(undone, /<button/);
|
||||
});
|
||||
|
||||
test('daily badges expire and filters combine month and day without mutating markers', () => {
|
||||
const p = {
|
||||
id: 'test',
|
||||
kind: 'account',
|
||||
side: 'liability',
|
||||
lastRepaymentMonth: '2026-10',
|
||||
lastBookedDate: '2026-10-04',
|
||||
archived: false,
|
||||
} as any;
|
||||
assert.equal(isBookedToday(p, '2026-10-04'), true);
|
||||
assert.equal(isBookedToday(p, '2026-10-05'), false);
|
||||
assert.equal(quickAccountVisible(p, '2026-10', '2026-10-05', true, false), false);
|
||||
assert.equal(quickAccountVisible(p, '2026-11', '2026-10-04', true, true), false);
|
||||
assert.equal(quickAccountVisible(p, '2026-11', '2026-10-05', true, true), true);
|
||||
assert.equal(
|
||||
quickAccountVisible({ ...p, side: 'asset' }, '2026-11', '2026-10-05', true, false),
|
||||
false,
|
||||
);
|
||||
assert.equal(p.lastBookedDate, '2026-10-04');
|
||||
});
|
||||
Reference in new issue
Block a user