420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
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,
|
||
};
|
||
}}
|
||
/>
|
||
);
|
||
}
|