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 = { 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 }) { if (!data || Object.keys(data).length === 0) { return --; } return (
{Object.entries(data).map(([key, value]) => (
{key}:{" "} {String(value)}
))}
); } 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(null); const [rejectOpen, setRejectOpen] = useState(false); const [paidOpen, setPaidOpen] = useState(false); const [currentRow, setCurrentRow] = useState( null ); const rejectForm = useForm<{ reason: string }>({ resolver: zodResolver(rejectSchema(t)), defaultValues: { reason: "" }, }); const paidForm = useForm>({ 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 ( <> action={ref} actions={{ render: (row) => { const actions: ReactNode[] = []; if (row.status === "reviewing") { actions.push( { await approveMutation.mutateAsync(row.id); }} title={t("claim.approveTitle", "Approve this claim?")} trigger={ } /> ); } if (row.status === "paying") { actions.push( ); } if (row.status === "reviewing" || row.status === "paying") { actions.push( ); } return actions; }, }} columns={[ { accessorKey: "id", header: "ID" }, { accessorKey: "user", header: t("claim.user", "User"), cell: ({ row }) => (
#{row.original.user.id}
{row.original.user.email && (
{row.original.user.email}
)}
), }, { accessorKey: "prize", header: t("claim.prize", "Prize"), cell: ({ row }) => (
{row.original.prize.name}
{prizeTypeLabel(row.original.prize.type, t)}
), }, { accessorKey: "claim_data", header: t("claim.claimData", "Claim info"), cell: ({ row }) => , }, { accessorKey: "status", header: t("claim.status", "Status"), cell: ({ row }) => { const meta = STATUS_META[row.original.status]; return ( {meta ? t(meta.labelKey, meta.labelDefault) : row.original.status} ); }, }, { accessorKey: "fulfillment", header: t("claim.fulfillment", "Tx / Delivery"), cell: ({ row }) => (
{row.original.tx_hash || row.original.delivery_ref || "--"} {row.original.reject_reason && (
{row.original.reject_reason}
)}
), }, { 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 */} { setRejectOpen(open); if (!open) { setCurrentRow(null); rejectForm.reset({ reason: "" }); } }} open={rejectOpen} > {t("claim.rejectTitle", "Reject claim")} {t( "claim.rejectDescription", "The reason is shown to the user, who may then resubmit." )}
{ if (!currentRow) return; await rejectMutation.mutateAsync({ id: currentRow.id, reason: values.reason.trim(), }); })} > ( {t("claim.reason", "Reason")}