diff --git a/AGENTS.md b/AGENTS.md index f909a03..fa45268 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,32 +8,35 @@ This version has breaking changes — APIs, conventions, and file structure may ## Commands -- Use `pnpm`. The available checks are `pnpm lint`, `pnpm typecheck`, and `pnpm build`; run `pnpm typecheck && pnpm build` after application changes. There is no committed test suite or CI workflow. -- Add UI primitives only through `pnpm dlx shadcn@latest add --yes`. Before using a shadcn component, run `pnpm dlx shadcn@latest docs ` and read the referenced docs. -- Formatting is Prettier with Tailwind sorting (`pnpm format`). It uses double quotes, no semicolons, 2 spaces, LF, and 80-column wrapping. +- Use `pnpm`. Primary checks are `pnpm lint`, `pnpm typecheck`, and `pnpm build`; run `pnpm typecheck && pnpm build` to verify changes. There is no committed test suite. +- Add UI primitives only via `pnpm dlx shadcn@latest add --yes`. Read the referenced docs via `pnpm dlx shadcn@latest docs ` before using. +- Format with `pnpm format` (Prettier + Tailwind plugin: double quotes, no semicolons, 2 spaces, LF, 80-column wrap). ## App And Auth -- This is a single Next.js App Router application; routes live under `app/`. Authenticated pages compose `SidebarProvider`, `AppSidebar`, and `SidebarInset` themselves. -- Next.js 16 uses `proxy.ts`, not `middleware.ts`. Keep `proxy.ts` edge-safe: it imports only `lib/auth/config.ts`, never database or password-hashing code. -- Login, registration, and `/api/auth` are public. All other routes are protected by `authConfig`; server pages and actions must still validate the session themselves. -- `lib/auth/index.ts` owns Node-side Auth.js providers and database work. OIDC is configuration-driven and disabled unless `AUTH_OIDC_ENABLED=true` with a complete issuer/client configuration. +- Next.js 16 App Router application under `app/`. Authenticated pages compose `SidebarProvider`, `AppSidebar`, and `SidebarInset` themselves. +- Next.js 16 uses `proxy.ts`, NOT `middleware.ts`. Keep `proxy.ts` edge-safe: import only `lib/auth/config.ts`, never database or password-hashing code. +- Public routes: `/login`, `/register`, `/api/auth/*`. All other routes are protected by `proxy.ts`; server pages and server actions must still validate sessions via `auth()`. +- `lib/auth/index.ts` owns Node-side Auth.js providers and database operations. OIDC is disabled unless `AUTH_OIDC_ENABLED=true` with full issuer/client credentials configured. +- Sidebar active state must derive from `usePathname()` (`/` exact match, child routes prefix match). Do not add navigation links until their route exists. -## Database +## Database And Ledger Architecture -- PostgreSQL access is Drizzle + `postgres`; the schema source of truth is `lib/db/schema.ts`. Drizzle Kit loads `DATABASE_URL` from `.env.local` and uses `drizzle.config.ts`. -- Use `pnpm drizzle-kit push --force` only when a schema change is intended; it can apply destructive changes. `pnpm tsx lib/db/reset.ts` drops `transactions`, `channels`, `accounts`, `user_accounts`, and `users` and must never be run casually. -- Business tables are user-scoped. Every read, update, or delete in `lib/actions/` must obtain `auth().user.id` and constrain the query with that `user_id`; validate that referenced account/channel IDs belong to the same user. -- Keep soft deletion (`deleted_at`) semantics in reads and mutations. Account and channel management actions already follow this model. +- PostgreSQL access via Drizzle ORM + `postgres`; schema truth is `lib/db/schema.ts`. Drizzle Kit loads `DATABASE_URL` from `.env.local` via `drizzle.config.ts`. +- Schema sync: `pnpm drizzle-kit push --force`. Never execute `pnpm tsx lib/db/reset.ts` unless explicitly instructed (drops all data tables). +- Multi-tenancy & safety: Every action in `lib/actions/` must enforce `userId = session.user.id` and filter out soft-deleted records with `isNull(table.deletedAt)`. Verify that referenced accounts or channels belong to the same user. +- Two-tier transaction ledger: + - Fast/draft entry stores records in `transactions_dirty`. + - Cleansing action (`cleanseTransactionsAction` in `lib/actions/bookkeeping.ts`) performs atomic zero-tolerance validation before moving items into `transactions`. +- Card brands: Stored as lowercase machine keys (`visa`, `mastercard`, `unionpay`, `amex`, `diners`, `discover`, `jcb`). Always use `normalizeCardBrand()` from `lib/payment/card-brand.ts` and local SVGs under `/payment-logos/`. ## UI And Copy -- The project uses shadcn `base-nova` on `@base-ui/react`, Lucide icons, Tailwind v4, and semantic CSS variables from `app/globals.css`. -- Prefer stock shadcn composition and variants over custom styling. Use `className` only for necessary layout, responsiveness, truncation, or stable dimensions; do not override component padding, margins, colors, typography, or default Dialog/Footer behavior without a verified need. -- Forms use `FieldGroup`, `Field`, and `FieldLabel`; use `FieldSet`/`FieldLegend` only when the grouping adds user-facing meaning. Put `SelectItem` inside `SelectGroup`. -- Errors and callouts use `Alert`; destructive errors use `Alert variant="destructive"` with `AlertTitle` and `AlertDescription`, never a hand-styled `div`. -- Follow the default Dialog composition: `DialogHeader`, form/content, then `DialogFooter` as a direct `DialogContent` child. Do not add `p-0`, custom negative margins, fixed heights, sticky footers, or isolated scroll containers unless the task explicitly requires them. -- Use `Button`, `Badge`, `Empty`, `Separator`, `Tooltip`, and other installed primitives instead of recreating them. Use `data-icon="inline-start"` or `data-icon="inline-end"` for icons in buttons. -- Use `gap-*`, never `space-x-*` or `space-y-*`; use semantic tokens instead of raw color palettes or manual `dark:` overrides. -- Product-facing UI copy is Chinese only. Do not add English parentheticals to menus, labels, options, or headings; retain user-entered business values such as currency codes and card brands verbatim. -- Sidebar active state must derive from `usePathname()` with exact matching for `/` and route-boundary matching for child paths. Do not add navigation links until their route exists. +- Tech stack: shadcn `base-nova` on `@base-ui/react`, Lucide icons, Tailwind v4, semantic CSS variables from `app/globals.css`. +- Base UI Select & Combobox: + - ``: When `` contains complex JSX/icons and no plain string children, Base UI falls back to rendering raw values (e.g. UUIDs). Provide plain text `children` or explicit formatting logic. + - ``: When `items` is an array of objects, always provide both `itemToStringValue` (string serialization for search matching and value keying) and `itemToStringLabel` (input display string) to avoid `[object Object]` display bugs. +- Form composition: Use `FieldGroup`, `Field`, and `FieldLabel`. Put `SelectItem` inside `SelectGroup`. Use `FieldSet`/`FieldLegend` only when grouping adds visible user meaning. +- Dialog composition: Follow standard structure (`DialogHeader`, form/content, `DialogFooter` directly inside `DialogContent`). Do not add `p-0`, negative margins, sticky footers, or manual scroll wrappers without a verified requirement. +- Styling: Use `gap-*`, NEVER `space-x-*` or `space-y-*`. Use semantic color tokens, avoid hardcoded palettes or manual `dark:` overrides. Buttons with icons use `data-icon="inline-start"` or `data-icon="inline-end"`. +- Product copy is Chinese only: Do not add English parentheticals to labels, menus, options, or headings (e.g., no "账户 (Accounts)"). Retain user-entered values and standard business tokens (e.g., currency codes `CNY`, card brand names `Visa`) verbatim. diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index c55a45e..866b2be 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,3 +1,3 @@ -import { handlers } from "@/lib/auth"; +import { handlers } from "@/lib/auth" -export const { GET, POST } = handlers; +export const { GET, POST } = handlers diff --git a/app/bookkeeping/bookkeeping-view.tsx b/app/bookkeeping/bookkeeping-view.tsx new file mode 100644 index 0000000..82b3c04 --- /dev/null +++ b/app/bookkeeping/bookkeeping-view.tsx @@ -0,0 +1,1122 @@ +"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 ( + {CARD_BRAND_LABELS[brand]} + ) + } + return + } + if (channel.channelType === "E_WALLET") { + return + } + if (channel.channelType === "CASH") { + return + } + return ( + + ) +} + +function getSceneBadge(scene: string) { + switch (scene) { + case "PAYMENT": + return 消费支出 + case "MISC_IN": + return ( + + 日常收入 + + ) + case "TRANSFER": + return 转账 + case "ATM": + return 取现 + default: + return {scene} + } +} + +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( + initialDirtyTransactions + ) + const [channels] = React.useState(initialChannels) + + // 移动端 Tab: "form" | "list" + const [mobileTab, setMobileTab] = React.useState<"form" | "list">("form") + + // 编辑态,选中的待清洗记录 + const [selectedTxnId, setSelectedTxnId] = React.useState(null) + + // 表单状态 + const [txnScene, setTxnScene] = React.useState("PAYMENT") + const [dcFlag, setDcFlag] = React.useState<"DEBIT" | "CREDIT">("DEBIT") + const [txnAmt, setTxnAmt] = React.useState("") + const [txnCcy, setTxnCcy] = React.useState("CNY") + const [channelId, setChannelId] = React.useState( + initialChannels[0]?.id || "" + ) + const [txnDate, setTxnDate] = React.useState(() => + formatDateForInput() + ) + const [merchantName, setMerchantName] = React.useState("") + const [description, setDescription] = React.useState("") + const [memo, setMemo] = React.useState("") + + // 折算与入账扩展字段 + const [postingAmt, setPostingAmt] = React.useState("") + const [postingCcy, setPostingCcy] = React.useState("") + const [commAmt, setCommAmt] = React.useState("") + const [commCcy, setCommCcy] = React.useState("") + + // 交互与执行状态 + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isCleansing, setIsCleansing] = React.useState(false) + const [formError, setFormError] = React.useState(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(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() + 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 ( + +
+ {/* 全局阻断错误报告 (标准 Alert variant="destructive") */} + {cleanseGlobalError && ( + + + + 清洗阻断报告:数据未满足入账标准,原子合并已完全中止 + + +

{cleanseGlobalError}

+ {cleanseErrors.length > 0 && ( +
+ + 阻断交易明细(已在列表中标红高亮,请修正): + +
    + {cleanseErrors.map((err) => ( +
  • + 「{err.name}」:{" "} + {err.error} +
  • + ))} +
+
+ )} +
+
+ )} + + {/* 页头控制区 */} +
+
+
+

+ 流水记账 +

+ + 待清洗 {dirtyList.length} 笔 + +
+

+ 录入与核对暂存流水,确认无误后完成清洗入账 +

+
+ +
+ +
+
+ + {/* 移动端视图切换分段器 (md: 及以下) */} +
+ + +
+ + {/* PC 端左右分栏工作台 (lg: 5:7 黄金比例双栏工作台) */} +
+ {/* 左栏:录入 / 联动编辑工作区 (5/12) */} +
+ +
+
+ {selectedTxnId ? ( + <> + +

编辑暂存流水

+ + ) : ( + <> + +

新建暂存流水

+ + )} +
+ {selectedTxnId && ( + + )} +
+ + +
+ + {/* 表单内行内错误提示 */} + {formError && ( + + + {formError} + + )} + + {/* 1. 交易场景快捷选择 */} + + 交易场景 +
+ {TXN_SCENES.map((scene) => ( + + ))} +
+
+ + {/* 2. 借贷方向选择 */} + + 资金流向 +
+ + +
+
+ + {/* 3. 发生金额与币种 */} +
+ + 发生金额 + setTxnAmt(e.target.value)} + className="font-mono text-lg font-semibold tracking-tight" + required + /> + + + 交易币种 + + setTxnCcy(e.target.value.toUpperCase()) + } + className="font-mono uppercase" + maxLength={6} + required + /> + +
+ + {/* 4. 渠道 */} + + 渠道 + {channels.length === 0 ? ( +
+ 暂无启用渠道,请先在渠道管理中新增 +
+ ) : ( + 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 + > + + + 没有匹配的渠道 + + {(ch: ChannelWithAccountNames) => { + const identifier = getChannelIdentifier(ch) + return ( + +
+
+ + {renderChannelIcon(ch)} + +
+ + {ch.desc || + ch.platformAccountId || + ch.subChannel || + channelName(ch)} + + + {channelName(ch)} + +
+
+ {identifier && ( + + {identifier} + + )} +
+
+ ) + }} +
+
+
+ )} +
+ + {/* 5. 交易时间 */} + + 交易时间 + setTxnDate(e.target.value)} + className="font-mono text-xs" + required + /> + + + {/* 6. 商户与描述 */} + + 商户名称 + setMerchantName(e.target.value)} + /> + + + + 消费描述与说明 + setDescription(e.target.value)} + /> + + + {/* 7. 折算与入账信息 (可选折叠面板概念) */} +
+
+ + 折算与入账 (跨币种或手续费选填) + +
+
+ + 入账金额 + setPostingAmt(e.target.value)} + className="font-mono text-xs" + /> + + + 入账币种 + + setPostingCcy(e.target.value.toUpperCase()) + } + className="font-mono text-xs uppercase" + maxLength={6} + /> + +
+ +
+ + + 手续费金额 + + setCommAmt(e.target.value)} + className="font-mono text-xs" + /> + + + + 手续费币种 + + + setCommCcy(e.target.value.toUpperCase()) + } + className="font-mono text-xs uppercase" + maxLength={6} + /> + +
+
+ + {/* 8. 备注 */} + + 内部备注 +