feat: add withdrawal management pages
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user