"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 ( {CARD_BRAND_LABELS[brand]} ) } return } if (channel.channelType === "E_WALLET") { return } if (channel.channelType === "CASH") { return } return ( ) } function getSceneBadge(scene: string) { switch (scene) { case "PAYMENT": return 消费支出 case "MISC_IN": return ( 日常收入 ) case "TRANSFER": return 转账 case "ATM": return 取现 default: return {scene} } } 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( initialDirtyTransactions ) const [channels] = React.useState(initialChannels) // 移动端 Tab: "form" | "list" const [mobileTab, setMobileTab] = React.useState<"form" | "list">("form") // 编辑态,选中的待清洗记录 const [selectedTxnId, setSelectedTxnId] = React.useState(null) // 表单状态 const [txnScene, setTxnScene] = React.useState("PAYMENT") const [dcFlag, setDcFlag] = React.useState<"DEBIT" | "CREDIT">("DEBIT") const [txnAmt, setTxnAmt] = React.useState("") const [txnCcy, setTxnCcy] = React.useState("CNY") const [channelId, setChannelId] = React.useState( initialChannels[0]?.id || "" ) const [txnDate, setTxnDate] = React.useState(() => formatDateForInput() ) const [merchantName, setMerchantName] = React.useState("") const [description, setDescription] = React.useState("") const [memo, setMemo] = React.useState("") // 折算与入账扩展字段 const [postingAmt, setPostingAmt] = React.useState("") const [postingCcy, setPostingCcy] = React.useState("") const [commAmt, setCommAmt] = React.useState("") const [commCcy, setCommCcy] = React.useState("") // 交互与执行状态 const [isSubmitting, setIsSubmitting] = React.useState(false) const [isCleansing, setIsCleansing] = React.useState(false) const [formError, setFormError] = React.useState(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(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() 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 (
{/* 全局阻断错误报告 (标准 Alert variant="destructive") */} {cleanseGlobalError && ( 清洗阻断报告:数据未满足入账标准,原子合并已完全中止

{cleanseGlobalError}

{cleanseErrors.length > 0 && (
阻断交易明细(已在列表中标红高亮,请修正):
    {cleanseErrors.map((err) => (
  • 「{err.name}」:{" "} {err.error}
  • ))}
)}
)} {/* 页头控制区 */}

流水记账

待清洗 {dirtyList.length} 笔

录入与核对暂存流水,确认无误后完成清洗入账

{/* 移动端视图切换分段器 (md: 及以下) */}
{/* PC 端左右分栏工作台 (lg: 5:7 黄金比例双栏工作台) */}
{/* 左栏:录入 / 联动编辑工作区 (5/12) */}
{selectedTxnId ? ( <>

编辑暂存流水

) : ( <>

新建暂存流水

)}
{selectedTxnId && ( )}
{/* 表单内行内错误提示 */} {formError && ( {formError} )} {/* 1. 交易场景快捷选择 */} 交易场景
{TXN_SCENES.map((scene) => ( ))}
{/* 2. 借贷方向选择 */} 资金流向
{/* 3. 发生金额与币种 */}
发生金额 setTxnAmt(e.target.value)} className="font-mono text-lg font-semibold tracking-tight" required /> 交易币种 setTxnCcy(e.target.value.toUpperCase()) } className="font-mono uppercase" maxLength={6} required />
{/* 4. 渠道 */} 渠道 {channels.length === 0 ? (
暂无启用渠道,请先在渠道管理中新增
) : ( 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 > 没有匹配的渠道 {(ch: ChannelWithAccountNames) => { const identifier = getChannelIdentifier(ch) return (
{renderChannelIcon(ch)}
{ch.desc || ch.platformAccountId || ch.subChannel || channelName(ch)} {channelName(ch)}
{identifier && ( {identifier} )}
) }}
)}
{/* 5. 交易时间 */} 交易时间 setTxnDate(e.target.value)} className="font-mono text-xs" required /> {/* 6. 商户与描述 */} 商户名称 setMerchantName(e.target.value)} /> 消费描述与说明 setDescription(e.target.value)} /> {/* 7. 折算与入账信息 (可选折叠面板概念) */}
折算与入账 (跨币种或手续费选填)
入账金额 setPostingAmt(e.target.value)} className="font-mono text-xs" /> 入账币种 setPostingCcy(e.target.value.toUpperCase()) } className="font-mono text-xs uppercase" maxLength={6} />
手续费金额 setCommAmt(e.target.value)} className="font-mono text-xs" /> 手续费币种 setCommCcy(e.target.value.toUpperCase()) } className="font-mono text-xs uppercase" maxLength={6} />
{/* 8. 备注 */} 内部备注