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:
@@ -1,3 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
import { handlers } from "@/lib/auth"
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
export const { GET, POST } = handlers
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { getDirtyTransactionsAction } from "@/lib/actions/bookkeeping"
|
||||
import { getChannelsAction } from "@/lib/actions/channel"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { AppHeader } from "@/components/app-header"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { BookkeepingView } from "./bookkeeping-view"
|
||||
|
||||
export default async function BookkeepingPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const user = session.user
|
||||
const userName = user.name || "Fluxent 用户"
|
||||
const userEmail = user.email || ""
|
||||
const userAvatar = user.image || null
|
||||
|
||||
const [dirtyTxnsRes, channelsRes] = await Promise.all([
|
||||
getDirtyTransactionsAction(),
|
||||
getChannelsAction(),
|
||||
])
|
||||
|
||||
const initialDirtyTransactions =
|
||||
dirtyTxnsRes.success && dirtyTxnsRes.data ? dirtyTxnsRes.data : []
|
||||
const initialChannels =
|
||||
channelsRes.success && channelsRes.data ? channelsRes.data : []
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
user={{
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
avatar: userAvatar,
|
||||
}}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<AppHeader title="流水记账" />
|
||||
|
||||
<BookkeepingView
|
||||
initialDirtyTransactions={initialDirtyTransactions}
|
||||
initialChannels={initialChannels}
|
||||
/>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -473,7 +473,9 @@ export function ChannelsView({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs md:block">
|
||||
<span className="text-muted-foreground md:hidden">类型</span>
|
||||
<span className="text-muted-foreground md:hidden">
|
||||
类型
|
||||
</span>
|
||||
<span>{channelTypeLabels[channel.channelType]}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-xs md:block">
|
||||
|
||||
+1
-5
@@ -8,11 +8,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className="antialiased"
|
||||
>
|
||||
<html lang="en" suppressHydrationWarning className="antialiased">
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
|
||||
+65
-63
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { signIn } from "next-auth/react"
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
PieChartIcon,
|
||||
TrendingUpIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
} from "lucide-react"
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -27,90 +27,90 @@ import {
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FluxentLogo } from "@/components/fluxent-logo";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
} from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { FluxentLogo } from "@/components/fluxent-logo"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
|
||||
interface LoginFormProps {
|
||||
oidcEnabled: boolean;
|
||||
oidcName: string;
|
||||
oidcEnabled: boolean
|
||||
oidcName: string
|
||||
}
|
||||
|
||||
export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/";
|
||||
const authError = searchParams.get("error");
|
||||
const registered = searchParams.get("registered");
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/"
|
||||
const authError = searchParams.get("error")
|
||||
const registered = searchParams.get("registered")
|
||||
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [rememberMe, setRememberMe] = React.useState(false);
|
||||
const [isPending, setIsPending] = React.useState(false);
|
||||
const [isOidcPending, setIsOidcPending] = React.useState(false);
|
||||
const [email, setEmail] = React.useState("")
|
||||
const [password, setPassword] = React.useState("")
|
||||
const [rememberMe, setRememberMe] = React.useState(false)
|
||||
const [isPending, setIsPending] = React.useState(false)
|
||||
const [isOidcPending, setIsOidcPending] = React.useState(false)
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(() => {
|
||||
if (authError === "CredentialsSignin") {
|
||||
return "邮箱或密码错误,请核对后重试";
|
||||
return "邮箱或密码错误,请核对后重试"
|
||||
}
|
||||
if (authError) {
|
||||
return "认证过程中遇到问题,请重新登录";
|
||||
return "认证过程中遇到问题,请重新登录"
|
||||
}
|
||||
return null;
|
||||
});
|
||||
return null
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
e.preventDefault()
|
||||
setErrorMessage(null)
|
||||
|
||||
if (!email.trim()) {
|
||||
setErrorMessage("请输入登录邮箱");
|
||||
return;
|
||||
setErrorMessage("请输入登录邮箱")
|
||||
return
|
||||
}
|
||||
if (!password) {
|
||||
setErrorMessage("请输入登录密码");
|
||||
return;
|
||||
setErrorMessage("请输入登录密码")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsPending(true);
|
||||
setIsPending(true)
|
||||
const res = await signIn("credentials", {
|
||||
email: email.trim(),
|
||||
password,
|
||||
redirect: false,
|
||||
callbackUrl,
|
||||
});
|
||||
})
|
||||
|
||||
if (res?.error) {
|
||||
if (res.error === "CredentialsSignin" || res.code === "credentials") {
|
||||
setErrorMessage("账号或密码不正确,请重新检查");
|
||||
setErrorMessage("账号或密码不正确,请重新检查")
|
||||
} else {
|
||||
setErrorMessage("登录失败,请稍后重试");
|
||||
setErrorMessage("登录失败,请稍后重试")
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
router.push(callbackUrl)
|
||||
router.refresh()
|
||||
} catch {
|
||||
setErrorMessage("网络异常,无法连接到认证服务器");
|
||||
setErrorMessage("网络异常,无法连接到认证服务器")
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
setIsPending(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleOidcLogin = async () => {
|
||||
try {
|
||||
setIsOidcPending(true);
|
||||
await signIn("oidc", { callbackUrl });
|
||||
setIsOidcPending(true)
|
||||
await signIn("oidc", { callbackUrl })
|
||||
} catch {
|
||||
setIsOidcPending(false);
|
||||
setErrorMessage("SSO 单点登录发起失败,请稍后重试");
|
||||
setIsOidcPending(false)
|
||||
setErrorMessage("SSO 单点登录发起失败,请稍后重试")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-svh flex-col justify-between overflow-hidden bg-background">
|
||||
@@ -147,7 +147,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
<div className="hidden flex-col gap-8 pr-4 lg:flex">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-border/80 bg-muted/60 px-3 py-1 text-xs text-muted-foreground backdrop-blur-xs">
|
||||
<span className="inline-block size-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<span className="inline-block size-1.5 animate-pulse rounded-full bg-primary" />
|
||||
全球多币种 • 全场景记账 • 实时汇率
|
||||
</div>
|
||||
<h1 className="font-heading text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
||||
@@ -155,7 +155,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
<br />
|
||||
让财务管理行云流水。
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
无论是跨国多币种账户、信用卡记账,还是银行与电子钱包资金调度,Fluxent
|
||||
提供银行级的精确记录与现代化的优雅交互体验。
|
||||
</p>
|
||||
@@ -170,7 +170,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
<h3 className="text-xs font-semibold text-foreground">
|
||||
全币种与汇率追踪
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
<p className="text-[11px] leading-normal text-muted-foreground">
|
||||
多币种入账与清算汇率对账,资产估值一目了然
|
||||
</p>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
<h3 className="text-xs font-semibold text-foreground">
|
||||
全渠道多账户管理
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
<p className="text-[11px] leading-normal text-muted-foreground">
|
||||
银行账户、电子钱包与支付卡渠道统一调度
|
||||
</p>
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
<h3 className="text-xs font-semibold text-foreground">
|
||||
复式记账与场景分类
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
<p className="text-[11px] leading-normal text-muted-foreground">
|
||||
标准借贷分录与消费场景画像,财务合规严谨
|
||||
</p>
|
||||
</div>
|
||||
@@ -206,7 +206,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
<h3 className="text-xs font-semibold text-foreground">
|
||||
资产汇总与统计
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
<p className="text-[11px] leading-normal text-muted-foreground">
|
||||
跨账户资金结构分布分析,收支报表一览无余
|
||||
</p>
|
||||
</div>
|
||||
@@ -273,7 +273,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Separator className="w-full" />
|
||||
<span className="absolute bg-card px-2 text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<span className="absolute bg-card px-2 text-[11px] tracking-wider text-muted-foreground uppercase">
|
||||
或者使用邮箱密码
|
||||
</span>
|
||||
</div>
|
||||
@@ -337,7 +337,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
/>
|
||||
<label
|
||||
htmlFor="rememberMe"
|
||||
className="text-xs text-muted-foreground cursor-pointer select-none"
|
||||
className="cursor-pointer text-xs text-muted-foreground select-none"
|
||||
>
|
||||
保持此设备的登录状态
|
||||
</label>
|
||||
@@ -373,7 +373,7 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
立即注册新账号
|
||||
<ArrowRightIcon
|
||||
data-icon="inline-end"
|
||||
className="inline size-3 ml-0.5"
|
||||
className="ml-0.5 inline size-3"
|
||||
/>
|
||||
</Link>
|
||||
</p>
|
||||
@@ -389,8 +389,10 @@ export function LoginForm({ oidcEnabled, oidcName }: LoginFormProps) {
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 flex h-12 items-center justify-center border-t border-border/40 px-6 text-center text-[11px] text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。</span>
|
||||
<span>
|
||||
© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,14 +1,14 @@
|
||||
import { Suspense } from "react";
|
||||
import { LoginForm } from "./login-form";
|
||||
import { Suspense } from "react"
|
||||
import { LoginForm } from "./login-form"
|
||||
|
||||
export const metadata = {
|
||||
title: "登录 - Fluxent",
|
||||
description: "Fluxent 多币种资产与全场景记账系统",
|
||||
};
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const oidcEnabled = process.env.AUTH_OIDC_ENABLED === "true";
|
||||
const oidcName = process.env.AUTH_OIDC_NAME || "统一身份认证 (SSO)";
|
||||
const oidcEnabled = process.env.AUTH_OIDC_ENABLED === "true"
|
||||
const oidcName = process.env.AUTH_OIDC_NAME || "统一身份认证 (SSO)"
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
@@ -20,5 +20,5 @@ export default function LoginPage() {
|
||||
>
|
||||
<LoginForm oidcEnabled={oidcEnabled} oidcName={oidcName} />
|
||||
</Suspense>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+22
-29
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CreditCardIcon,
|
||||
@@ -9,35 +9,27 @@ import {
|
||||
SlidersHorizontalIcon,
|
||||
TrendingUpIcon,
|
||||
WalletIcon,
|
||||
} from "lucide-react";
|
||||
} from "lucide-react"
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { AppHeader } from "@/components/app-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { auth } from "@/lib/auth"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { AppHeader } from "@/components/app-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
const session = await auth()
|
||||
|
||||
// 若用户未登录,由于 proxy 会拦截并重定向,作为服务端页面增加双重保障
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const user = session.user;
|
||||
const userName = user.name || "Fluxent 用户";
|
||||
const userEmail = user.email || "";
|
||||
const userAvatar = user.image || null;
|
||||
const user = session.user
|
||||
const userName = user.name || "Fluxent 用户"
|
||||
const userEmail = user.email || ""
|
||||
const userAvatar = user.image || null
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
@@ -59,8 +51,9 @@ export default async function HomePage() {
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight text-foreground md:text-3xl">
|
||||
欢迎回来,{userName}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
这是您的 Fluxent 多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
这是您的 Fluxent
|
||||
多币种资产与记账中心。通过左侧导航栏,您可以轻松追踪你的资金流动、管理银行与支付卡账户、维护清晰的收支流水。
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
@@ -150,7 +143,7 @@ export default async function HomePage() {
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
1. 添加账户与支付渠道
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
录入您的现金账户、活期存款,或绑定支持港币、美元、日元结算的跨境信用卡与电子钱包。
|
||||
</p>
|
||||
</div>
|
||||
@@ -171,7 +164,7 @@ export default async function HomePage() {
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
2. 建立首笔复式流水
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
支持自动计算交易币种到入账币种的清算汇率与手续费,让每一分折损清清楚楚。
|
||||
</p>
|
||||
</div>
|
||||
@@ -192,7 +185,7 @@ export default async function HomePage() {
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">
|
||||
3. 账户首选项与设置
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
配置个人偏好货币、导出记账数据或连接外部服务。
|
||||
</p>
|
||||
</div>
|
||||
@@ -209,5 +202,5 @@ export default async function HomePage() {
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { RegisterForm } from "./register-form";
|
||||
import { Suspense } from "react"
|
||||
import { RegisterForm } from "./register-form"
|
||||
|
||||
export const metadata = {
|
||||
title: "注册新账号 - Fluxent",
|
||||
description: "创建您的 Fluxent 多币种资产与记账账户",
|
||||
};
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
@@ -17,5 +17,5 @@ export default function RegisterPage() {
|
||||
>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState } from "react";
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useActionState } from "react"
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckCircle2Icon,
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
MailIcon,
|
||||
SparklesIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
} from "lucide-react"
|
||||
|
||||
import { registerAction, type RegisterState } from "@/lib/actions/auth";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { registerAction, type RegisterState } from "@/lib/actions/auth"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -25,45 +25,45 @@ import {
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FluxentLogo } from "@/components/fluxent-logo";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FluxentLogo } from "@/components/fluxent-logo"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
|
||||
const initialState: RegisterState = {};
|
||||
const initialState: RegisterState = {}
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
registerAction,
|
||||
initialState
|
||||
);
|
||||
)
|
||||
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
const [password, setPassword] = React.useState("")
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("")
|
||||
|
||||
// 当注册成功时,优雅倒计时或自动跳转
|
||||
React.useEffect(() => {
|
||||
if (state?.success) {
|
||||
const timer = setTimeout(() => {
|
||||
router.push("/login?registered=1");
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
router.push("/login?registered=1")
|
||||
}, 1500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [state?.success, router]);
|
||||
}, [state?.success, router])
|
||||
|
||||
// 密码复杂度提示计算
|
||||
const hasMinLen = password.length >= 8;
|
||||
const hasMinLen = password.length >= 8
|
||||
const passwordsMatch = Boolean(
|
||||
password && confirmPassword && password === confirmPassword
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-svh flex-col justify-between overflow-hidden bg-background">
|
||||
{/* Decorative ambient gradients */}
|
||||
<div className="pointer-events-none absolute -top-40 -left-40 size-[32rem] rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-40 -right-40 size-[34rem] rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -right-40 -bottom-40 size-[34rem] rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.08),rgba(255,255,255,0))]" />
|
||||
|
||||
{/* Header */}
|
||||
@@ -300,7 +300,7 @@ export function RegisterForm() {
|
||||
>
|
||||
<ArrowLeftIcon
|
||||
data-icon="inline-start"
|
||||
className="inline size-3 mr-0.5"
|
||||
className="mr-0.5 inline size-3"
|
||||
/>
|
||||
返回直接登录
|
||||
</Link>
|
||||
@@ -312,8 +312,10 @@ export function RegisterForm() {
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 flex h-12 items-center justify-center border-t border-border/40 px-6 text-center text-[11px] text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。</span>
|
||||
<span>
|
||||
© {new Date().getFullYear()} Fluxent Financial. 保留所有权利。
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { getOfficialTransactionsAction } from "@/lib/actions/transaction"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { AppHeader } from "@/components/app-header"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { TransactionsView } from "./transactions-view"
|
||||
|
||||
export default async function TransactionsPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const user = session.user
|
||||
const userName = user.name || "Fluxent 用户"
|
||||
const userEmail = user.email || ""
|
||||
const userAvatar = user.image || null
|
||||
|
||||
const txnsRes = await getOfficialTransactionsAction()
|
||||
const initialTransactions =
|
||||
txnsRes.success && txnsRes.data ? txnsRes.data : []
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
user={{
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
avatar: userAvatar,
|
||||
}}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<AppHeader title="交易记录" />
|
||||
|
||||
<TransactionsView initialTransactions={initialTransactions} />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
ArrowRightLeftIcon,
|
||||
BanknoteIcon,
|
||||
CreditCardIcon,
|
||||
FileTextIcon,
|
||||
InboxIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
WalletCardsIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
deleteOfficialTransactionAction,
|
||||
type TransactionWithChannelDetails,
|
||||
} from "@/lib/actions/transaction"
|
||||
import {
|
||||
CARD_BRAND_LABELS,
|
||||
getCardBrandLogoUrl,
|
||||
normalizeCardBrand,
|
||||
} from "@/lib/payment/card-brand"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
interface TransactionsViewProps {
|
||||
initialTransactions: TransactionWithChannelDetails[]
|
||||
}
|
||||
|
||||
const SCENE_MAP: Record<string, string> = {
|
||||
PAYMENT: "消费支出",
|
||||
ECOM_PAYMENT: "线上消费",
|
||||
POS_PAYMENT: "线下刷卡",
|
||||
MISC_IN: "日常收入",
|
||||
TRANSFER: "转账",
|
||||
ATM: "取现",
|
||||
}
|
||||
|
||||
function getSceneBadge(scene: string, dcFlag: string) {
|
||||
const label = SCENE_MAP[scene] || (dcFlag === "CREDIT" ? "收入" : "支出")
|
||||
if (dcFlag === "CREDIT") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return <Badge variant="secondary">{label}</Badge>
|
||||
}
|
||||
|
||||
function renderChannelLogoOrIcon(
|
||||
ch?: TransactionWithChannelDetails["channelDetails"][0]
|
||||
) {
|
||||
if (!ch) {
|
||||
return <CreditCardIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
|
||||
if (ch.channelType === "PAYMENT_CARD") {
|
||||
const brand = normalizeCardBrand(ch.cardBrand)
|
||||
const logoUrl = getCardBrandLogoUrl(ch.cardBrand)
|
||||
if (logoUrl && brand) {
|
||||
return (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={CARD_BRAND_LABELS[brand]}
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-4 shrink-0 object-contain"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <CreditCardIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
if (ch.channelType === "E_WALLET") {
|
||||
return <WalletCardsIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
if (ch.channelType === "CASH") {
|
||||
return <BanknoteIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
return (
|
||||
<ArrowRightLeftIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateHeader(dateVal: Date | string): string {
|
||||
const d = new Date(dateVal)
|
||||
if (isNaN(d.getTime())) return "未分类日期"
|
||||
const y = d.getFullYear()
|
||||
const m = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
const weekDays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||
const week = weekDays[d.getDay()]
|
||||
return `${y}年${m}月${day}日 ${week}`
|
||||
}
|
||||
|
||||
function formatTime(dateVal: Date | string): string {
|
||||
const d = new Date(dateVal)
|
||||
if (isNaN(d.getTime())) return "--"
|
||||
const hh = String(d.getHours()).padStart(2, "0")
|
||||
const mm = String(d.getMinutes()).padStart(2, "0")
|
||||
return `${hh}:${mm}`
|
||||
}
|
||||
|
||||
function formatFullDateTime(dateVal?: Date | string | null): string {
|
||||
if (!dateVal) return "--"
|
||||
const d = new Date(dateVal)
|
||||
if (isNaN(d.getTime())) return "--"
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
const hh = String(d.getHours()).padStart(2, "0")
|
||||
const mm = String(d.getMinutes()).padStart(2, "0")
|
||||
const ss = String(d.getSeconds()).padStart(2, "0")
|
||||
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
||||
}
|
||||
|
||||
export function TransactionsView({
|
||||
initialTransactions,
|
||||
}: TransactionsViewProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const [transactionsList, setTransactionsList] =
|
||||
React.useState<TransactionWithChannelDetails[]>(initialTransactions)
|
||||
const [searchQuery, setSearchQuery] = React.useState("")
|
||||
const [dcFilter, setDcFilter] = React.useState<"ALL" | "DEBIT" | "CREDIT">(
|
||||
"ALL"
|
||||
)
|
||||
|
||||
// 查看分录详情弹窗
|
||||
const [viewingTxn, setViewingTxn] =
|
||||
React.useState<TransactionWithChannelDetails | null>(null)
|
||||
|
||||
// 删除确认弹窗
|
||||
const [deletingTxn, setDeletingTxn] =
|
||||
React.useState<TransactionWithChannelDetails | null>(null)
|
||||
const [isDeleting, setIsDeleting] = React.useState(false)
|
||||
|
||||
// 检索过滤
|
||||
const filteredTransactions = React.useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
return transactionsList.filter((item) => {
|
||||
// 借贷过滤
|
||||
if (dcFilter !== "ALL" && item.dcFlag !== dcFilter) {
|
||||
return false
|
||||
}
|
||||
if (!query) return true
|
||||
|
||||
const chNames = (item.channelDetails || [])
|
||||
.map((c) => c.displayName)
|
||||
.join(" ")
|
||||
|
||||
const searchContent = [
|
||||
item.merchantName,
|
||||
item.cp,
|
||||
item.description,
|
||||
item.memo,
|
||||
item.txnAmt,
|
||||
item.txnCcy,
|
||||
item.postingAmt,
|
||||
item.postingCcy,
|
||||
chNames,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
|
||||
return searchContent.includes(query)
|
||||
})
|
||||
}, [transactionsList, searchQuery, dcFilter])
|
||||
|
||||
// 按自然日期分组
|
||||
const groupedTransactions = React.useMemo(() => {
|
||||
const groups: {
|
||||
dateKey: string
|
||||
dateLabel: string
|
||||
items: TransactionWithChannelDetails[]
|
||||
}[] = []
|
||||
|
||||
const groupMap = new Map<string, TransactionWithChannelDetails[]>()
|
||||
|
||||
filteredTransactions.forEach((txn) => {
|
||||
const d = new Date(txn.txnDate)
|
||||
const dateKey = isNaN(d.getTime())
|
||||
? "unknown"
|
||||
: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
|
||||
if (!groupMap.has(dateKey)) {
|
||||
groupMap.set(dateKey, [])
|
||||
}
|
||||
groupMap.get(dateKey)!.push(txn)
|
||||
})
|
||||
|
||||
groupMap.forEach((items, dateKey) => {
|
||||
groups.push({
|
||||
dateKey,
|
||||
dateLabel:
|
||||
dateKey === "unknown"
|
||||
? "未分类日期"
|
||||
: formatDateHeader(items[0].txnDate),
|
||||
items,
|
||||
})
|
||||
})
|
||||
|
||||
return groups
|
||||
}, [filteredTransactions])
|
||||
|
||||
const hasFilters = Boolean(searchQuery) || dcFilter !== "ALL"
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("")
|
||||
setDcFilter("ALL")
|
||||
}
|
||||
|
||||
// 确认删除流水
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingTxn) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await deleteOfficialTransactionAction(deletingTxn.id)
|
||||
if (res.success) {
|
||||
setTransactionsList((prev) =>
|
||||
prev.filter((item) => item.id !== deletingTxn.id)
|
||||
)
|
||||
setDeletingTxn(null)
|
||||
router.refresh()
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<main className="flex min-w-0 flex-1 flex-col gap-4 p-4 md:p-6 lg:p-8">
|
||||
{/* 页头控制与过滤检索区 */}
|
||||
<div className="flex flex-col gap-4 border-b border-border/70 pb-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
||||
交易记录
|
||||
</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-2 py-0.5 text-xs font-normal"
|
||||
>
|
||||
已入账 {transactionsList.length} 笔
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
查看已清洗入账的正式总账流水与多币种对账
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
render={<Link href="/bookkeeping" />}
|
||||
size="default"
|
||||
className="w-full shadow-xs sm:w-auto"
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
记一笔
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选控制器 */}
|
||||
<div className="flex flex-col gap-2.5 md:flex-row md:items-center">
|
||||
<div className="relative min-w-0 flex-1 md:max-w-md">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索商户、对手方、说明、备注或金额"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={dcFilter}
|
||||
onValueChange={(val) =>
|
||||
setDcFilter((val ?? "ALL") as "ALL" | "DEBIT" | "CREDIT")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-32">
|
||||
<SelectValue>
|
||||
{dcFilter === "ALL"
|
||||
? "全部方向"
|
||||
: dcFilter === "DEBIT"
|
||||
? "仅支出"
|
||||
: "仅收入"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="ALL">全部方向</SelectItem>
|
||||
<SelectItem value="DEBIT">仅支出</SelectItem>
|
||||
<SelectItem value="CREDIT">仅收入</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
清除筛选
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 流水列表区 */}
|
||||
{groupedTransactions.length > 0 ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
{groupedTransactions.map((group) => (
|
||||
<section key={group.dateKey} className="flex flex-col gap-2">
|
||||
{/* 日期分组标题 */}
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<h2 className="text-xs font-semibold tracking-wide text-muted-foreground">
|
||||
{group.dateLabel}
|
||||
</h2>
|
||||
<span className="text-[11px] text-muted-foreground/80">
|
||||
({group.items.length} 笔)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 分组流水卡片容器 */}
|
||||
<div className="overflow-hidden rounded-lg border border-border/70 bg-card shadow-xs">
|
||||
{/* PC 列表表头 */}
|
||||
<div className="hidden grid-cols-[minmax(200px,2fr)_minmax(120px,1.2fr)_minmax(140px,1.2fr)_minmax(140px,1.2fr)_80px_48px] items-center gap-3 border-b bg-muted/30 px-4 py-2 text-[11px] font-medium text-muted-foreground md:grid">
|
||||
<span>商户与描述</span>
|
||||
<span>结算渠道</span>
|
||||
<span className="text-right">发生金额 (原币)</span>
|
||||
<span className="text-right">入账金额 (折算)</span>
|
||||
<span className="text-center">场景</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/60">
|
||||
{group.items.map((txn) => {
|
||||
const ch = txn.channelDetails?.[0]
|
||||
const isMultiCurrency =
|
||||
txn.postingCcy &&
|
||||
txn.postingCcy !== txn.txnCcy &&
|
||||
txn.postingAmt
|
||||
|
||||
// 汇率计算显示
|
||||
let rateDisplay: string | null = null
|
||||
if (isMultiCurrency) {
|
||||
const originalAmt = parseFloat(txn.txnAmt)
|
||||
const postAmt = parseFloat(txn.postingAmt!)
|
||||
if (
|
||||
!isNaN(originalAmt) &&
|
||||
!isNaN(postAmt) &&
|
||||
originalAmt > 0
|
||||
) {
|
||||
const rate = postAmt / originalAmt
|
||||
rateDisplay = `1 ${txn.txnCcy} ≈ ${rate.toFixed(4)} ${txn.postingCcy}`
|
||||
}
|
||||
}
|
||||
|
||||
const actionMenu = (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="打开操作菜单"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>更多操作</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setViewingTxn(txn)}
|
||||
>
|
||||
<FileTextIcon data-icon="inline-start" />
|
||||
查看明细
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeletingTxn(txn)}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
删除流水
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={txn.id}
|
||||
className="flex flex-col gap-2 p-3 transition-colors hover:bg-muted/30 md:grid md:grid-cols-[minmax(200px,2fr)_minmax(120px,1.2fr)_minmax(140px,1.2fr)_minmax(140px,1.2fr)_80px_48px] md:items-center md:gap-3 md:px-4 md:py-3"
|
||||
>
|
||||
{/* 移动端顶栏 (商户 + 更多操作在最右侧) / PC 端商户描述 */}
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 md:block">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground md:hidden">
|
||||
{renderChannelLogoOrIcon(ch)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{txn.merchantName ||
|
||||
txn.cp ||
|
||||
txn.description ||
|
||||
"未命名交易"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground md:mt-0.5">
|
||||
<span className="font-mono text-[11px]">
|
||||
{formatTime(txn.txnDate)}
|
||||
</span>
|
||||
{txn.description && txn.merchantName && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="max-w-[200px] truncate">
|
||||
{txn.description}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端自然流右侧菜单 */}
|
||||
<div className="shrink-0 md:hidden">
|
||||
{actionMenu}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结算渠道 */}
|
||||
<div className="hidden min-w-0 items-center gap-2 text-xs md:flex">
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
{renderChannelLogoOrIcon(ch)}
|
||||
</div>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{ch?.displayName || "未关联渠道"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 发生金额 (原币) */}
|
||||
<div className="flex items-baseline justify-between md:block md:text-right">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
发生金额
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1 md:justify-end">
|
||||
<span
|
||||
className={`font-mono text-base font-semibold tracking-tight ${
|
||||
txn.dcFlag === "CREDIT"
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{txn.dcFlag === "CREDIT" ? "+" : "-"}
|
||||
{txn.txnAmt}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground uppercase">
|
||||
{txn.txnCcy}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 入账金额 (折算) */}
|
||||
<div className="flex items-baseline justify-between text-xs md:block md:text-right">
|
||||
<span className="text-muted-foreground md:hidden">
|
||||
入账折算
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
{isMultiCurrency ? (
|
||||
<>
|
||||
<div className="flex items-baseline gap-1 font-mono font-medium">
|
||||
<span>
|
||||
{txn.dcFlag === "CREDIT" ? "+" : "-"}
|
||||
{txn.postingAmt}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase">
|
||||
{txn.postingCcy}
|
||||
</span>
|
||||
</div>
|
||||
{rateDisplay && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{rateDisplay}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground">
|
||||
等额入账
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景徽标 */}
|
||||
<div className="flex items-center justify-between md:justify-center">
|
||||
<span className="text-xs text-muted-foreground md:hidden">
|
||||
场景类型
|
||||
</span>
|
||||
{getSceneBadge(txn.txnScene, txn.dcFlag)}
|
||||
</div>
|
||||
|
||||
{/* PC 端操作按钮 */}
|
||||
<div className="hidden md:flex md:justify-end">
|
||||
{actionMenu}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 空状态展示 */
|
||||
<div className="flex min-h-[380px] flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 p-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<InboxIcon className="size-6" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-sm font-semibold">
|
||||
{hasFilters ? "未找到符合条件的流水" : "暂无已入账的交易流水"}
|
||||
</h3>
|
||||
<p className="mt-1.5 max-w-sm text-xs text-muted-foreground">
|
||||
{hasFilters
|
||||
? "调整关键词或重置筛选条件后再试。"
|
||||
: "可在「流水记账」中快速录入并确认清洗入账,入账后的总账流水将完整汇总于此。"}
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
{hasFilters ? (
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
清除筛选
|
||||
</Button>
|
||||
) : (
|
||||
<Button render={<Link href="/bookkeeping" />} size="sm">
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
前往记账
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分录明细弹窗 */}
|
||||
<Dialog
|
||||
open={Boolean(viewingTxn)}
|
||||
onOpenChange={(open) => !open && setViewingTxn(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>交易分录明细</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看该笔总账交易的原始要素、渠道及折算入账数据
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{viewingTxn && (
|
||||
<div className="flex flex-col gap-3.5 py-1 text-xs">
|
||||
<div className="grid grid-cols-2 gap-3 rounded-lg border bg-muted/20 p-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">流水标识</span>
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] select-all">
|
||||
{viewingTxn.id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">版本号</span>
|
||||
<p className="mt-0.5 font-mono font-medium">
|
||||
v{viewingTxn.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">交易时间</span>
|
||||
<p className="mt-0.5 font-medium">
|
||||
{formatFullDateTime(viewingTxn.txnDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">借贷方向</span>
|
||||
<p className="mt-0.5 font-medium">
|
||||
{viewingTxn.dcFlag === "CREDIT"
|
||||
? "收入 (贷方)"
|
||||
: "支出 (借方)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">原始发生额</span>
|
||||
<p className="mt-0.5 font-mono text-sm font-bold">
|
||||
{viewingTxn.txnAmt} {viewingTxn.txnCcy}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">最终入账额</span>
|
||||
<p className="mt-0.5 font-mono text-sm font-bold text-primary">
|
||||
{viewingTxn.postingAmt || viewingTxn.txnAmt}{" "}
|
||||
{viewingTxn.postingCcy || viewingTxn.txnCcy}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(viewingTxn.commAmt ||
|
||||
viewingTxn.surchargeAmt ||
|
||||
viewingTxn.discAmt) && (
|
||||
<div className="space-y-1 rounded-md border bg-muted/10 p-2.5">
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
费用与优惠细目
|
||||
</span>
|
||||
{viewingTxn.commAmt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">手续费</span>
|
||||
<span className="font-mono">
|
||||
{viewingTxn.commAmt}{" "}
|
||||
{viewingTxn.commCcy || viewingTxn.txnCcy}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{viewingTxn.surchargeAmt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">附加费</span>
|
||||
<span className="font-mono">
|
||||
{viewingTxn.surchargeAmt}{" "}
|
||||
{viewingTxn.surchargeCcy || viewingTxn.txnCcy}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{viewingTxn.discAmt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">优惠折扣</span>
|
||||
<span className="font-mono text-emerald-600">
|
||||
-{viewingTxn.discAmt}{" "}
|
||||
{viewingTxn.discCcy || viewingTxn.txnCcy}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground">结算渠道</span>
|
||||
<p className="font-medium">
|
||||
{viewingTxn.channelDetails?.[0]?.displayName ||
|
||||
"未关联渠道"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{viewingTxn.merchantName && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground">商户名称</span>
|
||||
<p className="font-medium">{viewingTxn.merchantName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewingTxn.description && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground">交易说明</span>
|
||||
<p className="text-muted-foreground">
|
||||
{viewingTxn.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewingTxn.memo && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground">备注信息</span>
|
||||
<p className="text-muted-foreground">{viewingTxn.memo}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setViewingTxn(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Dialog
|
||||
open={Boolean(deletingTxn)}
|
||||
onOpenChange={(open) => !open && setDeletingTxn(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除总账流水</DialogTitle>
|
||||
<DialogDescription>
|
||||
将删除「
|
||||
{deletingTxn?.merchantName ||
|
||||
deletingTxn?.cp ||
|
||||
deletingTxn?.description ||
|
||||
`金额 ${deletingTxn?.txnAmt} ${deletingTxn?.txnCcy}`}
|
||||
」的正式总账记录。该操作不可撤回,确认要删除吗?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeletingTxn(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting && (
|
||||
<Loader2Icon
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
/>
|
||||
)}
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user