- Add transactions_dirty table and zero-tolerance cleansing action - Implement bookkeeping workbench (/bookkeeping) with searchable Combobox channel selector and card suffix / account identifier - Implement transaction ledger (/transactions) with date groupings, multi-currency metrics, and filters - Update AGENTS.md guidelines for Base UI Select and Combobox bindings
1123 lines
44 KiB
TypeScript
1123 lines
44 KiB
TypeScript
"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 (
|
|
<Image
|
|
src={logoUrl}
|
|
alt={CARD_BRAND_LABELS[brand]}
|
|
width={18}
|
|
height={18}
|
|
className="size-4 shrink-0 object-contain"
|
|
/>
|
|
)
|
|
}
|
|
return <CreditCardIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
}
|
|
if (channel.channelType === "E_WALLET") {
|
|
return <WalletCardsIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
}
|
|
if (channel.channelType === "CASH") {
|
|
return <BanknoteIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
}
|
|
return (
|
|
<ArrowRightLeftIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
)
|
|
}
|
|
|
|
function getSceneBadge(scene: string) {
|
|
switch (scene) {
|
|
case "PAYMENT":
|
|
return <Badge variant="secondary">消费支出</Badge>
|
|
case "MISC_IN":
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
|
|
>
|
|
日常收入
|
|
</Badge>
|
|
)
|
|
case "TRANSFER":
|
|
return <Badge variant="outline">转账</Badge>
|
|
case "ATM":
|
|
return <Badge variant="outline">取现</Badge>
|
|
default:
|
|
return <Badge variant="secondary">{scene}</Badge>
|
|
}
|
|
}
|
|
|
|
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<TransactionDirty[]>(
|
|
initialDirtyTransactions
|
|
)
|
|
const [channels] = React.useState<ChannelWithAccountNames[]>(initialChannels)
|
|
|
|
// 移动端 Tab: "form" | "list"
|
|
const [mobileTab, setMobileTab] = React.useState<"form" | "list">("form")
|
|
|
|
// 编辑态,选中的待清洗记录
|
|
const [selectedTxnId, setSelectedTxnId] = React.useState<string | null>(null)
|
|
|
|
// 表单状态
|
|
const [txnScene, setTxnScene] = React.useState<string>("PAYMENT")
|
|
const [dcFlag, setDcFlag] = React.useState<"DEBIT" | "CREDIT">("DEBIT")
|
|
const [txnAmt, setTxnAmt] = React.useState<string>("")
|
|
const [txnCcy, setTxnCcy] = React.useState<string>("CNY")
|
|
const [channelId, setChannelId] = React.useState<string>(
|
|
initialChannels[0]?.id || ""
|
|
)
|
|
const [txnDate, setTxnDate] = React.useState<string>(() =>
|
|
formatDateForInput()
|
|
)
|
|
const [merchantName, setMerchantName] = React.useState<string>("")
|
|
const [description, setDescription] = React.useState<string>("")
|
|
const [memo, setMemo] = React.useState<string>("")
|
|
|
|
// 折算与入账扩展字段
|
|
const [postingAmt, setPostingAmt] = React.useState<string>("")
|
|
const [postingCcy, setPostingCcy] = React.useState<string>("")
|
|
const [commAmt, setCommAmt] = React.useState<string>("")
|
|
const [commCcy, setCommCcy] = React.useState<string>("")
|
|
|
|
// 交互与执行状态
|
|
const [isSubmitting, setIsSubmitting] = React.useState<boolean>(false)
|
|
const [isCleansing, setIsCleansing] = React.useState<boolean>(false)
|
|
const [formError, setFormError] = React.useState<string | null>(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<string | null>(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<string, string>()
|
|
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 (
|
|
<TooltipProvider>
|
|
<main className="flex min-w-0 flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">
|
|
{/* 全局阻断错误报告 (标准 Alert variant="destructive") */}
|
|
{cleanseGlobalError && (
|
|
<Alert
|
|
variant="destructive"
|
|
className="animate-in duration-200 fade-in-50"
|
|
>
|
|
<AlertCircleIcon />
|
|
<AlertTitle>
|
|
清洗阻断报告:数据未满足入账标准,原子合并已完全中止
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
<p className="font-medium">{cleanseGlobalError}</p>
|
|
{cleanseErrors.length > 0 && (
|
|
<div className="mt-3 flex flex-col gap-1.5 border-t border-destructive/20 pt-2 text-xs">
|
|
<span className="font-semibold">
|
|
阻断交易明细(已在列表中标红高亮,请修正):
|
|
</span>
|
|
<ul className="list-inside list-disc space-y-1">
|
|
{cleanseErrors.map((err) => (
|
|
<li key={err.id}>
|
|
<span className="font-medium">「{err.name}」:</span>{" "}
|
|
{err.error}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* 页头控制区 */}
|
|
<div className="flex flex-col gap-4 border-b border-border/70 pb-5 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2.5">
|
|
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
|
流水记账
|
|
</h1>
|
|
<Badge
|
|
variant="secondary"
|
|
className="px-2 py-0.5 text-xs font-normal"
|
|
>
|
|
待清洗 {dirtyList.length} 笔
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
录入与核对暂存流水,确认无误后完成清洗入账
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
onClick={handleCleanseAll}
|
|
disabled={dirtyList.length === 0 || isCleansing}
|
|
size="default"
|
|
className="w-full shadow-sm sm:w-auto"
|
|
>
|
|
{isCleansing ? (
|
|
<Loader2Icon
|
|
data-icon="inline-start"
|
|
className="animate-spin"
|
|
/>
|
|
) : (
|
|
<SparklesIcon data-icon="inline-start" />
|
|
)}
|
|
完成清洗 ({dirtyList.length})
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 移动端视图切换分段器 (md: 及以下) */}
|
|
<div className="flex gap-2 rounded-lg bg-muted/60 p-1 lg:hidden">
|
|
<Button
|
|
variant={mobileTab === "form" ? "default" : "ghost"}
|
|
size="sm"
|
|
className="flex-1 text-xs"
|
|
onClick={() => setMobileTab("form")}
|
|
>
|
|
{selectedTxnId ? "编辑暂存流水" : "录入表单"}
|
|
</Button>
|
|
<Button
|
|
variant={mobileTab === "list" ? "default" : "ghost"}
|
|
size="sm"
|
|
className="flex-1 text-xs"
|
|
onClick={() => setMobileTab("list")}
|
|
>
|
|
待清洗列表 ({dirtyList.length})
|
|
{cleanseErrors.length > 0 && (
|
|
<span className="ml-1.5 size-2 rounded-full bg-destructive" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* PC 端左右分栏工作台 (lg: 5:7 黄金比例双栏工作台) */}
|
|
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-12">
|
|
{/* 左栏:录入 / 联动编辑工作区 (5/12) */}
|
|
<section
|
|
className={`min-w-0 lg:col-span-5 ${
|
|
mobileTab === "form" ? "block" : "hidden lg:block"
|
|
}`}
|
|
>
|
|
<Card className="border-border/70 shadow-xs">
|
|
<div className="flex items-center justify-between border-b px-5 py-3.5">
|
|
<div className="flex items-center gap-2">
|
|
{selectedTxnId ? (
|
|
<>
|
|
<PenLineIcon className="size-4 text-primary" />
|
|
<h2 className="text-sm font-semibold">编辑暂存流水</h2>
|
|
</>
|
|
) : (
|
|
<>
|
|
<PlusCircleIcon className="size-4 text-muted-foreground" />
|
|
<h2 className="text-sm font-semibold">新建暂存流水</h2>
|
|
</>
|
|
)}
|
|
</div>
|
|
{selectedTxnId && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={resetForm}
|
|
className="h-7 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<RotateCcwIcon data-icon="inline-start" />
|
|
返回新建
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<CardContent className="p-5">
|
|
<form onSubmit={handleSubmitForm}>
|
|
<FieldGroup>
|
|
{/* 表单内行内错误提示 */}
|
|
{formError && (
|
|
<Alert variant="destructive" className="py-2 text-xs">
|
|
<AlertCircleIcon className="size-3.5" />
|
|
<AlertDescription>{formError}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* 1. 交易场景快捷选择 */}
|
|
<Field>
|
|
<FieldLabel>交易场景</FieldLabel>
|
|
<div className="grid grid-cols-4 gap-1.5 rounded-lg border bg-muted/20 p-1">
|
|
{TXN_SCENES.map((scene) => (
|
|
<button
|
|
key={scene.value}
|
|
type="button"
|
|
onClick={() => handleSceneChange(scene.value)}
|
|
className={`rounded-md py-1.5 text-xs font-medium transition-all ${
|
|
txnScene === scene.value
|
|
? "bg-background text-foreground shadow-xs"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{scene.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Field>
|
|
|
|
{/* 2. 借贷方向选择 */}
|
|
<Field>
|
|
<FieldLabel>资金流向</FieldLabel>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setDcFlag("DEBIT")}
|
|
className={`flex items-center justify-center gap-1.5 rounded-md border p-2 text-xs font-medium transition-colors ${
|
|
dcFlag === "DEBIT"
|
|
? "border-destructive/40 bg-destructive/10 font-semibold text-destructive"
|
|
: "border-input bg-card text-muted-foreground hover:bg-muted/40"
|
|
}`}
|
|
>
|
|
<ArrowUpRightIcon className="size-3.5" />
|
|
支出
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDcFlag("CREDIT")}
|
|
className={`flex items-center justify-center gap-1.5 rounded-md border p-2 text-xs font-medium transition-colors ${
|
|
dcFlag === "CREDIT"
|
|
? "border-emerald-500/40 bg-emerald-500/10 font-semibold text-emerald-600 dark:text-emerald-400"
|
|
: "border-input bg-card text-muted-foreground hover:bg-muted/40"
|
|
}`}
|
|
>
|
|
<ArrowDownLeftIcon className="size-3.5" />
|
|
收入
|
|
</button>
|
|
</div>
|
|
</Field>
|
|
|
|
{/* 3. 发生金额与币种 */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<Field className="col-span-2">
|
|
<FieldLabel>发生金额</FieldLabel>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
placeholder="0.00"
|
|
value={txnAmt}
|
|
onChange={(e) => setTxnAmt(e.target.value)}
|
|
className="font-mono text-lg font-semibold tracking-tight"
|
|
required
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel>交易币种</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
placeholder="CNY"
|
|
value={txnCcy}
|
|
onChange={(e) =>
|
|
setTxnCcy(e.target.value.toUpperCase())
|
|
}
|
|
className="font-mono uppercase"
|
|
maxLength={6}
|
|
required
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
{/* 4. 渠道 */}
|
|
<Field>
|
|
<FieldLabel>渠道</FieldLabel>
|
|
{channels.length === 0 ? (
|
|
<div className="rounded-md border border-dashed p-2.5 text-center text-xs text-muted-foreground">
|
|
暂无启用渠道,请先在渠道管理中新增
|
|
</div>
|
|
) : (
|
|
<Combobox
|
|
items={channels}
|
|
value={
|
|
channels.find((c) => 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
|
|
>
|
|
<ComboboxInput
|
|
placeholder="请选择或搜索渠道"
|
|
className="w-full"
|
|
/>
|
|
<ComboboxContent>
|
|
<ComboboxEmpty>没有匹配的渠道</ComboboxEmpty>
|
|
<ComboboxList>
|
|
{(ch: ChannelWithAccountNames) => {
|
|
const identifier = getChannelIdentifier(ch)
|
|
return (
|
|
<ComboboxItem key={ch.id} value={ch}>
|
|
<div className="flex min-w-0 flex-1 items-center justify-between gap-2 py-1">
|
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
|
{renderChannelIcon(ch)}
|
|
</span>
|
|
<div className="flex min-w-0 flex-col">
|
|
<span className="truncate text-xs font-medium text-foreground">
|
|
{ch.desc ||
|
|
ch.platformAccountId ||
|
|
ch.subChannel ||
|
|
channelName(ch)}
|
|
</span>
|
|
<span className="truncate text-[11px] text-muted-foreground">
|
|
{channelName(ch)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{identifier && (
|
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
|
{identifier}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</ComboboxItem>
|
|
)
|
|
}}
|
|
</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
)}
|
|
</Field>
|
|
|
|
{/* 5. 交易时间 */}
|
|
<Field>
|
|
<FieldLabel>交易时间</FieldLabel>
|
|
<Input
|
|
type="datetime-local"
|
|
value={txnDate}
|
|
onChange={(e) => setTxnDate(e.target.value)}
|
|
className="font-mono text-xs"
|
|
required
|
|
/>
|
|
</Field>
|
|
|
|
{/* 6. 商户与描述 */}
|
|
<Field>
|
|
<FieldLabel>商户名称</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
placeholder="如:Apple Store、星巴克、工资发放"
|
|
value={merchantName}
|
|
onChange={(e) => setMerchantName(e.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
<Field>
|
|
<FieldLabel>消费描述与说明</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
placeholder="选填,如:数码配件、日常聚餐"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</Field>
|
|
|
|
{/* 7. 折算与入账信息 (可选折叠面板概念) */}
|
|
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="font-medium text-muted-foreground">
|
|
折算与入账 (跨币种或手续费选填)
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field>
|
|
<FieldLabel className="text-xs">入账金额</FieldLabel>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
placeholder="留空则等额入账"
|
|
value={postingAmt}
|
|
onChange={(e) => setPostingAmt(e.target.value)}
|
|
className="font-mono text-xs"
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel className="text-xs">入账币种</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
placeholder="同原币"
|
|
value={postingCcy}
|
|
onChange={(e) =>
|
|
setPostingCcy(e.target.value.toUpperCase())
|
|
}
|
|
className="font-mono text-xs uppercase"
|
|
maxLength={6}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field>
|
|
<FieldLabel className="text-xs">
|
|
手续费金额
|
|
</FieldLabel>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
placeholder="0.00"
|
|
value={commAmt}
|
|
onChange={(e) => setCommAmt(e.target.value)}
|
|
className="font-mono text-xs"
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel className="text-xs">
|
|
手续费币种
|
|
</FieldLabel>
|
|
<Input
|
|
type="text"
|
|
placeholder={txnCcy}
|
|
value={commCcy}
|
|
onChange={(e) =>
|
|
setCommCcy(e.target.value.toUpperCase())
|
|
}
|
|
className="font-mono text-xs uppercase"
|
|
maxLength={6}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 8. 备注 */}
|
|
<Field>
|
|
<FieldLabel>内部备注</FieldLabel>
|
|
<Textarea
|
|
placeholder="选填,辅助核账记录"
|
|
value={memo}
|
|
onChange={(e) => setMemo(e.target.value)}
|
|
rows={2}
|
|
className="resize-none text-xs"
|
|
/>
|
|
</Field>
|
|
|
|
{/* 提交动作栏 */}
|
|
<div className="flex items-center gap-2 pt-2">
|
|
{selectedTxnId ? (
|
|
<>
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="flex-1"
|
|
>
|
|
{isSubmitting && (
|
|
<Loader2Icon
|
|
data-icon="inline-start"
|
|
className="animate-spin"
|
|
/>
|
|
)}
|
|
保存修改
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={resetForm}
|
|
disabled={isSubmitting}
|
|
>
|
|
取消编辑
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="w-full"
|
|
>
|
|
{isSubmitting && (
|
|
<Loader2Icon
|
|
data-icon="inline-start"
|
|
className="animate-spin"
|
|
/>
|
|
)}
|
|
暂存到列表
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</FieldGroup>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
|
|
{/* 右栏:待清洗流水密集列表 (7/12) */}
|
|
<section
|
|
className={`min-w-0 lg:col-span-7 ${
|
|
mobileTab === "list" ? "block" : "hidden lg:block"
|
|
}`}
|
|
>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-sm font-semibold">待清洗流水列表</h2>
|
|
<span className="text-xs text-muted-foreground">
|
|
共 {dirtyList.length} 笔待处理
|
|
</span>
|
|
</div>
|
|
{dirtyList.length > 0 && (
|
|
<span className="text-[11px] text-muted-foreground">
|
|
点击任意条目即可在左栏编辑
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{dirtyList.length > 0 ? (
|
|
<div className="overflow-hidden rounded-lg border border-border/70 bg-card shadow-xs">
|
|
{/* PC 列表表头 */}
|
|
<div className="hidden grid-cols-[1fr_auto_80px_40px] items-center gap-3 border-b bg-muted/30 px-4 py-2.5 text-[11px] font-medium text-muted-foreground md:grid">
|
|
<span>交易要素 / 渠道</span>
|
|
<span className="text-right">发生金额</span>
|
|
<span className="text-center">场景</span>
|
|
<span />
|
|
</div>
|
|
|
|
<div className="divide-y divide-border/60">
|
|
{dirtyList.map((item) => {
|
|
const isSelected = selectedTxnId === item.id
|
|
const hasError = errorMap.has(item.id)
|
|
const errorMsg = errorMap.get(item.id)
|
|
const primaryChannelId =
|
|
Array.isArray(item.refChannels) && item.refChannels[0]
|
|
const ch = primaryChannelId
|
|
? channelMap.get(primaryChannelId)
|
|
: null
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleSelectTxn(item)}
|
|
className={`group relative flex cursor-pointer flex-col gap-2 p-3 transition-colors md:grid md:grid-cols-[1fr_auto_80px_40px] md:items-center md:gap-3 md:px-4 md:py-3 ${
|
|
isSelected
|
|
? "border-l-4 border-l-primary bg-primary/5"
|
|
: "hover:bg-muted/40"
|
|
} ${
|
|
hasError
|
|
? "border-destructive/40 bg-destructive/5"
|
|
: ""
|
|
}`}
|
|
>
|
|
{/* 交易主体信息 */}
|
|
<div className="flex min-w-0 items-start gap-2.5">
|
|
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
|
{ch ? (
|
|
renderChannelIcon(ch)
|
|
) : (
|
|
<CreditCardIcon className="size-3.5" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-foreground">
|
|
{item.merchantName ||
|
|
item.description ||
|
|
"未命名交易"}
|
|
</span>
|
|
{hasError && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="h-4 px-1.5 text-[10px]"
|
|
>
|
|
待修正
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
|
<span className="font-mono text-[11px]">
|
|
{formatDisplayDate(item.txnDate)}
|
|
</span>
|
|
<span>•</span>
|
|
<span className="max-w-[140px] truncate">
|
|
{ch
|
|
? formatChannelName(ch)
|
|
: "未关联有效渠道"}
|
|
</span>
|
|
{item.description && item.merchantName && (
|
|
<>
|
|
<span>•</span>
|
|
<span className="max-w-[140px] truncate">
|
|
{item.description}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* 行内错误提示 */}
|
|
{hasError && (
|
|
<p className="mt-1 text-[11px] text-destructive">
|
|
{errorMsg}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 金额 */}
|
|
<div className="flex items-baseline justify-between md:block md:text-right">
|
|
<span className="text-xs text-muted-foreground md:hidden">
|
|
发生金额
|
|
</span>
|
|
<div className="flex items-baseline gap-1 md:justify-end">
|
|
<span
|
|
className={`font-mono text-base font-bold tracking-tight ${
|
|
item.dcFlag === "CREDIT"
|
|
? "text-emerald-600 dark:text-emerald-400"
|
|
: "text-foreground"
|
|
}`}
|
|
>
|
|
{item.dcFlag === "CREDIT" ? "+" : "-"}
|
|
{item.txnAmt}
|
|
</span>
|
|
<span className="font-mono text-[11px] text-muted-foreground uppercase">
|
|
{item.txnCcy}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 场景徽标 */}
|
|
<div className="flex items-center justify-between md:justify-center">
|
|
<span className="text-xs text-muted-foreground md:hidden">
|
|
场景
|
|
</span>
|
|
{getSceneBadge(item.txnScene)}
|
|
</div>
|
|
|
|
{/* 操作:删除 */}
|
|
<div
|
|
className="flex items-center justify-end"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label="删除此笔流水"
|
|
className="text-muted-foreground opacity-60 hover:text-destructive hover:opacity-100"
|
|
disabled={deletingId === item.id}
|
|
onClick={() => handleDeleteTxn(item.id)}
|
|
/>
|
|
}
|
|
>
|
|
{deletingId === item.id ? (
|
|
<Loader2Icon className="animate-spin" />
|
|
) : (
|
|
<Trash2Icon />
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>删除暂存流水</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* 空状态 */
|
|
<div className="flex min-h-[380px] flex-col items-center justify-center rounded-lg border border-dashed border-border/80 bg-muted/10 p-8 text-center">
|
|
<div className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
<InboxIcon className="size-6" />
|
|
</div>
|
|
<h3 className="mt-4 text-sm font-semibold">
|
|
暂无待清洗的暂存流水
|
|
</h3>
|
|
<p className="mt-1.5 max-w-sm text-xs text-muted-foreground">
|
|
当前没有待清洗的暂存流水,可通过左侧表单开始录入。全部暂存数据校验合格后,可一键完成清洗入账。
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</TooltipProvider>
|
|
)
|
|
}
|