feat: Add quota management features and localization updates

This commit is contained in:
web
2025-09-11 03:34:36 -07:00
parent e2d83ec9e6
commit fce627ba11
33 changed files with 2716 additions and 93 deletions
@@ -0,0 +1,457 @@
'use client';
import { Display } from '@/components/display';
import { createQuotaTask, queryQuotaTaskPreCount } from '@/services/admin/marketing';
import { getSubscribeList } from '@/services/admin/subscribe';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@workspace/ui/components/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@workspace/ui/components/form';
import { RadioGroup, RadioGroupItem } from '@workspace/ui/components/radio-group';
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 { Combobox } from '@workspace/ui/custom-components/combobox';
import { EnhancedInput } from '@workspace/ui/custom-components/enhanced-input';
import { Icon } from '@workspace/ui/custom-components/icon';
import { unitConversion } from '@workspace/ui/utils';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
export default function QuotaBroadcastForm() {
const t = useTranslations('marketing');
// Define schema with internationalized error messages
const quotaBroadcastSchema = z.object({
subscribers: z.array(z.number()).min(1, t('pleaseSelectSubscribers')),
is_active: z.boolean(),
start_time: z.string().optional(),
end_time: z.string().optional(),
reset_traffic: z.boolean(),
days: z.number().min(1, t('days') + ' ' + t('cannotBeEmpty')),
gift_type: z.number(),
gift_value: z.number().min(0, t('giftValue') + ' ' + t('mustBeGreaterThanOrEqualToZero')),
});
type QuotaBroadcastFormData = z.infer<typeof quotaBroadcastSchema>;
const form = useForm<QuotaBroadcastFormData>({
resolver: zodResolver(quotaBroadcastSchema),
mode: 'onChange', // Enable real-time validation
defaultValues: {
subscribers: [],
is_active: true,
start_time: '',
end_time: '',
reset_traffic: false,
days: 0,
gift_type: 1,
gift_value: 0,
},
});
const [recipients, setRecipients] = useState<number>(0);
const [isCalculating, setIsCalculating] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
// Get subscribe list
const { data: subscribeList } = useQuery({
queryKey: ['getSubscribeList', 'all'],
queryFn: async () => {
const { data } = await getSubscribeList({
page: 1,
size: 999999999,
});
return data.data?.list as API.SubscribeItem[];
},
});
// Calculate recipient count
const calculateRecipients = async () => {
setIsCalculating(true);
try {
const formData = form.getValues();
let start_time: number = 0;
let end_time: number = 0;
if (formData.start_time) {
start_time = Math.floor(new Date(formData.start_time).getTime() / 1000);
}
if (formData.end_time) {
end_time = Math.floor(new Date(formData.end_time).getTime() / 1000);
}
const response = await queryQuotaTaskPreCount({
subscribers: formData.subscribers,
is_active: formData.is_active,
start_time,
end_time,
});
if (response.data?.data?.count !== undefined) {
setRecipients(response.data.data.count);
}
} catch (error) {
console.error('Failed to calculate recipients:', error);
toast.error(t('failedToCalculateRecipients'));
setRecipients(0);
} finally {
setIsCalculating(false);
}
};
// Watch form values and recalculate recipients only when sheet is open
const watchedValues = form.watch();
useEffect(() => {
if (!open) return; // Only calculate when sheet is open
const debounceTimer = setTimeout(() => {
calculateRecipients();
}, 500); // Add debounce to avoid too frequent API calls
return () => clearTimeout(debounceTimer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
open,
watchedValues.subscribers,
watchedValues.is_active,
watchedValues.start_time,
watchedValues.end_time,
]);
const onSubmit = async (data: QuotaBroadcastFormData) => {
setIsSubmitting(true);
try {
let start_time: number = 0;
let end_time: number = 0;
if (data.start_time) {
start_time = Math.floor(new Date(data.start_time).getTime() / 1000);
}
if (data.end_time) {
end_time = Math.floor(new Date(data.end_time).getTime() / 1000);
}
await createQuotaTask({
subscribers: data.subscribers,
is_active: data.is_active,
start_time,
end_time,
reset_traffic: data.reset_traffic,
days: data.days,
gift_type: data.gift_type,
gift_value: data.gift_value,
});
toast.success(t('quotaTaskCreatedSuccessfully'));
form.reset();
setRecipients(0);
setOpen(false); // Close the sheet after successful submission
} catch (error) {
console.error('Failed to create quota task:', error);
toast.error(t('failedToCreateQuotaTask'));
} finally {
setIsSubmitting(false);
}
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:gift' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('quotaBroadcast')}</p>
<p className='text-muted-foreground text-sm'>{t('createAndSendQuotaTasks')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[600px] max-w-full md:max-w-screen-md'>
<SheetHeader>
<SheetTitle>{t('createQuotaTask')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-32px-env(safe-area-inset-top))] px-6'>
<Form {...form}>
<form
id='quota-broadcast-form'
onSubmit={form.handleSubmit(onSubmit)}
className='mt-4 space-y-6'
>
{/* Subscribers selection */}
<FormField
control={form.control}
name='subscribers'
render={({ field }) => (
<FormItem>
<FormLabel>{t('subscribers')}</FormLabel>
<FormControl>
<Combobox
multiple={true}
value={field.value || []}
onChange={field.onChange}
placeholder={t('pleaseSelectSubscribers')}
options={
subscribeList?.map((subscribe) => ({
value: subscribe.id!,
label: subscribe.name!,
children: (
<div>
<div>{subscribe.name}</div>
<div className='text-muted-foreground text-xs'>
<Display type='traffic' value={subscribe.traffic || 0} /> /{' '}
<Display type='currency' value={subscribe.unit_price || 0} />
</div>
</div>
),
})) || []
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Subscription count info and active status */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<FormField
control={form.control}
name='is_active'
render={({ field }) => (
<FormItem>
<FormLabel>{t('validOnly')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('selectValidSubscriptionsOnly')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='border-l-primary bg-primary/10 flex items-center border-l-4 px-4 py-3 text-sm'>
<span className='text-muted-foreground'>{t('subscriptionCount')}: </span>
<span className='text-primary text-lg font-medium'>
{isCalculating ? (
<Icon icon='mdi:loading' className='ml-2 h-4 w-4 animate-spin' />
) : (
recipients.toLocaleString()
)}
</span>
</div>
</div>
{/* Subscription validity period range */}
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='start_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('subscriptionValidityStartDate')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('includeSubscriptionsValidAfter')}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name='end_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('subscriptionValidityEndDate')}</FormLabel>
<FormControl>
<EnhancedInput
type='datetime-local'
value={field.value}
onValueChange={field.onChange}
/>
</FormControl>
<FormDescription>{t('includeSubscriptionsValidBefore')}</FormDescription>
</FormItem>
)}
/>
</div>
{/* Reset traffic */}
<FormField
control={form.control}
name='reset_traffic'
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetTraffic')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className='float-end !mt-0'
/>
</FormControl>
<FormDescription>{t('resetTrafficDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Quota days */}
<FormField
control={form.control}
name='days'
render={({ field }) => (
<FormItem>
<FormLabel>{t('quotaDays')}</FormLabel>
<FormControl>
<EnhancedInput
type='number'
min={1}
value={field.value?.toString()}
onValueChange={(value) => field.onChange(parseInt(value, 10))}
/>
</FormControl>
<FormDescription>{t('numberOfDaysForTheQuota')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Gift configuration */}
<FormField
control={form.control}
name='gift_type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('giftType')}</FormLabel>
<FormControl>
<RadioGroup
defaultValue={String(field.value)}
onValueChange={(value) => {
field.onChange(Number(value));
form.setValue('gift_value', 0);
}}
className='flex gap-4'
>
<FormItem className='flex items-center space-x-3 space-y-0'>
<FormControl>
<RadioGroupItem value='1' />
</FormControl>
<FormLabel className='font-normal'>{t('fixedAmount')}</FormLabel>
</FormItem>
<FormItem className='flex items-center space-x-3 space-y-0'>
<FormControl>
<RadioGroupItem value='2' />
</FormControl>
<FormLabel className='font-normal'>{t('percentageAmount')}</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Gift amount based on type */}
{form.watch('gift_type') === 1 && (
<FormField
control={form.control}
name='gift_value'
render={({ field }) => (
<FormItem>
<FormLabel>{t('giftAmount')}</FormLabel>
<FormControl>
<EnhancedInput<number>
placeholder={t('enterAmount')}
type='number'
value={field.value}
formatInput={(value) => unitConversion('centsToDollars', value)}
formatOutput={(value) => unitConversion('dollarsToCents', value)}
onValueChange={(value) => field.onChange(value)}
min={1}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch('gift_type') === 2 && (
<FormField
control={form.control}
name='gift_value'
render={({ field }) => (
<FormItem>
<FormLabel>{t('giftAmount')}</FormLabel>
<FormControl>
<EnhancedInput
placeholder={t('enterPercentage')}
type='number'
suffix='%'
value={field.value}
onValueChange={(value) => field.onChange(value)}
min={1}
max={100}
/>
</FormControl>
<FormDescription>{t('percentageAmountDescription')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</ScrollArea>
<SheetFooter className='flex flex-row items-center justify-end gap-2 pt-3'>
<Button variant='outline' onClick={() => setOpen(false)}>
{t('cancel')}
</Button>
<Button
type='submit'
form='quota-broadcast-form'
disabled={
isSubmitting || !form.formState.isValid || form.watch('subscribers').length === 0
}
>
{isSubmitting && <Icon icon='mdi:loading' className='mr-2 h-4 w-4 animate-spin' />}
{t('createQuotaTask')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,243 @@
'use client';
import { Display } from '@/components/display';
import { ProTable } from '@/components/pro-table';
import { queryQuotaTaskList } from '@/services/admin/marketing';
import { getSubscribeList } from '@/services/admin/subscribe';
import { formatDate } from '@/utils/common';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@workspace/ui/components/badge';
import { ScrollArea } from '@workspace/ui/components/scroll-area';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@workspace/ui/components/sheet';
import { Icon } from '@workspace/ui/custom-components/icon';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
export default function QuotaTaskManager() {
const t = useTranslations('marketing');
const [open, setOpen] = useState(false);
// Get subscribe list to show subscription names
const { data: subscribeList } = useQuery({
queryKey: ['getSubscribeList', 'all'],
queryFn: async () => {
const { data } = await getSubscribeList({
page: 1,
size: 999999999,
});
return data.data?.list as API.SubscribeItem[];
},
});
// Create a map for quick lookup of subscription names
const subscribeMap =
subscribeList?.reduce(
(acc, subscribe) => {
acc[subscribe.id!] = subscribe.name!;
return acc;
},
{} as Record<number, string>,
) || {};
const getStatusBadge = (status: number) => {
const statusConfig = {
0: { label: t('notStarted'), variant: 'secondary' as const },
1: { label: t('inProgress'), variant: 'default' as const },
2: { label: t('completed'), variant: 'default' as const },
};
const config = statusConfig[status as keyof typeof statusConfig] || {
label: `${t('status')} ${status}`,
variant: 'secondary' as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<div className='flex cursor-pointer items-center justify-between transition-colors'>
<div className='flex items-center gap-3'>
<div className='bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg'>
<Icon icon='mdi:database-plus' className='text-primary h-5 w-5' />
</div>
<div className='flex-1'>
<p className='font-medium'>{t('quotaTaskManager')}</p>
<p className='text-muted-foreground text-sm'>{t('viewAndManageQuotaTasks')}</p>
</div>
</div>
<Icon icon='mdi:chevron-right' className='size-6' />
</div>
</SheetTrigger>
<SheetContent className='w-[1000px] max-w-full md:max-w-screen-lg'>
<SheetHeader>
<SheetTitle>{t('quotaTasks')}</SheetTitle>
</SheetHeader>
<ScrollArea className='-mx-6 h-[calc(100dvh-48px-36px-env(safe-area-inset-top))] px-6'>
<div className='mt-4 space-y-4'>
{open && (
<ProTable<API.QuotaTask, API.QueryQuotaTaskListParams>
columns={[
{
accessorKey: 'subscribers',
header: t('subscribers'),
size: 200,
cell: ({ row }) => {
const subscribers = row.getValue('subscribers') as number[];
const subscriptionNames =
subscribers?.map((id) => subscribeMap[id]).filter(Boolean) || [];
if (subscriptionNames.length === 0) {
return (
<span className='text-muted-foreground text-sm'>
{t('noSubscriptions')}
</span>
);
}
return (
<div className='flex flex-wrap gap-1'>
{subscriptionNames.map((name, index) => (
<span key={index} className='bg-muted rounded px-2 py-1 text-xs'>
{name}
</span>
))}
</div>
);
},
},
{
accessorKey: 'is_active',
header: t('validOnly'),
size: 120,
cell: ({ row }) => {
const isActive = row.getValue('is_active') as boolean;
return <span className='text-sm'>{isActive ? t('yes') : t('no')}</span>;
},
},
{
accessorKey: 'reset_traffic',
header: t('resetTraffic'),
size: 120,
cell: ({ row }) => {
const resetTraffic = row.getValue('reset_traffic') as boolean;
return <span className='text-sm'>{resetTraffic ? t('yes') : t('no')}</span>;
},
},
{
accessorKey: 'gift_value',
header: t('giftAmount'),
size: 120,
cell: ({ row }) => {
const giftValue = row.getValue('gift_value') as number;
const task = row.original as API.QuotaTask;
const giftType = task.gift_type;
return (
<div className='text-sm font-medium'>
{giftType === 1 ? (
<Display type='currency' value={giftValue} />
) : (
`${giftValue}%`
)}
</div>
);
},
},
{
accessorKey: 'days',
header: t('quotaDays'),
size: 100,
cell: ({ row }) => {
const days = row.getValue('days') as number;
return (
<span className='font-medium'>
{days} {t('days')}
</span>
);
},
},
{
accessorKey: 'time_range',
header: t('timeRange'),
size: 180,
cell: ({ row }) => {
const task = row.original as API.QuotaTask;
const startTime = task.start_time;
const endTime = task.end_time;
if (!startTime && !endTime) {
return (
<span className='text-muted-foreground text-sm'>{t('noTimeLimit')}</span>
);
}
return (
<div className='space-y-1 text-xs'>
{startTime && (
<div>
{t('startTime')}: {formatDate(startTime)}
</div>
)}
{endTime && (
<div>
{t('endTime')}: {formatDate(endTime)}
</div>
)}
</div>
);
},
},
{
accessorKey: 'status',
header: t('status'),
size: 100,
cell: ({ row }) => getStatusBadge(row.getValue('status') as number),
},
{
accessorKey: 'created_at',
header: t('createdAt'),
size: 150,
cell: ({ row }) => {
const createdAt = row.getValue('created_at') as number;
return formatDate(createdAt);
},
},
]}
request={async (pagination, filters) => {
const response = await queryQuotaTaskList({
...filters,
page: pagination.page,
size: pagination.size,
});
return {
list: response.data?.data?.list || [],
total: response.data?.data?.total || 0,
};
}}
params={[
{
key: 'status',
placeholder: t('status'),
options: [
{ label: t('notStarted'), value: '0' },
{ label: t('inProgress'), value: '1' },
{ label: t('completed'), value: '2' },
],
},
]}
/>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}