🎉 chore(init): project initialization
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { Icon } from '@iconify/react';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shadcn/ui/form';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@shadcn/ui/sheet';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
interface GroupFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function GroupForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: GroupFormProps<T>) {
|
||||
const t = useTranslations('subscribe');
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
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 open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-[500px] max-w-full md:max-w-screen-md'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className='space-y-4 px-6 pt-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('group.form.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('group.form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('group.form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteSubscribeGroup,
|
||||
createSubscribeGroup,
|
||||
deleteSubscribeGroup,
|
||||
getSubscribeGroupList,
|
||||
updateSubscribeGroup,
|
||||
} from '@/services/admin/subscribe';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { formatDate } from '@repo/ui/utils';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import GroupForm from './group-form';
|
||||
|
||||
const GroupTable = () => {
|
||||
const t = useTranslations('subscribe');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ref = useRef<ProTableActions>();
|
||||
|
||||
return (
|
||||
<ProTable<API.SubscribeGroup, any>
|
||||
action={ref}
|
||||
header={{
|
||||
title: t('group.title'),
|
||||
toolbar: (
|
||||
<GroupForm<API.CreateSubscribeGroupRequest>
|
||||
trigger={t('group.create')}
|
||||
title={t('group.createSubscribeGroup')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createSubscribeGroup(values);
|
||||
toast.success(t('group.createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('group.name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: t('group.description'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
header: t('group.updatedAt'),
|
||||
cell: ({ row }) => formatDate(row.getValue('updated_at')),
|
||||
},
|
||||
]}
|
||||
request={async () => {
|
||||
const { data } = await getSubscribeGroupList();
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<GroupForm<API.SubscribeGroup>
|
||||
key='edit'
|
||||
trigger={t('group.edit')}
|
||||
title={t('group.editSubscribeGroup')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSubscribeGroup({
|
||||
...row,
|
||||
...values,
|
||||
});
|
||||
toast.success(t('group.updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await deleteSubscribeGroup({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t('group.deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
/>,
|
||||
],
|
||||
batchRender(rows) {
|
||||
return [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('group.delete')}</Button>}
|
||||
title={t('group.confirmDelete')}
|
||||
description={t('group.deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await batchDeleteSubscribeGroup({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
toast.success(t('group.deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('group.cancel')}
|
||||
confirmText={t('group.confirm')}
|
||||
/>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupTable;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shadcn/ui/tabs';
|
||||
|
||||
import GroupTable from './group-table';
|
||||
import SubscribeTable from './subscribe-table';
|
||||
|
||||
export default async function Page() {
|
||||
const t = await getTranslations('subscribe');
|
||||
|
||||
return (
|
||||
<Tabs defaultValue='subscribe'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='subscribe'>{t('tabs.subscribe')}</TabsTrigger>
|
||||
<TabsTrigger value='group'>{t('tabs.subscribeGroup')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='subscribe'>
|
||||
<SubscribeTable />
|
||||
</TabsContent>
|
||||
<TabsContent value='group'>
|
||||
<GroupTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import { getNodeGroupList, getNodeList } from '@/services/admin/server';
|
||||
import { getSubscribeGroupList } from '@/services/admin/subscribe';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Combobox } from '@repo/ui/combobox';
|
||||
import { ArrayInput } from '@repo/ui/dynamic-Inputs';
|
||||
import { JSONEditor } from '@repo/ui/editor';
|
||||
import { EnhancedInput } from '@repo/ui/enhanced-input';
|
||||
import { unitConversion } from '@repo/ui/utils';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@shadcn/ui/accordion';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { Checkbox } from '@shadcn/ui/checkbox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@shadcn/ui/form';
|
||||
import { Label } from '@shadcn/ui/label';
|
||||
import { useForm } from '@shadcn/ui/lib/react-hook-form';
|
||||
import { z, zodResolver } from '@shadcn/ui/lib/zod';
|
||||
import { ScrollArea } from '@shadcn/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@shadcn/ui/sheet';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { assign, shake } from 'radash';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface SubscribeFormProps<T> {
|
||||
onSubmit: (data: T) => Promise<boolean> | boolean;
|
||||
initialValues?: T;
|
||||
loading?: boolean;
|
||||
trigger: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
inventory: -1,
|
||||
speed_limit: 0,
|
||||
device_limit: 0,
|
||||
traffic: 0,
|
||||
quota: 0,
|
||||
discount: [],
|
||||
server_group: [],
|
||||
server: [],
|
||||
};
|
||||
|
||||
export default function SubscribeForm<T extends Record<string, any>>({
|
||||
onSubmit,
|
||||
initialValues,
|
||||
loading,
|
||||
trigger,
|
||||
title,
|
||||
}: SubscribeFormProps<T>) {
|
||||
const t = useTranslations('subscribe');
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
unit_price: z.number(),
|
||||
replacement: z.number().optional(),
|
||||
discount: z
|
||||
.array(
|
||||
z.object({
|
||||
months: z.number(),
|
||||
discount: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
inventory: z.number().optional(),
|
||||
speed_limit: z.number().optional(),
|
||||
device_limit: z.number().optional(),
|
||||
traffic: z.number().optional(),
|
||||
quota: z.number().optional(),
|
||||
group_id: z.number().optional().nullish(),
|
||||
server_group: z.array(z.number()).optional(),
|
||||
server: z.array(z.number()).optional(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: assign(
|
||||
defaultValues,
|
||||
shake(initialValues, (value) => value === null) as Record<string, any>,
|
||||
),
|
||||
});
|
||||
useEffect(() => {
|
||||
form?.reset(
|
||||
assign(defaultValues, shake(initialValues, (value) => value === null) as Record<string, any>),
|
||||
);
|
||||
}, [form, initialValues]);
|
||||
|
||||
async function handleSubmit(data: { [x: string]: any }) {
|
||||
const bool = await onSubmit(data as T);
|
||||
|
||||
if (bool) setOpen(false);
|
||||
}
|
||||
const { data: group } = useQuery({
|
||||
queryKey: ['getSubscribeGroupList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeGroupList();
|
||||
return data.data?.list as API.SubscribeGroup[];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: server } = useQuery({
|
||||
queryKey: ['getNodeList', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeList({
|
||||
page: 1,
|
||||
size: 9999,
|
||||
});
|
||||
return data.data?.list;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: server_groups } = useQuery({
|
||||
queryKey: ['getNodeGroupList'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getNodeGroupList();
|
||||
return (data.data?.list || []) as API.ServerGroup[];
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='w-screen max-w-full md:max-w-full'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className='grid gap-4 px-6 pt-4 lg:grid-cols-3'
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<JSONEditor
|
||||
title={t('form.description')}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
placeholder={{
|
||||
description: 'description',
|
||||
features: [
|
||||
{
|
||||
type: 'default',
|
||||
icon: '',
|
||||
label: 'label',
|
||||
},
|
||||
],
|
||||
}}
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'A brief description of the item.',
|
||||
},
|
||||
features: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
icon: {
|
||||
type: 'string',
|
||||
description:
|
||||
"Enter an Iconify icon identifier (e.g., 'mdi:account').",
|
||||
pattern: '^[a-z0-9]+:[a-z0-9-]+$',
|
||||
examples: [
|
||||
'uil:shield-check',
|
||||
'uil:shield-exclamation',
|
||||
'uil:database',
|
||||
'uil:server',
|
||||
'Visit https://icon-sets.iconify.design to browse available icons.',
|
||||
],
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'The label describing the feature.',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['default', 'success', 'destructive'],
|
||||
description:
|
||||
'The type of feature, limited to specific values.',
|
||||
},
|
||||
},
|
||||
},
|
||||
description: 'A list of feature objects.',
|
||||
},
|
||||
},
|
||||
required: ['description', 'features'],
|
||||
additionalProperties: false,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-3 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='unit_price'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.unit_price')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='replacement'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.replacement')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
type='number'
|
||||
{...field}
|
||||
min={0}
|
||||
formatInput={(value) => unitConversion('centsToDollars', value)}
|
||||
formatOutput={(value) => unitConversion('dollarsToCents', value)}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='traffic'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.traffic')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
formatInput={(value) => unitConversion('bytesToGb', value)}
|
||||
formatOutput={(value) => unitConversion('gbToBytes', value)}
|
||||
suffix='GB'
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='speed_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.speedLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
formatInput={(value) => unitConversion('bitsToMb', value)}
|
||||
formatOutput={(value) => unitConversion('mbToBits', value)}
|
||||
suffix='MB'
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='device_limit'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.deviceLimit')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='inventory'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.inventory')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={`-1 ${t('form.noLimit')}`}
|
||||
type='number'
|
||||
{...field}
|
||||
min={-1}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='quota'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.quota')}</FormLabel>
|
||||
<FormControl>
|
||||
<EnhancedInput
|
||||
placeholder={t('form.noLimit')}
|
||||
type='number'
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='group_id'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.subscribeGroup')}</FormLabel>
|
||||
<FormControl>
|
||||
<Combobox<number, false>
|
||||
placeholder={t('form.selectSubscribeGroup')}
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
options={group?.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='col-span-3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='discount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.discount')}</FormLabel>
|
||||
<FormControl>
|
||||
<ArrayInput<API.SubscribeDiscount>
|
||||
fields={[
|
||||
{
|
||||
name: 'months',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
suffix: t('form.discountMonths'),
|
||||
},
|
||||
{
|
||||
name: 'discount',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 100,
|
||||
placeholder: t('form.discountPercent'),
|
||||
suffix: '%',
|
||||
},
|
||||
]}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
form.setValue(field.name, value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t('form.discountDescription')}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='server_group'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.serverGroup')}</FormLabel>
|
||||
<FormControl>
|
||||
<Accordion type='single' collapsible className='w-full'>
|
||||
{server_groups?.map((group: API.ServerGroup) => {
|
||||
const value = field.value || [];
|
||||
|
||||
return (
|
||||
<AccordionItem key={group.id} value={String(group.id)}>
|
||||
<AccordionTrigger>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={value.includes(group.id!)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? form.setValue(field.name, [...value, group.id])
|
||||
: form.setValue(
|
||||
field.name,
|
||||
value.filter((value: number) => value !== group.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label>{group.name}</Label>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className='list-disc [&>li]:mt-2'>
|
||||
{server
|
||||
?.filter((server: API.Server) => server.groupId === group.id)
|
||||
?.map((node: API.Server) => {
|
||||
return (
|
||||
<li
|
||||
key={node.id}
|
||||
className='flex items-center justify-between *:flex-1'
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
<span>{node.server_addr}</span>
|
||||
<span className='text-right'>{node.protocol}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='server'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('form.server')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{server
|
||||
?.filter((item: API.Server) => !item.groupId)
|
||||
?.map((item: API.Server) => {
|
||||
const value = field.value || [];
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2' key={item.id}>
|
||||
<Checkbox
|
||||
checked={value.includes(item.id!)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? form.setValue(field.name, [...value, item.id])
|
||||
: form.setValue(
|
||||
field.name,
|
||||
value.filter((value: number) => value !== item.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label className='flex w-full items-center justify-between *:flex-1'>
|
||||
<span>{item.name}</span>
|
||||
<span>{item.server_addr}</span>
|
||||
<span className='text-right'>{item.protocol}</span>
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</ScrollArea>
|
||||
<SheetFooter className='flex-row justify-end gap-2 pt-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
|
||||
{loading && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
||||
{t('form.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { Display } from '@/components/display';
|
||||
import { ProTable, ProTableActions } from '@/components/pro-table';
|
||||
import {
|
||||
batchDeleteSubscribe,
|
||||
createSubscribe,
|
||||
deleteSubscribe,
|
||||
getSubscribeGroupList,
|
||||
getSubscribeList,
|
||||
updateSubscribe,
|
||||
} from '@/services/admin/subscribe';
|
||||
import { ConfirmButton } from '@repo/ui/confirm-button';
|
||||
import { Badge } from '@shadcn/ui/badge';
|
||||
import { Button } from '@shadcn/ui/button';
|
||||
import { toast } from '@shadcn/ui/lib/sonner';
|
||||
import { Switch } from '@shadcn/ui/switch';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import SubscribeForm from './subscribe-form';
|
||||
|
||||
export default function SubscribeTable() {
|
||||
const t = useTranslations('subscribe');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { data: groups } = useQuery({
|
||||
queryKey: ['getSubscribeGroupList', 'all'],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSubscribeGroupList({
|
||||
page: 1,
|
||||
size: 9999,
|
||||
});
|
||||
return data.data?.list as API.SubscribeGroup[];
|
||||
},
|
||||
});
|
||||
const ref = useRef<ProTableActions>();
|
||||
return (
|
||||
<ProTable<API.Subscribe, { group_id: number; query: string }>
|
||||
action={ref}
|
||||
header={{
|
||||
toolbar: (
|
||||
<SubscribeForm<API.CreateSubscribeRequest>
|
||||
trigger={t('create')}
|
||||
title={t('createSubscribe')}
|
||||
loading={loading}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createSubscribe({
|
||||
...values,
|
||||
show: false,
|
||||
sell: false,
|
||||
});
|
||||
toast.success(t('createSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
params={[
|
||||
{
|
||||
key: 'search',
|
||||
},
|
||||
{
|
||||
key: 'group_id',
|
||||
placeholder: t('subscribeGroup'),
|
||||
options: groups?.map((item) => ({
|
||||
label: item.name,
|
||||
value: String(item.id),
|
||||
})),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filters) => {
|
||||
const { data } = await getSubscribeList({
|
||||
...pagination,
|
||||
...filters,
|
||||
});
|
||||
return {
|
||||
list: data.data?.list || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
accessorKey: 'show',
|
||||
header: t('show'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('show')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateSubscribe({
|
||||
...row.original,
|
||||
show: checked,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'sell',
|
||||
header: t('sell'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={row.getValue('sell')}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateSubscribe({
|
||||
...row.original,
|
||||
sell: checked,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('name'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'unit_price',
|
||||
header: t('unitPrice'),
|
||||
cell: ({ row }) => <Display type='currency' value={row.getValue('unit_price')} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'replacement',
|
||||
header: t('replacement'),
|
||||
cell: ({ row }) => <Display type='currency' value={row.getValue('replacement')} />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'traffic',
|
||||
header: t('traffic'),
|
||||
cell: ({ row }) => <Display type='traffic' value={row.getValue('traffic')} unlimited />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'device_limit',
|
||||
header: t('deviceLimit'),
|
||||
cell: ({ row }) => (
|
||||
<Display type='number' value={row.getValue('device_limit')} unlimited />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'inventory',
|
||||
header: t('inventory'),
|
||||
cell: ({ row }) => (
|
||||
<Display
|
||||
type='number'
|
||||
value={row.getValue('inventory') === -1 ? 0 : row.getValue('inventory')}
|
||||
unlimited
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'quota',
|
||||
header: t('quota'),
|
||||
cell: ({ row }) => <Display type='number' value={row.getValue('quota')} unlimited />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'group_id',
|
||||
header: t('subscribeGroup'),
|
||||
cell: ({ row }) => {
|
||||
const name = groups?.find((group) => group.id === row.getValue('group_id'))?.name;
|
||||
return name ? <Badge variant='outline'>{name}</Badge> : '--';
|
||||
},
|
||||
},
|
||||
]}
|
||||
actions={{
|
||||
render: (row) => [
|
||||
<SubscribeForm<API.Subscribe>
|
||||
key='edit'
|
||||
trigger={t('edit')}
|
||||
title={t('editSubscribe')}
|
||||
loading={loading}
|
||||
initialValues={row}
|
||||
onSubmit={async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateSubscribe({
|
||||
...row,
|
||||
...values,
|
||||
} as API.UpdateSubscribeRequest);
|
||||
toast.success(t('updateSuccess'));
|
||||
ref.current?.refresh();
|
||||
setLoading(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await deleteSubscribe({
|
||||
id: row.id,
|
||||
});
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.refresh();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
],
|
||||
batchRender: (rows) => [
|
||||
<ConfirmButton
|
||||
key='delete'
|
||||
trigger={<Button variant='destructive'>{t('delete')}</Button>}
|
||||
title={t('confirmDelete')}
|
||||
description={t('deleteWarning')}
|
||||
onConfirm={async () => {
|
||||
await batchDeleteSubscribe({
|
||||
ids: rows.map((item) => item.id),
|
||||
});
|
||||
|
||||
toast.success(t('deleteSuccess'));
|
||||
ref.current?.reset();
|
||||
}}
|
||||
cancelText={t('cancel')}
|
||||
confirmText={t('confirm')}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user