- 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
777 lines
29 KiB
TypeScript
777 lines
29 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import Image from "next/image"
|
|
import { useRouter } from "next/navigation"
|
|
import Link from "next/link"
|
|
import {
|
|
ArrowRightLeftIcon,
|
|
BanknoteIcon,
|
|
CreditCardIcon,
|
|
FileTextIcon,
|
|
InboxIcon,
|
|
Loader2Icon,
|
|
MoreHorizontalIcon,
|
|
PlusIcon,
|
|
SearchIcon,
|
|
Trash2Icon,
|
|
WalletCardsIcon,
|
|
} from "lucide-react"
|
|
|
|
import {
|
|
deleteOfficialTransactionAction,
|
|
type TransactionWithChannelDetails,
|
|
} from "@/lib/actions/transaction"
|
|
import {
|
|
CARD_BRAND_LABELS,
|
|
getCardBrandLogoUrl,
|
|
normalizeCardBrand,
|
|
} from "@/lib/payment/card-brand"
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
|
|
interface TransactionsViewProps {
|
|
initialTransactions: TransactionWithChannelDetails[]
|
|
}
|
|
|
|
const SCENE_MAP: Record<string, string> = {
|
|
PAYMENT: "消费支出",
|
|
ECOM_PAYMENT: "线上消费",
|
|
POS_PAYMENT: "线下刷卡",
|
|
MISC_IN: "日常收入",
|
|
TRANSFER: "转账",
|
|
ATM: "取现",
|
|
}
|
|
|
|
function getSceneBadge(scene: string, dcFlag: string) {
|
|
const label = SCENE_MAP[scene] || (dcFlag === "CREDIT" ? "收入" : "支出")
|
|
if (dcFlag === "CREDIT") {
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-emerald-500/30 text-emerald-600 dark:text-emerald-400"
|
|
>
|
|
{label}
|
|
</Badge>
|
|
)
|
|
}
|
|
return <Badge variant="secondary">{label}</Badge>
|
|
}
|
|
|
|
function renderChannelLogoOrIcon(
|
|
ch?: TransactionWithChannelDetails["channelDetails"][0]
|
|
) {
|
|
if (!ch) {
|
|
return <CreditCardIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
}
|
|
|
|
if (ch.channelType === "PAYMENT_CARD") {
|
|
const brand = normalizeCardBrand(ch.cardBrand)
|
|
const logoUrl = getCardBrandLogoUrl(ch.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 (ch.channelType === "E_WALLET") {
|
|
return <WalletCardsIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
}
|
|
if (ch.channelType === "CASH") {
|
|
return <BanknoteIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
}
|
|
return (
|
|
<ArrowRightLeftIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
)
|
|
}
|
|
|
|
function formatDateHeader(dateVal: Date | string): string {
|
|
const d = new Date(dateVal)
|
|
if (isNaN(d.getTime())) return "未分类日期"
|
|
const y = d.getFullYear()
|
|
const m = d.getMonth() + 1
|
|
const day = d.getDate()
|
|
const weekDays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
|
const week = weekDays[d.getDay()]
|
|
return `${y}年${m}月${day}日 ${week}`
|
|
}
|
|
|
|
function formatTime(dateVal: Date | string): string {
|
|
const d = new Date(dateVal)
|
|
if (isNaN(d.getTime())) return "--"
|
|
const hh = String(d.getHours()).padStart(2, "0")
|
|
const mm = String(d.getMinutes()).padStart(2, "0")
|
|
return `${hh}:${mm}`
|
|
}
|
|
|
|
function formatFullDateTime(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")
|
|
const ss = String(d.getSeconds()).padStart(2, "0")
|
|
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`
|
|
}
|
|
|
|
export function TransactionsView({
|
|
initialTransactions,
|
|
}: TransactionsViewProps) {
|
|
const router = useRouter()
|
|
|
|
const [transactionsList, setTransactionsList] =
|
|
React.useState<TransactionWithChannelDetails[]>(initialTransactions)
|
|
const [searchQuery, setSearchQuery] = React.useState("")
|
|
const [dcFilter, setDcFilter] = React.useState<"ALL" | "DEBIT" | "CREDIT">(
|
|
"ALL"
|
|
)
|
|
|
|
// 查看分录详情弹窗
|
|
const [viewingTxn, setViewingTxn] =
|
|
React.useState<TransactionWithChannelDetails | null>(null)
|
|
|
|
// 删除确认弹窗
|
|
const [deletingTxn, setDeletingTxn] =
|
|
React.useState<TransactionWithChannelDetails | null>(null)
|
|
const [isDeleting, setIsDeleting] = React.useState(false)
|
|
|
|
// 检索过滤
|
|
const filteredTransactions = React.useMemo(() => {
|
|
const query = searchQuery.trim().toLowerCase()
|
|
return transactionsList.filter((item) => {
|
|
// 借贷过滤
|
|
if (dcFilter !== "ALL" && item.dcFlag !== dcFilter) {
|
|
return false
|
|
}
|
|
if (!query) return true
|
|
|
|
const chNames = (item.channelDetails || [])
|
|
.map((c) => c.displayName)
|
|
.join(" ")
|
|
|
|
const searchContent = [
|
|
item.merchantName,
|
|
item.cp,
|
|
item.description,
|
|
item.memo,
|
|
item.txnAmt,
|
|
item.txnCcy,
|
|
item.postingAmt,
|
|
item.postingCcy,
|
|
chNames,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase()
|
|
|
|
return searchContent.includes(query)
|
|
})
|
|
}, [transactionsList, searchQuery, dcFilter])
|
|
|
|
// 按自然日期分组
|
|
const groupedTransactions = React.useMemo(() => {
|
|
const groups: {
|
|
dateKey: string
|
|
dateLabel: string
|
|
items: TransactionWithChannelDetails[]
|
|
}[] = []
|
|
|
|
const groupMap = new Map<string, TransactionWithChannelDetails[]>()
|
|
|
|
filteredTransactions.forEach((txn) => {
|
|
const d = new Date(txn.txnDate)
|
|
const dateKey = isNaN(d.getTime())
|
|
? "unknown"
|
|
: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
|
|
|
if (!groupMap.has(dateKey)) {
|
|
groupMap.set(dateKey, [])
|
|
}
|
|
groupMap.get(dateKey)!.push(txn)
|
|
})
|
|
|
|
groupMap.forEach((items, dateKey) => {
|
|
groups.push({
|
|
dateKey,
|
|
dateLabel:
|
|
dateKey === "unknown"
|
|
? "未分类日期"
|
|
: formatDateHeader(items[0].txnDate),
|
|
items,
|
|
})
|
|
})
|
|
|
|
return groups
|
|
}, [filteredTransactions])
|
|
|
|
const hasFilters = Boolean(searchQuery) || dcFilter !== "ALL"
|
|
|
|
const clearFilters = () => {
|
|
setSearchQuery("")
|
|
setDcFilter("ALL")
|
|
}
|
|
|
|
// 确认删除流水
|
|
const handleConfirmDelete = async () => {
|
|
if (!deletingTxn) return
|
|
setIsDeleting(true)
|
|
try {
|
|
const res = await deleteOfficialTransactionAction(deletingTxn.id)
|
|
if (res.success) {
|
|
setTransactionsList((prev) =>
|
|
prev.filter((item) => item.id !== deletingTxn.id)
|
|
)
|
|
setDeletingTxn(null)
|
|
router.refresh()
|
|
}
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<main className="flex min-w-0 flex-1 flex-col gap-4 p-4 md:p-6 lg:p-8">
|
|
{/* 页头控制与过滤检索区 */}
|
|
<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"
|
|
>
|
|
已入账 {transactionsList.length} 笔
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
查看已清洗入账的正式总账流水与多币种对账
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
render={<Link href="/bookkeeping" />}
|
|
size="default"
|
|
className="w-full shadow-xs sm:w-auto"
|
|
>
|
|
<PlusIcon data-icon="inline-start" />
|
|
记一笔
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 筛选控制器 */}
|
|
<div className="flex flex-col gap-2.5 md:flex-row md:items-center">
|
|
<div className="relative min-w-0 flex-1 md:max-w-md">
|
|
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="搜索商户、对手方、说明、备注或金额"
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
|
|
<Select
|
|
value={dcFilter}
|
|
onValueChange={(val) =>
|
|
setDcFilter((val ?? "ALL") as "ALL" | "DEBIT" | "CREDIT")
|
|
}
|
|
>
|
|
<SelectTrigger className="w-full md:w-32">
|
|
<SelectValue>
|
|
{dcFilter === "ALL"
|
|
? "全部方向"
|
|
: dcFilter === "DEBIT"
|
|
? "仅支出"
|
|
: "仅收入"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="ALL">全部方向</SelectItem>
|
|
<SelectItem value="DEBIT">仅支出</SelectItem>
|
|
<SelectItem value="CREDIT">仅收入</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{hasFilters && (
|
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
|
清除筛选
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 流水列表区 */}
|
|
{groupedTransactions.length > 0 ? (
|
|
<div className="flex flex-col gap-6">
|
|
{groupedTransactions.map((group) => (
|
|
<section key={group.dateKey} className="flex flex-col gap-2">
|
|
{/* 日期分组标题 */}
|
|
<div className="flex items-center gap-2 px-1">
|
|
<h2 className="text-xs font-semibold tracking-wide text-muted-foreground">
|
|
{group.dateLabel}
|
|
</h2>
|
|
<span className="text-[11px] text-muted-foreground/80">
|
|
({group.items.length} 笔)
|
|
</span>
|
|
</div>
|
|
|
|
{/* 分组流水卡片容器 */}
|
|
<div className="overflow-hidden rounded-lg border border-border/70 bg-card shadow-xs">
|
|
{/* PC 列表表头 */}
|
|
<div className="hidden grid-cols-[minmax(200px,2fr)_minmax(120px,1.2fr)_minmax(140px,1.2fr)_minmax(140px,1.2fr)_80px_48px] items-center gap-3 border-b bg-muted/30 px-4 py-2 text-[11px] font-medium text-muted-foreground md:grid">
|
|
<span>商户与描述</span>
|
|
<span>结算渠道</span>
|
|
<span className="text-right">发生金额 (原币)</span>
|
|
<span className="text-right">入账金额 (折算)</span>
|
|
<span className="text-center">场景</span>
|
|
<span />
|
|
</div>
|
|
|
|
<div className="divide-y divide-border/60">
|
|
{group.items.map((txn) => {
|
|
const ch = txn.channelDetails?.[0]
|
|
const isMultiCurrency =
|
|
txn.postingCcy &&
|
|
txn.postingCcy !== txn.txnCcy &&
|
|
txn.postingAmt
|
|
|
|
// 汇率计算显示
|
|
let rateDisplay: string | null = null
|
|
if (isMultiCurrency) {
|
|
const originalAmt = parseFloat(txn.txnAmt)
|
|
const postAmt = parseFloat(txn.postingAmt!)
|
|
if (
|
|
!isNaN(originalAmt) &&
|
|
!isNaN(postAmt) &&
|
|
originalAmt > 0
|
|
) {
|
|
const rate = postAmt / originalAmt
|
|
rateDisplay = `1 ${txn.txnCcy} ≈ ${rate.toFixed(4)} ${txn.postingCcy}`
|
|
}
|
|
}
|
|
|
|
const actionMenu = (
|
|
<DropdownMenu>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label="打开操作菜单"
|
|
/>
|
|
}
|
|
/>
|
|
}
|
|
>
|
|
<MoreHorizontalIcon />
|
|
</TooltipTrigger>
|
|
<TooltipContent>更多操作</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() => setViewingTxn(txn)}
|
|
>
|
|
<FileTextIcon data-icon="inline-start" />
|
|
查看明细
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => setDeletingTxn(txn)}
|
|
>
|
|
<Trash2Icon data-icon="inline-start" />
|
|
删除流水
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
|
|
return (
|
|
<div
|
|
key={txn.id}
|
|
className="flex flex-col gap-2 p-3 transition-colors hover:bg-muted/30 md:grid md:grid-cols-[minmax(200px,2fr)_minmax(120px,1.2fr)_minmax(140px,1.2fr)_minmax(140px,1.2fr)_80px_48px] md:items-center md:gap-3 md:px-4 md:py-3"
|
|
>
|
|
{/* 移动端顶栏 (商户 + 更多操作在最右侧) / PC 端商户描述 */}
|
|
<div className="flex min-w-0 items-center justify-between gap-2 md:block">
|
|
<div className="flex min-w-0 items-center gap-2.5">
|
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground md:hidden">
|
|
{renderChannelLogoOrIcon(ch)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate text-sm font-medium text-foreground">
|
|
{txn.merchantName ||
|
|
txn.cp ||
|
|
txn.description ||
|
|
"未命名交易"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground md:mt-0.5">
|
|
<span className="font-mono text-[11px]">
|
|
{formatTime(txn.txnDate)}
|
|
</span>
|
|
{txn.description && txn.merchantName && (
|
|
<>
|
|
<span>•</span>
|
|
<span className="max-w-[200px] truncate">
|
|
{txn.description}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 移动端自然流右侧菜单 */}
|
|
<div className="shrink-0 md:hidden">
|
|
{actionMenu}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 结算渠道 */}
|
|
<div className="hidden min-w-0 items-center gap-2 text-xs md:flex">
|
|
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
|
{renderChannelLogoOrIcon(ch)}
|
|
</div>
|
|
<span className="truncate text-muted-foreground">
|
|
{ch?.displayName || "未关联渠道"}
|
|
</span>
|
|
</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-semibold tracking-tight ${
|
|
txn.dcFlag === "CREDIT"
|
|
? "text-emerald-600 dark:text-emerald-400"
|
|
: "text-foreground"
|
|
}`}
|
|
>
|
|
{txn.dcFlag === "CREDIT" ? "+" : "-"}
|
|
{txn.txnAmt}
|
|
</span>
|
|
<span className="font-mono text-[11px] text-muted-foreground uppercase">
|
|
{txn.txnCcy}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 入账金额 (折算) */}
|
|
<div className="flex items-baseline justify-between text-xs md:block md:text-right">
|
|
<span className="text-muted-foreground md:hidden">
|
|
入账折算
|
|
</span>
|
|
<div className="flex flex-col items-end gap-0.5">
|
|
{isMultiCurrency ? (
|
|
<>
|
|
<div className="flex items-baseline gap-1 font-mono font-medium">
|
|
<span>
|
|
{txn.dcFlag === "CREDIT" ? "+" : "-"}
|
|
{txn.postingAmt}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground uppercase">
|
|
{txn.postingCcy}
|
|
</span>
|
|
</div>
|
|
{rateDisplay && (
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{rateDisplay}
|
|
</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="font-mono text-muted-foreground">
|
|
等额入账
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 场景徽标 */}
|
|
<div className="flex items-center justify-between md:justify-center">
|
|
<span className="text-xs text-muted-foreground md:hidden">
|
|
场景类型
|
|
</span>
|
|
{getSceneBadge(txn.txnScene, txn.dcFlag)}
|
|
</div>
|
|
|
|
{/* PC 端操作按钮 */}
|
|
<div className="hidden md:flex md:justify-end">
|
|
{actionMenu}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
))}
|
|
</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">
|
|
{hasFilters ? "未找到符合条件的流水" : "暂无已入账的交易流水"}
|
|
</h3>
|
|
<p className="mt-1.5 max-w-sm text-xs text-muted-foreground">
|
|
{hasFilters
|
|
? "调整关键词或重置筛选条件后再试。"
|
|
: "可在「流水记账」中快速录入并确认清洗入账,入账后的总账流水将完整汇总于此。"}
|
|
</p>
|
|
<div className="mt-4 flex gap-2">
|
|
{hasFilters ? (
|
|
<Button variant="outline" size="sm" onClick={clearFilters}>
|
|
清除筛选
|
|
</Button>
|
|
) : (
|
|
<Button render={<Link href="/bookkeeping" />} size="sm">
|
|
<PlusIcon data-icon="inline-start" />
|
|
前往记账
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 分录明细弹窗 */}
|
|
<Dialog
|
|
open={Boolean(viewingTxn)}
|
|
onOpenChange={(open) => !open && setViewingTxn(null)}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>交易分录明细</DialogTitle>
|
|
<DialogDescription>
|
|
查看该笔总账交易的原始要素、渠道及折算入账数据
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{viewingTxn && (
|
|
<div className="flex flex-col gap-3.5 py-1 text-xs">
|
|
<div className="grid grid-cols-2 gap-3 rounded-lg border bg-muted/20 p-3">
|
|
<div>
|
|
<span className="text-muted-foreground">流水标识</span>
|
|
<p className="mt-0.5 truncate font-mono text-[11px] select-all">
|
|
{viewingTxn.id}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">版本号</span>
|
|
<p className="mt-0.5 font-mono font-medium">
|
|
v{viewingTxn.version}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<span className="text-muted-foreground">交易时间</span>
|
|
<p className="mt-0.5 font-medium">
|
|
{formatFullDateTime(viewingTxn.txnDate)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">借贷方向</span>
|
|
<p className="mt-0.5 font-medium">
|
|
{viewingTxn.dcFlag === "CREDIT"
|
|
? "收入 (贷方)"
|
|
: "支出 (借方)"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<span className="text-muted-foreground">原始发生额</span>
|
|
<p className="mt-0.5 font-mono text-sm font-bold">
|
|
{viewingTxn.txnAmt} {viewingTxn.txnCcy}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">最终入账额</span>
|
|
<p className="mt-0.5 font-mono text-sm font-bold text-primary">
|
|
{viewingTxn.postingAmt || viewingTxn.txnAmt}{" "}
|
|
{viewingTxn.postingCcy || viewingTxn.txnCcy}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{(viewingTxn.commAmt ||
|
|
viewingTxn.surchargeAmt ||
|
|
viewingTxn.discAmt) && (
|
|
<div className="space-y-1 rounded-md border bg-muted/10 p-2.5">
|
|
<span className="font-semibold text-muted-foreground">
|
|
费用与优惠细目
|
|
</span>
|
|
{viewingTxn.commAmt && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">手续费</span>
|
|
<span className="font-mono">
|
|
{viewingTxn.commAmt}{" "}
|
|
{viewingTxn.commCcy || viewingTxn.txnCcy}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{viewingTxn.surchargeAmt && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">附加费</span>
|
|
<span className="font-mono">
|
|
{viewingTxn.surchargeAmt}{" "}
|
|
{viewingTxn.surchargeCcy || viewingTxn.txnCcy}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{viewingTxn.discAmt && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">优惠折扣</span>
|
|
<span className="font-mono text-emerald-600">
|
|
-{viewingTxn.discAmt}{" "}
|
|
{viewingTxn.discCcy || viewingTxn.txnCcy}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
<span className="text-muted-foreground">结算渠道</span>
|
|
<p className="font-medium">
|
|
{viewingTxn.channelDetails?.[0]?.displayName ||
|
|
"未关联渠道"}
|
|
</p>
|
|
</div>
|
|
|
|
{viewingTxn.merchantName && (
|
|
<div className="space-y-1">
|
|
<span className="text-muted-foreground">商户名称</span>
|
|
<p className="font-medium">{viewingTxn.merchantName}</p>
|
|
</div>
|
|
)}
|
|
|
|
{viewingTxn.description && (
|
|
<div className="space-y-1">
|
|
<span className="text-muted-foreground">交易说明</span>
|
|
<p className="text-muted-foreground">
|
|
{viewingTxn.description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{viewingTxn.memo && (
|
|
<div className="space-y-1">
|
|
<span className="text-muted-foreground">备注信息</span>
|
|
<p className="text-muted-foreground">{viewingTxn.memo}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setViewingTxn(null)}>
|
|
关闭
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 删除确认弹窗 */}
|
|
<Dialog
|
|
open={Boolean(deletingTxn)}
|
|
onOpenChange={(open) => !open && setDeletingTxn(null)}
|
|
>
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>确认删除总账流水</DialogTitle>
|
|
<DialogDescription>
|
|
将删除「
|
|
{deletingTxn?.merchantName ||
|
|
deletingTxn?.cp ||
|
|
deletingTxn?.description ||
|
|
`金额 ${deletingTxn?.txnAmt} ${deletingTxn?.txnCcy}`}
|
|
」的正式总账记录。该操作不可撤回,确认要删除吗?
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDeletingTxn(null)}
|
|
disabled={isDeleting}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleConfirmDelete}
|
|
disabled={isDeleting}
|
|
>
|
|
{isDeleting && (
|
|
<Loader2Icon
|
|
data-icon="inline-start"
|
|
className="animate-spin"
|
|
/>
|
|
)}
|
|
确认删除
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</main>
|
|
</TooltipProvider>
|
|
)
|
|
}
|