"use client" import * as React from "react" import Link from "next/link" import { AlertCircleIcon, ArrowRightIcon, CheckCircle2Icon, FileUpIcon, LandmarkIcon, Table2Icon, WalletIcon, } from "lucide-react" import { importCcbaRowsAction, type CcbaParsedRow } from "@/lib/actions/import" import { type ChannelWithAccountNames } from "@/lib/actions/channel" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@/components/ui/empty" import { Field, FieldDescription, FieldGroup, FieldLabel, } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Spinner } from "@/components/ui/spinner" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" interface ImportViewProps { channels: ChannelWithAccountNames[] } interface ImportResult { success: boolean imported?: number skipped?: number unmatchedAccounts?: string[] error?: string } const CSV_HEADER = [ "trans_date", "post_date", "description", "ref_no", "amt", "ccy", "account", "fx_amt", "fx_ccy", "fx_rate", ] as const const UNMAPPED_PLACEHOLDER = "请选择目标渠道" const SKIP_ACCOUNT_LABEL = "暂不导入该账户" const SKIP_ACCOUNT_VALUE = "__skip__" function channelName(channel: ChannelWithAccountNames) { if (channel.channelType === "PAYMENT_CARD") return channel.issuerName || "支付卡" if (channel.channelType === "E_WALLET") return channel.platform || "电子钱包" return channel.channelType === "CASH" ? "现金渠道" : "转账渠道" } function channelOptionLabel(channel: ChannelWithAccountNames) { const base = channel.desc?.trim() || channel.issuerName?.trim() || channelName(channel) const digits = ( channel.cardNumberSuffix || channel.cardNumberFull || "" ).replace(/\D/g, "") const suffix = digits.length >= 4 ? digits.slice(-4) : "" return suffix ? `${base} •••• ${suffix}` : channelName(channel) } function matchChannelId(account: string, channels: ChannelWithAccountNames[]) { const digitsMatch = account.match(/\d{12,}/) if (!digitsMatch) return "" const digits = digitsMatch[0] const exact = channels.find( (channel) => (channel.cardNumberFull || "").replace(/\D/g, "") === digits ) if (exact) return exact.id const suffix = digits.slice(-4) const bySuffix = channels.find((channel) => { const channelSuffix = (channel.cardNumberSuffix || "").replace(/\D/g, "") return channelSuffix.length >= 4 && channelSuffix.slice(-4) === suffix }) return bySuffix?.id || "" } function nullableValue(value?: string): string | null { const trimmed = (value ?? "").trim() return trimmed === "" ? null : trimmed } function parseCsv(input: string): string[][] { let text = input if (text.charCodeAt(0) === 0xfeff) { text = text.slice(1) } const rows: string[][] = [] let row: string[] = [] let field = "" let inQuotes = false let index = 0 while (index < text.length) { const char = text[index] if (inQuotes) { if (char === '"') { if (text[index + 1] === '"') { field += '"' index += 2 continue } inQuotes = false index += 1 continue } field += char index += 1 continue } if (char === '"') { inQuotes = true index += 1 continue } if (char === ",") { row.push(field) field = "" index += 1 continue } if (char === "\r" || char === "\n") { row.push(field) rows.push(row) row = [] field = "" if (char === "\r" && text[index + 1] === "\n") { index += 2 } else { index += 1 } continue } field += char index += 1 } if (field !== "" || row.length > 0) { row.push(field) rows.push(row) } return rows.filter((cells) => cells.some((cell) => cell.trim() !== "")) } function parseCcbaCsv(text: string): { rows: CcbaParsedRow[] error: string | null } { const table = parseCsv(text) if (table.length === 0) { return { rows: [], error: "文件内容为空,请检查后重新导出。" } } const header = table[0].map((cell) => cell.trim()) const headerValid = header.length === CSV_HEADER.length && CSV_HEADER.every((name, index) => header[index] === name) if (!headerValid) { return { rows: [], error: "CSV 表头与中国建设银行(亚洲)信用卡月结单模板不一致,请使用银行原始导出的文件。", } } const rows: CcbaParsedRow[] = table .slice(1) .filter((cells) => cells.some((cell) => cell.trim() !== "")) .map((cells) => ({ transDate: (cells[0] ?? "").trim(), postDate: (cells[1] ?? "").trim(), description: (cells[2] ?? "").trim(), refNo: (cells[3] ?? "").trim(), amt: (cells[4] ?? "").trim(), ccy: (cells[5] ?? "").trim(), account: (cells[6] ?? "").trim(), fxAmt: nullableValue(cells[7]), fxCcy: nullableValue(cells[8]), fxRate: nullableValue(cells[9]), })) if (rows.length === 0) { return { rows: [], error: "未解析到任何数据行,请确认文件包含交易记录。" } } return { rows, error: null } } export function ImportView({ channels }: ImportViewProps) { const [activeTab, setActiveTab] = React.useState("upload") const [fileName, setFileName] = React.useState("") const [parsedRows, setParsedRows] = React.useState([]) const [selections, setSelections] = React.useState< Record >({}) const [fileError, setFileError] = React.useState(null) const [isImporting, setIsImporting] = React.useState(false) const [result, setResult] = React.useState(null) const fileInputRef = React.useRef(null) const channelMap = React.useMemo( () => new Map(channels.map((channel) => [channel.id, channel])), [channels] ) const uniqueAccounts = React.useMemo(() => { const set = new Set() for (const row of parsedRows) { if (row.account) set.add(row.account) } return Array.from(set) }, [parsedRows]) const mappedCount = uniqueAccounts.filter((account) => { const value = selections[account] return Boolean(value) && value !== SKIP_ACCOUNT_VALUE }).length const pendingCount = uniqueAccounts.filter( (account) => selections[account] == null ).length const hasUnmappedAccount = pendingCount > 0 const submitDisabled = parsedRows.length === 0 || hasUnmappedAccount || isImporting const importableCount = parsedRows.filter((row) => { const value = selections[row.account] return Boolean(value) && value !== SKIP_ACCOUNT_VALUE }).length const skipRowCount = parsedRows.filter( (row) => selections[row.account] === SKIP_ACCOUNT_VALUE ).length const stats = [ { label: "总行数", value: parsedRows.length }, { label: "识别账户数", value: uniqueAccounts.length }, { label: "已映射账户", value: mappedCount }, { label: "待映射账户", value: pendingCount }, ] const handleFileChange = async ( event: React.ChangeEvent ) => { const file = event.target.files?.[0] event.target.value = "" if (!file) return setFileError(null) setResult(null) try { const text = await file.text() const parsed = parseCcbaCsv(text) if (parsed.error) { setFileName(file.name) setParsedRows([]) setSelections({}) setFileError(parsed.error) return } const nextSelections: Record = {} for (const row of parsed.rows) { if (!row.account || row.account in nextSelections) continue nextSelections[row.account] = matchChannelId(row.account, channels) || null } setFileName(file.name) setParsedRows(parsed.rows) setSelections(nextSelections) } catch { setFileName(file.name) setParsedRows([]) setSelections({}) setFileError("文件读取失败,请重新选择文件。") } } const handleSelectionChange = (account: string, value: string | null) => { setSelections((prev) => ({ ...prev, [account]: value })) } const handleImport = async () => { if (submitDisabled) return setIsImporting(true) setResult(null) try { const accountChannelMap: Record = {} for (const account of uniqueAccounts) { const selected = selections[account] if (selected && selected !== SKIP_ACCOUNT_VALUE) { accountChannelMap[account] = selected } } const rowsToImport = parsedRows.filter((row) => { const selected = selections[row.account] return Boolean(selected) && selected !== SKIP_ACCOUNT_VALUE }) if (rowsToImport.length === 0) { setResult({ success: false, error: "没有可导入的记录,请至少为一个账户选择目标渠道。", }) return } const response = await importCcbaRowsAction( rowsToImport, accountChannelMap ) setResult(response) } catch (error) { setResult({ success: false, error: error instanceof Error ? error.message : "导入失败,请重试。", }) } finally { setIsImporting(false) } } return (

导入账单

上传中国建设银行(亚洲)信用卡月结单,完成账户映射后批量导入到待清洗流水。

{fileError && ( 无法解析该文件 {fileError} )} setActiveTab(String(value))} className="gap-4" > 上传账单 账户映射 数据预览 {parsedRows.length === 0 ? ( 尚未导入账单 目前仅支持中国建设银行(亚洲)信用卡月结单 CSV。PDF 转 CSV 步骤暂略,解析在浏览器本地完成,文件不会上传。 ) : ( 解析概览 已读取 {fileName},解析在浏览器本地完成。
{stats.map((stat) => (
{stat.label} {stat.value}
))}
重新选择 CSV 文件 重新选择会清除当前解析结果与账户映射。
)}
{parsedRows.length === 0 ? ( 尚未解析到账户 请先在「上传账单」步骤选择并解析中国建设银行(亚洲)信用卡月结单 CSV。 ) : channels.length === 0 ? ( 暂无可用支付渠道 账户映射需要至少一个已创建的支付渠道,请先创建后再继续。 ) : ( 账户映射 将月结单中的账户对应到已创建的支付渠道,未匹配到的可手动选择,或选择暂不导入。 {uniqueAccounts.map((account) => { const value = selections[account] ?? null const selectedChannel = value ? (channelMap.get(value) ?? null) : null const isSkipped = value === SKIP_ACCOUNT_VALUE const displayValue = value == null ? UNMAPPED_PLACEHOLDER : isSkipped ? SKIP_ACCOUNT_LABEL : selectedChannel ? channelOptionLabel(selectedChannel) : UNMAPPED_PLACEHOLDER return ( {account} {value == null && ( 待映射 )} {isSkipped && ( 暂不导入 )} ) })}

{pendingCount > 0 ? `还有 ${pendingCount} 个账户待映射,可手动选择或选择暂不导入。` : "全部账户已处理,可前往数据预览并导入。"}

)}
{parsedRows.length === 0 ? ( 暂无可预览的数据 上传并解析月结单后,可在此逐笔核对交易明细。 ) : ( <>
交易日期 描述 原始金额/币种 金额/币种 {parsedRows.map((row, index) => { const amtText = row.amt.trim() const negative = amtText.startsWith("-") || /^\(.*\)$/.test(amtText) return ( {row.transDate || "—"} {row.description || "—"} {row.fxAmt ? `${row.fxAmt}${row.fxCcy ? ` ${row.fxCcy}` : ""}` : "—"} {row.amt ? `${row.amt}${row.ccy ? ` ${row.ccy}` : ""}` : "—"} ) })}

{hasUnmappedAccount ? `还有 ${pendingCount} 个账户待映射,请先在「账户映射」处理或选择暂不导入。` : `共 ${parsedRows.length} 行,将导入 ${importableCount} 行,暂不导入 ${skipRowCount} 行。`}

{result && (
{result.success && ( 导入完成

成功导入 {result.imported ?? 0} 行,跳过重复{" "} {result.skipped ?? 0} 行。

)} {!result.success && result.error && ( 导入失败 {result.error} )} {result.unmatchedAccounts && result.unmatchedAccounts.length > 0 && ( 部分账户未能匹配渠道

以下账户未匹配到已创建的支付渠道,请先到支付渠道页创建对应渠道,或返回「账户映射」手动处理后重新导入:

{result.unmatchedAccounts.map((account) => ( {account} ))}
)}
)}
)}
) }