"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 = { 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 ( {label} ) } return {label} } function renderChannelLogoOrIcon( ch?: TransactionWithChannelDetails["channelDetails"][0] ) { if (!ch) { return } if (ch.channelType === "PAYMENT_CARD") { const brand = normalizeCardBrand(ch.cardBrand) const logoUrl = getCardBrandLogoUrl(ch.cardBrand) if (logoUrl && brand) { return ( {CARD_BRAND_LABELS[brand]} ) } return } if (ch.channelType === "E_WALLET") { return } if (ch.channelType === "CASH") { return } return ( ) } 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(initialTransactions) const [searchQuery, setSearchQuery] = React.useState("") const [dcFilter, setDcFilter] = React.useState<"ALL" | "DEBIT" | "CREDIT">( "ALL" ) // 查看分录详情弹窗 const [viewingTxn, setViewingTxn] = React.useState(null) // 删除确认弹窗 const [deletingTxn, setDeletingTxn] = React.useState(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() 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 (
{/* 页头控制与过滤检索区 */}

交易记录

已入账 {transactionsList.length} 笔

查看已清洗入账的正式总账流水与多币种对账

{/* 筛选控制器 */}
setSearchQuery(e.target.value)} placeholder="搜索商户、对手方、说明、备注或金额" className="pl-8" />
{hasFilters && ( )}
{/* 流水列表区 */} {groupedTransactions.length > 0 ? (
{groupedTransactions.map((group) => (
{/* 日期分组标题 */}

{group.dateLabel}

({group.items.length} 笔)
{/* 分组流水卡片容器 */}
{/* PC 列表表头 */}
商户与描述 结算渠道 发生金额 (原币) 入账金额 (折算) 场景
{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 = ( } /> } > 更多操作 setViewingTxn(txn)} > 查看明细 setDeletingTxn(txn)} > 删除流水 ) return (
{/* 移动端顶栏 (商户 + 更多操作在最右侧) / PC 端商户描述 */}
{renderChannelLogoOrIcon(ch)}
{txn.merchantName || txn.cp || txn.description || "未命名交易"}
{formatTime(txn.txnDate)} {txn.description && txn.merchantName && ( <> • {txn.description} )}
{/* 移动端自然流右侧菜单 */}
{actionMenu}
{/* 结算渠道 */}
{renderChannelLogoOrIcon(ch)}
{ch?.displayName || "未关联渠道"}
{/* 发生金额 (原币) */}
发生金额
{txn.dcFlag === "CREDIT" ? "+" : "-"} {txn.txnAmt} {txn.txnCcy}
{/* 入账金额 (折算) */}
入账折算
{isMultiCurrency ? ( <>
{txn.dcFlag === "CREDIT" ? "+" : "-"} {txn.postingAmt} {txn.postingCcy}
{rateDisplay && ( {rateDisplay} )} ) : ( 等额入账 )}
{/* 场景徽标 */}
场景类型 {getSceneBadge(txn.txnScene, txn.dcFlag)}
{/* PC 端操作按钮 */}
{actionMenu}
) })}
))}
) : ( /* 空状态展示 */

{hasFilters ? "未找到符合条件的流水" : "暂无已入账的交易流水"}

{hasFilters ? "调整关键词或重置筛选条件后再试。" : "可在「流水记账」中快速录入并确认清洗入账,入账后的总账流水将完整汇总于此。"}

{hasFilters ? ( ) : ( )}
)} {/* 分录明细弹窗 */} !open && setViewingTxn(null)} > 交易分录明细 查看该笔总账交易的原始要素、渠道及折算入账数据 {viewingTxn && (
流水标识

{viewingTxn.id}

版本号

v{viewingTxn.version}

交易时间

{formatFullDateTime(viewingTxn.txnDate)}

借贷方向

{viewingTxn.dcFlag === "CREDIT" ? "收入 (贷方)" : "支出 (借方)"}

原始发生额

{viewingTxn.txnAmt} {viewingTxn.txnCcy}

最终入账额

{viewingTxn.postingAmt || viewingTxn.txnAmt}{" "} {viewingTxn.postingCcy || viewingTxn.txnCcy}

{(viewingTxn.commAmt || viewingTxn.surchargeAmt || viewingTxn.discAmt) && (
费用与优惠细目 {viewingTxn.commAmt && (
手续费 {viewingTxn.commAmt}{" "} {viewingTxn.commCcy || viewingTxn.txnCcy}
)} {viewingTxn.surchargeAmt && (
附加费 {viewingTxn.surchargeAmt}{" "} {viewingTxn.surchargeCcy || viewingTxn.txnCcy}
)} {viewingTxn.discAmt && (
优惠折扣 -{viewingTxn.discAmt}{" "} {viewingTxn.discCcy || viewingTxn.txnCcy}
)}
)}
结算渠道

{viewingTxn.channelDetails?.[0]?.displayName || "未关联渠道"}

{viewingTxn.merchantName && (
商户名称

{viewingTxn.merchantName}

)} {viewingTxn.description && (
交易说明

{viewingTxn.description}

)} {viewingTxn.memo && (
备注信息

{viewingTxn.memo}

)}
)}
{/* 删除确认弹窗 */} !open && setDeletingTxn(null)} > 确认删除总账流水 将删除「 {deletingTxn?.merchantName || deletingTxn?.cp || deletingTxn?.description || `金额 ${deletingTxn?.txnAmt} ${deletingTxn?.txnCcy}`} 」的正式总账记录。该操作不可撤回,确认要删除吗?
) }