🎉 feat: initialization
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@workspace/ui/components/tabs";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserDetail,
|
||||
getUserList,
|
||||
updateUserBasicInfo,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Display } from "@/components/display";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
import { formatDate } from "@/utils/common";
|
||||
import { UserDetail } from "./user-detail";
|
||||
import UserForm from "./user-form";
|
||||
import { AuthMethodsForm } from "./user-profile/auth-methods-form";
|
||||
import { BasicInfoForm } from "./user-profile/basic-info-form";
|
||||
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
||||
import UserSubscription from "./user-subscription";
|
||||
|
||||
export default function User() {
|
||||
const { t } = useTranslation("user");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
|
||||
const initialFilters = {
|
||||
search: sp.search || undefined,
|
||||
user_id: sp.user_id || undefined,
|
||||
subscribe_id: sp.subscribe_id || undefined,
|
||||
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<ProTable<API.User, API.GetUserListParams>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<ProfileSheet key="profile" userId={row.id} />,
|
||||
<SubscriptionSheet key="subscription" userId={row.id} />,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteDescription",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="edit"
|
||||
onConfirm={async () => {
|
||||
await deleteUser({ id: row.id });
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
<DropdownMenu key="more">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{t("more", "More")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/order"
|
||||
>
|
||||
{t("orderList", "Order List")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/log/login"
|
||||
>
|
||||
{t("loginLogs", "Login Logs")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/log/balance"
|
||||
>
|
||||
{t("balanceLogs", "Balance Logs")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/log/commission"
|
||||
>
|
||||
{t("commissionLogs", "Commission Logs")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
search={{ user_id: String(row.id) }}
|
||||
to="/dashboard/log/gift"
|
||||
>
|
||||
{t("giftLogs", "Gift Logs")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "enable",
|
||||
header: t("enable", "Enable"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.getValue("enable")}
|
||||
onCheckedChange={async (checked) => {
|
||||
const {
|
||||
auth_methods: _auth_methods,
|
||||
user_devices: _user_devices,
|
||||
enable_balance_notify: _enable_balance_notify,
|
||||
enable_login_notify: _enable_login_notify,
|
||||
enable_subscribe_notify: _enable_subscribe_notify,
|
||||
enable_trade_notify: _enable_trade_notify,
|
||||
updated_at: _updated_at,
|
||||
created_at: _created_at,
|
||||
id,
|
||||
...rest
|
||||
} = row.original;
|
||||
await updateUserBasicInfo({
|
||||
user_id: id,
|
||||
...rest,
|
||||
enable: checked,
|
||||
} as unknown as API.UpdateUserBasiceInfoRequest);
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
},
|
||||
{
|
||||
accessorKey: "auth_methods",
|
||||
header: t("userName", "Username"),
|
||||
cell: ({ row }) => {
|
||||
const method = row.original.auth_methods?.[0];
|
||||
return (
|
||||
<div>
|
||||
<Badge
|
||||
className="mr-1 uppercase"
|
||||
title={method?.verified ? t("verified", "Verified") : ""}
|
||||
>
|
||||
{method?.auth_type}
|
||||
</Badge>
|
||||
{method?.auth_identifier}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "balance",
|
||||
header: t("balance", "Balance"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.getValue("balance")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "gift_amount",
|
||||
header: t("giftAmount", "Gift Amount"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.getValue("gift_amount")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "commission",
|
||||
header: t("commission", "Commission"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="currency" value={row.getValue("commission")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "refer_code",
|
||||
header: t("inviteCode", "Invite Code"),
|
||||
cell: ({ row }) => row.getValue("refer_code") || "--",
|
||||
},
|
||||
{
|
||||
accessorKey: "referer_id",
|
||||
header: t("referer", "Referer"),
|
||||
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("createdAt", "Created At"),
|
||||
cell: ({ row }) => formatDate(row.getValue("created_at")),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("userList", "User List"),
|
||||
toolbar: (
|
||||
<UserForm<API.CreateUserRequest>
|
||||
key="create"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createUser(values);
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
title={t("createUser", "Create User")}
|
||||
trigger={t("create", "Create")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
initialFilters={initialFilters}
|
||||
key={initialFilters.user_id}
|
||||
params={[
|
||||
{
|
||||
key: "subscribe_id",
|
||||
placeholder: t("subscription", "Subscription"),
|
||||
options: subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id!),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
placeholder: "Search",
|
||||
},
|
||||
{
|
||||
key: "user_id",
|
||||
placeholder: t("userId", "User ID"),
|
||||
},
|
||||
{
|
||||
key: "user_subscribe_id",
|
||||
placeholder: t("subscriptionId", "Subscription ID"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getUserList({
|
||||
...pagination,
|
||||
...filter,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSheet({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: user, refetch } = useQuery({
|
||||
enabled: open,
|
||||
queryKey: ["user", userId],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserDetail({ id: userId });
|
||||
return data.data as API.User;
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="default">{t("edit", "Edit")}</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
className="w-[700px] max-w-full md:max-w-screen-lg"
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("userProfile", "User Profile")} · ID: {userId}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
{user && (
|
||||
<ScrollArea className="h-[calc(100dvh-140px)] p-2">
|
||||
<Tabs defaultValue="basic">
|
||||
<TabsList className="mb-3">
|
||||
<TabsTrigger value="basic">
|
||||
{t("basicInfoTitle", "Basic Info")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notify">
|
||||
{t("notifySettingsTitle", "Notify Settings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="auth">
|
||||
{t("authMethodsTitle", "Auth Methods")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="mt-0" value="basic">
|
||||
<BasicInfoForm refetch={refetch as any} user={user} />
|
||||
</TabsContent>
|
||||
<TabsContent className="mt-0" value="notify">
|
||||
<NotifySettingsForm refetch={refetch as any} user={user} />
|
||||
</TabsContent>
|
||||
<TabsContent className="mt-0" value="auth">
|
||||
<AuthMethodsForm refetch={refetch as any} user={user} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionSheet({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="secondary">{t("subscription", "Subscription")}</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
className="w-[1000px] max-w-full md:max-w-screen-xl"
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("subscriptionList", "Subscription List")} · ID: {userId}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-2">
|
||||
<UserSubscription userId={userId} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@workspace/ui/components/hover-card";
|
||||
import {
|
||||
getUserDetail,
|
||||
getUserSubscribeById,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export function UserSubscribeDetail({
|
||||
id,
|
||||
enabled,
|
||||
hoverCard = false,
|
||||
}: {
|
||||
id: number;
|
||||
enabled: boolean;
|
||||
hoverCard?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: id !== 0 && enabled,
|
||||
queryKey: ["getUserSubscribeById", id],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserSubscribeById({ id });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (!id) return "--";
|
||||
|
||||
const usedTraffic = data ? data.upload + data.download : 0;
|
||||
const totalTraffic = data?.traffic || 0;
|
||||
|
||||
const subscribeContent = (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium text-sm">{t("subscriptionInfo")}</h3>
|
||||
<div className="rounded-lg bg-muted/30 p-3">
|
||||
<ul className="grid gap-3">
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">
|
||||
{t("subscriptionId")}
|
||||
</span>
|
||||
<span>{data?.id || "--"}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("subscriptionName")}
|
||||
</span>
|
||||
<span>{data?.subscribe?.name || "--"}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("token")}</span>
|
||||
<div className="font-mono text-xs" title={data?.token || ""}>
|
||||
{data?.token || "--"}
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("trafficUsage")}</span>
|
||||
<span>
|
||||
{data
|
||||
? totalTraffic === 0
|
||||
? `${formatBytes(usedTraffic)} / ${t("unlimited")}`
|
||||
: `${formatBytes(usedTraffic)} / ${formatBytes(totalTraffic)}`
|
||||
: "--"}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("startTime")}</span>
|
||||
<span>
|
||||
{data?.start_time ? formatDate(data.start_time) : "--"}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("expireTime")}</span>
|
||||
<span>
|
||||
{data?.expire_time ? formatDate(data.expire_time) : "--"}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hoverCard && (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium text-sm">
|
||||
{t("userInfo")}
|
||||
{/* Removed link to legacy user detail page */}
|
||||
</h3>
|
||||
<ul className="grid gap-3">
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">{t("userId")}</span>
|
||||
<span>{data?.user_id}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">{t("balance")}</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.user.balance} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("giftAmount")}</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.user?.gift_amount} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("commission")}</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.user?.commission} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("createdAt")}</span>
|
||||
<span>
|
||||
{data?.user?.created_at && formatDate(data?.user?.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hoverCard) {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button className="p-0" variant="link">
|
||||
{data?.subscribe?.name || t("loading")}
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96">{subscribeContent}</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
return subscribeContent;
|
||||
}
|
||||
|
||||
export function UserDetail({ id }: { id: number }) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const { data } = useQuery({
|
||||
enabled: id !== 0,
|
||||
queryKey: ["getUserDetail", id],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserDetail({ id });
|
||||
return data.data;
|
||||
},
|
||||
});
|
||||
|
||||
if (!id) return "--";
|
||||
|
||||
const identifier =
|
||||
data?.auth_methods.find((m) => m.auth_type === "email")?.auth_identifier ||
|
||||
data?.auth_methods[0]?.auth_identifier;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button asChild className="p-0" variant="link">
|
||||
<Link search={{ user_id: id }} to="/dashboard/user">
|
||||
{identifier || t("loading", "Loading...")}
|
||||
</Link>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="grid gap-3">
|
||||
<ul className="grid gap-3">
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">ID</span>
|
||||
<span>{data?.id}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">
|
||||
{t("balance", "Balance")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.balance} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("giftAmount", "Gift Amount")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.gift_amount} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("commission", "Commission")}
|
||||
</span>
|
||||
<span>
|
||||
<Display type="currency" value={data?.commission} />
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("createdAt", "Created At")}
|
||||
</span>
|
||||
<span>{data?.created_at && formatDate(data?.created_at)}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { AreaCodeSelect } from "@workspace/ui/composed/area-code-select";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
interface UserFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
update?: boolean;
|
||||
}
|
||||
|
||||
export default function UserForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: Readonly<UserFormProps<T>>) {
|
||||
const { t } = useTranslation("user");
|
||||
const { common } = useGlobalStore();
|
||||
const { currency } = common;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const formSchema = z.object({
|
||||
email: z.email(t("invalidEmailFormat", "Invalid email format")),
|
||||
telephone_area_code: z.string().optional(),
|
||||
telephone: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
referer_id: z.number().optional(),
|
||||
refer_code: z.string().optional(),
|
||||
referral_percentage: z.number().optional(),
|
||||
only_first_purchase: z.boolean().optional(),
|
||||
is_admin: z.boolean().optional(),
|
||||
balance: z.number().optional(),
|
||||
gift_amount: z.number().optional(),
|
||||
commission: z.number().optional(),
|
||||
});
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
...initialValues,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form?.reset(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] max-w-full gap-0 md:max-w-screen-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4 px-6 pt-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("userEmail", "Email")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t("userEmailPlaceholder", "Enter email")}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="telephone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("telephone", "Phone")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"telephonePlaceholder",
|
||||
"Enter phone number"
|
||||
)}
|
||||
prefix={
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="telephone_area_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<AreaCodeSelect
|
||||
className="w-32 rounded-none border-y-0 border-l-0"
|
||||
onChange={(value) => {
|
||||
form.setValue(
|
||||
field.name,
|
||||
value.phone as string
|
||||
);
|
||||
}}
|
||||
placeholder={t(
|
||||
"areaCodePlaceholder",
|
||||
"Area code"
|
||||
)}
|
||||
simple
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password", "Password")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
autoComplete="new-password"
|
||||
placeholder={t("passwordPlaceholder", "Enter password")}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referer_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("refererId", "Referer ID")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"refererIdPlaceholder",
|
||||
"Enter referer ID"
|
||||
)}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="refer_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("inviteCode", "Invite Code")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"inviteCodePlaceholder",
|
||||
"Enter invite code"
|
||||
)}
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referral_percentage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("referralPercentage", "Referral Percentage")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
max={100}
|
||||
min={0}
|
||||
placeholder={t(
|
||||
"referralPercentagePlaceholder",
|
||||
"Enter percentage"
|
||||
)}
|
||||
type="number"
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, Number(value));
|
||||
}}
|
||||
suffix="%"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="only_first_purchase"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("onlyFirstPurchase", "First Purchase Only")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="balance"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("balance", "Balance")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t("balancePlaceholder", "Enter balance")}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gift_amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"giftAmountPlaceholder",
|
||||
"Enter gift amount"
|
||||
)}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="commission"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("commission", "Commission")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t(
|
||||
"commissionPlaceholder",
|
||||
"Enter commission"
|
||||
)}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_admin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("manager", "Administrator")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="pt-2">
|
||||
<Switch
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}{" "}
|
||||
{t("confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import {
|
||||
createUserAuthMethod,
|
||||
deleteUserAuthMethod,
|
||||
updateUserAuthMethod,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function AuthMethodsForm({
|
||||
user,
|
||||
refetch,
|
||||
}: {
|
||||
user: API.User;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const [emailChanges, setEmailChanges] = useState<Record<string, string>>({});
|
||||
|
||||
const handleRemoveAuth = async (authType: string) => {
|
||||
await deleteUserAuthMethod({
|
||||
user_id: user.id,
|
||||
auth_type: authType,
|
||||
});
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
};
|
||||
|
||||
const handleUpdateEmail = async (email: string) => {
|
||||
await updateUserAuthMethod({
|
||||
user_id: user.id,
|
||||
auth_type: "email",
|
||||
auth_identifier: email,
|
||||
});
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleCreateEmail = async (email: string) => {
|
||||
await createUserAuthMethod({
|
||||
user_id: user.id,
|
||||
auth_type: "email",
|
||||
auth_identifier: email,
|
||||
});
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleEmailChange = (authType: string, value: string) => {
|
||||
setEmailChanges((prev) => ({
|
||||
...prev,
|
||||
[authType]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const emailMethod = user.auth_methods.find(
|
||||
(method) => method.auth_type === "email"
|
||||
);
|
||||
const otherMethods = user.auth_methods.filter(
|
||||
(method) => method.auth_type !== "email"
|
||||
);
|
||||
|
||||
const defaultEmailMethod = {
|
||||
auth_type: "email",
|
||||
auth_identifier: "",
|
||||
verified: false,
|
||||
...emailMethod,
|
||||
};
|
||||
|
||||
const isEmailExists = !!emailMethod;
|
||||
const handleEmailAction = () => {
|
||||
const email = emailChanges.email;
|
||||
if (isEmailExists) {
|
||||
handleUpdateEmail(email as string);
|
||||
} else {
|
||||
handleCreateEmail(email as string);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("authMethodsTitle", "Auth Methods")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<Card className="border-none shadow-none">
|
||||
<CardContent className="space-y-3 p-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium uppercase">email</div>
|
||||
<Badge
|
||||
variant={
|
||||
defaultEmailMethod.verified ? "default" : "destructive"
|
||||
}
|
||||
>
|
||||
{defaultEmailMethod.verified
|
||||
? t("verified", "Verified")
|
||||
: t("unverified", "Unverified")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<EnhancedInput
|
||||
onValueChange={(value) =>
|
||||
handleEmailChange("email", value as string)
|
||||
}
|
||||
placeholder={t("pleaseEnterEmail", "Enter email")}
|
||||
value={defaultEmailMethod.auth_identifier}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={
|
||||
!emailChanges.email ||
|
||||
(isEmailExists &&
|
||||
emailChanges.email === defaultEmailMethod.auth_identifier)
|
||||
}
|
||||
onClick={handleEmailAction}
|
||||
>
|
||||
{isEmailExists ? t("update", "Update") : t("add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{otherMethods.map((method) => (
|
||||
<Card className="border-none shadow-none" key={method.auth_type}>
|
||||
<CardContent className="space-y-3 p-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium uppercase">
|
||||
{method.auth_type}
|
||||
</div>
|
||||
<Badge variant={method.verified ? "default" : "destructive"}>
|
||||
{method.verified
|
||||
? t("verified", "Verified")
|
||||
: t("unverified", "Unverified")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{method.auth_identifier}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleRemoveAuth(method.auth_type)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{t("remove", "Remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { UploadImage } from "@workspace/ui/composed/upload-image";
|
||||
import { updateUserBasicInfo } from "@workspace/ui/services/admin/user";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
const basicInfoSchema = z.object({
|
||||
avatar: z.string().optional(),
|
||||
balance: z.number().optional(),
|
||||
commission: z.number().optional(),
|
||||
gift_amount: z.number().optional(),
|
||||
refer_code: z.string().optional(),
|
||||
referer_id: z.number().optional(),
|
||||
referral_percentage: z.number().optional(),
|
||||
only_first_purchase: z.boolean().optional(),
|
||||
is_admin: z.boolean().optional(),
|
||||
password: z.string().optional(),
|
||||
enable: z.boolean(),
|
||||
});
|
||||
|
||||
type BasicInfoValues = z.infer<typeof basicInfoSchema>;
|
||||
|
||||
export function BasicInfoForm({
|
||||
user,
|
||||
refetch,
|
||||
}: {
|
||||
user: API.User;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const { common } = useGlobalStore();
|
||||
const { currency } = common;
|
||||
|
||||
const form = useForm<BasicInfoValues>({
|
||||
resolver: zodResolver(basicInfoSchema),
|
||||
defaultValues: {
|
||||
avatar: user.avatar,
|
||||
balance: user.balance,
|
||||
commission: user.commission,
|
||||
gift_amount: user.gift_amount,
|
||||
refer_code: user.refer_code,
|
||||
referer_id: user.referer_id,
|
||||
referral_percentage: user.referral_percentage,
|
||||
only_first_purchase: user.only_first_purchase,
|
||||
is_admin: user.is_admin,
|
||||
enable: user.enable,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: BasicInfoValues) {
|
||||
await updateUserBasicInfo({
|
||||
user_id: user.id,
|
||||
telegram: user.telegram,
|
||||
...data,
|
||||
} as API.UpdateUserBasiceInfoRequest);
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{t("basicInfoTitle", "Basic Info")}</CardTitle>
|
||||
<Button size="sm" type="submit">
|
||||
{t("save", "Save")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>{t("accountEnable", "Account Enable")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_admin"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>{t("administrator", "Administrator")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="balance"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("balance", "Balance")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="commission"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("commission", "Commission")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gift_amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("giftAmount", "Gift Amount")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
formatInput={(value) =>
|
||||
unitConversion("centsToDollars", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("dollarsToCents", value)
|
||||
}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
prefix={currency?.currency_symbol ?? "$"}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="refer_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("referralCode", "Referral Code")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as string);
|
||||
}}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referer_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("referrerUserId", "Referrer User ID")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="referral_percentage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("referralPercentage", "Referral Percentage")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
max={100}
|
||||
min={0}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
suffix="%"
|
||||
type="number"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="only_first_purchase"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("onlyFirstPurchase", "First Purchase Only")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="avatar"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("avatar", "Avatar")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as string);
|
||||
}}
|
||||
suffix={
|
||||
<UploadImage
|
||||
className="h-9 rounded-none border-none bg-muted px-2"
|
||||
onChange={(value) =>
|
||||
form.setValue("avatar", value as string)
|
||||
}
|
||||
returnType="base64"
|
||||
/>
|
||||
}
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password", "Password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"passwordPlaceholder",
|
||||
"Enter new password"
|
||||
)}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@workspace/ui/components/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { updateUserNotifySetting } from "@workspace/ui/services/admin/user";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
const notifySettingsSchema = z.object({
|
||||
enable_balance_notify: z.boolean(),
|
||||
enable_login_notify: z.boolean(),
|
||||
enable_subscribe_notify: z.boolean(),
|
||||
enable_trade_notify: z.boolean(),
|
||||
});
|
||||
|
||||
type NotifySettingsValues = z.infer<typeof notifySettingsSchema>;
|
||||
|
||||
export function NotifySettingsForm({
|
||||
user,
|
||||
refetch,
|
||||
}: {
|
||||
user: API.User;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
|
||||
const form = useForm<NotifySettingsValues>({
|
||||
resolver: zodResolver(notifySettingsSchema),
|
||||
defaultValues: {
|
||||
enable_balance_notify: user.enable_balance_notify,
|
||||
enable_login_notify: user.enable_login_notify,
|
||||
enable_subscribe_notify: user.enable_subscribe_notify,
|
||||
enable_trade_notify: user.enable_trade_notify,
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: NotifySettingsValues) {
|
||||
await updateUserNotifySetting({
|
||||
...data,
|
||||
user_id: user.id,
|
||||
});
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>{t("notifySettingsTitle", "Notify Settings")}</CardTitle>
|
||||
<Button size="sm" type="submit">
|
||||
{t("save", "Save")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_balance_notify"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("balanceNotifications", "Balance Notifications")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_login_notify"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("loginNotifications", "Login Notifications")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_subscribe_notify"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"subscriptionNotifications",
|
||||
"Subscription Notifications"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_trade_notify"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>
|
||||
{t("tradeNotifications", "Trade Notifications")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
createUserSubscribe,
|
||||
deleteUserSubscribe,
|
||||
getUserSubscribe,
|
||||
updateUserSubscribe,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
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 { formatDate } from "@/utils/common";
|
||||
import { SubscriptionDetail } from "./subscription-detail";
|
||||
import { SubscriptionForm } from "./subscription-form";
|
||||
|
||||
export default function UserSubscription({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("user");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
const { getUserSubscribe: getUserSubscribeUrls } = useGlobalStore();
|
||||
|
||||
return (
|
||||
<ProTable<API.UserSubscribe, Record<string, unknown>>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<SubscriptionForm
|
||||
initialData={row}
|
||||
key="edit"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
await updateUserSubscribe({
|
||||
user_id: Number(userId),
|
||||
user_subscribe_id: row.id,
|
||||
...values,
|
||||
});
|
||||
toast.success(t("updateSuccess", "Updated successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
}}
|
||||
title={t("editSubscription", "Edit Subscription")}
|
||||
trigger={t("edit", "Edit")}
|
||||
/>,
|
||||
<Button
|
||||
key="copy"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
getUserSubscribeUrls(row.token)[0] || ""
|
||||
);
|
||||
toast.success(t("copySuccess", "Copied successfully"));
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{t("copySubscription", "Copy Subscription")}
|
||||
</Button>,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"deleteSubscriptionDescription",
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
key="delete"
|
||||
onConfirm={async () => {
|
||||
await deleteUserSubscribe({ user_subscribe_id: row.id });
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
title={t("confirmDelete", "Confirm Delete")}
|
||||
trigger={
|
||||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||||
}
|
||||
/>,
|
||||
<RowMoreActions key="more" subId={row.id} userId={userId} />,
|
||||
],
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("subscriptionName", "Subscription Name"),
|
||||
cell: ({ row }) => row.original.subscribe.name,
|
||||
},
|
||||
{
|
||||
accessorKey: "upload",
|
||||
header: t("upload", "Upload"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="traffic" value={row.getValue("upload")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "download",
|
||||
header: t("download", "Download"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="traffic" value={row.getValue("download")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "traffic",
|
||||
header: t("totalTraffic", "Total Traffic"),
|
||||
cell: ({ row }) => (
|
||||
<Display type="traffic" unlimited value={row.getValue("traffic")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "speed_limit",
|
||||
header: t("speedLimit", "Speed Limit"),
|
||||
cell: ({ row }) => {
|
||||
const speed = row.original?.subscribe?.speed_limit;
|
||||
return <Display type="trafficSpeed" value={speed} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "device_limit",
|
||||
header: t("deviceLimit", "Device Limit"),
|
||||
cell: ({ row }) => {
|
||||
const limit = row.original?.subscribe?.device_limit;
|
||||
return <Display type="number" unlimited value={limit} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "reset_time",
|
||||
header: t("resetTime", "Reset Time"),
|
||||
cell: ({ row }) => (
|
||||
<Display
|
||||
type="number"
|
||||
unlimited
|
||||
value={row.getValue("reset_time")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "expire_time",
|
||||
header: t("expireTime", "Expire Time"),
|
||||
cell: ({ row }) =>
|
||||
row.getValue("expire_time")
|
||||
? formatDate(row.getValue("expire_time"))
|
||||
: t("permanent", "Permanent"),
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("createdAt", "Created At"),
|
||||
cell: ({ row }) => formatDate(row.getValue("created_at")),
|
||||
},
|
||||
]}
|
||||
header={{
|
||||
title: t("subscriptionList", "Subscription List"),
|
||||
toolbar: (
|
||||
<SubscriptionForm
|
||||
key="create"
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
await createUserSubscribe({
|
||||
user_id: Number(userId),
|
||||
...values,
|
||||
});
|
||||
toast.success(t("createSuccess", "Created successfully"));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
}}
|
||||
title={t("createSubscription", "Create Subscription")}
|
||||
trigger={t("add", "Add")}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
request={async (pagination) => {
|
||||
const { data } = await getUserSubscribe({
|
||||
user_id: userId,
|
||||
...pagination,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RowMoreActions({ userId, subId }: { userId: number; subId: number }) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const { t } = useTranslation("user");
|
||||
return (
|
||||
<div className="inline-flex">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{t("more", "More")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`/dashboard/log/subscribe?user_id=${userId}&user_subscribe_id=${subId}`}
|
||||
>
|
||||
{t("subscriptionLogs", "Subscription Logs")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`/dashboard/log/reset-subscribe?user_id=${userId}&user_subscribe_id=${subId}`}
|
||||
>
|
||||
{t("resetLogs", "Reset Logs")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`/dashboard/log/subscribe-traffic?user_id=${userId}&user_subscribe_id=${subId}`}
|
||||
>
|
||||
{t("trafficStats", "Traffic Stats")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={`/dashboard/log/traffic-details?user_id=${userId}&subscribe_id=${subId}`}
|
||||
>
|
||||
{t("trafficDetails", "Traffic Details")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
triggerRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{t("onlineDevices", "Online Devices")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<SubscriptionDetail
|
||||
subscriptionId={subId}
|
||||
trigger={<Button className="hidden" ref={triggerRef} />}
|
||||
userId={userId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
getUserSubscribeDevices,
|
||||
kickOfflineByUserDevice,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IpLink } from "@/components/ip-link";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
export function SubscriptionDetail({
|
||||
trigger,
|
||||
userId,
|
||||
subscriptionId,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
userId: number;
|
||||
subscriptionId: number;
|
||||
}) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>{trigger}</SheetTrigger>
|
||||
<SheetContent
|
||||
className="w-[700px] max-w-full md:max-w-screen-md"
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("onlineDevices", "Online Devices")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 max-h-[calc(100dvh-120px)] overflow-y-auto">
|
||||
<ProTable<API.UserDevice, Record<string, unknown>>
|
||||
actions={{
|
||||
render: (row) => {
|
||||
if (!row.identifier) return [];
|
||||
return [
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
description={t(
|
||||
"kickOfflineConfirm",
|
||||
`Kick device ${row.ip} offline?`
|
||||
)}
|
||||
key="offline"
|
||||
onConfirm={async () => {
|
||||
await kickOfflineByUserDevice({ id: row.id });
|
||||
toast.success(
|
||||
t("kickOfflineSuccess", "Device kicked offline")
|
||||
);
|
||||
}}
|
||||
title={t("confirmOffline", "Confirm Offline")}
|
||||
trigger={
|
||||
<Button variant="destructive">
|
||||
{t("confirmOffline", "Confirm Offline")}
|
||||
</Button>
|
||||
}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: t("enable", "Enable"),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.getValue("enabled")}
|
||||
onChange={(checked) => {
|
||||
console.log("Switch:", checked);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "id", header: "ID" },
|
||||
{ accessorKey: "identifier", header: "IMEI" },
|
||||
{
|
||||
accessorKey: "user_agent",
|
||||
header: t("userAgent", "User Agent"),
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP",
|
||||
cell: ({ row }) => <IpLink ip={row.getValue("ip")} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
header: t("loginStatus", "Login Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.getValue("online") ? "default" : "destructive"}
|
||||
>
|
||||
{row.getValue("online")
|
||||
? t("online", "Online")
|
||||
: t("offline", "Offline")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: t("lastSeen", "Last Seen"),
|
||||
cell: ({ row }) => formatDate(row.getValue("updated_at")),
|
||||
},
|
||||
]}
|
||||
request={async (pagination) => {
|
||||
const { data } = await getUserSubscribeDevices({
|
||||
user_id: userId,
|
||||
subscribe_id: subscriptionId,
|
||||
...pagination,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@workspace/ui/components/sheet";
|
||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||
import { DatePicker } from "@workspace/ui/composed/date-picker";
|
||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useSubscribe } from "@/stores/subscribe";
|
||||
|
||||
interface Props {
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
initialData?: API.UserSubscribe;
|
||||
onSubmit: (values: any) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
subscribe_id: z.number().optional(),
|
||||
traffic: z.number().optional(),
|
||||
speed_limit: z.number().optional(),
|
||||
device_limit: z.number().optional(),
|
||||
expired_at: z.number().nullish().optional(),
|
||||
upload: z.number().optional(),
|
||||
download: z.number().optional(),
|
||||
id: z.number().optional(),
|
||||
});
|
||||
|
||||
export function SubscriptionForm({
|
||||
trigger,
|
||||
title,
|
||||
loading,
|
||||
initialData,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
subscribe_id: initialData?.subscribe_id || 0,
|
||||
traffic: initialData?.traffic || 0,
|
||||
upload: initialData?.upload || 0,
|
||||
download: initialData?.download || 0,
|
||||
expired_at: initialData?.expire_time || 0,
|
||||
...(initialData && { id: initialData.id }),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
const success = await onSubmit(values);
|
||||
if (success) {
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subscribe_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("subscription", "Subscription")}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, false>
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
options={subscribes?.map((item) => ({
|
||||
value: item.id!,
|
||||
label: item.name!,
|
||||
}))}
|
||||
placeholder="Select Subscription"
|
||||
value={field.value}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="traffic"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("trafficLimit", "Traffic Limit")}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t("unlimited", "Unlimited")}
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("bytesToGb", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("gbToBytes", value)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
suffix="GB"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="upload"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("uploadTraffic", "Upload Traffic")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder="0"
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("bytesToGb", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("gbToBytes", value)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
suffix="GB"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="download"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("downloadTraffic", "Download Traffic")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder="0"
|
||||
type="number"
|
||||
{...field}
|
||||
formatInput={(value) =>
|
||||
unitConversion("bytesToGb", value)
|
||||
}
|
||||
formatOutput={(value) =>
|
||||
unitConversion("gbToBytes", value)
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value as number);
|
||||
}}
|
||||
suffix="GB"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expired_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("expiredAt", "Expired At")}</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
onChange={(value: number | null | undefined) => {
|
||||
if (value === field.value) {
|
||||
form.setValue(field.name, 0);
|
||||
} else {
|
||||
form.setValue(field.name, value!);
|
||||
}
|
||||
}}
|
||||
placeholder={t("permanent", "Permanent")}
|
||||
value={field.value ?? undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="flex-row justify-end gap-2 pt-3">
|
||||
<Button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && (
|
||||
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
|
||||
)}
|
||||
{t("confirm", "Confirm")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user