diff --git a/apps/admin/public/assets/locales/en-US/order.json b/apps/admin/public/assets/locales/en-US/order.json index 39ba88e..c0809ba 100644 --- a/apps/admin/public/assets/locales/en-US/order.json +++ b/apps/admin/public/assets/locales/en-US/order.json @@ -1,17 +1,25 @@ { "amount": "Amount", "couponDiscount": "Coupon Discount", + "confirmRefund": "Confirm refund", "discount": "Discount Amount", "feeAmount": "Fee Amount", "method": "Payment Method", "orderNumber": "Order Number", + "refund": "Refund", + "refundConfirmDescription": "This refund will immediately invalidate the user's subscription and deduct the related agent commission.", + "refundConfirmTitle": "Confirm refund", + "refundConfirmWarning": "This action cannot be repeated after the order is refunded.", + "refundSubmitting": "Refunding...", + "refundSuccess": "Refund completed.", "status": { "0": "Status", "1": "Pending", "2": "Paid", "3": "Cancelled", "4": "Closed", - "5": "Completed" + "5": "Completed", + "6": "Refunded" }, "subscribe": "Subscribe", "subscribePrice": "Subscription Price", diff --git a/apps/admin/public/assets/locales/zh-CN/order.json b/apps/admin/public/assets/locales/zh-CN/order.json index 65c4534..20dc641 100644 --- a/apps/admin/public/assets/locales/zh-CN/order.json +++ b/apps/admin/public/assets/locales/zh-CN/order.json @@ -1,17 +1,25 @@ { "amount": "金额", "couponDiscount": "优惠券折扣", + "confirmRefund": "确认退费", "discount": "折扣金额", "feeAmount": "手续费", "method": "支付方式", "orderNumber": "订单编号", + "refund": "退费", + "refundConfirmDescription": "该退费操作会使用户订阅立即失效,并扣减关联代理佣金。", + "refundConfirmTitle": "确认退费", + "refundConfirmWarning": "订单退费后不可再次重复执行,请谨慎操作。", + "refundSubmitting": "退费中...", + "refundSuccess": "退费成功。", "status": { "0": "状态", "1": "待支付", "2": "已支付", "3": "已取消", "4": "已关闭", - "5": "已完成" + "5": "已完成", + "6": "已退费" }, "subscribe": "订阅", "subscribePrice": "订阅价格", diff --git a/apps/admin/src/sections/order/index.tsx b/apps/admin/src/sections/order/index.tsx index 2515c16..e9d1e28 100644 --- a/apps/admin/src/sections/order/index.tsx +++ b/apps/admin/src/sections/order/index.tsx @@ -1,4 +1,16 @@ +import { useMutation } from "@tanstack/react-query"; import { useSearch } from "@tanstack/react-router"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@workspace/ui/components/alert-dialog"; import { Badge } from "@workspace/ui/components/badge"; import { Button } from "@workspace/ui/components/button"; import { @@ -16,18 +28,27 @@ import { cn } from "@workspace/ui/lib/utils"; import { activateOrder, getOrderList, + refundOrder, updateOrderStatus, } from "@workspace/ui/services/admin/order"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import { Display } from "@/components/display"; +import { useGlobalStore } from "@/stores/global"; import { useSubscribe } from "@/stores/subscribe"; import { formatDate } from "@/utils/common"; import { UserDetail } from "../user/user-detail"; +const REFUNDED_ORDER_STATUS = 6; + export default function Order() { const { t } = useTranslation("order"); const sp = useSearch({ strict: false }) as Record; + const user = useGlobalStore((state) => state.user); + const [confirmingOrderId, setConfirmingOrderId] = useState( + null + ); const initialFilters = { user_id: sp.user_id ? Number(sp.user_id) : undefined, search: sp.search || undefined, @@ -53,6 +74,11 @@ export default function Order() { label: t("status.5", "Completed"), className: "bg-green-500", }, + { + value: REFUNDED_ORDER_STATUS, + label: t("status.6", "Refunded"), + className: "bg-red-500", + }, ]; const typeOptions = [ @@ -65,10 +91,101 @@ export default function Order() { const ref = useRef(null); const { subscribes, getSubscribeName } = useSubscribe(); + const canRefundOrders = Boolean(user?.is_admin); + + const refundMutation = useMutation({ + mutationFn: async (order: API.Order) => { + await refundOrder({ id: order.id }); + }, + onSuccess: () => { + toast.success(t("refundSuccess", "Refund completed.")); + setConfirmingOrderId(null); + ref.current?.refresh(); + }, + onError: (error) => { + console.error("Refund order failed", error); + setConfirmingOrderId(null); + }, + }); + + const isRefundedOrder = (order: API.Order) => + order.status === REFUNDED_ORDER_STATUS || + order.status_name === t("status.6", "Refunded") || + order.status_name?.toLowerCase() === "refunded"; + + const canRefundOrder = (order: API.Order) => + canRefundOrders && !isRefundedOrder(order); return ( action={ref} + actions={{ + render: (order) => { + if (!canRefundOrder(order)) return []; + + const isPending = + refundMutation.isPending && confirmingOrderId === order.id; + + return [ + { + setConfirmingOrderId(open ? order.id : null); + }} + open={confirmingOrderId === order.id} + > + + + + + + + {t("refundConfirmTitle", "Confirm refund")} + + +
+

+ {t( + "refundConfirmDescription", + "This refund will immediately invalidate the user's subscription and deduct the related agent commission." + )} +

+

+ {t( + "refundConfirmWarning", + "This action cannot be repeated after the order is refunded." + )} +

+
+
+
+ + + {t("cancel", "Cancel")} + + { + event.preventDefault(); + if (isPending) return; + await refundMutation.mutateAsync(order); + }} + > + {isPending + ? t("refundSubmitting", "Refunding...") + : t("confirmRefund", "Confirm refund")} + + +
+
, + ]; + }, + }} columns={[ { accessorKey: "order_no", @@ -253,8 +370,13 @@ export default function Order() { ); } return ( - - {option?.label || t(`status.${row.getValue("status")}`)} + + {option?.label || + (order.status_name + ? t(`status.${row.getValue("status")}`, order.status_name) + : t(`status.${row.getValue("status")}`))} ); }, diff --git a/packages/ui/src/services/admin/order.ts b/packages/ui/src/services/admin/order.ts index e2a7d6c..a87af01 100644 --- a/packages/ui/src/services/admin/order.ts +++ b/packages/ui/src/services/admin/order.ts @@ -78,3 +78,21 @@ export async function activateOrder( } ); } + +/** Refund order POST /v1/admin/order/refund */ +export async function refundOrder( + body: API.RefundOrderRequest, + options?: { [key: string]: any } +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/order/refund`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + data: body, + ...(options || {}), + } + ); +} diff --git a/packages/ui/src/services/admin/typings.d.ts b/packages/ui/src/services/admin/typings.d.ts index d5dbf5f..195fe82 100644 --- a/packages/ui/src/services/admin/typings.d.ts +++ b/packages/ui/src/services/admin/typings.d.ts @@ -1439,6 +1439,7 @@ declare namespace API { fee_amount: number; trade_no: string; status: number; + status_name?: string; subscribe_id: number; created_at: number; updated_at: number; @@ -1462,6 +1463,7 @@ declare namespace API { fee_amount: number; trade_no: string; status: number; + status_name?: string; subscribe_id: number; subscribe: Subscribe; created_at: number; @@ -2748,6 +2750,11 @@ declare namespace API { order_no: string; }; + type RefundOrderRequest = { + id: number; + reason?: string; + }; + type RedemptionCode = { id: number; code: string;