feat(import): add CCBA statement CSV workflow

This commit is contained in:
2026-09-28 14:27:53 +08:00
parent 1d780ba4d6
commit df5586709d
8 changed files with 1496 additions and 0 deletions
+792
View File
@@ -0,0 +1,792 @@
"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<CcbaParsedRow[]>([])
const [selections, setSelections] = React.useState<
Record<string, string | null>
>({})
const [fileError, setFileError] = React.useState<string | null>(null)
const [isImporting, setIsImporting] = React.useState(false)
const [result, setResult] = React.useState<ImportResult | null>(null)
const fileInputRef = React.useRef<HTMLInputElement>(null)
const channelMap = React.useMemo(
() => new Map(channels.map((channel) => [channel.id, channel])),
[channels]
)
const uniqueAccounts = React.useMemo(() => {
const set = new Set<string>()
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<HTMLInputElement>
) => {
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<string, string | null> = {}
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<string, string> = {}
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 (
<main className="flex min-w-0 flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">
<div className="flex flex-col gap-4 border-b border-border/70 pb-5">
<div>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
导入账单
</h1>
<p className="mt-1 text-sm text-muted-foreground">
上传中国建设银行(亚洲)信用卡月结单,完成账户映射后批量导入到待清洗流水。
</p>
</div>
</div>
{fileError && (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>无法解析该文件</AlertTitle>
<AlertDescription>{fileError}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(String(value))}
className="gap-4"
>
<TabsList className="w-full sm:w-fit">
<TabsTrigger value="upload">上传账单</TabsTrigger>
<TabsTrigger value="mapping">账户映射</TabsTrigger>
<TabsTrigger value="preview">数据预览</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="flex flex-col gap-4">
{parsedRows.length === 0 ? (
<Empty className="min-h-64 border border-dashed border-border/70 bg-muted/10">
<EmptyHeader>
<EmptyMedia variant="icon">
<FileUpIcon />
</EmptyMedia>
<EmptyTitle>尚未导入账单</EmptyTitle>
<EmptyDescription>
目前仅支持中国建设银行(亚洲)信用卡月结单 CSV。PDF 转 CSV
步骤暂略,解析在浏览器本地完成,文件不会上传。
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
onChange={handleFileChange}
className="hidden"
/>
<Button onClick={() => fileInputRef.current?.click()}>
<FileUpIcon data-icon="inline-start" />
选择 CSV 文件
</Button>
</EmptyContent>
</Empty>
) : (
<Card className="border-border/70 shadow-xs">
<CardHeader>
<CardTitle>解析概览</CardTitle>
<CardDescription>
已读取 {fileName},解析在浏览器本地完成。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{stat.label}
</span>
<span className="font-mono text-2xl font-semibold">
{stat.value}
</span>
</div>
))}
</div>
<FieldGroup>
<Field>
<FieldLabel htmlFor="ccba-csv">
重新选择 CSV 文件
</FieldLabel>
<Input
id="ccba-csv"
type="file"
accept=".csv,text/csv"
onChange={handleFileChange}
/>
<FieldDescription>
重新选择会清除当前解析结果与账户映射。
</FieldDescription>
</Field>
</FieldGroup>
<div className="flex justify-end">
<Button onClick={() => setActiveTab("mapping")}>
下一步:账户映射
<ArrowRightIcon data-icon="inline-end" />
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="mapping" className="flex flex-col gap-4">
{parsedRows.length === 0 ? (
<Empty className="min-h-64 border border-dashed border-border/70 bg-muted/10">
<EmptyHeader>
<EmptyMedia variant="icon">
<LandmarkIcon />
</EmptyMedia>
<EmptyTitle>尚未解析到账户</EmptyTitle>
<EmptyDescription>
请先在「上传账单」步骤选择并解析中国建设银行(亚洲)信用卡月结单
CSV。
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
variant="outline"
onClick={() => setActiveTab("upload")}
>
前往上传账单
</Button>
</EmptyContent>
</Empty>
) : channels.length === 0 ? (
<Empty className="min-h-64 border border-dashed border-border/70 bg-muted/10">
<EmptyHeader>
<EmptyMedia variant="icon">
<WalletIcon />
</EmptyMedia>
<EmptyTitle>暂无可用支付渠道</EmptyTitle>
<EmptyDescription>
账户映射需要至少一个已创建的支付渠道,请先创建后再继续。
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
variant="outline"
render={<Link href="/channels" />}
nativeButton={false}
>
前往支付渠道
<ArrowRightIcon data-icon="inline-end" />
</Button>
</EmptyContent>
</Empty>
) : (
<Card className="border-border/70 shadow-xs">
<CardHeader>
<CardTitle>账户映射</CardTitle>
<CardDescription>
将月结单中的账户对应到已创建的支付渠道,未匹配到的可手动选择,或选择暂不导入。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<FieldGroup>
{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 (
<Field key={account} orientation="responsive">
<FieldLabel className="min-w-0">
<span className="truncate font-mono text-xs">
{account}
</span>
{value == null && (
<Badge variant="destructive">待映射</Badge>
)}
{isSkipped && (
<Badge variant="outline">暂不导入</Badge>
)}
</FieldLabel>
<Select
value={value}
onValueChange={(next) =>
handleSelectionChange(account, next)
}
>
<SelectTrigger className="w-full md:w-72">
<SelectValue
className={
value == null
? "text-muted-foreground"
: "text-foreground"
}
>
{displayValue}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={SKIP_ACCOUNT_VALUE}>
{SKIP_ACCOUNT_LABEL}
</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channelOptionLabel(channel)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
)
})}
</FieldGroup>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{pendingCount > 0
? `还有 ${pendingCount} 个账户待映射,可手动选择或选择暂不导入。`
: "全部账户已处理,可前往数据预览并导入。"}
</p>
<Button
variant="outline"
onClick={() => setActiveTab("preview")}
>
下一步:数据预览
<ArrowRightIcon data-icon="inline-end" />
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="preview" className="flex flex-col gap-4">
{parsedRows.length === 0 ? (
<Empty className="min-h-64 border border-dashed border-border/70 bg-muted/10">
<EmptyHeader>
<EmptyMedia variant="icon">
<Table2Icon />
</EmptyMedia>
<EmptyTitle>暂无可预览的数据</EmptyTitle>
<EmptyDescription>
上传并解析月结单后,可在此逐笔核对交易明细。
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
variant="outline"
onClick={() => setActiveTab("upload")}
>
前往上传账单
</Button>
</EmptyContent>
</Empty>
) : (
<>
<div className="overflow-hidden rounded-xl border border-border/70 [&_[data-slot=table-container]]:max-h-[440px] [&_[data-slot=table-container]]:overflow-y-auto">
<Table>
<TableHeader className="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-muted [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_tr]:border-b-0">
<TableRow>
<TableHead>交易日期</TableHead>
<TableHead>描述</TableHead>
<TableHead className="text-right">
原始金额/币种
</TableHead>
<TableHead className="text-right">金额/币种</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parsedRows.map((row, index) => {
const amtText = row.amt.trim()
const negative =
amtText.startsWith("-") || /^\(.*\)$/.test(amtText)
return (
<TableRow key={index}>
<TableCell className="font-mono text-xs">
{row.transDate || "—"}
</TableCell>
<TableCell>
<span
className="block max-w-[280px] truncate"
title={row.description}
>
{row.description || "—"}
</span>
</TableCell>
<TableCell
className={`text-right font-mono text-xs uppercase ${
negative ? "text-destructive" : "text-foreground"
}`}
>
{row.fxAmt
? `${row.fxAmt}${row.fxCcy ? ` ${row.fxCcy}` : ""}`
: "—"}
</TableCell>
<TableCell
className={`text-right font-mono text-xs uppercase ${
negative ? "text-destructive" : "text-foreground"
}`}
>
{row.amt
? `${row.amt}${row.ccy ? ` ${row.ccy}` : ""}`
: "—"}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{hasUnmappedAccount
? `还有 ${pendingCount} 个账户待映射,请先在「账户映射」处理或选择暂不导入。`
: `共 ${parsedRows.length} 行,将导入 ${importableCount} 行,暂不导入 ${skipRowCount} 行。`}
</p>
<Button
onClick={handleImport}
disabled={submitDisabled}
className="w-full sm:w-auto"
>
{isImporting && <Spinner data-icon="inline-start" />}
导入账单
</Button>
</div>
{result && (
<div className="flex flex-col gap-3">
{result.success && (
<Alert>
<CheckCircle2Icon />
<AlertTitle>导入完成</AlertTitle>
<AlertDescription>
<p>
成功导入 {result.imported ?? 0} 行,跳过重复{" "}
{result.skipped ?? 0} 行。
</p>
<div className="mt-3">
<Button
variant="outline"
size="sm"
render={<Link href="/bookkeeping" />}
nativeButton={false}
>
前往流水记账完成清洗
<ArrowRightIcon data-icon="inline-end" />
</Button>
</div>
</AlertDescription>
</Alert>
)}
{!result.success && result.error && (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>导入失败</AlertTitle>
<AlertDescription>{result.error}</AlertDescription>
</Alert>
)}
{result.unmatchedAccounts &&
result.unmatchedAccounts.length > 0 && (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>部分账户未能匹配渠道</AlertTitle>
<AlertDescription>
<p>
以下账户未匹配到已创建的支付渠道,请先到支付渠道页创建对应渠道,或返回「账户映射」手动处理后重新导入:
</p>
<div className="mt-2 flex flex-col gap-1">
{result.unmatchedAccounts.map((account) => (
<span
key={account}
className="font-mono text-xs"
>
{account}
</span>
))}
</div>
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
</>
)}
</TabsContent>
</Tabs>
</main>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { redirect } from "next/navigation"
import { auth } from "@/lib/auth"
import { getChannelsAction } from "@/lib/actions/channel"
import { AppSidebar } from "@/components/app-sidebar"
import { AppHeader } from "@/components/app-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { ImportView } from "./import-view"
export default async function ImportPage() {
const session = await auth()
if (!session?.user) {
redirect("/login")
}
const user = session.user
const userName = user.name || "Fluxent 用户"
const userEmail = user.email || ""
const userAvatar = user.image || null
const channelsRes = await getChannelsAction()
const channels =
channelsRes.success && channelsRes.data ? channelsRes.data : []
return (
<SidebarProvider>
<AppSidebar
user={{
name: userName,
email: userEmail,
avatar: userAvatar,
}}
/>
<SidebarInset>
<AppHeader title="导入账单" />
<ImportView channels={channels} />
</SidebarInset>
</SidebarProvider>
)
}
+6
View File
@@ -4,6 +4,7 @@ import * as React from "react"
import Link from "next/link"
import {
CreditCardIcon,
FileUpIcon,
LayoutDashboardIcon,
PenToolIcon,
ReceiptTextIcon,
@@ -35,6 +36,11 @@ const navMainItems: NavMainItem[] = [
url: "/bookkeeping",
icon: PenToolIcon,
},
{
title: "导入账单",
url: "/import",
icon: FileUpIcon,
},
{
title: "交易记录",
url: "/transactions",
+103
View File
@@ -0,0 +1,103 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+16
View File
@@ -0,0 +1,16 @@
import { cn } from "cn"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
data-slot="spinner"
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
+115
View File
@@ -0,0 +1,115 @@
"use client"
import * as React from "react"
import { cn } from "cn"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+81
View File
@@ -0,0 +1,81 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+342
View File
@@ -0,0 +1,342 @@
"use server"
import { and, eq, isNull, like } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import {
channels,
transactions,
transactionsDirty,
type NewTransactionDirty,
} from "@/lib/db/schema"
export interface CcbaParsedRow {
transDate: string
postDate: string
description: string
refNo: string
amt: string
ccy: string
account: string
fxAmt: string | null
fxCcy: string | null
fxRate: string | null
}
export interface CcbaImportResult {
success: boolean
imported?: number
skipped?: number
unmatchedAccounts?: string[]
error?: string
}
/** 存入 rawData 的原始行结构(含去重签名) */
interface CcbaRawData {
transDate: string
postDate: string
description: string
refNo: string
amt: string
ccy: string
account: string
fxAmt: string | null
fxCcy: string | null
fxRate: string | null
dedupHash: string
}
interface ChannelMatchContext {
userChannelIds: Set<string>
fullDigitsMap: Map<string, string>
suffixMap: Map<string, string>
}
async function requireUser() {
const session = await auth()
if (!session?.user?.id) {
throw new Error("请先登录")
}
return session.user.id
}
/** 去掉金额字符串的前导正负号,保留原始精度(不经过浮点往返) */
function stripSign(value: string): string {
return value.trim().replace(/^[-+]/, "")
}
/** 解析 CCBA "YYYY-MM-DD" 日期,非法时返回 null */
function parseImportDate(value: string): Date | null {
const date = new Date(`${value}T00:00:00`)
return Number.isNaN(date.getTime()) ? null : date
}
/** 从账户描述中提取不少于 12 位的连续数字卡号 */
function extractCardDigits(
account: string
): { fullDigits: string; last4: string } | null {
const match = /\d{12,}/.exec(account)
if (!match) return null
return { fullDigits: match[0], last4: match[0].slice(-4) }
}
/** 去掉描述末尾的大写两位国家码,返回商户名与地理位置 */
function splitMerchantAndGeo(description: string): {
merchantName: string
geo: string | null
} {
const trimmed = description.trim()
const match = /\s+([A-Z]{2})$/.exec(trimmed)
if (!match) {
return { merchantName: trimmed.slice(0, 255), geo: null }
}
return {
merchantName: trimmed.slice(0, match.index).trim().slice(0, 255),
geo: match[1],
}
}
/** 构建渠道匹配索引:完整卡号 -> id、卡号后缀 -> id */
function buildChannelMatchContext(
userChannels: {
id: string
cardNumberFull: string | null
cardNumberSuffix: string | null
}[]
): ChannelMatchContext {
const userChannelIds = new Set<string>()
const fullDigitsMap = new Map<string, string>()
const suffixMap = new Map<string, string>()
for (const channel of userChannels) {
userChannelIds.add(channel.id)
const fullDigits = channel.cardNumberFull?.replace(/\D/g, "") ?? ""
if (fullDigits && !fullDigitsMap.has(fullDigits)) {
fullDigitsMap.set(fullDigits, channel.id)
}
const suffix = channel.cardNumberSuffix?.replace(/\D/g, "") ?? ""
if (suffix.length >= 4 && !suffixMap.has(suffix)) {
suffixMap.set(suffix, channel.id)
}
}
return { userChannelIds, fullDigitsMap, suffixMap }
}
/**
* 解析账户字符串对应的渠道 id:
* 手动映射优先(且必须属于当前用户),否则按完整卡号、再按后四位自动匹配。
*/
function resolveChannelId(
account: string,
accountChannelMap: Record<string, string> | undefined,
ctx: ChannelMatchContext
): string | null {
const manualId = accountChannelMap?.[account]
if (manualId && ctx.userChannelIds.has(manualId)) {
return manualId
}
const digits = extractCardDigits(account)
if (!digits) return null
const byFull = ctx.fullDigitsMap.get(digits.fullDigits)
if (byFull) return byFull
return ctx.suffixMap.get(digits.last4) ?? null
}
/** 导入去重签名:优先银行流水号,缺失时退化为行内容哈希 */
function buildSignature(row: CcbaParsedRow): string {
const refNo = row.refNo.trim()
return refNo !== ""
? `ccba:ref:${refNo}`
: `ccba:hash:${row.transDate}|${row.account}|${row.amt}|${row.description}`
}
export async function importCcbaRowsAction(
rows: CcbaParsedRow[],
accountChannelMap?: Record<string, string>
): Promise<CcbaImportResult> {
try {
const userId = await requireUser()
if (rows.length === 0) {
return { success: false, error: "没有可导入的账单记录" }
}
const userChannels = await db
.select({
id: channels.id,
cardNumberFull: channels.cardNumberFull,
cardNumberSuffix: channels.cardNumberSuffix,
})
.from(channels)
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
const ctx = buildChannelMatchContext(userChannels)
// 已存在的导入签名(待清洗表 + 正式账本)
const [dirtyExts, txnExts] = await Promise.all([
db
.select({ ext: transactionsDirty.ext })
.from(transactionsDirty)
.where(
and(
eq(transactionsDirty.userId, userId),
isNull(transactionsDirty.deletedAt),
like(transactionsDirty.ext, "ccba:%")
)
),
db
.select({ ext: transactions.ext })
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
isNull(transactions.deletedAt),
like(transactions.ext, "ccba:%")
)
),
])
const existingSignatures = new Set<string>()
for (const existing of dirtyExts) {
if (existing.ext) existingSignatures.add(existing.ext)
}
for (const existing of txnExts) {
if (existing.ext) existingSignatures.add(existing.ext)
}
const insertedValues: NewTransactionDirty[] = []
const batchSignatures = new Set<string>()
const unmatchedAccounts = new Set<string>()
let skipped = 0
for (const row of rows) {
const txnDate = parseImportDate(row.transDate)
const postingDate = parseImportDate(row.postDate)
if (!txnDate || !postingDate) {
skipped++
continue
}
const amtNum = parseFloat(row.amt)
if (Number.isNaN(amtNum)) {
skipped++
continue
}
const channelId = resolveChannelId(row.account, accountChannelMap, ctx)
if (!channelId) {
unmatchedAccounts.add(row.account)
continue
}
const signature = buildSignature(row)
if (existingSignatures.has(signature) || batchSignatures.has(signature)) {
skipped++
continue
}
batchSignatures.add(signature)
const ccy = row.ccy.toUpperCase()
const isForeign = Boolean(
row.fxAmt && row.fxCcy && row.fxCcy.toUpperCase() !== ccy
)
const { merchantName, geo } = splitMerchantAndGeo(row.description)
const dcFlag = amtNum < 0 ? "CREDIT" : "DEBIT"
let txnScene = "PAYMENT"
if (row.description.includes("INHOUSE FUND TRANSFER")) {
txnScene = "TRANSFER"
} else if (amtNum < 0 && /rebate/i.test(row.description)) {
txnScene = "MISC_IN"
}
let txnAmt: string
let txnCcy: string
let postingAmt: string | null
let postingCcy: string | null
let fxRates: { fromCcy: string; toCcy: string; rate: string }[] | null
if (isForeign && row.fxAmt && row.fxCcy) {
const fxCcy = row.fxCcy.toUpperCase()
txnAmt = stripSign(row.fxAmt)
txnCcy = fxCcy
postingAmt = stripSign(row.amt)
postingCcy = ccy
fxRates = row.fxRate
? [{ fromCcy: fxCcy, toCcy: ccy, rate: row.fxRate }]
: null
} else {
txnAmt = stripSign(row.amt)
txnCcy = ccy
postingAmt = null
postingCcy = null
fxRates = null
}
const rawData: CcbaRawData = {
transDate: row.transDate,
postDate: row.postDate,
description: row.description,
refNo: row.refNo,
amt: row.amt,
ccy: row.ccy,
account: row.account,
fxAmt: row.fxAmt,
fxCcy: row.fxCcy,
fxRate: row.fxRate,
dedupHash: signature,
}
insertedValues.push({
userId,
txnDate,
postingDate,
txnAmt,
txnCcy,
postingAmt,
postingCcy,
fxRates,
dcFlag,
refChannels: [channelId],
txnScene,
merchantName,
description: row.description,
rawDescription: row.description,
rawData,
// ext 存导入去重签名;银行 ref_no 仅保留在上方 rawData 内
ext: signature,
geo,
})
}
if (insertedValues.length > 0) {
await db.insert(transactionsDirty).values(insertedValues)
}
revalidatePath("/bookkeeping")
revalidatePath("/import")
const result: CcbaImportResult = {
success: true,
imported: insertedValues.length,
skipped,
}
if (unmatchedAccounts.size > 0) {
result.unmatchedAccounts = Array.from(unmatchedAccounts)
}
return result
} catch (err) {
console.error("importCcbaRowsAction error:", err)
return {
success: false,
error: err instanceof Error ? err.message : "导入账单失败",
}
}
}