Compare commits
6
Commits
c33857ffac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b20f9a292b
|
||
|
|
df5586709d
|
||
|
|
1d780ba4d6
|
||
|
|
2982d560ce
|
||
|
|
ea4a54f5c6
|
||
|
|
99d4a85ece
|
@@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL=postgresql://postgres:Aa110011@localhost:5432/fluxent
|
||||||
@@ -30,6 +30,7 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
# This is NOT the Next.js you know
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
@@ -8,35 +9,30 @@ This version has breaking changes — APIs, conventions, and file structure may
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- 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.
|
- Use `pnpm`. Checks: `pnpm lint`, `pnpm typecheck`, `pnpm build`; there is no `test` script. `pnpm format` formats all `**/*.{ts,tsx}` with Prettier and the Tailwind plugin (2 spaces, double quotes, no semicolons, LF, 80 columns).
|
||||||
- Add UI primitives only via `pnpm dlx shadcn@latest add <component> --yes`. Read the referenced docs via `pnpm dlx shadcn@latest docs <component>` before using.
|
- Add shadcn primitives only with `pnpm dlx shadcn@latest add <component> --yes`; read their API first with `pnpm dlx shadcn@latest docs <component>`.
|
||||||
- Format with `pnpm format` (Prettier + Tailwind plugin: double quotes, no semicolons, 2 spaces, LF, 80-column wrap).
|
- This is Next.js 16.2.6. Read the relevant guide under `node_modules/next/dist/docs/` before changing Next.js APIs or conventions; do not assume older Next.js patterns.
|
||||||
|
|
||||||
## App And Auth
|
## App And Auth
|
||||||
|
|
||||||
- Next.js 16 App Router application under `app/`. Authenticated pages compose `SidebarProvider`, `AppSidebar`, and `SidebarInset` themselves.
|
- App Router entry points live in `app/`. Each authenticated page validates `auth()` and composes `SidebarProvider`, `AppSidebar`, and `SidebarInset`; the proxy check does not replace server-side validation.
|
||||||
- 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.
|
- Next.js 16 uses `proxy.ts`, not `middleware.ts`. Keep auth configuration imported by `proxy.ts` edge-safe; database and password code belongs in `lib/auth/index.ts`.
|
||||||
- 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()`.
|
- Public routes are `/login`, `/register`, and `/api/auth/*`; other routes are protected. OIDC registers only when `AUTH_OIDC_ENABLED=true` and issuer/client ID are configured.
|
||||||
- `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 comes from `usePathname()` (`/` exact, child routes prefix). Add a navigation item only with its route.
|
||||||
- Sidebar active state must derive from `usePathname()` (`/` exact match, child routes prefix match). Do not add navigation links until their route exists.
|
|
||||||
|
|
||||||
## Database And Ledger Architecture
|
## Database And Ledger
|
||||||
|
|
||||||
- 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`.
|
- PostgreSQL uses Drizzle + `postgres`; schema source of truth is `lib/db/schema.ts`. `drizzle.config.ts` explicitly loads `.env.local`. For local DB use `pnpm db:up` (Compose waits for health), `pnpm db:down` (keeps the named volume), and `pnpm db:logs`. Compose binds only to `127.0.0.1:5432`; Postgres data mounts at `/var/lib/postgresql` for the current PostgreSQL image layout. `docker compose down -v` permanently deletes local DB data.
|
||||||
- Schema sync: `pnpm drizzle-kit push --force`. Never execute `pnpm tsx lib/db/reset.ts` unless explicitly instructed (drops all data tables).
|
- Apply schema changes with `pnpm drizzle-kit push --force`. Never run `pnpm tsx lib/db/reset.ts` unless explicitly instructed; it drops application 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.
|
- Scope business-data queries and mutations by the authenticated `session.user.id`, exclude soft-deleted rows with `isNull(table.deletedAt)`, and verify referenced accounts/channels belong to that user.
|
||||||
- Two-tier transaction ledger:
|
- Draft/imported transactions belong in `transactions_dirty`. `cleanseTransactionsAction` in `lib/actions/bookkeeping.ts` validates the whole batch and atomically moves valid rows to `transactions`.
|
||||||
- Fast/draft entry stores records in `transactions_dirty`.
|
- `/import` currently accepts CCBA CSV only (PDF conversion is not implemented); its server action stages rows in `transactions_dirty` and resolves account-to-channel mappings.
|
||||||
- Cleansing action (`cleanseTransactionsAction` in `lib/actions/bookkeeping.ts`) performs atomic zero-tolerance validation before moving items into `transactions`.
|
- Card brands are stored as lowercase keys. Use `normalizeCardBrand()` from `lib/payment/card-brand.ts` and local SVGs under `/payment-logos/`.
|
||||||
- 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
|
## UI And Copy
|
||||||
|
|
||||||
- Tech stack: shadcn `base-nova` on `@base-ui/react`, Lucide icons, Tailwind v4, semantic CSS variables from `app/globals.css`.
|
- UI stack: shadcn `base-nova` on `@base-ui/react`, Lucide, Tailwind v4, and CSS variables in `app/globals.css`. `components/ui/` contains shadcn CLI-managed primitives; the reusable responsive grid list is `components/data-list.tsx`.
|
||||||
- Base UI Select & Combobox:
|
- Base UI: keep `SelectItem` inside `SelectGroup`; give `SelectValue` plain-text children or explicit formatting when item content is complex. For object-valued Combobox `items`, provide both `itemToStringValue` and `itemToStringLabel`.
|
||||||
- `<SelectValue />`: When `<SelectItem>` 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.
|
- Forms use `FieldGroup`, `Field`, and `FieldLabel`; use `FieldSet`/`FieldLegend` only for meaningful groups. Dialogs use `DialogHeader`, content/form, and `DialogFooter` directly inside `DialogContent`.
|
||||||
- `<Combobox>`: 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.
|
- Use `gap-*`, not `space-x-*`/`space-y-*`; use semantic tokens, not hardcoded palettes or manual `dark:` overrides. Icon buttons use `data-icon="inline-start"` or `data-icon="inline-end"`.
|
||||||
- Form composition: Use `FieldGroup`, `Field`, and `FieldLabel`. Put `SelectItem` inside `SelectGroup`. Use `FieldSet`/`FieldLegend` only when grouping adds visible user meaning.
|
- Product-facing copy is Chinese. Do not add English parentheticals to labels, menus, options, or headings; preserve user-entered values and standard tokens such as `CNY` and `Visa`.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
# Next.js template
|
# Fluxent Web
|
||||||
|
|
||||||
This is a Next.js template with shadcn/ui.
|
## 本地开发
|
||||||
|
|
||||||
## Adding components
|
需要已安装 Docker 和 pnpm。在项目目录执行:
|
||||||
|
|
||||||
To add components to your app, run the following command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx shadcn@latest add button
|
pnpm install
|
||||||
|
test -f .env.local || cp .env.example .env.local
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
pnpm db:logs
|
||||||
|
pnpm drizzle-kit push --force
|
||||||
```
|
```
|
||||||
|
|
||||||
This will place the ui components in the `components` directory.
|
数据库镜像通过 `registry1.fw.ac.cn/docker.io/` 代理拉取最新的
|
||||||
|
PostgreSQL Alpine 版本。
|
||||||
|
|
||||||
## Using components
|
`pnpm db:logs` 会持续显示数据库日志,可按 `Ctrl-C` 返回终端。数据库停止后仍会保留命名卷中的数据:
|
||||||
|
|
||||||
To use the components in your app, import them as follows:
|
```bash
|
||||||
|
pnpm db:down
|
||||||
```tsx
|
```
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
如需有意删除数据库及全部数据,执行以下命令。此操作不可恢复:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -71,14 +71,12 @@ const TXN_SCENES = [
|
|||||||
|
|
||||||
function getChannelIdentifier(channel: ChannelWithAccountNames): string {
|
function getChannelIdentifier(channel: ChannelWithAccountNames): string {
|
||||||
if (channel.channelType === "PAYMENT_CARD") {
|
if (channel.channelType === "PAYMENT_CARD") {
|
||||||
let suffix = channel.cardNumberSuffix?.trim()
|
const digits = (
|
||||||
if (
|
channel.cardNumberSuffix ||
|
||||||
!suffix &&
|
channel.cardNumberFull ||
|
||||||
channel.cardNumberFull &&
|
""
|
||||||
channel.cardNumberFull.length >= 4
|
).replace(/\D/g, "")
|
||||||
) {
|
const suffix = digits.length >= 4 ? digits.slice(-4) : digits
|
||||||
suffix = channel.cardNumberFull.slice(-4)
|
|
||||||
}
|
|
||||||
return suffix ? `•••• ${suffix}` : ""
|
return suffix ? `•••• ${suffix}` : ""
|
||||||
}
|
}
|
||||||
if (channel.channelType === "E_WALLET") {
|
if (channel.channelType === "E_WALLET") {
|
||||||
|
|||||||
@@ -275,11 +275,20 @@ export function ChannelDialog({
|
|||||||
cardBrandRequest.current += 1
|
cardBrandRequest.current += 1
|
||||||
setCardBrand((value as CardBrand | null) || "")
|
setCardBrand((value as CardBrand | null) || "")
|
||||||
}}
|
}}
|
||||||
|
itemToStringLabel={(brand: CardBrand) =>
|
||||||
|
brand ? CARD_BRAND_LABELS[brand] || brand : ""
|
||||||
|
}
|
||||||
|
itemToStringValue={(brand: CardBrand) =>
|
||||||
|
brand
|
||||||
|
? `${brand} ${CARD_BRAND_LABELS[brand] || ""}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
autoHighlight
|
autoHighlight
|
||||||
>
|
>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
id="cardBrand"
|
id="cardBrand"
|
||||||
placeholder="选择卡组织"
|
placeholder="选择卡组织"
|
||||||
|
className="w-full"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
<ComboboxContent>
|
<ComboboxContent>
|
||||||
|
|||||||
@@ -118,6 +118,15 @@ function formatCardNumber(cardNumber: string) {
|
|||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCardSuffix(channel: ChannelWithAccountNames) {
|
||||||
|
const digits = (
|
||||||
|
channel.cardNumberSuffix ||
|
||||||
|
channel.cardNumberFull ||
|
||||||
|
""
|
||||||
|
).replace(/\D/g, "")
|
||||||
|
return digits.length >= 4 ? digits.slice(-4) : digits
|
||||||
|
}
|
||||||
|
|
||||||
export function ChannelsView({
|
export function ChannelsView({
|
||||||
initialChannels,
|
initialChannels,
|
||||||
initialAccounts,
|
initialAccounts,
|
||||||
@@ -402,12 +411,7 @@ export function ChannelsView({
|
|||||||
{visibleCardIds.has(channel.id) &&
|
{visibleCardIds.has(channel.id) &&
|
||||||
channel.cardNumberFull
|
channel.cardNumberFull
|
||||||
? formatCardNumber(channel.cardNumberFull)
|
? formatCardNumber(channel.cardNumberFull)
|
||||||
: `•••• ${
|
: `•••• ${getCardSuffix(channel)}`}
|
||||||
channel.cardNumberSuffix ||
|
|
||||||
formatCardNumber(
|
|
||||||
channel.cardNumberFull || ""
|
|
||||||
).slice(-4)
|
|
||||||
}`}
|
|
||||||
</span>
|
</span>
|
||||||
{channel.cardNumberFull && (
|
{channel.cardNumberFull && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { DataList } from "@/components/data-list"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -294,6 +295,7 @@ export function TransactionsView({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
render={<Link href="/bookkeeping" />}
|
render={<Link href="/bookkeeping" />}
|
||||||
|
nativeButton={false}
|
||||||
size="default"
|
size="default"
|
||||||
className="w-full shadow-xs sm:w-auto"
|
className="w-full shadow-xs sm:w-auto"
|
||||||
>
|
>
|
||||||
@@ -361,18 +363,21 @@ export function TransactionsView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分组流水卡片容器 */}
|
{/* 分组流水卡片容器 */}
|
||||||
<div className="overflow-hidden rounded-lg border border-border/70 bg-card shadow-xs">
|
<DataList
|
||||||
|
columns="minmax(200px,2fr) minmax(120px,1.2fr) minmax(140px,1.2fr) minmax(140px,1.2fr) 80px 48px"
|
||||||
|
className="shadow-xs"
|
||||||
|
>
|
||||||
{/* PC 列表表头 */}
|
{/* 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">
|
<DataList.Header>
|
||||||
<span>商户与描述</span>
|
<DataList.Head>商户与描述</DataList.Head>
|
||||||
<span>结算渠道</span>
|
<DataList.Head>结算渠道</DataList.Head>
|
||||||
<span className="text-right">发生金额 (原币)</span>
|
<DataList.Head align="right">发生金额 (原币)</DataList.Head>
|
||||||
<span className="text-right">入账金额 (折算)</span>
|
<DataList.Head align="right">入账金额 (折算)</DataList.Head>
|
||||||
<span className="text-center">场景</span>
|
<DataList.Head align="center">场景</DataList.Head>
|
||||||
<span />
|
<DataList.Head />
|
||||||
</div>
|
</DataList.Header>
|
||||||
|
|
||||||
<div className="divide-y divide-border/60">
|
<DataList.Body>
|
||||||
{group.items.map((txn) => {
|
{group.items.map((txn) => {
|
||||||
const ch = txn.channelDetails?.[0]
|
const ch = txn.channelDetails?.[0]
|
||||||
const isMultiCurrency =
|
const isMultiCurrency =
|
||||||
@@ -435,12 +440,9 @@ export function TransactionsView({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DataList.Row key={txn.id}>
|
||||||
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 端商户描述 */}
|
{/* 移动端顶栏 (商户 + 更多操作在最右侧) / PC 端商户描述 */}
|
||||||
<div className="flex min-w-0 items-center justify-between gap-2 md:block">
|
<DataList.Cell 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 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">
|
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground md:hidden">
|
||||||
{renderChannelLogoOrIcon(ch)}
|
{renderChannelLogoOrIcon(ch)}
|
||||||
@@ -474,23 +476,25 @@ export function TransactionsView({
|
|||||||
<div className="shrink-0 md:hidden">
|
<div className="shrink-0 md:hidden">
|
||||||
{actionMenu}
|
{actionMenu}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DataList.Cell>
|
||||||
|
|
||||||
{/* 结算渠道 */}
|
{/* 结算渠道 */}
|
||||||
<div className="hidden min-w-0 items-center gap-2 text-xs md:flex">
|
<DataList.Cell 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">
|
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
{renderChannelLogoOrIcon(ch)}
|
{renderChannelLogoOrIcon(ch)}
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate text-muted-foreground">
|
<span className="truncate text-muted-foreground">
|
||||||
{ch?.displayName || "未关联渠道"}
|
{ch?.displayName || "未关联渠道"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</DataList.Cell>
|
||||||
|
|
||||||
{/* 发生金额 (原币) */}
|
{/* 发生金额 (原币) */}
|
||||||
<div className="flex items-baseline justify-between md:block md:text-right">
|
<DataList.Cell
|
||||||
<span className="text-xs text-muted-foreground md:hidden">
|
label="发生金额"
|
||||||
发生金额
|
labelClassName="text-xs"
|
||||||
</span>
|
align="right"
|
||||||
|
className="flex items-baseline justify-between md:block"
|
||||||
|
>
|
||||||
<div className="flex items-baseline gap-1 md:justify-end">
|
<div className="flex items-baseline gap-1 md:justify-end">
|
||||||
<span
|
<span
|
||||||
className={`font-mono text-base font-semibold tracking-tight ${
|
className={`font-mono text-base font-semibold tracking-tight ${
|
||||||
@@ -506,13 +510,14 @@ export function TransactionsView({
|
|||||||
{txn.txnCcy}
|
{txn.txnCcy}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DataList.Cell>
|
||||||
|
|
||||||
{/* 入账金额 (折算) */}
|
{/* 入账金额 (折算) */}
|
||||||
<div className="flex items-baseline justify-between text-xs md:block md:text-right">
|
<DataList.Cell
|
||||||
<span className="text-muted-foreground md:hidden">
|
label="入账折算"
|
||||||
入账折算
|
align="right"
|
||||||
</span>
|
className="flex items-baseline justify-between text-xs md:block"
|
||||||
|
>
|
||||||
<div className="flex flex-col items-end gap-0.5">
|
<div className="flex flex-col items-end gap-0.5">
|
||||||
{isMultiCurrency ? (
|
{isMultiCurrency ? (
|
||||||
<>
|
<>
|
||||||
@@ -537,25 +542,27 @@ export function TransactionsView({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DataList.Cell>
|
||||||
|
|
||||||
{/* 场景徽标 */}
|
{/* 场景徽标 */}
|
||||||
<div className="flex items-center justify-between md:justify-center">
|
<DataList.Cell
|
||||||
<span className="text-xs text-muted-foreground md:hidden">
|
label="场景类型"
|
||||||
场景类型
|
labelClassName="text-xs"
|
||||||
</span>
|
align="center"
|
||||||
|
className="flex items-center justify-between md:justify-center"
|
||||||
|
>
|
||||||
{getSceneBadge(txn.txnScene, txn.dcFlag)}
|
{getSceneBadge(txn.txnScene, txn.dcFlag)}
|
||||||
</div>
|
</DataList.Cell>
|
||||||
|
|
||||||
{/* PC 端操作按钮 */}
|
{/* PC 端操作按钮 */}
|
||||||
<div className="hidden md:flex md:justify-end">
|
<DataList.Cell className="hidden md:flex md:justify-end">
|
||||||
{actionMenu}
|
{actionMenu}
|
||||||
</div>
|
</DataList.Cell>
|
||||||
</div>
|
</DataList.Row>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</DataList.Body>
|
||||||
</div>
|
</DataList>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -579,7 +586,11 @@ export function TransactionsView({
|
|||||||
清除筛选
|
清除筛选
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button render={<Link href="/bookkeeping" />} size="sm">
|
<Button
|
||||||
|
render={<Link href="/bookkeeping" />}
|
||||||
|
nativeButton={false}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<PlusIcon data-icon="inline-start" />
|
<PlusIcon data-icon="inline-start" />
|
||||||
前往记账
|
前往记账
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
|
FileUpIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
PenToolIcon,
|
PenToolIcon,
|
||||||
ReceiptTextIcon,
|
ReceiptTextIcon,
|
||||||
@@ -35,6 +36,11 @@ const navMainItems: NavMainItem[] = [
|
|||||||
url: "/bookkeeping",
|
url: "/bookkeeping",
|
||||||
icon: PenToolIcon,
|
icon: PenToolIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "导入账单",
|
||||||
|
url: "/import",
|
||||||
|
icon: FileUpIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "交易记录",
|
title: "交易记录",
|
||||||
url: "/transactions",
|
url: "/transactions",
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
// Intended consumers: transactions, channels, and accounts.
|
||||||
|
|
||||||
|
export type DataListRootProps = React.ComponentProps<"div"> & {
|
||||||
|
columns: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataListRoot({
|
||||||
|
className,
|
||||||
|
columns,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: DataListRootProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="data-list"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-lg border border-border/70 bg-card",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ ...style, "--data-list-cols": columns } as React.CSSProperties}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataListHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="data-list-header"
|
||||||
|
className={cn(
|
||||||
|
"hidden grid-cols-[var(--data-list-cols)] items-center gap-3 border-b bg-muted/30 px-4 py-2 text-[11px] font-medium text-muted-foreground md:grid",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataListHeadProps = React.ComponentProps<"span"> & {
|
||||||
|
align?: "left" | "center" | "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataListHead({
|
||||||
|
align = "left",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DataListHeadProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="data-list-head"
|
||||||
|
className={cn(
|
||||||
|
align === "right" && "text-right",
|
||||||
|
align === "center" && "text-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataListBody({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="data-list-body"
|
||||||
|
className={cn("divide-y divide-border/60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataListRow({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="data-list-row"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-2 p-3 transition-colors hover:bg-muted/30 md:grid md:grid-cols-[var(--data-list-cols)] md:items-center md:gap-3 md:px-4 md:py-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataListCellProps = React.ComponentProps<"div"> & {
|
||||||
|
label?: React.ReactNode
|
||||||
|
labelClassName?: string
|
||||||
|
align?: "left" | "center" | "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataListCell({
|
||||||
|
align = "left",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: DataListCellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="data-list-cell"
|
||||||
|
className={cn(
|
||||||
|
align === "right" && "md:text-right",
|
||||||
|
align === "center" && "md:text-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{label != null && (
|
||||||
|
<span className={cn("text-muted-foreground md:hidden", labelClassName)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataList = Object.assign(DataListRoot, {
|
||||||
|
Header: DataListHeader,
|
||||||
|
Head: DataListHead,
|
||||||
|
Body: DataListBody,
|
||||||
|
Row: DataListRow,
|
||||||
|
Cell: DataListCell,
|
||||||
|
})
|
||||||
@@ -77,6 +77,7 @@ export function NavUser({ user }: { user: NavUserData }) {
|
|||||||
align="end"
|
align="end"
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
<div className="flex items-center gap-2.5 px-1 py-1.5 text-left text-xs">
|
<div className="flex items-center gap-2.5 px-1 py-1.5 text-left text-xs">
|
||||||
<Avatar size="sm" className="rounded-lg">
|
<Avatar size="sm" className="rounded-lg">
|
||||||
@@ -97,6 +98,7 @@ export function NavUser({ user }: { user: NavUserData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-header"
|
||||||
|
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva(
|
||||||
|
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function EmptyMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-sm font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-content"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Empty,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyMedia,
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from "cn"
|
||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon
|
||||||
|
data-slot="spinner"
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn("size-4 animate-spin", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Tab
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Panel
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 text-sm outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: registry1.fw.ac.cn/docker.io/postgres:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: Aa110011
|
||||||
|
POSTGRES_DB: fluxent
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
volumes:
|
||||||
|
- fluxent-postgres-data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" -h 127.0.0.1 -p 5432',
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
fluxent-postgres-data:
|
||||||
+26
-11
@@ -133,10 +133,16 @@ export async function createChannelAction(
|
|||||||
return { success: false, error: "关联的部分账户不存在或已被删除" }
|
return { success: false, error: "关联的部分账户不存在或已被删除" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动提取或补全卡号后4位
|
// 自动提取并清洗纯数字卡号及后4位
|
||||||
let suffix = val.cardNumberSuffix?.trim() || null
|
const cleanFull = val.cardNumberFull
|
||||||
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
|
? val.cardNumberFull.replace(/\D/g, "")
|
||||||
suffix = val.cardNumberFull.slice(-4)
|
: ""
|
||||||
|
const cleanSuffix = val.cardNumberSuffix
|
||||||
|
? val.cardNumberSuffix.replace(/\D/g, "")
|
||||||
|
: ""
|
||||||
|
let suffix = cleanSuffix.slice(-4) || null
|
||||||
|
if (!suffix && cleanFull.length >= 4) {
|
||||||
|
suffix = cleanFull.slice(-4)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [newChannel] = await db
|
const [newChannel] = await db
|
||||||
@@ -151,8 +157,9 @@ export async function createChannelAction(
|
|||||||
region: val.region ? val.region.toUpperCase().trim() : null,
|
region: val.region ? val.region.toUpperCase().trim() : null,
|
||||||
issuerName: val.issuerName?.trim() || null,
|
issuerName: val.issuerName?.trim() || null,
|
||||||
cardType: val.cardType || null,
|
cardType: val.cardType || null,
|
||||||
cardNumberFull: val.cardNumberFull?.trim() || null,
|
cardNumberFull:
|
||||||
cardNumberSuffix: suffix,
|
val.channelType === "PAYMENT_CARD" ? cleanFull || null : null,
|
||||||
|
cardNumberSuffix: val.channelType === "PAYMENT_CARD" ? suffix : null,
|
||||||
cardBrand: val.cardBrand?.trim() || null,
|
cardBrand: val.cardBrand?.trim() || null,
|
||||||
platform: val.platform?.trim() || null,
|
platform: val.platform?.trim() || null,
|
||||||
platformAccountId: val.platformAccountId?.trim() || null,
|
platformAccountId: val.platformAccountId?.trim() || null,
|
||||||
@@ -205,9 +212,16 @@ export async function updateChannelAction(
|
|||||||
return { success: false, error: "关联的部分账户不存在或已被删除" }
|
return { success: false, error: "关联的部分账户不存在或已被删除" }
|
||||||
}
|
}
|
||||||
|
|
||||||
let suffix = val.cardNumberSuffix?.trim() || null
|
// 自动提取并清洗纯数字卡号及后4位
|
||||||
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
|
const cleanFull = val.cardNumberFull
|
||||||
suffix = val.cardNumberFull.slice(-4)
|
? val.cardNumberFull.replace(/\D/g, "")
|
||||||
|
: ""
|
||||||
|
const cleanSuffix = val.cardNumberSuffix
|
||||||
|
? val.cardNumberSuffix.replace(/\D/g, "")
|
||||||
|
: ""
|
||||||
|
let suffix = cleanSuffix.slice(-4) || null
|
||||||
|
if (!suffix && cleanFull.length >= 4) {
|
||||||
|
suffix = cleanFull.slice(-4)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
@@ -220,8 +234,9 @@ export async function updateChannelAction(
|
|||||||
region: val.region ? val.region.toUpperCase().trim() : null,
|
region: val.region ? val.region.toUpperCase().trim() : null,
|
||||||
issuerName: val.issuerName?.trim() || null,
|
issuerName: val.issuerName?.trim() || null,
|
||||||
cardType: val.cardType || null,
|
cardType: val.cardType || null,
|
||||||
cardNumberFull: val.cardNumberFull?.trim() || null,
|
cardNumberFull:
|
||||||
cardNumberSuffix: suffix,
|
val.channelType === "PAYMENT_CARD" ? cleanFull || null : null,
|
||||||
|
cardNumberSuffix: val.channelType === "PAYMENT_CARD" ? suffix : null,
|
||||||
cardBrand: val.cardBrand?.trim() || null,
|
cardBrand: val.cardBrand?.trim() || null,
|
||||||
platform: val.platform?.trim() || null,
|
platform: val.platform?.trim() || null,
|
||||||
platformAccountId: val.platformAccountId?.trim() || null,
|
platformAccountId: val.platformAccountId?.trim() || null,
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { and, eq, isNull, like } from "drizzle-orm"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { db } from "@/lib/db"
|
||||||
|
import {
|
||||||
|
channels,
|
||||||
|
transactions,
|
||||||
|
transactionsDirty,
|
||||||
|
type NewTransactionDirty,
|
||||||
|
} from "@/lib/db/schema"
|
||||||
|
|
||||||
|
export interface CcbaParsedRow {
|
||||||
|
transDate: string
|
||||||
|
postDate: string
|
||||||
|
description: string
|
||||||
|
refNo: string
|
||||||
|
amt: string
|
||||||
|
ccy: string
|
||||||
|
account: string
|
||||||
|
fxAmt: string | null
|
||||||
|
fxCcy: string | null
|
||||||
|
fxRate: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CcbaImportResult {
|
||||||
|
success: boolean
|
||||||
|
imported?: number
|
||||||
|
skipped?: number
|
||||||
|
unmatchedAccounts?: string[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 存入 rawData 的原始行结构(含去重签名) */
|
||||||
|
interface CcbaRawData {
|
||||||
|
transDate: string
|
||||||
|
postDate: string
|
||||||
|
description: string
|
||||||
|
refNo: string
|
||||||
|
amt: string
|
||||||
|
ccy: string
|
||||||
|
account: string
|
||||||
|
fxAmt: string | null
|
||||||
|
fxCcy: string | null
|
||||||
|
fxRate: string | null
|
||||||
|
dedupHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChannelMatchContext {
|
||||||
|
userChannelIds: Set<string>
|
||||||
|
fullDigitsMap: Map<string, string>
|
||||||
|
suffixMap: Map<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireUser() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw new Error("请先登录")
|
||||||
|
}
|
||||||
|
return session.user.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉金额字符串的前导正负号,保留原始精度(不经过浮点往返) */
|
||||||
|
function stripSign(value: string): string {
|
||||||
|
return value.trim().replace(/^[-+]/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 CCBA "YYYY-MM-DD" 日期,非法时返回 null */
|
||||||
|
function parseImportDate(value: string): Date | null {
|
||||||
|
const date = new Date(`${value}T00:00:00`)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从账户描述中提取不少于 12 位的连续数字卡号 */
|
||||||
|
function extractCardDigits(
|
||||||
|
account: string
|
||||||
|
): { fullDigits: string; last4: string } | null {
|
||||||
|
const match = /\d{12,}/.exec(account)
|
||||||
|
if (!match) return null
|
||||||
|
return { fullDigits: match[0], last4: match[0].slice(-4) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉描述末尾的大写两位国家码,返回商户名与地理位置 */
|
||||||
|
function splitMerchantAndGeo(description: string): {
|
||||||
|
merchantName: string
|
||||||
|
geo: string | null
|
||||||
|
} {
|
||||||
|
const trimmed = description.trim()
|
||||||
|
const match = /\s+([A-Z]{2})$/.exec(trimmed)
|
||||||
|
if (!match) {
|
||||||
|
return { merchantName: trimmed.slice(0, 255), geo: null }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
merchantName: trimmed.slice(0, match.index).trim().slice(0, 255),
|
||||||
|
geo: match[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建渠道匹配索引:完整卡号 -> id、卡号后缀 -> id */
|
||||||
|
function buildChannelMatchContext(
|
||||||
|
userChannels: {
|
||||||
|
id: string
|
||||||
|
cardNumberFull: string | null
|
||||||
|
cardNumberSuffix: string | null
|
||||||
|
}[]
|
||||||
|
): ChannelMatchContext {
|
||||||
|
const userChannelIds = new Set<string>()
|
||||||
|
const fullDigitsMap = new Map<string, string>()
|
||||||
|
const suffixMap = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const channel of userChannels) {
|
||||||
|
userChannelIds.add(channel.id)
|
||||||
|
|
||||||
|
const fullDigits = channel.cardNumberFull?.replace(/\D/g, "") ?? ""
|
||||||
|
if (fullDigits && !fullDigitsMap.has(fullDigits)) {
|
||||||
|
fullDigitsMap.set(fullDigits, channel.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = channel.cardNumberSuffix?.replace(/\D/g, "") ?? ""
|
||||||
|
if (suffix.length >= 4 && !suffixMap.has(suffix)) {
|
||||||
|
suffixMap.set(suffix, channel.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userChannelIds, fullDigitsMap, suffixMap }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析账户字符串对应的渠道 id:
|
||||||
|
* 手动映射优先(且必须属于当前用户),否则按完整卡号、再按后四位自动匹配。
|
||||||
|
*/
|
||||||
|
function resolveChannelId(
|
||||||
|
account: string,
|
||||||
|
accountChannelMap: Record<string, string> | undefined,
|
||||||
|
ctx: ChannelMatchContext
|
||||||
|
): string | null {
|
||||||
|
const manualId = accountChannelMap?.[account]
|
||||||
|
if (manualId && ctx.userChannelIds.has(manualId)) {
|
||||||
|
return manualId
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = extractCardDigits(account)
|
||||||
|
if (!digits) return null
|
||||||
|
|
||||||
|
const byFull = ctx.fullDigitsMap.get(digits.fullDigits)
|
||||||
|
if (byFull) return byFull
|
||||||
|
|
||||||
|
return ctx.suffixMap.get(digits.last4) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导入去重签名:优先银行流水号,缺失时退化为行内容哈希 */
|
||||||
|
function buildSignature(row: CcbaParsedRow): string {
|
||||||
|
const refNo = row.refNo.trim()
|
||||||
|
return refNo !== ""
|
||||||
|
? `ccba:ref:${refNo}`
|
||||||
|
: `ccba:hash:${row.transDate}|${row.account}|${row.amt}|${row.description}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importCcbaRowsAction(
|
||||||
|
rows: CcbaParsedRow[],
|
||||||
|
accountChannelMap?: Record<string, string>
|
||||||
|
): Promise<CcbaImportResult> {
|
||||||
|
try {
|
||||||
|
const userId = await requireUser()
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return { success: false, error: "没有可导入的账单记录" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const userChannels = await db
|
||||||
|
.select({
|
||||||
|
id: channels.id,
|
||||||
|
cardNumberFull: channels.cardNumberFull,
|
||||||
|
cardNumberSuffix: channels.cardNumberSuffix,
|
||||||
|
})
|
||||||
|
.from(channels)
|
||||||
|
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
|
||||||
|
|
||||||
|
const ctx = buildChannelMatchContext(userChannels)
|
||||||
|
|
||||||
|
// 已存在的导入签名(待清洗表 + 正式账本)
|
||||||
|
const [dirtyExts, txnExts] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ ext: transactionsDirty.ext })
|
||||||
|
.from(transactionsDirty)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactionsDirty.userId, userId),
|
||||||
|
isNull(transactionsDirty.deletedAt),
|
||||||
|
like(transactionsDirty.ext, "ccba:%")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({ ext: transactions.ext })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
isNull(transactions.deletedAt),
|
||||||
|
like(transactions.ext, "ccba:%")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const existingSignatures = new Set<string>()
|
||||||
|
for (const existing of dirtyExts) {
|
||||||
|
if (existing.ext) existingSignatures.add(existing.ext)
|
||||||
|
}
|
||||||
|
for (const existing of txnExts) {
|
||||||
|
if (existing.ext) existingSignatures.add(existing.ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedValues: NewTransactionDirty[] = []
|
||||||
|
const batchSignatures = new Set<string>()
|
||||||
|
const unmatchedAccounts = new Set<string>()
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const txnDate = parseImportDate(row.transDate)
|
||||||
|
const postingDate = parseImportDate(row.postDate)
|
||||||
|
if (!txnDate || !postingDate) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const amtNum = parseFloat(row.amt)
|
||||||
|
if (Number.isNaN(amtNum)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = resolveChannelId(row.account, accountChannelMap, ctx)
|
||||||
|
if (!channelId) {
|
||||||
|
unmatchedAccounts.add(row.account)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = buildSignature(row)
|
||||||
|
if (existingSignatures.has(signature) || batchSignatures.has(signature)) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batchSignatures.add(signature)
|
||||||
|
|
||||||
|
const ccy = row.ccy.toUpperCase()
|
||||||
|
const isForeign = Boolean(
|
||||||
|
row.fxAmt && row.fxCcy && row.fxCcy.toUpperCase() !== ccy
|
||||||
|
)
|
||||||
|
const { merchantName, geo } = splitMerchantAndGeo(row.description)
|
||||||
|
|
||||||
|
const dcFlag = amtNum < 0 ? "CREDIT" : "DEBIT"
|
||||||
|
let txnScene = "PAYMENT"
|
||||||
|
if (row.description.includes("INHOUSE FUND TRANSFER")) {
|
||||||
|
txnScene = "TRANSFER"
|
||||||
|
} else if (amtNum < 0 && /rebate/i.test(row.description)) {
|
||||||
|
txnScene = "MISC_IN"
|
||||||
|
}
|
||||||
|
|
||||||
|
let txnAmt: string
|
||||||
|
let txnCcy: string
|
||||||
|
let postingAmt: string | null
|
||||||
|
let postingCcy: string | null
|
||||||
|
let fxRates: { fromCcy: string; toCcy: string; rate: string }[] | null
|
||||||
|
|
||||||
|
if (isForeign && row.fxAmt && row.fxCcy) {
|
||||||
|
const fxCcy = row.fxCcy.toUpperCase()
|
||||||
|
txnAmt = stripSign(row.fxAmt)
|
||||||
|
txnCcy = fxCcy
|
||||||
|
postingAmt = stripSign(row.amt)
|
||||||
|
postingCcy = ccy
|
||||||
|
fxRates = row.fxRate
|
||||||
|
? [{ fromCcy: fxCcy, toCcy: ccy, rate: row.fxRate }]
|
||||||
|
: null
|
||||||
|
} else {
|
||||||
|
txnAmt = stripSign(row.amt)
|
||||||
|
txnCcy = ccy
|
||||||
|
postingAmt = null
|
||||||
|
postingCcy = null
|
||||||
|
fxRates = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawData: CcbaRawData = {
|
||||||
|
transDate: row.transDate,
|
||||||
|
postDate: row.postDate,
|
||||||
|
description: row.description,
|
||||||
|
refNo: row.refNo,
|
||||||
|
amt: row.amt,
|
||||||
|
ccy: row.ccy,
|
||||||
|
account: row.account,
|
||||||
|
fxAmt: row.fxAmt,
|
||||||
|
fxCcy: row.fxCcy,
|
||||||
|
fxRate: row.fxRate,
|
||||||
|
dedupHash: signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
insertedValues.push({
|
||||||
|
userId,
|
||||||
|
txnDate,
|
||||||
|
postingDate,
|
||||||
|
txnAmt,
|
||||||
|
txnCcy,
|
||||||
|
postingAmt,
|
||||||
|
postingCcy,
|
||||||
|
fxRates,
|
||||||
|
dcFlag,
|
||||||
|
refChannels: [channelId],
|
||||||
|
txnScene,
|
||||||
|
merchantName,
|
||||||
|
description: row.description,
|
||||||
|
rawDescription: row.description,
|
||||||
|
rawData,
|
||||||
|
// ext 存导入去重签名;银行 ref_no 仅保留在上方 rawData 内
|
||||||
|
ext: signature,
|
||||||
|
geo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insertedValues.length > 0) {
|
||||||
|
await db.insert(transactionsDirty).values(insertedValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/bookkeeping")
|
||||||
|
revalidatePath("/import")
|
||||||
|
|
||||||
|
const result: CcbaImportResult = {
|
||||||
|
success: true,
|
||||||
|
imported: insertedValues.length,
|
||||||
|
skipped,
|
||||||
|
}
|
||||||
|
if (unmatchedAccounts.size > 0) {
|
||||||
|
result.unmatchedAccounts = Array.from(unmatchedAccounts)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
console.error("importCcbaRowsAction error:", err)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "导入账单失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"db:up": "docker compose up -d --wait",
|
||||||
|
"db:down": "docker compose down",
|
||||||
|
"db:logs": "docker compose logs -f db",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
Reference in New Issue
Block a user