dd837cac7c
- prize-form: 解禁 crypto/physical/manual_other 并加各自 config;权重改为中奖概率(%), 合计校验;vpn_duration 加"无订阅时新建订阅套餐" - prize-grid: 概率合计=100% 校验告警;卡片渲染真实 config(时长/金额) - claims-panel(新): 领奖工单列表 + 审核通过/驳回/标记发放 - activity-detail: 新增"领奖工单"Tab - service: 奖品改/删 id 走 /prizes/:id(对齐后端 REST);新增 claims 5 个接口 + 类型 - i18n: 补齐 zh-CN/en-US lottery 文案 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
556 lines
18 KiB
TypeScript
556 lines
18 KiB
TypeScript
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { Badge } from "@workspace/ui/components/badge";
|
|
import { Button } from "@workspace/ui/components/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@workspace/ui/components/dialog";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@workspace/ui/components/form";
|
|
import { Input } from "@workspace/ui/components/input";
|
|
import { Textarea } from "@workspace/ui/components/textarea";
|
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
|
import {
|
|
ProTable,
|
|
type ProTableActions,
|
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
|
import {
|
|
approveLotteryClaim,
|
|
getLotteryClaimList,
|
|
markPaidLotteryClaim,
|
|
rejectLotteryClaim,
|
|
} from "@workspace/ui/services/admin/lottery";
|
|
import { type ReactNode, useRef, useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { z } from "zod";
|
|
import { formatDate } from "@/utils/common";
|
|
|
|
interface ClaimsPanelProps {
|
|
activity: API.LotteryActivity;
|
|
}
|
|
|
|
type TranslateFn = (key: string, defaultValue: string) => string;
|
|
|
|
const STATUS_META: Record<
|
|
API.LotteryClaimStatus,
|
|
{ labelKey: string; labelDefault: string; className: string }
|
|
> = {
|
|
pending_claim: {
|
|
labelKey: "claim.statusPending",
|
|
labelDefault: "Awaiting user",
|
|
className: "bg-gray-100 text-gray-600 border-gray-200",
|
|
},
|
|
reviewing: {
|
|
labelKey: "claim.statusReviewing",
|
|
labelDefault: "Reviewing",
|
|
className: "bg-amber-100 text-amber-700 border-amber-200",
|
|
},
|
|
paying: {
|
|
labelKey: "claim.statusPaying",
|
|
labelDefault: "Paying / Shipping",
|
|
className: "bg-blue-100 text-blue-700 border-blue-200",
|
|
},
|
|
paid: {
|
|
labelKey: "claim.statusPaid",
|
|
labelDefault: "Fulfilled",
|
|
className: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
|
},
|
|
rejected: {
|
|
labelKey: "claim.statusRejected",
|
|
labelDefault: "Rejected",
|
|
className: "bg-rose-100 text-rose-700 border-rose-200",
|
|
},
|
|
expired: {
|
|
labelKey: "claim.statusExpired",
|
|
labelDefault: "Expired",
|
|
className: "bg-gray-100 text-gray-500 border-gray-200",
|
|
},
|
|
};
|
|
|
|
function prizeTypeLabel(type: API.LotteryPrizeType, t: TranslateFn): string {
|
|
const labels: Record<string, string> = {
|
|
crypto: t("prize.typeCrypto", "Crypto"),
|
|
physical: t("prize.typePhysical", "Physical"),
|
|
manual_other: t("prize.typeManualOther", "Manual (Other)"),
|
|
};
|
|
return labels[type] ?? type;
|
|
}
|
|
|
|
function ClaimDataCell({ data }: { data?: Record<string, any> }) {
|
|
if (!data || Object.keys(data).length === 0) {
|
|
return <span className="text-muted-foreground">--</span>;
|
|
}
|
|
return (
|
|
<div className="max-w-[240px] space-y-0.5 text-xs">
|
|
{Object.entries(data).map(([key, value]) => (
|
|
<div className="break-all" key={key}>
|
|
<span className="text-muted-foreground">{key}:</span>{" "}
|
|
<span className="font-medium">{String(value)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const rejectSchema = (t: TranslateFn) =>
|
|
z.object({
|
|
reason: z
|
|
.string()
|
|
.trim()
|
|
.min(2, t("claim.reasonRequired", "Please enter a reason"))
|
|
.max(512, t("claim.reasonTooLong", "Reason cannot exceed 512 chars")),
|
|
});
|
|
|
|
const markPaidSchema = z.object({
|
|
tx_hash: z.string().trim().max(128).optional(),
|
|
delivery_ref: z.string().trim().max(128).optional(),
|
|
});
|
|
|
|
export default function ClaimsPanel({ activity }: ClaimsPanelProps) {
|
|
const { t } = useTranslation("lottery");
|
|
const ref = useRef<ProTableActions>(null);
|
|
const [rejectOpen, setRejectOpen] = useState(false);
|
|
const [paidOpen, setPaidOpen] = useState(false);
|
|
const [currentRow, setCurrentRow] = useState<API.AdminLotteryClaim | null>(
|
|
null
|
|
);
|
|
|
|
const rejectForm = useForm<{ reason: string }>({
|
|
resolver: zodResolver(rejectSchema(t)),
|
|
defaultValues: { reason: "" },
|
|
});
|
|
const paidForm = useForm<z.infer<typeof markPaidSchema>>({
|
|
resolver: zodResolver(markPaidSchema),
|
|
defaultValues: { tx_hash: "", delivery_ref: "" },
|
|
});
|
|
|
|
const approveMutation = useMutation({
|
|
mutationFn: (id: number) => approveLotteryClaim({ id }),
|
|
onSuccess: () => {
|
|
toast.success(t("claim.approveSuccess", "Claim approved"));
|
|
ref.current?.refresh();
|
|
},
|
|
});
|
|
|
|
const rejectMutation = useMutation({
|
|
mutationFn: (body: API.RejectLotteryClaimRequest) =>
|
|
rejectLotteryClaim(body),
|
|
onSuccess: () => {
|
|
toast.success(t("claim.rejectSuccess", "Claim rejected"));
|
|
setRejectOpen(false);
|
|
setCurrentRow(null);
|
|
rejectForm.reset({ reason: "" });
|
|
ref.current?.refresh();
|
|
},
|
|
});
|
|
|
|
const paidMutation = useMutation({
|
|
mutationFn: (body: API.MarkPaidLotteryClaimRequest) =>
|
|
markPaidLotteryClaim(body),
|
|
onSuccess: () => {
|
|
toast.success(t("claim.markPaidSuccess", "Marked as fulfilled"));
|
|
setPaidOpen(false);
|
|
setCurrentRow(null);
|
|
paidForm.reset({ tx_hash: "", delivery_ref: "" });
|
|
ref.current?.refresh();
|
|
},
|
|
});
|
|
|
|
const statusOptions = (
|
|
Object.keys(STATUS_META) as API.LotteryClaimStatus[]
|
|
).map((value) => ({
|
|
value,
|
|
label: t(STATUS_META[value].labelKey, STATUS_META[value].labelDefault),
|
|
}));
|
|
|
|
const typeOptions = [
|
|
{ value: "crypto", label: t("prize.typeCrypto", "Crypto") },
|
|
{ value: "physical", label: t("prize.typePhysical", "Physical") },
|
|
{
|
|
value: "manual_other",
|
|
label: t("prize.typeManualOther", "Manual (Other)"),
|
|
},
|
|
];
|
|
|
|
const openReject = (row: API.AdminLotteryClaim) => {
|
|
setCurrentRow(row);
|
|
rejectForm.reset({ reason: "" });
|
|
setRejectOpen(true);
|
|
};
|
|
const openPaid = (row: API.AdminLotteryClaim) => {
|
|
setCurrentRow(row);
|
|
paidForm.reset({ tx_hash: "", delivery_ref: "" });
|
|
setPaidOpen(true);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ProTable<
|
|
API.AdminLotteryClaim,
|
|
{ type?: string; status?: string; user_id?: string }
|
|
>
|
|
action={ref}
|
|
actions={{
|
|
render: (row) => {
|
|
const actions: ReactNode[] = [];
|
|
if (row.status === "reviewing") {
|
|
actions.push(
|
|
<ConfirmButton
|
|
cancelText={t("claim.cancel", "Cancel")}
|
|
confirmText={t("claim.confirm", "Confirm")}
|
|
description={t(
|
|
"claim.approveDescription",
|
|
"Approving moves this claim to the paying/shipping stage."
|
|
)}
|
|
key="approve"
|
|
onConfirm={async () => {
|
|
await approveMutation.mutateAsync(row.id);
|
|
}}
|
|
title={t("claim.approveTitle", "Approve this claim?")}
|
|
trigger={
|
|
<Button size="sm" variant="default">
|
|
{t("claim.approve", "Approve")}
|
|
</Button>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (row.status === "paying") {
|
|
actions.push(
|
|
<Button
|
|
key="mark-paid"
|
|
onClick={() => openPaid(row)}
|
|
size="sm"
|
|
variant="default"
|
|
>
|
|
{t("claim.markPaid", "Mark fulfilled")}
|
|
</Button>
|
|
);
|
|
}
|
|
if (row.status === "reviewing" || row.status === "paying") {
|
|
actions.push(
|
|
<Button
|
|
key="reject"
|
|
onClick={() => openReject(row)}
|
|
size="sm"
|
|
variant="destructive"
|
|
>
|
|
{t("claim.reject", "Reject")}
|
|
</Button>
|
|
);
|
|
}
|
|
return actions;
|
|
},
|
|
}}
|
|
columns={[
|
|
{ accessorKey: "id", header: "ID" },
|
|
{
|
|
accessorKey: "user",
|
|
header: t("claim.user", "User"),
|
|
cell: ({ row }) => (
|
|
<div className="text-sm">
|
|
<div>#{row.original.user.id}</div>
|
|
{row.original.user.email && (
|
|
<div className="text-muted-foreground text-xs">
|
|
{row.original.user.email}
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "prize",
|
|
header: t("claim.prize", "Prize"),
|
|
cell: ({ row }) => (
|
|
<div className="text-sm">
|
|
<div className="font-medium">{row.original.prize.name}</div>
|
|
<Badge className="mt-0.5" variant="secondary">
|
|
{prizeTypeLabel(row.original.prize.type, t)}
|
|
</Badge>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "claim_data",
|
|
header: t("claim.claimData", "Claim info"),
|
|
cell: ({ row }) => <ClaimDataCell data={row.original.claim_data} />,
|
|
},
|
|
{
|
|
accessorKey: "status",
|
|
header: t("claim.status", "Status"),
|
|
cell: ({ row }) => {
|
|
const meta = STATUS_META[row.original.status];
|
|
return (
|
|
<Badge className={meta?.className} variant="outline">
|
|
{meta
|
|
? t(meta.labelKey, meta.labelDefault)
|
|
: row.original.status}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "fulfillment",
|
|
header: t("claim.fulfillment", "Tx / Delivery"),
|
|
cell: ({ row }) => (
|
|
<div className="max-w-[180px] break-all text-xs">
|
|
{row.original.tx_hash || row.original.delivery_ref || "--"}
|
|
{row.original.reject_reason && (
|
|
<div className="text-rose-600">
|
|
{row.original.reject_reason}
|
|
</div>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "expires_at",
|
|
header: t("claim.expiresAt", "Expires"),
|
|
cell: ({ row }) =>
|
|
row.original.expires_at
|
|
? formatDate(row.original.expires_at)
|
|
: "--",
|
|
},
|
|
{
|
|
accessorKey: "created_at",
|
|
header: t("claim.createdAt", "Won at"),
|
|
cell: ({ row }) =>
|
|
row.original.created_at
|
|
? formatDate(row.original.created_at)
|
|
: "--",
|
|
},
|
|
]}
|
|
header={{ title: t("claim.tableTitle", "Claims") }}
|
|
params={[
|
|
{
|
|
key: "type",
|
|
options: typeOptions,
|
|
placeholder: t("claim.filterType", "Type"),
|
|
},
|
|
{
|
|
key: "status",
|
|
options: statusOptions,
|
|
placeholder: t("claim.filterStatus", "Status"),
|
|
},
|
|
{
|
|
key: "user_id",
|
|
placeholder: t("claim.filterUserId", "User ID"),
|
|
},
|
|
]}
|
|
request={async (pagination, filter) => {
|
|
const { data } = await getLotteryClaimList({
|
|
page: pagination.page,
|
|
size: pagination.size,
|
|
activity_id: activity.id,
|
|
type: (filter.type as API.LotteryPrizeType) || undefined,
|
|
status: (filter.status as API.LotteryClaimStatus) || undefined,
|
|
user_id: filter.user_id ? Number(filter.user_id) : undefined,
|
|
});
|
|
return {
|
|
list: data.data?.claims || [],
|
|
total: data.data?.total || 0,
|
|
};
|
|
}}
|
|
/>
|
|
|
|
{/* Reject dialog */}
|
|
<Dialog
|
|
onOpenChange={(open) => {
|
|
setRejectOpen(open);
|
|
if (!open) {
|
|
setCurrentRow(null);
|
|
rejectForm.reset({ reason: "" });
|
|
}
|
|
}}
|
|
open={rejectOpen}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("claim.rejectTitle", "Reject claim")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
"claim.rejectDescription",
|
|
"The reason is shown to the user, who may then resubmit."
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Form {...rejectForm}>
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={rejectForm.handleSubmit(async (values) => {
|
|
if (!currentRow) return;
|
|
await rejectMutation.mutateAsync({
|
|
id: currentRow.id,
|
|
reason: values.reason.trim(),
|
|
});
|
|
})}
|
|
>
|
|
<FormField
|
|
control={rejectForm.control}
|
|
name="reason"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("claim.reason", "Reason")}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(
|
|
"claim.reasonPlaceholder",
|
|
"Reason shown to the user"
|
|
)}
|
|
rows={4}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => setRejectOpen(false)}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
{t("claim.cancel", "Cancel")}
|
|
</Button>
|
|
<Button disabled={rejectMutation.isPending} type="submit">
|
|
{t("claim.confirmReject", "Confirm reject")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Mark-paid dialog */}
|
|
<Dialog
|
|
onOpenChange={(open) => {
|
|
setPaidOpen(open);
|
|
if (!open) {
|
|
setCurrentRow(null);
|
|
paidForm.reset({ tx_hash: "", delivery_ref: "" });
|
|
}
|
|
}}
|
|
open={paidOpen}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("claim.markPaidTitle", "Mark as fulfilled")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
"claim.markPaidDescription",
|
|
"Crypto requires a tx hash; physical requires a delivery ref; manual requires at least one."
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Form {...paidForm}>
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={paidForm.handleSubmit(async (values) => {
|
|
if (!currentRow) return;
|
|
const txHash = values.tx_hash?.trim();
|
|
const deliveryRef = values.delivery_ref?.trim();
|
|
const prizeType = currentRow.prize.type;
|
|
if (prizeType === "crypto" && !txHash) {
|
|
paidForm.setError("tx_hash", {
|
|
message: t(
|
|
"claim.txHashRequired",
|
|
"Tx hash is required for crypto"
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
if (prizeType === "physical" && !deliveryRef) {
|
|
paidForm.setError("delivery_ref", {
|
|
message: t(
|
|
"claim.deliveryRefRequired",
|
|
"Delivery ref is required for physical"
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
if (!(txHash || deliveryRef)) {
|
|
paidForm.setError("tx_hash", {
|
|
message: t(
|
|
"claim.oneRefRequired",
|
|
"Fill at least one field"
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
await paidMutation.mutateAsync({
|
|
id: currentRow.id,
|
|
tx_hash: txHash || undefined,
|
|
delivery_ref: deliveryRef || undefined,
|
|
});
|
|
})}
|
|
>
|
|
<FormField
|
|
control={paidForm.control}
|
|
name="tx_hash"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t("claim.txHash", "Tx hash")}</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="0x..." {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={paidForm.control}
|
|
name="delivery_ref"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t("claim.deliveryRef", "Delivery ref")}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(
|
|
"claim.deliveryRefPlaceholder",
|
|
"Tracking number"
|
|
)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => setPaidOpen(false)}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
{t("claim.cancel", "Cancel")}
|
|
</Button>
|
|
<Button disabled={paidMutation.isPending} type="submit">
|
|
{t("claim.confirmMarkPaid", "Confirm")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|