Files
fluxent-web/app/transactions/transactions-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

777 lines
29 KiB
TypeScript

"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>
)
}