feat(ledger): add bookkeeping workbench and transaction views

- Add transactions_dirty table and zero-tolerance cleansing action

- Implement bookkeeping workbench (/bookkeeping) with searchable Combobox channel selector and card suffix / account identifier

- Implement transaction ledger (/transactions) with date groupings, multi-currency metrics, and filters

- Update AGENTS.md guidelines for Base UI Select and Combobox bindings
This commit is contained in:
2026-09-06 23:23:29 +08:00
parent 1ccee1414c
commit c33857ffac
42 changed files with 3329 additions and 598 deletions
+121 -72
View File
@@ -1,18 +1,18 @@
"use server";
"use server"
import { z } from "zod";
import { and, desc, eq, isNull } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { accounts, channels, type Account } from "@/lib/db/schema";
import { z } from "zod"
import { and, desc, eq, isNull } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { accounts, channels, type Account } from "@/lib/db/schema"
async function requireUser() {
const session = await auth();
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录");
throw new Error("请先登录")
}
return session.user.id;
return session.user.id
}
const accountSchema = z.object({
@@ -34,47 +34,50 @@ const accountSchema = z.object({
// 现金账户字段
location: z.string().max(150).optional().nullable(),
});
})
export type AccountInput = z.infer<typeof accountSchema>;
export type AccountInput = z.infer<typeof accountSchema>
export type AccountWithChannels = Account & {
channelCount: number;
};
channelCount: number
}
export async function getAccountsAction(): Promise<{
success: boolean;
data?: AccountWithChannels[];
error?: string;
success: boolean
data?: AccountWithChannels[]
error?: string
}> {
try {
const userId = await requireUser();
const userId = await requireUser()
const userAccounts = await db
.select()
.from(accounts)
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
.orderBy(desc(accounts.createdAt));
.orderBy(desc(accounts.createdAt))
const userChannels = await db
.select({ id: channels.id, refAccounts: channels.refAccounts })
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)));
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
const result: AccountWithChannels[] = userAccounts.map((acc) => {
const count = userChannels.filter((ch) =>
Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
).length;
const count = userChannels.filter(
(ch) => Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
).length
return {
...acc,
channelCount: count,
};
});
}
})
return { success: true, data: result };
return { success: true, data: result }
} catch (err) {
console.error("getAccountsAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "获取账户失败" };
console.error("getAccountsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "获取账户失败",
}
}
}
@@ -82,13 +85,16 @@ export async function createAccountAction(
data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> {
try {
const userId = await requireUser();
const parsed = accountSchema.safeParse(data);
const userId = await requireUser()
const parsed = accountSchema.safeParse(data)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data;
const val = parsed.data
const [newAccount] = await db
.insert(accounts)
.values({
@@ -96,8 +102,13 @@ export async function createAccountAction(
name: val.name.trim(),
accountType: val.accountType,
balanceType: val.balanceType,
primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null,
supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null,
primaryCurrency: val.primaryCurrency
? val.primaryCurrency.toUpperCase()
: null,
supportedCurrencies:
val.supportedCurrencies && val.supportedCurrencies.length > 0
? val.supportedCurrencies
: null,
remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null,
@@ -107,15 +118,18 @@ export async function createAccountAction(
location: val.location?.trim() || null,
isActive: true,
})
.returning();
.returning()
revalidatePath("/accounts");
revalidatePath("/channels");
revalidatePath("/");
return { success: true, data: newAccount };
revalidatePath("/accounts")
revalidatePath("/channels")
revalidatePath("/")
return { success: true, data: newAccount }
} catch (err) {
console.error("createAccountAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "创建账户失败" };
console.error("createAccountAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "创建账户失败",
}
}
}
@@ -124,21 +138,29 @@ export async function updateAccountAction(
data: AccountInput
): Promise<{ success: boolean; data?: Account; error?: string }> {
try {
const userId = await requireUser();
const parsed = accountSchema.safeParse(data);
const userId = await requireUser()
const parsed = accountSchema.safeParse(data)
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
return {
success: false,
error: parsed.error.issues[0]?.message || "参数错误",
}
}
const val = parsed.data;
const val = parsed.data
const [updated] = await db
.update(accounts)
.set({
name: val.name.trim(),
accountType: val.accountType,
balanceType: val.balanceType,
primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null,
supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null,
primaryCurrency: val.primaryCurrency
? val.primaryCurrency.toUpperCase()
: null,
supportedCurrencies:
val.supportedCurrencies && val.supportedCurrencies.length > 0
? val.supportedCurrencies
: null,
remark: val.remark?.trim() || null,
ext: val.ext?.trim() || null,
issuerName: val.issuerName?.trim() || null,
@@ -148,20 +170,29 @@ export async function updateAccountAction(
location: val.location?.trim() || null,
updatedAt: new Date(),
})
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
.returning();
.where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
.returning()
if (!updated) {
return { success: false, error: "未找到该账户或无权修改" };
return { success: false, error: "未找到该账户或无权修改" }
}
revalidatePath("/accounts");
revalidatePath("/channels");
revalidatePath("/");
return { success: true, data: updated };
revalidatePath("/accounts")
revalidatePath("/channels")
revalidatePath("/")
return { success: true, data: updated }
} catch (err) {
console.error("updateAccountAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "更新账户失败" };
console.error("updateAccountAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "更新账户失败",
}
}
}
@@ -170,20 +201,29 @@ export async function toggleAccountActiveAction(
isActive: boolean
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser();
const userId = await requireUser()
await db
.update(accounts)
.set({
isActive,
updatedAt: new Date(),
})
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)));
.where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
revalidatePath("/accounts");
return { success: true };
revalidatePath("/accounts")
return { success: true }
} catch (err) {
console.error("toggleAccountActiveAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
console.error("toggleAccountActiveAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "状态切换失败",
}
}
}
@@ -191,7 +231,7 @@ export async function deleteAccountAction(
id: string
): Promise<{ success: boolean; error?: string }> {
try {
const userId = await requireUser();
const userId = await requireUser()
// 软删除
const [deleted] = await db
.update(accounts)
@@ -199,19 +239,28 @@ export async function deleteAccountAction(
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
.returning();
.where(
and(
eq(accounts.id, id),
eq(accounts.userId, userId),
isNull(accounts.deletedAt)
)
)
.returning()
if (!deleted) {
return { success: false, error: "账户不存在或已删除" };
return { success: false, error: "账户不存在或已删除" }
}
revalidatePath("/accounts");
revalidatePath("/channels");
revalidatePath("/");
return { success: true };
revalidatePath("/accounts")
revalidatePath("/channels")
revalidatePath("/")
return { success: true }
} catch (err) {
console.error("deleteAccountAction error:", err);
return { success: false, error: err instanceof Error ? err.message : "删除账户失败" };
console.error("deleteAccountAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "删除账户失败",
}
}
}