feat(import): add CCBA statement CSV workflow
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user