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:
+121
-72
@@ -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 : "删除账户失败",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user