Files
fluxent-web/app/bookkeeping/bookkeeping-view.tsx
T
SerinaNya c33857ffac 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
2026-09-06 23:23:29 +08:00

1123 lines
44 KiB
TypeScript

"use client"
import * as React from "react"
import Image from "next/image"
import { useRouter } from "next/navigation"
import {
AlertCircleIcon,
ArrowDownLeftIcon,
ArrowRightLeftIcon,
ArrowUpRightIcon,
BanknoteIcon,
CreditCardIcon,
InboxIcon,
Loader2Icon,
PenLineIcon,
PlusCircleIcon,
RotateCcwIcon,
SparklesIcon,
Trash2Icon,
WalletCardsIcon,
} from "lucide-react"
import {
createDirtyTransactionAction,
updateDirtyTransactionAction,
deleteDirtyTransactionAction,
cleanseTransactionsAction,
type DirtyTransactionInput,
} from "@/lib/actions/bookkeeping"
import type { ChannelWithAccountNames } from "@/lib/actions/channel"
import type { TransactionDirty } from "@/lib/db/schema"
import {
CARD_BRAND_LABELS,
getCardBrandLogoUrl,
normalizeCardBrand,
} from "@/lib/payment/card-brand"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"
import { Textarea } from "@/components/ui/textarea"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface BookkeepingViewProps {
initialDirtyTransactions: TransactionDirty[]
initialChannels: ChannelWithAccountNames[]
}
const TXN_SCENES = [
{ value: "PAYMENT", label: "消费支出" },
{ value: "MISC_IN", label: "日常收入" },
{ value: "TRANSFER", label: "转账" },
{ value: "ATM", label: "取现" },
] as const
function getChannelIdentifier(channel: ChannelWithAccountNames): string {
if (channel.channelType === "PAYMENT_CARD") {
let suffix = channel.cardNumberSuffix?.trim()
if (
!suffix &&
channel.cardNumberFull &&
channel.cardNumberFull.length >= 4
) {
suffix = channel.cardNumberFull.slice(-4)
}
return suffix ? `•••• ${suffix}` : ""
}
if (channel.channelType === "E_WALLET") {
return channel.platformAccountId?.trim() || ""
}
return channel.subChannel?.trim() || ""
}
function channelName(channel: ChannelWithAccountNames) {
if (channel.channelType === "PAYMENT_CARD") {
const brand = normalizeCardBrand(channel.cardBrand)
const brandName = brand
? ` ${CARD_BRAND_LABELS[brand]}`
: channel.cardBrand
? ` ${channel.cardBrand}`
: ""
return `${channel.issuerName || "支付卡"}${brandName}`
}
if (channel.channelType === "E_WALLET") return channel.platform || "电子钱包"
return channel.channelType === "CASH" ? "现金渠道" : "转账渠道"
}
function channelPrimaryTitle(channel: ChannelWithAccountNames) {
return (
channel.desc ||
channel.platformAccountId ||
channel.subChannel ||
channelName(channel)
)
}
function channelDisplayLabel(channel: ChannelWithAccountNames) {
const primary = channelPrimaryTitle(channel)
const secondary = channelName(channel)
const identifier = getChannelIdentifier(channel)
// 拼接回显标签,确保卡号末四位或账号标识清晰可见
let label = primary
if (secondary && secondary !== primary) {
label = `${primary} · ${secondary}`
}
if (identifier && !label.includes(identifier)) {
label = `${label} ${identifier}`
}
return label
}
function formatChannelName(channel: ChannelWithAccountNames) {
if (channel.channelType === "PAYMENT_CARD") {
const brand = channel.cardBrand ? ` ${channel.cardBrand}` : ""
const suffix = channel.cardNumberSuffix
? ` (${channel.cardNumberSuffix})`
: ""
return `${channel.issuerName || "支付卡"}${brand}${suffix}`
}
if (channel.channelType === "E_WALLET") {
return channel.platform
? `${channel.platform}${channel.platformAccountId ? ` - ${channel.platformAccountId}` : ""}`
: "电子钱包"
}
if (channel.channelType === "CASH") {
return channel.desc || "现金渠道"
}
return channel.desc || "转账渠道"
}
function renderChannelIcon(channel: ChannelWithAccountNames) {
if (channel.channelType === "PAYMENT_CARD") {
const brand = normalizeCardBrand(channel.cardBrand)
const logoUrl = getCardBrandLogoUrl(channel.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 (channel.channelType === "E_WALLET") {
return <WalletCardsIcon className="size-4 shrink-0 text-muted-foreground" />
}
if (channel.channelType === "CASH") {
return <BanknoteIcon className="size-4 shrink-0 text-muted-foreground" />
}
return (
<ArrowRightLeftIcon className="size-4 shrink-0 text-muted-foreground" />
)
}
function getSceneBadge(scene: string) {
switch (scene) {
case "PAYMENT":
return <Badge variant="secondary">消费支出</Badge>
case "MISC_IN":
return (
<Badge
variant="outline"
className="border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
>
日常收入
</Badge>
)
case "TRANSFER":
return <Badge variant="outline">转账</Badge>
case "ATM":
return <Badge variant="outline">取现</Badge>
default:
return <Badge variant="secondary">{scene}</Badge>
}
}
function formatDateForInput(dateVal?: Date | string | null): string {
if (!dateVal) {
const now = new Date()
const offset = now.getTimezoneOffset() * 60000
const localISODate = new Date(now.getTime() - offset).toISOString()
return localISODate.slice(0, 16)
}
const d = new Date(dateVal)
if (isNaN(d.getTime())) {
const now = new Date()
const offset = now.getTimezoneOffset() * 60000
return new Date(now.getTime() - offset).toISOString().slice(0, 16)
}
const offset = d.getTimezoneOffset() * 60000
const localISODate = new Date(d.getTime() - offset).toISOString()
return localISODate.slice(0, 16)
}
function formatDisplayDate(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")
return `${y}-${m}-${day} ${hh}:${mm}`
}
export function BookkeepingView({
initialDirtyTransactions,
initialChannels,
}: BookkeepingViewProps) {
const router = useRouter()
const [dirtyList, setDirtyList] = React.useState<TransactionDirty[]>(
initialDirtyTransactions
)
const [channels] = React.useState<ChannelWithAccountNames[]>(initialChannels)
// 移动端 Tab: "form" | "list"
const [mobileTab, setMobileTab] = React.useState<"form" | "list">("form")
// 编辑态,选中的待清洗记录
const [selectedTxnId, setSelectedTxnId] = React.useState<string | null>(null)
// 表单状态
const [txnScene, setTxnScene] = React.useState<string>("PAYMENT")
const [dcFlag, setDcFlag] = React.useState<"DEBIT" | "CREDIT">("DEBIT")
const [txnAmt, setTxnAmt] = React.useState<string>("")
const [txnCcy, setTxnCcy] = React.useState<string>("CNY")
const [channelId, setChannelId] = React.useState<string>(
initialChannels[0]?.id || ""
)
const [txnDate, setTxnDate] = React.useState<string>(() =>
formatDateForInput()
)
const [merchantName, setMerchantName] = React.useState<string>("")
const [description, setDescription] = React.useState<string>("")
const [memo, setMemo] = React.useState<string>("")
// 折算与入账扩展字段
const [postingAmt, setPostingAmt] = React.useState<string>("")
const [postingCcy, setPostingCcy] = React.useState<string>("")
const [commAmt, setCommAmt] = React.useState<string>("")
const [commCcy, setCommCcy] = React.useState<string>("")
// 交互与执行状态
const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false)
const [isCleansing, setIsCleansing] = React.useState<boolean>(false)
const [formError, setFormError] = React.useState<string | null>(null)
// 清洗阻断错误清单与全局提示
const [cleanseErrors, setCleanseErrors] = React.useState<
{ id: string; name: string; error: string }[]
>([])
const [cleanseGlobalError, setCleanseGlobalError] = React.useState<
string | null
>(null)
// 单条删除执行状态
const [deletingId, setDeletingId] = React.useState<string | null>(null)
// 渠道映射 Map 方便快速索引
const channelMap = React.useMemo(() => {
return new Map(channels.map((c) => [c.id, c]))
}, [channels])
// 阻断错误 ID 映射 Map
const errorMap = React.useMemo(() => {
const map = new Map<string, string>()
cleanseErrors.forEach((e) => {
map.set(e.id, e.error)
})
return map
}, [cleanseErrors])
// 重置表单为默认新建态
const resetForm = React.useCallback(() => {
setSelectedTxnId(null)
setTxnScene("PAYMENT")
setDcFlag("DEBIT")
setTxnAmt("")
setTxnCcy("CNY")
setChannelId(channels[0]?.id || "")
setTxnDate(formatDateForInput())
setMerchantName("")
setDescription("")
setMemo("")
setPostingAmt("")
setPostingCcy("")
setCommAmt("")
setCommCcy("")
setFormError(null)
}, [channels])
// 点击待清洗记录载入编辑
const handleSelectTxn = React.useCallback((item: TransactionDirty) => {
setSelectedTxnId(item.id)
setTxnScene(item.txnScene || "PAYMENT")
setDcFlag(item.dcFlag === "CREDIT" ? "CREDIT" : "DEBIT")
setTxnAmt(item.txnAmt || "")
setTxnCcy(item.txnCcy || "CNY")
const ch = Array.isArray(item.refChannels) && item.refChannels[0]
setChannelId(ch || "")
setTxnDate(formatDateForInput(item.txnDate))
setMerchantName(item.merchantName || "")
setDescription(item.description || "")
setMemo(item.memo || "")
setPostingAmt(item.postingAmt || "")
setPostingCcy(item.postingCcy || "")
setCommAmt(item.commAmt || "")
setCommCcy(item.commCcy || "")
setFormError(null)
// 移动端切换到表单视口
setMobileTab("form")
}, [])
// 场景与借贷方向联动快捷设值
const handleSceneChange = (scene: string) => {
setTxnScene(scene)
if (scene === "MISC_IN") {
setDcFlag("CREDIT")
} else if (scene === "PAYMENT" || scene === "ATM") {
setDcFlag("DEBIT")
}
}
// 提交暂存或修改
const handleSubmitForm = async (e: React.FormEvent) => {
e.preventDefault()
setFormError(null)
const amtNum = parseFloat(txnAmt)
if (!txnAmt || isNaN(amtNum) || amtNum <= 0) {
setFormError("请输入有效的交易金额(大于0的数值)")
return
}
if (!txnCcy.trim()) {
setFormError("请输入交易币种(如 CNY, USD, HKD 等)")
return
}
if (!channelId) {
setFormError("请选择渠道")
return
}
setIsSubmitting(true)
try {
const payload: DirtyTransactionInput = {
txnDate: new Date(txnDate).toISOString(),
txnAmt: txnAmt.trim(),
txnCcy: txnCcy.trim().toUpperCase(),
postingAmt: postingAmt.trim() ? postingAmt.trim() : null,
postingCcy: postingCcy.trim() ? postingCcy.trim().toUpperCase() : null,
commAmt: commAmt.trim() ? commAmt.trim() : null,
commCcy: commCcy.trim() ? commCcy.trim().toUpperCase() : null,
dcFlag,
refChannels: [channelId],
txnScene,
merchantName: merchantName.trim() || null,
description: description.trim() || null,
memo: memo.trim() || null,
}
if (selectedTxnId) {
// 编辑模式
const res = await updateDirtyTransactionAction(selectedTxnId, payload)
if (!res.success || !res.data) {
setFormError(res.error || "更新暂存交易失败")
return
}
// 更新本地列表
setDirtyList((prev) =>
prev.map((item) => (item.id === selectedTxnId ? res.data! : item))
)
// 清理当前项可能存在的阻断错误提示
setCleanseErrors((prev) =>
prev.filter((err) => err.id !== selectedTxnId)
)
resetForm()
router.refresh()
} else {
// 新建模式
const res = await createDirtyTransactionAction(payload)
if (!res.success || !res.data) {
setFormError(res.error || "暂存交易失败")
return
}
// 插入到列表首位
setDirtyList((prev) => [res.data!, ...prev])
resetForm()
router.refresh()
}
} catch (err) {
setFormError(err instanceof Error ? err.message : "提交异常,请重试")
} finally {
setIsSubmitting(false)
}
}
// 删除单笔流水(无需二次确认,直接删除)
const handleDeleteTxn = async (id: string) => {
if (deletingId) return
setDeletingId(id)
try {
const res = await deleteDirtyTransactionAction(id)
if (res.success) {
setDirtyList((prev) => prev.filter((item) => item.id !== id))
setCleanseErrors((prev) => prev.filter((err) => err.id !== id))
if (selectedTxnId === id) {
resetForm()
}
router.refresh()
} else {
setCleanseGlobalError(res.error || "删除暂存流水失败")
}
} finally {
setDeletingId(null)
}
}
// 执行全量原子阻断清洗入账
const handleCleanseAll = async () => {
if (dirtyList.length === 0) return
setIsCleansing(true)
setCleanseGlobalError(null)
setCleanseErrors([])
try {
const res = await cleanseTransactionsAction()
if (res.success) {
setDirtyList([])
resetForm()
router.refresh()
} else {
setCleanseGlobalError(
res.error || "存在未平衡或不合规的交易记录,全量阻断合并!"
)
if (res.errors && res.errors.length > 0) {
setCleanseErrors(res.errors)
}
}
} catch (err) {
setCleanseGlobalError(
err instanceof Error ? err.message : "清洗请求异常,合并已阻断"
)
} finally {
setIsCleansing(false)
}
}
return (
<TooltipProvider>
<main className="flex min-w-0 flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">
{/* 全局阻断错误报告 (标准 Alert variant="destructive") */}
{cleanseGlobalError && (
<Alert
variant="destructive"
className="animate-in duration-200 fade-in-50"
>
<AlertCircleIcon />
<AlertTitle>
清洗阻断报告:数据未满足入账标准,原子合并已完全中止
</AlertTitle>
<AlertDescription>
<p className="font-medium">{cleanseGlobalError}</p>
{cleanseErrors.length > 0 && (
<div className="mt-3 flex flex-col gap-1.5 border-t border-destructive/20 pt-2 text-xs">
<span className="font-semibold">
阻断交易明细(已在列表中标红高亮,请修正):
</span>
<ul className="list-inside list-disc space-y-1">
{cleanseErrors.map((err) => (
<li key={err.id}>
<span className="font-medium">「{err.name}」:</span>{" "}
{err.error}
</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
)}
{/* 页头控制区 */}
<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"
>
待清洗 {dirtyList.length} 笔
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
录入与核对暂存流水,确认无误后完成清洗入账
</p>
</div>
<div className="flex items-center gap-3">
<Button
onClick={handleCleanseAll}
disabled={dirtyList.length === 0 || isCleansing}
size="default"
className="w-full shadow-sm sm:w-auto"
>
{isCleansing ? (
<Loader2Icon
data-icon="inline-start"
className="animate-spin"
/>
) : (
<SparklesIcon data-icon="inline-start" />
)}
完成清洗 ({dirtyList.length})
</Button>
</div>
</div>
{/* 移动端视图切换分段器 (md: 及以下) */}
<div className="flex gap-2 rounded-lg bg-muted/60 p-1 lg:hidden">
<Button
variant={mobileTab === "form" ? "default" : "ghost"}
size="sm"
className="flex-1 text-xs"
onClick={() => setMobileTab("form")}
>
{selectedTxnId ? "编辑暂存流水" : "录入表单"}
</Button>
<Button
variant={mobileTab === "list" ? "default" : "ghost"}
size="sm"
className="flex-1 text-xs"
onClick={() => setMobileTab("list")}
>
待清洗列表 ({dirtyList.length})
{cleanseErrors.length > 0 && (
<span className="ml-1.5 size-2 rounded-full bg-destructive" />
)}
</Button>
</div>
{/* PC 端左右分栏工作台 (lg: 5:7 黄金比例双栏工作台) */}
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-12">
{/* 左栏:录入 / 联动编辑工作区 (5/12) */}
<section
className={`min-w-0 lg:col-span-5 ${
mobileTab === "form" ? "block" : "hidden lg:block"
}`}
>
<Card className="border-border/70 shadow-xs">
<div className="flex items-center justify-between border-b px-5 py-3.5">
<div className="flex items-center gap-2">
{selectedTxnId ? (
<>
<PenLineIcon className="size-4 text-primary" />
<h2 className="text-sm font-semibold">编辑暂存流水</h2>
</>
) : (
<>
<PlusCircleIcon className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">新建暂存流水</h2>
</>
)}
</div>
{selectedTxnId && (
<Button
variant="ghost"
size="sm"
onClick={resetForm}
className="h-7 text-xs text-muted-foreground hover:text-foreground"
>
<RotateCcwIcon data-icon="inline-start" />
返回新建
</Button>
)}
</div>
<CardContent className="p-5">
<form onSubmit={handleSubmitForm}>
<FieldGroup>
{/* 表单内行内错误提示 */}
{formError && (
<Alert variant="destructive" className="py-2 text-xs">
<AlertCircleIcon className="size-3.5" />
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
{/* 1. 交易场景快捷选择 */}
<Field>
<FieldLabel>交易场景</FieldLabel>
<div className="grid grid-cols-4 gap-1.5 rounded-lg border bg-muted/20 p-1">
{TXN_SCENES.map((scene) => (
<button
key={scene.value}
type="button"
onClick={() => handleSceneChange(scene.value)}
className={`rounded-md py-1.5 text-xs font-medium transition-all ${
txnScene === scene.value
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground"
}`}
>
{scene.label}
</button>
))}
</div>
</Field>
{/* 2. 借贷方向选择 */}
<Field>
<FieldLabel>资金流向</FieldLabel>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setDcFlag("DEBIT")}
className={`flex items-center justify-center gap-1.5 rounded-md border p-2 text-xs font-medium transition-colors ${
dcFlag === "DEBIT"
? "border-destructive/40 bg-destructive/10 font-semibold text-destructive"
: "border-input bg-card text-muted-foreground hover:bg-muted/40"
}`}
>
<ArrowUpRightIcon className="size-3.5" />
支出
</button>
<button
type="button"
onClick={() => setDcFlag("CREDIT")}
className={`flex items-center justify-center gap-1.5 rounded-md border p-2 text-xs font-medium transition-colors ${
dcFlag === "CREDIT"
? "border-emerald-500/40 bg-emerald-500/10 font-semibold text-emerald-600 dark:text-emerald-400"
: "border-input bg-card text-muted-foreground hover:bg-muted/40"
}`}
>
<ArrowDownLeftIcon className="size-3.5" />
收入
</button>
</div>
</Field>
{/* 3. 发生金额与币种 */}
<div className="grid grid-cols-3 gap-3">
<Field className="col-span-2">
<FieldLabel>发生金额</FieldLabel>
<Input
type="number"
step="0.01"
placeholder="0.00"
value={txnAmt}
onChange={(e) => setTxnAmt(e.target.value)}
className="font-mono text-lg font-semibold tracking-tight"
required
/>
</Field>
<Field>
<FieldLabel>交易币种</FieldLabel>
<Input
type="text"
placeholder="CNY"
value={txnCcy}
onChange={(e) =>
setTxnCcy(e.target.value.toUpperCase())
}
className="font-mono uppercase"
maxLength={6}
required
/>
</Field>
</div>
{/* 4. 渠道 */}
<Field>
<FieldLabel>渠道</FieldLabel>
{channels.length === 0 ? (
<div className="rounded-md border border-dashed p-2.5 text-center text-xs text-muted-foreground">
暂无启用渠道,请先在渠道管理中新增
</div>
) : (
<Combobox
items={channels}
value={
channels.find((c) => c.id === channelId) || null
}
onValueChange={(
val: ChannelWithAccountNames | null
) => {
setChannelId(val?.id || "")
}}
itemToStringValue={(ch: ChannelWithAccountNames) =>
ch
? `${channelPrimaryTitle(ch)} ${channelName(ch)} ${getChannelIdentifier(ch)} ${ch.cardNumberSuffix || ""}`
: ""
}
itemToStringLabel={(ch: ChannelWithAccountNames) =>
ch ? channelDisplayLabel(ch) : ""
}
autoHighlight
>
<ComboboxInput
placeholder="请选择或搜索渠道"
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>没有匹配的渠道</ComboboxEmpty>
<ComboboxList>
{(ch: ChannelWithAccountNames) => {
const identifier = getChannelIdentifier(ch)
return (
<ComboboxItem key={ch.id} value={ch}>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 py-1">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
{renderChannelIcon(ch)}
</span>
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium text-foreground">
{ch.desc ||
ch.platformAccountId ||
ch.subChannel ||
channelName(ch)}
</span>
<span className="truncate text-[11px] text-muted-foreground">
{channelName(ch)}
</span>
</div>
</div>
{identifier && (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
</span>
)}
</div>
</ComboboxItem>
)
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
)}
</Field>
{/* 5. 交易时间 */}
<Field>
<FieldLabel>交易时间</FieldLabel>
<Input
type="datetime-local"
value={txnDate}
onChange={(e) => setTxnDate(e.target.value)}
className="font-mono text-xs"
required
/>
</Field>
{/* 6. 商户与描述 */}
<Field>
<FieldLabel>商户名称</FieldLabel>
<Input
type="text"
placeholder="如:Apple Store、星巴克、工资发放"
value={merchantName}
onChange={(e) => setMerchantName(e.target.value)}
/>
</Field>
<Field>
<FieldLabel>消费描述与说明</FieldLabel>
<Input
type="text"
placeholder="选填,如:数码配件、日常聚餐"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</Field>
{/* 7. 折算与入账信息 (可选折叠面板概念) */}
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-muted-foreground">
折算与入账 (跨币种或手续费选填)
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel className="text-xs">入账金额</FieldLabel>
<Input
type="number"
step="0.01"
placeholder="留空则等额入账"
value={postingAmt}
onChange={(e) => setPostingAmt(e.target.value)}
className="font-mono text-xs"
/>
</Field>
<Field>
<FieldLabel className="text-xs">入账币种</FieldLabel>
<Input
type="text"
placeholder="同原币"
value={postingCcy}
onChange={(e) =>
setPostingCcy(e.target.value.toUpperCase())
}
className="font-mono text-xs uppercase"
maxLength={6}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel className="text-xs">
手续费金额
</FieldLabel>
<Input
type="number"
step="0.01"
placeholder="0.00"
value={commAmt}
onChange={(e) => setCommAmt(e.target.value)}
className="font-mono text-xs"
/>
</Field>
<Field>
<FieldLabel className="text-xs">
手续费币种
</FieldLabel>
<Input
type="text"
placeholder={txnCcy}
value={commCcy}
onChange={(e) =>
setCommCcy(e.target.value.toUpperCase())
}
className="font-mono text-xs uppercase"
maxLength={6}
/>
</Field>
</div>
</div>
{/* 8. 备注 */}
<Field>
<FieldLabel>内部备注</FieldLabel>
<Textarea
placeholder="选填,辅助核账记录"
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={2}
className="resize-none text-xs"
/>
</Field>
{/* 提交动作栏 */}
<div className="flex items-center gap-2 pt-2">
{selectedTxnId ? (
<>
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting && (
<Loader2Icon
data-icon="inline-start"
className="animate-spin"
/>
)}
保存修改
</Button>
<Button
type="button"
variant="outline"
onClick={resetForm}
disabled={isSubmitting}
>
取消编辑
</Button>
</>
) : (
<Button
type="submit"
disabled={isSubmitting}
className="w-full"
>
{isSubmitting && (
<Loader2Icon
data-icon="inline-start"
className="animate-spin"
/>
)}
暂存到列表
</Button>
)}
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
</section>
{/* 右栏:待清洗流水密集列表 (7/12) */}
<section
className={`min-w-0 lg:col-span-7 ${
mobileTab === "list" ? "block" : "hidden lg:block"
}`}
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold">待清洗流水列表</h2>
<span className="text-xs text-muted-foreground">
共 {dirtyList.length} 笔待处理
</span>
</div>
{dirtyList.length > 0 && (
<span className="text-[11px] text-muted-foreground">
点击任意条目即可在左栏编辑
</span>
)}
</div>
{dirtyList.length > 0 ? (
<div className="overflow-hidden rounded-lg border border-border/70 bg-card shadow-xs">
{/* PC 列表表头 */}
<div className="hidden grid-cols-[1fr_auto_80px_40px] items-center gap-3 border-b bg-muted/30 px-4 py-2.5 text-[11px] font-medium text-muted-foreground md:grid">
<span>交易要素 / 渠道</span>
<span className="text-right">发生金额</span>
<span className="text-center">场景</span>
<span />
</div>
<div className="divide-y divide-border/60">
{dirtyList.map((item) => {
const isSelected = selectedTxnId === item.id
const hasError = errorMap.has(item.id)
const errorMsg = errorMap.get(item.id)
const primaryChannelId =
Array.isArray(item.refChannels) && item.refChannels[0]
const ch = primaryChannelId
? channelMap.get(primaryChannelId)
: null
return (
<div
key={item.id}
onClick={() => handleSelectTxn(item)}
className={`group relative flex cursor-pointer flex-col gap-2 p-3 transition-colors md:grid md:grid-cols-[1fr_auto_80px_40px] md:items-center md:gap-3 md:px-4 md:py-3 ${
isSelected
? "border-l-4 border-l-primary bg-primary/5"
: "hover:bg-muted/40"
} ${
hasError
? "border-destructive/40 bg-destructive/5"
: ""
}`}
>
{/* 交易主体信息 */}
<div className="flex min-w-0 items-start gap-2.5">
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
{ch ? (
renderChannelIcon(ch)
) : (
<CreditCardIcon className="size-3.5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{item.merchantName ||
item.description ||
"未命名交易"}
</span>
{hasError && (
<Badge
variant="destructive"
className="h-4 px-1.5 text-[10px]"
>
待修正
</Badge>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<span className="font-mono text-[11px]">
{formatDisplayDate(item.txnDate)}
</span>
<span>•</span>
<span className="max-w-[140px] truncate">
{ch
? formatChannelName(ch)
: "未关联有效渠道"}
</span>
{item.description && item.merchantName && (
<>
<span>•</span>
<span className="max-w-[140px] truncate">
{item.description}
</span>
</>
)}
</div>
{/* 行内错误提示 */}
{hasError && (
<p className="mt-1 text-[11px] text-destructive">
{errorMsg}
</p>
)}
</div>
</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-bold tracking-tight ${
item.dcFlag === "CREDIT"
? "text-emerald-600 dark:text-emerald-400"
: "text-foreground"
}`}
>
{item.dcFlag === "CREDIT" ? "+" : "-"}
{item.txnAmt}
</span>
<span className="font-mono text-[11px] text-muted-foreground uppercase">
{item.txnCcy}
</span>
</div>
</div>
{/* 场景徽标 */}
<div className="flex items-center justify-between md:justify-center">
<span className="text-xs text-muted-foreground md:hidden">
场景
</span>
{getSceneBadge(item.txnScene)}
</div>
{/* 操作:删除 */}
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label="删除此笔流水"
className="text-muted-foreground opacity-60 hover:text-destructive hover:opacity-100"
disabled={deletingId === item.id}
onClick={() => handleDeleteTxn(item.id)}
/>
}
>
{deletingId === item.id ? (
<Loader2Icon className="animate-spin" />
) : (
<Trash2Icon />
)}
</TooltipTrigger>
<TooltipContent>删除暂存流水</TooltipContent>
</Tooltip>
</div>
</div>
)
})}
</div>
</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">
暂无待清洗的暂存流水
</h3>
<p className="mt-1.5 max-w-sm text-xs text-muted-foreground">
当前没有待清洗的暂存流水,可通过左侧表单开始录入。全部暂存数据校验合格后,可一键完成清洗入账。
</p>
</div>
)}
</div>
</section>
</div>
</main>
</TooltipProvider>
)
}