487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
"use client"
|
|
import * as React from "react"
|
|
import Image from "next/image"
|
|
import { CircleAlertIcon, Loader2Icon } from "lucide-react"
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Field,
|
|
FieldGroup,
|
|
FieldLabel,
|
|
FieldLegend,
|
|
FieldSet,
|
|
} from "@/components/ui/field"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
Combobox,
|
|
ComboboxContent,
|
|
ComboboxEmpty,
|
|
ComboboxInput,
|
|
ComboboxItem,
|
|
ComboboxList,
|
|
} from "@/components/ui/combobox"
|
|
import {
|
|
CARD_BRANDS,
|
|
CARD_BRAND_LABELS,
|
|
detectCardBrand,
|
|
getCardBrandLogoUrl,
|
|
normalizeCardBrand,
|
|
type CardBrand,
|
|
} from "@/lib/payment/card-brand"
|
|
import {
|
|
createChannelAction,
|
|
updateChannelAction,
|
|
type ChannelInput,
|
|
type ChannelWithAccountNames,
|
|
} from "@/lib/actions/channel"
|
|
import { type AccountWithChannels } from "@/lib/actions/account"
|
|
type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER"
|
|
const channelLabels = {
|
|
PAYMENT_CARD: "支付卡",
|
|
E_WALLET: "电子钱包",
|
|
CASH: "现金",
|
|
TRANSFER: "转账",
|
|
} as const
|
|
export function ChannelDialog({
|
|
open,
|
|
onOpenChange,
|
|
channel,
|
|
accounts,
|
|
onSuccess,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
channel?: ChannelWithAccountNames | null
|
|
accounts: AccountWithChannels[]
|
|
onSuccess: () => void
|
|
}) {
|
|
const editing = Boolean(channel)
|
|
const [channelType, setChannelType] = React.useState<ChannelType>(
|
|
channel?.channelType || "PAYMENT_CARD"
|
|
)
|
|
const [selectedAccounts, setSelectedAccounts] = React.useState<string[]>(
|
|
channel
|
|
? Array.isArray(channel.refAccounts)
|
|
? channel.refAccounts
|
|
: []
|
|
: accounts.length
|
|
? [accounts[0].id]
|
|
: []
|
|
)
|
|
const [desc, setDesc] = React.useState(channel?.desc || "")
|
|
const [region, setRegion] = React.useState(channel?.region || "")
|
|
const [issuerName, setIssuerName] = React.useState(channel?.issuerName || "")
|
|
const [cardType, setCardType] = React.useState<"CREDIT" | "DEBIT" | "NONE">(
|
|
channel?.cardType || "CREDIT"
|
|
)
|
|
const [cardBrand, setCardBrand] = React.useState(channel?.cardBrand || "")
|
|
const [cardNumberFull, setCardNumberFull] = React.useState(
|
|
channel?.cardNumberFull || ""
|
|
)
|
|
const [cardNumberSuffix, setCardNumberSuffix] = React.useState(
|
|
channel?.cardNumberFull ? "" : channel?.cardNumberSuffix || ""
|
|
)
|
|
const [platform, setPlatform] = React.useState(channel?.platform || "")
|
|
const [platformAccountId, setPlatformAccountId] = React.useState(
|
|
channel?.platformAccountId || ""
|
|
)
|
|
const [subChannel, setSubChannel] = React.useState(channel?.subChannel || "")
|
|
const [subChannelType, setSubChannelType] = React.useState<
|
|
"CREDIT" | "DEBIT" | "NONE"
|
|
>(channel?.subChannelType || "DEBIT")
|
|
const [isPending, setIsPending] = React.useState(false)
|
|
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
|
|
const cardBrandRequest = React.useRef(0)
|
|
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault()
|
|
setErrorMessage(null)
|
|
if (!selectedAccounts.length) {
|
|
setErrorMessage("请至少关联一个资金账户")
|
|
return
|
|
}
|
|
const full = cardNumberFull.trim()
|
|
const payload: ChannelInput = {
|
|
channelType,
|
|
refAccounts: selectedAccounts,
|
|
desc: desc.trim() || null,
|
|
region: region.trim().toUpperCase() || null,
|
|
issuerName:
|
|
channelType === "PAYMENT_CARD" ? issuerName.trim() || null : null,
|
|
cardType:
|
|
channelType === "PAYMENT_CARD" && cardType !== "NONE" ? cardType : null,
|
|
cardNumberFull: channelType === "PAYMENT_CARD" ? full || null : null,
|
|
cardNumberSuffix:
|
|
channelType === "PAYMENT_CARD" && !full
|
|
? cardNumberSuffix.trim() || null
|
|
: null,
|
|
cardBrand:
|
|
channelType === "PAYMENT_CARD" ? normalizeCardBrand(cardBrand) : null,
|
|
platform: channelType === "E_WALLET" ? platform.trim() || null : null,
|
|
platformAccountId:
|
|
channelType === "E_WALLET" ? platformAccountId.trim() || null : null,
|
|
subChannel: channelType === "E_WALLET" ? subChannel.trim() || null : null,
|
|
subChannelType:
|
|
channelType === "E_WALLET" && subChannelType !== "NONE"
|
|
? subChannelType
|
|
: null,
|
|
}
|
|
setIsPending(true)
|
|
try {
|
|
const result =
|
|
editing && channel
|
|
? await updateChannelAction(channel.id, payload)
|
|
: await createChannelAction(payload)
|
|
if (!result.success) {
|
|
setErrorMessage(result.error || "保存失败,请检查输入")
|
|
return
|
|
}
|
|
onOpenChange(false)
|
|
onSuccess()
|
|
} catch {
|
|
setErrorMessage("网络异常,提交失败")
|
|
} finally {
|
|
setIsPending(false)
|
|
}
|
|
}
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{editing ? "编辑支付渠道" : "新建支付渠道"}</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
配置渠道信息,并指定实际结算的资金账户。
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form id="channel-form" onSubmit={submit}>
|
|
<FieldGroup>
|
|
{errorMessage && (
|
|
<Alert variant="destructive">
|
|
<CircleAlertIcon />
|
|
<AlertTitle>无法保存渠道</AlertTitle>
|
|
<AlertDescription>{errorMessage}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<FieldGroup>
|
|
<Field>
|
|
<FieldLabel htmlFor="desc">描述</FieldLabel>
|
|
<Input
|
|
id="desc"
|
|
value={desc}
|
|
onChange={(event) => setDesc(event.target.value)}
|
|
placeholder="例如:日常主用渠道"
|
|
disabled={isPending}
|
|
/>
|
|
</Field>
|
|
</FieldGroup>
|
|
<FieldGroup>
|
|
<Field>
|
|
<FieldLabel htmlFor="channelType">类型</FieldLabel>
|
|
<Select
|
|
value={channelType}
|
|
onValueChange={(value) =>
|
|
value && setChannelType(value as ChannelType)
|
|
}
|
|
>
|
|
<SelectTrigger id="channelType">
|
|
<SelectValue>{channelLabels[channelType]}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="PAYMENT_CARD">支付卡</SelectItem>
|
|
<SelectItem value="E_WALLET">电子钱包</SelectItem>
|
|
<SelectItem value="CASH">现金</SelectItem>
|
|
<SelectItem value="TRANSFER">转账</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
</FieldGroup>
|
|
<FieldSet>
|
|
<div className="flex items-center justify-between">
|
|
<FieldLegend variant="label">归属账户</FieldLegend>
|
|
<span className="text-xs text-muted-foreground">
|
|
已选 {selectedAccounts.length} 个
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
{accounts.length ? (
|
|
accounts.map((account) => (
|
|
<label
|
|
key={account.id}
|
|
className="flex cursor-pointer items-center gap-2 rounded-md border border-border/70 px-3 py-2 text-xs hover:bg-muted/50"
|
|
>
|
|
<Checkbox
|
|
checked={selectedAccounts.includes(account.id)}
|
|
onCheckedChange={(checked) =>
|
|
setSelectedAccounts((items) =>
|
|
checked
|
|
? [...items, account.id]
|
|
: items.filter((id) => id !== account.id)
|
|
)
|
|
}
|
|
disabled={isPending}
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{account.name}
|
|
</span>
|
|
<span className="font-mono text-muted-foreground">
|
|
{account.primaryCurrency || "通用"}
|
|
</span>
|
|
</label>
|
|
))
|
|
) : (
|
|
<p className="border border-dashed border-border p-3 text-center text-xs text-muted-foreground">
|
|
暂无资金账户,请先创建账户。
|
|
</p>
|
|
)}
|
|
</div>
|
|
</FieldSet>
|
|
<FieldGroup>
|
|
{channelType === "PAYMENT_CARD" && (
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Field>
|
|
<FieldLabel htmlFor="issuerName">发卡机构</FieldLabel>
|
|
<Input
|
|
id="issuerName"
|
|
value={issuerName}
|
|
onChange={(event) => setIssuerName(event.target.value)}
|
|
placeholder="例如:汇丰银行"
|
|
disabled={isPending}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="cardBrand">卡组织</FieldLabel>
|
|
<Combobox
|
|
items={CARD_BRANDS}
|
|
value={normalizeCardBrand(cardBrand)}
|
|
onValueChange={(value) => {
|
|
cardBrandRequest.current += 1
|
|
setCardBrand((value as CardBrand | null) || "")
|
|
}}
|
|
autoHighlight
|
|
>
|
|
<ComboboxInput
|
|
id="cardBrand"
|
|
placeholder="选择卡组织"
|
|
disabled={isPending}
|
|
/>
|
|
<ComboboxContent>
|
|
<ComboboxEmpty>没有匹配的卡组织</ComboboxEmpty>
|
|
<ComboboxList>
|
|
{(brand: CardBrand) => (
|
|
<ComboboxItem key={brand} value={brand}>
|
|
<Image
|
|
src={getCardBrandLogoUrl(brand) || ""}
|
|
alt=""
|
|
width={20}
|
|
height={20}
|
|
className="size-5 object-contain"
|
|
/>
|
|
{CARD_BRAND_LABELS[brand]}
|
|
</ComboboxItem>
|
|
)}
|
|
</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="cardType">卡片类型</FieldLabel>
|
|
<Select
|
|
value={cardType}
|
|
onValueChange={(value) =>
|
|
value && setCardType(value as typeof cardType)
|
|
}
|
|
>
|
|
<SelectTrigger id="cardType">
|
|
<SelectValue>
|
|
{cardType === "CREDIT"
|
|
? "信用卡"
|
|
: cardType === "DEBIT"
|
|
? "借记卡"
|
|
: "未指定"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="CREDIT">信用卡</SelectItem>
|
|
<SelectItem value="DEBIT">借记卡</SelectItem>
|
|
<SelectItem value="NONE">未指定</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="region">地区代码</FieldLabel>
|
|
<Input
|
|
id="region"
|
|
value={region}
|
|
onChange={(event) => setRegion(event.target.value)}
|
|
placeholder="例如:HK"
|
|
disabled={isPending}
|
|
/>
|
|
</Field>
|
|
{!cardNumberSuffix && (
|
|
<Field>
|
|
<FieldLabel htmlFor="cardNumberFull">完整卡号</FieldLabel>
|
|
<Input
|
|
id="cardNumberFull"
|
|
value={cardNumberFull}
|
|
className="font-mono"
|
|
onChange={(event) => {
|
|
const value = event.target.value
|
|
const request = ++cardBrandRequest.current
|
|
setCardNumberFull(value)
|
|
if (value) setCardNumberSuffix("")
|
|
if (!value) return
|
|
void detectCardBrand(value).then((brand) => {
|
|
if (request === cardBrandRequest.current && brand)
|
|
setCardBrand(brand)
|
|
})
|
|
}}
|
|
placeholder="可留空"
|
|
disabled={isPending}
|
|
/>
|
|
</Field>
|
|
)}
|
|
{!cardNumberFull && (
|
|
<Field>
|
|
<FieldLabel htmlFor="cardNumberSuffix">
|
|
卡号末尾四位
|
|
</FieldLabel>
|
|
<Input
|
|
id="cardNumberSuffix"
|
|
value={cardNumberSuffix}
|
|
className="font-mono"
|
|
onChange={(event) => {
|
|
const value = event.target.value
|
|
setCardNumberSuffix(value)
|
|
if (value) setCardNumberFull("")
|
|
}}
|
|
placeholder="例如:8888"
|
|
disabled={isPending}
|
|
/>
|
|
</Field>
|
|
)}
|
|
</div>
|
|
)}
|
|
{channelType === "E_WALLET" && (
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<Field>
|
|
<FieldLabel htmlFor="platform">平台名称</FieldLabel>
|
|
<Input
|
|
id="platform"
|
|
value={platform}
|
|
onChange={(event) => setPlatform(event.target.value)}
|
|
placeholder="例如:支付宝"
|
|
disabled={isPending}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="platformAccountId">
|
|
平台账号
|
|
</FieldLabel>
|
|
<Input
|
|
id="platformAccountId"
|
|
value={platformAccountId}
|
|
onChange={(event) =>
|
|
setPlatformAccountId(event.target.value)
|
|
}
|
|
placeholder="手机号或邮箱"
|
|
disabled={isPending}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="subChannel">子渠道名称</FieldLabel>
|
|
<Input
|
|
id="subChannel"
|
|
value={subChannel}
|
|
onChange={(event) => setSubChannel(event.target.value)}
|
|
placeholder="例如:余额"
|
|
disabled={isPending}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="subChannelType">子渠道类型</FieldLabel>
|
|
<Select
|
|
value={subChannelType}
|
|
onValueChange={(value) =>
|
|
value &&
|
|
setSubChannelType(value as typeof subChannelType)
|
|
}
|
|
>
|
|
<SelectTrigger id="subChannelType">
|
|
<SelectValue>
|
|
{subChannelType === "CREDIT"
|
|
? "信用消费"
|
|
: subChannelType === "DEBIT"
|
|
? "借记储值"
|
|
: "未指定"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="DEBIT">借记储值</SelectItem>
|
|
<SelectItem value="CREDIT">信用消费</SelectItem>
|
|
<SelectItem value="NONE">未指定</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="walletRegion">地区代码</FieldLabel>
|
|
<Input
|
|
id="walletRegion"
|
|
value={region}
|
|
onChange={(event) => setRegion(event.target.value)}
|
|
placeholder="例如:HK"
|
|
disabled={isPending}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
{(channelType === "CASH" || channelType === "TRANSFER") && (
|
|
<p className="text-xs text-muted-foreground">
|
|
此类型无需额外信息。
|
|
</p>
|
|
)}
|
|
</FieldGroup>
|
|
</FieldGroup>
|
|
</form>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isPending}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button form="channel-form" type="submit" disabled={isPending}>
|
|
{isPending && (
|
|
<Loader2Icon data-icon="inline-start" className="animate-spin" />
|
|
)}
|
|
{editing ? "保存修改" : "创建渠道"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|