Files
hi-frontend/apps/admin/src/sections/order/index.tsx
T
shanshanzhong147 65641838f9
Build and Release / Build (push) Has been cancelled
feat: add admin order refund action
2026-05-24 20:41:53 -07:00

420 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@workspace/ui/components/hover-card";
import { Separator } from "@workspace/ui/components/separator";
import { Combobox } from "@workspace/ui/composed/combobox";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { cn } from "@workspace/ui/lib/utils";
import {
activateOrder,
getOrderList,
refundOrder,
updateOrderStatus,
} from "@workspace/ui/services/admin/order";
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<string, string | undefined>;
const user = useGlobalStore((state) => state.user);
const [confirmingOrderId, setConfirmingOrderId] = useState<number | null>(
null
);
const initialFilters = {
user_id: sp.user_id ? Number(sp.user_id) : undefined,
search: sp.search || undefined,
status: sp.status || undefined,
subscribe_id: sp.subscribe_id || undefined,
};
const statusOptions = [
{
value: 1,
label: t("status.1", "Pending"),
className: "bg-orange-500",
},
{ value: 2, label: t("status.2", "Paid"), className: "bg-green-500" },
{
value: 3,
label: t("status.3", "Cancelled"),
className: "bg-gray-500",
},
{ value: 4, label: t("status.4", "Closed"), className: "bg-red-500" },
{
value: 5,
label: t("status.5", "Completed"),
className: "bg-green-500",
},
{
value: REFUNDED_ORDER_STATUS,
label: t("status.6", "Refunded"),
className: "bg-red-500",
},
];
const typeOptions = [
{ value: 1, label: t("type.1", "New Purchase") },
{ value: 2, label: t("type.2", "Renewal") },
{ value: 3, label: t("type.3", "Reset Traffic") },
{ value: 4, label: t("type.4", "Recharge") },
];
const ref = useRef<ProTableActions>(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 (
<ProTable<API.Order, any>
action={ref}
actions={{
render: (order) => {
if (!canRefundOrder(order)) return [];
const isPending =
refundMutation.isPending && confirmingOrderId === order.id;
return [
<AlertDialog
key={`refund-${order.id}`}
onOpenChange={(open) => {
setConfirmingOrderId(open ? order.id : null);
}}
open={confirmingOrderId === order.id}
>
<AlertDialogTrigger asChild>
<Button disabled={isPending} size="sm" variant="destructive">
{isPending
? t("refundSubmitting", "Refunding...")
: t("refund", "Refund")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("refundConfirmTitle", "Confirm refund")}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
{t(
"refundConfirmDescription",
"This refund will immediately invalidate the user's subscription and deduct the related agent commission."
)}
</p>
<p className="font-medium text-foreground">
{t(
"refundConfirmWarning",
"This action cannot be repeated after the order is refunded."
)}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isPending}
onClick={async (event) => {
event.preventDefault();
if (isPending) return;
await refundMutation.mutateAsync(order);
}}
>
{isPending
? t("refundSubmitting", "Refunding...")
: t("confirmRefund", "Confirm refund")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>,
];
},
}}
columns={[
{
accessorKey: "order_no",
header: t("orderNumber", "Order Number"),
},
{
accessorKey: "type",
header: t("type.0", "Type"),
cell: ({ row }) => {
const type = row.getValue("type") as number;
return (
typeOptions.find((opt) => opt.value === type)?.label ||
t(`type.${type}`)
);
},
},
{
accessorKey: "subscribe_id",
header: t("subscribe", "Subscribe"),
cell: ({ row }) => {
const order = row.original as API.Order;
if (order.type === 4) {
const type = row.getValue("type") as number;
return (
typeOptions.find((opt) => opt.value === type)?.label ||
t(`type.${type}`)
);
}
const name = getSubscribeName(order.subscribe_id);
const quantity = order.quantity;
return name ? `${name} × ${quantity}` : "";
},
},
{
accessorKey: "amount",
header: t("amount", "Amount"),
cell: ({ row }) => {
const order = row.original as API.Order;
return (
<HoverCard>
<HoverCardTrigger asChild>
<Button className="p-0" variant="link">
<Display type="currency" value={order.amount} />
</Button>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-3">
{order.trade_no && (
<>
<div className="font-semibold">
{t("tradeNo", "Transaction Number")}
</div>
<span className="text-muted-foreground">
{order.trade_no}
</span>
<Separator className="my-2" />
</>
)}
<ul className="grid gap-3">
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("subscribePrice", "Subscription Price")}
</span>
<span>
<Display type="currency" value={order.price} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("discount", "Discount Amount")}
</span>
<span>
<Display type="currency" value={order.discount} />
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("couponDiscount", "Coupon Discount")}
</span>
<span>
<Display
type="currency"
value={order.coupon_discount}
/>
</span>
</li>
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("feeAmount", "Fee Amount")}
</span>
<span>
<Display type="currency" value={order.fee_amount} />
</span>
</li>
<li className="flex items-center justify-between font-semibold">
<span className="text-muted-foreground">
{t("total", "Total")}
</span>
<span>
<Display type="currency" value={order.amount} />
</span>
</li>
</ul>
</div>
<Separator className="my-4" />
<ul className="grid gap-3">
<li className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("method", "Payment Method")}
</span>
<span>
{order.payment?.name || order.payment?.platform}
</span>
</li>
</ul>
</HoverCardContent>
</HoverCard>
);
},
},
{
accessorKey: "payment",
header: t("method", "Payment Method"),
cell: ({ row }) => {
const order = row.original as API.Order;
return order.payment?.name || order.payment?.platform || "--";
},
},
{
accessorKey: "user_id",
header: t("user", "User"),
cell: ({ row }) => {
const order = row.original as API.Order;
return <UserDetail id={order.user_id} />;
},
},
{
accessorKey: "updated_at",
header: t("updateTime", "Update Time"),
cell: ({ row }) => {
const order = row.original as API.Order;
return formatDate(order.updated_at);
},
},
{
accessorKey: "status",
header: t("status.0", "Status"),
cell: ({ row }) => {
const order = row.original as API.Order;
const option = statusOptions.find(
(opt) => opt.value === order.status
);
if ([1, 3, 4].includes(row.getValue("status"))) {
return (
<div className="flex items-center gap-1">
<Combobox<number, false>
className={cn(option?.className)}
onChange={async (value) => {
await updateOrderStatus({
id: order.id,
status: value,
});
ref.current?.refresh();
}}
options={statusOptions}
placeholder={t("status.0", "Status")}
value={order.status}
/>
{[1, 3].includes(order.status) && (
<Button
onClick={async () => {
await activateOrder({ order_no: order.order_no });
ref.current?.refresh();
}}
size="sm"
variant="outline"
>
{t("activate", "Activate")}
</Button>
)}
</div>
);
}
return (
<Badge
variant={isRefundedOrder(order) ? "destructive" : "default"}
>
{option?.label ||
(order.status_name
? t(`status.${row.getValue("status")}`, order.status_name)
: t(`status.${row.getValue("status")}`))}
</Badge>
);
},
},
]}
initialFilters={initialFilters}
params={[
{
key: "status",
placeholder: t("status.0", "Status"),
options: statusOptions.map((item) => ({
label: item.label,
value: String(item.value),
})),
},
{
key: "subscribe_id",
placeholder: `${t("subscribe", "Subscribe")}`,
options: subscribes?.map((item) => ({
label: item.name!,
value: String(item.id),
})),
},
{ key: "search" },
{
key: "user_id",
placeholder: `${t("user", "User")} ID`,
options: undefined,
},
]}
request={async (pagination, filter) => {
const { data } = await getOrderList({ ...pagination, ...filter });
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}