feat: add withdrawal management pages

This commit is contained in:
2026-05-24 20:30:19 -07:00
parent 55c78276fb
commit d6399cf796
10 changed files with 800 additions and 11 deletions
+5
View File
@@ -93,6 +93,11 @@ export function useNavs() {
url: "/dashboard/user",
icon: "flat-color-icons:conference-call",
},
{
title: t("Withdrawal Management", "Withdrawal Management"),
url: "/dashboard/withdrawal",
icon: "flat-color-icons:money-transfer",
},
{
title: t("Device Group", "Device Group"),
url: "/dashboard/family",
+25
View File
@@ -15,6 +15,9 @@ import { Route as rootRouteImport } from './routes/__root'
const DashboardRouteLazyRouteImport = createFileRoute('/dashboard')()
const IndexLazyRouteImport = createFileRoute('/')()
const DashboardIndexLazyRouteImport = createFileRoute('/dashboard/')()
const DashboardWithdrawalLazyRouteImport = createFileRoute(
'/dashboard/withdrawal',
)()
const DashboardServersLazyRouteImport = createFileRoute('/dashboard/servers')()
const DashboardNodesLazyRouteImport = createFileRoute('/dashboard/nodes')()
const DashboardUserIndexLazyRouteImport = createFileRoute('/dashboard/user/')()
@@ -109,6 +112,13 @@ const DashboardIndexLazyRoute = DashboardIndexLazyRouteImport.update({
} as any).lazy(() =>
import('./routes/dashboard/index.lazy').then((d) => d.Route),
)
const DashboardWithdrawalLazyRoute = DashboardWithdrawalLazyRouteImport.update({
id: '/withdrawal',
path: '/withdrawal',
getParentRoute: () => DashboardRouteLazyRoute,
} as any).lazy(() =>
import('./routes/dashboard/withdrawal.lazy').then((d) => d.Route),
)
const DashboardServersLazyRoute = DashboardServersLazyRouteImport.update({
id: '/servers',
path: '/servers',
@@ -346,6 +356,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -380,6 +391,7 @@ export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -416,6 +428,7 @@ export interface FileRoutesById {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -453,6 +466,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard/'
| '/dashboard/log/balance'
| '/dashboard/log/commission'
@@ -487,6 +501,7 @@ export interface FileRouteTypes {
| '/'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard'
| '/dashboard/log/balance'
| '/dashboard/log/commission'
@@ -522,6 +537,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/dashboard/nodes'
| '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard/'
| '/dashboard/log/balance'
| '/dashboard/log/commission'
@@ -581,6 +597,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/withdrawal': {
id: '/dashboard/withdrawal'
path: '/withdrawal'
fullPath: '/dashboard/withdrawal'
preLoaderRoute: typeof DashboardWithdrawalLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/servers': {
id: '/dashboard/servers'
path: '/servers'
@@ -797,6 +820,7 @@ declare module '@tanstack/react-router' {
interface DashboardRouteLazyRouteChildren {
DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute
DashboardServersLazyRoute: typeof DashboardServersLazyRoute
DashboardWithdrawalLazyRoute: typeof DashboardWithdrawalLazyRoute
DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute
DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute
DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute
@@ -831,6 +855,7 @@ interface DashboardRouteLazyRouteChildren {
const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardNodesLazyRoute: DashboardNodesLazyRoute,
DashboardServersLazyRoute: DashboardServersLazyRoute,
DashboardWithdrawalLazyRoute: DashboardWithdrawalLazyRoute,
DashboardIndexLazyRoute: DashboardIndexLazyRoute,
DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute,
DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute,
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import WithdrawalManagementPage from "@/sections/withdrawal";
export const Route = createLazyFileRoute("/dashboard/withdrawal")({
component: WithdrawalManagementPage,
});
@@ -0,0 +1,328 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Textarea } from "@workspace/ui/components/textarea";
import type { ProTableActions } from "@workspace/ui/composed/pro-table/pro-table";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import {
approveWithdrawal,
getWithdrawalList,
rejectWithdrawal,
} from "@workspace/ui/services/admin/withdrawal";
import { LoaderCircle } from "lucide-react";
import { useRef, useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { Display } from "@/components/display";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
const rejectionSchema = z.object({
reason: z
.string()
.trim()
.min(1, "Reason is required")
.max(500, "Reason must be 500 characters or fewer"),
});
const WITHDRAWAL_STATUS_OPTIONS = [
{ label: "All", value: "all" },
{ label: "Pending", value: "0" },
{ label: "Approved", value: "1" },
{ label: "Rejected", value: "2" },
] as const;
function getStatusMeta(
status: number,
t: ReturnType<typeof useTranslation<"user">>["t"]
) {
switch (status) {
case 1:
return {
label: t("withdrawal.status.approved", "Approved"),
variant: "default" as const,
};
case 2:
return {
label: t("withdrawal.status.rejected", "Rejected"),
variant: "destructive" as const,
};
default:
return {
label: t("withdrawal.status.pending", "Pending"),
variant: "secondary" as const,
};
}
}
function RejectWithdrawalDialog({
row,
onSuccess,
}: {
row: API.AdminWithdrawalLog;
onSuccess: () => Promise<void> | void;
}) {
const { t } = useTranslation("user");
const [open, setOpen] = useState(false);
const [loading, startTransition] = useTransition();
const form = useForm<z.infer<typeof rejectionSchema>>({
resolver: zodResolver(rejectionSchema),
defaultValues: {
reason: "",
},
});
const handleSubmit = (values: z.infer<typeof rejectionSchema>) => {
startTransition(async () => {
await rejectWithdrawal({
withdrawal_id: row.id,
reason: values.reason.trim(),
});
toast.success(t("withdrawal.rejectSuccess", "Withdrawal rejected"));
await onSuccess();
setOpen(false);
form.reset();
});
};
return (
<Dialog
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) form.reset();
}}
open={open}
>
<DialogTrigger asChild>
<Button size="sm" variant="destructive">
{t("withdrawal.reject", "Reject")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("withdrawal.rejectTitle", "Reject Withdrawal")}
</DialogTitle>
<DialogDescription>
{t(
"withdrawal.rejectDescription",
"Provide a reason that will be shown in the user's withdrawal history."
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
className="space-y-4"
id={`reject-withdrawal-${row.id}`}
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("withdrawal.rejectReason", "Reject Reason")}
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
"withdrawal.rejectReasonPlaceholder",
"Explain why this withdrawal cannot be approved"
)}
rows={4}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button
disabled={loading}
form={`reject-withdrawal-${row.id}`}
type="submit"
variant="destructive"
>
{loading && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{t("withdrawal.rejectConfirm", "Reject Withdrawal")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function WithdrawalManagementPage() {
const { t } = useTranslation("user");
const [approvingId, setApprovingId] = useState<number | null>(null);
const tableActionRef = useRef<ProTableActions | undefined>(undefined);
return (
<ProTable<API.AdminWithdrawalLog, { user_id?: string; status?: string }>
action={tableActionRef}
actions={{
render: (row) => {
if (Number(row.status) !== 0) return [];
const handleApprove = async () => {
try {
await approveWithdrawal({
withdrawal_id: Number(row.id),
});
toast.success(
t("withdrawal.approveSuccess", "Withdrawal approved")
);
await tableActionRef.current?.refresh();
} finally {
setApprovingId(null);
}
};
return [
<Button
disabled={approvingId === Number(row.id)}
key={`approve-${row.id}`}
onClick={() => {
setApprovingId(Number(row.id));
handleApprove();
}}
size="sm"
>
{approvingId === Number(row.id) && (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
)}
{t("withdrawal.approve", "Approve")}
</Button>,
<RejectWithdrawalDialog
key={`reject-${row.id}`}
onSuccess={() => tableActionRef.current?.refresh()}
row={row}
/>,
];
},
}}
columns={[
{
accessorKey: "id",
header: t("withdrawal.column.id", "ID"),
},
{
accessorKey: "user_id",
header: t("withdrawal.column.user", "User"),
cell: ({ row }) => <UserDetail id={Number(row.original.user_id)} />,
},
{
accessorKey: "amount",
header: t("withdrawal.column.amount", "Amount"),
cell: ({ row }) => (
<Display type="currency" value={Number(row.original.amount)} />
),
},
{
accessorKey: "content",
header: t("withdrawal.column.content", "Withdrawal Info"),
cell: ({ row }) => (
<div className="max-w-[280px] whitespace-pre-wrap break-words text-sm">
{row.original.content}
</div>
),
},
{
accessorKey: "status",
header: t("withdrawal.column.status", "Status"),
cell: ({ row }) => {
const meta = getStatusMeta(Number(row.original.status), t);
return <Badge variant={meta.variant}>{meta.label}</Badge>;
},
},
{
accessorKey: "reason",
header: t("withdrawal.column.reason", "Reject Reason"),
cell: ({ row }) =>
row.original.reason ? (
<div className="max-w-[220px] whitespace-pre-wrap break-words text-muted-foreground text-sm">
{row.original.reason}
</div>
) : (
<span className="text-muted-foreground text-sm">-</span>
),
},
{
accessorKey: "created_at",
header: t("withdrawal.column.createdAt", "Created At"),
cell: ({ row }) => formatDate(Number(row.original.created_at)),
},
]}
header={{
title: t("withdrawal.title", "Withdrawal Management"),
}}
params={[
{
key: "status",
type: "select",
placeholder: t("withdrawal.filter.status", "Status"),
options: WITHDRAWAL_STATUS_OPTIONS.map((option) => ({
label: t(`withdrawal.statusOption.${option.value}`, option.label),
value: option.value,
})),
},
{
key: "user_id",
placeholder: t("withdrawal.filter.userId", "User ID"),
},
]}
request={async (pagination, filter) => {
const status =
filter.status && filter.status !== "all"
? Number(filter.status)
: undefined;
const userId = filter.user_id ? Number(filter.user_id) : undefined;
const { data } = await getWithdrawalList({
page: pagination.page,
size: pagination.size,
status,
user_id: Number.isFinite(userId) ? userId : undefined,
});
const list = (data.data?.list || []) as API.AdminWithdrawalLog[];
return {
list,
total: Number(data.data?.total || list.length),
};
}}
/>
);
}