From a30d83505f5f3b9cc50a4884eabf4b664e9ac9ac Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Thu, 16 Jul 2026 20:29:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(=E6=8A=BD=E5=A5=96):=20=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E5=A5=96(crypto/physical/manual=5Fother)=20+?= =?UTF-8?q?=20=E9=A2=86=E5=A5=96=E5=B7=A5=E5=8D=95=E7=AE=A1=E7=90=86=20+?= =?UTF-8?q?=20REST=20=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../public/assets/locales/en-US/lottery.json | 86 ++- .../public/assets/locales/zh-CN/lottery.json | 86 ++- .../src/sections/lottery/activity-detail.tsx | 7 + .../src/sections/lottery/claims-panel.tsx | 555 ++++++++++++++++++ .../admin/src/sections/lottery/prize-form.tsx | 269 ++++++++- .../admin/src/sections/lottery/prize-grid.tsx | 70 ++- packages/ui/src/services/admin/lottery.ts | 102 +++- packages/ui/src/services/admin/typings.d.ts | 122 +++- 8 files changed, 1224 insertions(+), 73 deletions(-) create mode 100644 apps/admin/src/sections/lottery/claims-panel.tsx diff --git a/apps/admin/public/assets/locales/en-US/lottery.json b/apps/admin/public/assets/locales/en-US/lottery.json index 5cdb158..f6a6a33 100644 --- a/apps/admin/public/assets/locales/en-US/lottery.json +++ b/apps/admin/public/assets/locales/en-US/lottery.json @@ -9,7 +9,8 @@ "createSuccess": "Create Success", "detail": { "prizesTab": "Prizes", - "rulesTab": "Rules" + "rulesTab": "Rules", + "claimsTab": "Claims" }, "edit": "Edit", "editActivity": "Edit Activity", @@ -58,10 +59,21 @@ "amountYuan": "Commission Amount (CNY)", "cancel": "Cancel", "centerSlotHint": "Usually the draw button", + "claimTtlHint": "How long the user has to submit claim info. Default 168h (7 days).", + "claimTtlHours": "Claim window (hours)", + "claimTtlInvalid": "Claim window (hours) must be a positive integer", "confirm": "Confirm", "confirmDelete": "Delete", "createSuccess": "Prize created", "createTitle": "Add Prize", + "cryptoAmount": "Amount", + "cryptoAmountPlaceholder": "e.g. 0.01", + "cryptoAmountRequired": "Amount is required", + "cryptoCurrency": "Currency", + "cryptoCurrencyRequired": "Currency is required", + "cryptoNetworks": "Networks", + "cryptoNetworksHint": "Comma-separated. The user picks one of these when claiming.", + "cryptoNetworksRequired": "At least one network is required (comma-separated)", "daysSuffix": "days", "delete": "Delete", "deleteDescription": "This prize will be removed from the grid. This action cannot be undone.", @@ -70,13 +82,16 @@ "durationDays": "Duration (days)", "durationDaysPlaceholder": "e.g. 1", "durationDaysRequired": "Duration days must be a positive integer", + "durationSummary": "{{days}} days", "editTitle": "Edit Prize", "fallbackBadge": "Fallback", - "fallbackIgnoresWeight": "Fallback prize ignores weight", + "fallbackIgnoresWeight": "Fallback prize ignores probability (never randomly drawn)", + "hoursSuffix": "hours", "iconUrl": "Icon URL", "iconUrlInvalid": "Must be a valid URL", "isFallback": "Fallback Prize", "isFallbackHint": "Awarded when limited prizes are sold out", + "manualPrizeHint": "Manual prize: the winner submits claim info, then an operator approves and marks it paid/shipped under the Claims tab.", "name": "Prize Name", "namePlaceholder": "e.g. 1-Day Pass", "nameRequired": "Name is required", @@ -85,11 +100,18 @@ "noRandomPrizes": "No prizes participate in the random draw yet", "noneHint": "No config needed. Consider enabling fallback so users always land on this prize when limited prizes run out.", "probabilityPreview": "Probability Preview", + "probabilityTotal": "Total", "selectType": "Select type", + "skuName": "Item name (optional)", + "skuNamePlaceholder": "e.g. Limited T-shirt", "slotLabel": "Slot", "soldOutBadge": "Sold Out", - "stage2Hint": "Available in Stage 2", "stockShort": "Stock", + "subscribePlan": "New-subscription plan", + "subscribePlanHint": "When the user has no active subscription, create one on this plan and grant the duration. Leave empty to skip granting.", + "subscribePlanPlaceholder": "None — skip if user has no active subscription", + "totalNot100Hint": "The drawable prizes currently total {{total}}%. Adjust each prize's win probability so they sum to exactly 100%.", + "totalNot100Title": "Probabilities must total 100%", "totalStock": "Total Stock", "totalStockPlaceholder": "e.g. 100", "totalStockRequired": "Total stock must be a positive integer", @@ -104,11 +126,12 @@ "typeVpnDuration": "VPN Duration", "unlimitedStock": "Unlimited Stock", "updateSuccess": "Prize updated", - "weight": "Weight", - "weightHint": "0 = displayed but excluded from random draw", - "weightInteger": "Weight must be an integer", - "weightMin": "Weight must be at least 0", - "weightShort": "Weight", + "weight": "Win probability (%)", + "weightHint": "Percent chance in the grid. All drawable prizes must total 100%. 0 = shown but never drawn.", + "weightInteger": "Probability must be an integer", + "weightMax": "Probability cannot exceed 100", + "weightMin": "Probability must be at least 0", + "weightShort": "Prob", "yuanSuffix": "CNY" }, "publish": "Publish", @@ -177,5 +200,50 @@ "statusAll": "All", "statusLabel": "Status", "title": "Title", - "updateSuccess": "Update Success" + "updateSuccess": "Update Success", + "claim": { + "approve": "Approve", + "approveDescription": "Approving moves this claim to the paying/shipping stage.", + "approveSuccess": "Claim approved", + "approveTitle": "Approve this claim?", + "cancel": "Cancel", + "claimData": "Claim info", + "confirm": "Confirm", + "confirmMarkPaid": "Confirm", + "confirmReject": "Confirm reject", + "createdAt": "Won at", + "deliveryRef": "Delivery ref", + "deliveryRefPlaceholder": "Tracking number", + "deliveryRefRequired": "Delivery ref is required for physical", + "expiresAt": "Expires", + "filterStatus": "Status", + "filterType": "Type", + "filterUserId": "User ID", + "fulfillment": "Tx / Delivery", + "markPaid": "Mark fulfilled", + "markPaidDescription": "Crypto requires a tx hash; physical requires a delivery ref; manual requires at least one.", + "markPaidSuccess": "Marked as fulfilled", + "markPaidTitle": "Mark as fulfilled", + "oneRefRequired": "Fill at least one field", + "prize": "Prize", + "reason": "Reason", + "reasonPlaceholder": "Reason shown to the user", + "reasonRequired": "Please enter a reason", + "reasonTooLong": "Reason cannot exceed 512 chars", + "reject": "Reject", + "rejectDescription": "The reason is shown to the user, who may then resubmit.", + "rejectSuccess": "Claim rejected", + "rejectTitle": "Reject claim", + "status": "Status", + "statusExpired": "Expired", + "statusPaid": "Fulfilled", + "statusPaying": "Paying / Shipping", + "statusPending": "Awaiting user", + "statusRejected": "Rejected", + "statusReviewing": "Reviewing", + "tableTitle": "Claims", + "txHash": "Tx hash", + "txHashRequired": "Tx hash is required for crypto", + "user": "User" + } } diff --git a/apps/admin/public/assets/locales/zh-CN/lottery.json b/apps/admin/public/assets/locales/zh-CN/lottery.json index a44b324..4eff1c0 100644 --- a/apps/admin/public/assets/locales/zh-CN/lottery.json +++ b/apps/admin/public/assets/locales/zh-CN/lottery.json @@ -9,7 +9,8 @@ "createSuccess": "创建成功", "detail": { "prizesTab": "奖品", - "rulesTab": "规则" + "rulesTab": "规则", + "claimsTab": "领奖工单" }, "edit": "编辑", "editActivity": "编辑活动", @@ -58,10 +59,21 @@ "amountYuan": "佣金金额(元)", "cancel": "取消", "centerSlotHint": "通常为抽奖按钮", + "claimTtlHint": "用户提交领奖信息的时限,缺省 168 小时(7 天)。", + "claimTtlHours": "领奖窗口(小时)", + "claimTtlInvalid": "领奖窗口(小时)必须为正整数", "confirm": "确认", "confirmDelete": "删除", "createSuccess": "奖品创建成功", "createTitle": "添加奖品", + "cryptoAmount": "金额", + "cryptoAmountPlaceholder": "例如:0.01", + "cryptoAmountRequired": "请输入金额", + "cryptoCurrency": "币种", + "cryptoCurrencyRequired": "请输入币种", + "cryptoNetworks": "网络", + "cryptoNetworksHint": "逗号分隔,用户领奖时从中选择一个网络。", + "cryptoNetworksRequired": "至少填写一个网络(逗号分隔)", "daysSuffix": "天", "delete": "删除", "deleteDescription": "该奖品将从九宫格中移除,此操作不可撤销。", @@ -70,13 +82,16 @@ "durationDays": "时长(天)", "durationDaysPlaceholder": "例如:1", "durationDaysRequired": "时长天数必须为正整数", + "durationSummary": "{{days}} 天", "editTitle": "编辑奖品", "fallbackBadge": "保底", - "fallbackIgnoresWeight": "保底奖品不参与权重计算", + "fallbackIgnoresWeight": "保底奖品不参与概率(永不被随机抽中)", + "hoursSuffix": "小时", "iconUrl": "图标 URL", "iconUrlInvalid": "请输入有效的 URL", "isFallback": "保底奖品", "isFallbackHint": "限量奖品抽完后发放该奖品", + "manualPrizeHint": "人工奖:用户先提交领奖信息,运营在「领奖工单」页审核并标记发放/发货。", "name": "奖品名称", "namePlaceholder": "例如:1 天时长卡", "nameRequired": "请输入奖品名称", @@ -85,11 +100,18 @@ "noRandomPrizes": "暂无参与随机抽取的奖品", "noneHint": "无需额外配置。建议开启保底,限量奖品抽完后用户将始终抽中该奖品。", "probabilityPreview": "概率预览", + "probabilityTotal": "合计", "selectType": "选择类型", + "skuName": "物品名称(可选)", + "skuNamePlaceholder": "例如:限量 T 恤", "slotLabel": "格子", "soldOutBadge": "已抽完", - "stage2Hint": "Stage 2 开放", "stockShort": "库存", + "subscribePlan": "无订阅时新建套餐", + "subscribePlanHint": "当用户无活跃订阅时,按该套餐新建订阅并发放时长;留空则跳过发放。", + "subscribePlanPlaceholder": "不选:用户无活跃订阅时跳过发放", + "totalNot100Hint": "当前可抽奖品概率合计 {{total}}%。请调整各奖品的中奖概率,使其精确等于 100%。", + "totalNot100Title": "概率合计必须为 100%", "totalStock": "总库存", "totalStockPlaceholder": "例如:100", "totalStockRequired": "总库存必须为正整数", @@ -104,11 +126,12 @@ "typeVpnDuration": "免费时长", "unlimitedStock": "无限库存", "updateSuccess": "奖品更新成功", - "weight": "权重", - "weightHint": "权重为 0 时仅展示,不参与随机抽取", - "weightInteger": "权重必须为整数", - "weightMin": "权重不能小于 0", - "weightShort": "权重", + "weight": "中奖概率(%)", + "weightHint": "九宫格内的中奖概率。所有参与随机的奖品合计须为 100%。0 = 展示但不参与随机。", + "weightInteger": "概率必须为整数", + "weightMax": "概率不能超过 100", + "weightMin": "概率不能小于 0", + "weightShort": "概率", "yuanSuffix": "元" }, "publish": "上架", @@ -177,5 +200,50 @@ "statusAll": "全部", "statusLabel": "状态", "title": "标题", - "updateSuccess": "更新成功" + "updateSuccess": "更新成功", + "claim": { + "approve": "通过", + "approveDescription": "通过后工单进入待发放/发货阶段。", + "approveSuccess": "工单已通过", + "approveTitle": "确认通过该工单?", + "cancel": "取消", + "claimData": "领奖信息", + "confirm": "确认", + "confirmMarkPaid": "确认", + "confirmReject": "确认驳回", + "createdAt": "中奖时间", + "deliveryRef": "物流单号", + "deliveryRefPlaceholder": "快递单号", + "deliveryRefRequired": "实物奖品必须填写物流单号", + "expiresAt": "过期时间", + "filterStatus": "状态", + "filterType": "类型", + "filterUserId": "用户 ID", + "fulfillment": "交易/物流单号", + "markPaid": "标记发放", + "markPaidDescription": "加密货币需填交易哈希;实物需填物流单号;人工其他至少填一项。", + "markPaidSuccess": "已标记发放", + "markPaidTitle": "标记为已发放", + "oneRefRequired": "至少填写一项", + "prize": "奖品", + "reason": "驳回原因", + "reasonPlaceholder": "填写驳回原因,用户端会看到", + "reasonRequired": "请填写驳回原因", + "reasonTooLong": "驳回原因不能超过 512 字", + "reject": "驳回", + "rejectDescription": "驳回原因会展示给用户,用户可重新提交。", + "rejectSuccess": "工单已驳回", + "rejectTitle": "驳回工单", + "status": "状态", + "statusExpired": "已过期", + "statusPaid": "已发放", + "statusPaying": "待发放/发货", + "statusPending": "待用户填写", + "statusRejected": "已驳回", + "statusReviewing": "待审核", + "tableTitle": "领奖工单", + "txHash": "交易哈希", + "txHashRequired": "加密货币奖品必须填写交易哈希", + "user": "用户" + } } diff --git a/apps/admin/src/sections/lottery/activity-detail.tsx b/apps/admin/src/sections/lottery/activity-detail.tsx index e2630fd..86514f0 100644 --- a/apps/admin/src/sections/lottery/activity-detail.tsx +++ b/apps/admin/src/sections/lottery/activity-detail.tsx @@ -14,6 +14,7 @@ import { import { getLotteryActivityDetail } from "@workspace/ui/services/admin/lottery"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import ClaimsPanel from "./claims-panel"; import PrizeGrid from "./prize-grid"; import RuleEditor from "./rule-editor"; @@ -102,6 +103,9 @@ export default function ActivityDetail({ {t("detail.rulesTab", "Rules")} + + {t("detail.claimsTab", "Claims")} + @@ -109,6 +113,9 @@ export default function ActivityDetail({ + + + diff --git a/apps/admin/src/sections/lottery/claims-panel.tsx b/apps/admin/src/sections/lottery/claims-panel.tsx new file mode 100644 index 0000000..6a9a2f7 --- /dev/null +++ b/apps/admin/src/sections/lottery/claims-panel.tsx @@ -0,0 +1,555 @@ +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")} + +