gitscope
← marzneshin / v0.2.1 / app/dashboard/src/pages/users/dialog/index.tsx
https://dev.kronoz.win/r/marzneshin.git
app/dashboard/src/pages/users/dialog/index.tsx · TypeScript
Blame Raw
  1import {
  2  Alert,
  3  Button,
  4  Tooltip,
  5  IconButton,
  6  Spinner,
  7  AlertIcon,
  8  Box,
  9  Collapse,
 10  Flex,
 11  Grid,
 12  GridItem,
 13  ModalBody,
 14  ModalCloseButton,
 15  ModalContent,
 16  Modal,
 17  VStack,
 18  useColorMode,
 19  useToast,
 20  ModalOverlay,
 21  HStack,
 22} from '@chakra-ui/react';
 23import { zodResolver } from '@hookform/resolvers/zod';
 24import dayjs from 'dayjs';
 25import { FC, useEffect, useState } from 'react';
 26import ReactApexChart from 'react-apexcharts';
 27import { FormProvider, useForm, useWatch } from 'react-hook-form';
 28import { useTranslation } from 'react-i18next';
 29import {
 30  User,
 31  FilterUsageType,
 32  UserCreate,
 33  Service
 34} from 'types';
 35import { UsageFilter, createUsageConfig } from 'components/usage-filter';
 36import {
 37  AddIcon,
 38  EditIcon,
 39  DialogModalHeader,
 40  DialogModalFooter,
 41  DeleteIcon
 42} from 'components/dialog';
 43import { UserUsageIcon } from './icons';
 44import { getDefaultValues } from './default-values';
 45import { schema, FormType } from './form-schema';
 46import { ServicesField } from './services-field';
 47import { UsernameField } from './username-field';
 48import { DataLimitField } from './data-limit-field';
 49import { PeriodicUsageReset } from './periodic-usage-reset';
 50import { ExpireDateField } from './expire-date-field';
 51import { NoteField } from './note-field';
 52import { useUsers, useServices, fetchServices } from 'stores';
 53import { queryIds } from 'constants/query-ids';
 54import { useQuery } from '@tanstack/react-query';
 55
 56const formatUser = (user: User): FormType => {
 57  const services: number[] = user.services.map((service: number | Service): number => {
 58    return (typeof service !== 'number') ? service.id : service;
 59  });
 60  return {
 61    ...user,
 62    services,
 63    data_limit: user.data_limit
 64      ? Number((user.data_limit / 1073741824).toFixed(5))
 65      : user.data_limit,
 66  };
 67};
 68
 69export type UserDialogProps = {};
 70
 71export const UserDialog: FC<UserDialogProps> = () => {
 72  const {
 73    editingUser,
 74    isCreatingNewUser,
 75    onCreateUser,
 76    editUser,
 77    fetchUserUsage,
 78    onEditingUser,
 79    createUser,
 80    onDeletingUser,
 81  } = useUsers();
 82  const { refetchServices, servicesFilters: filters } = useServices();
 83  const { data: services } = useQuery({
 84    queryKey: [queryIds.services],
 85    initialData: [],
 86    queryFn: () => {
 87      return fetchServices(filters);
 88    }
 89  });
 90  const isEditing = !!editingUser;
 91  const isOpen = isCreatingNewUser || isEditing;
 92  const [loading, setLoading] = useState(false);
 93  const [error, setError] = useState<string | null>('');
 94  const toast = useToast();
 95  const { t, i18n } = useTranslation();
 96
 97  const { colorMode } = useColorMode();
 98
 99  const [usageVisible, setUsageVisible] = useState(false);
100  const handleUsageToggle = () => {
101    setUsageVisible((current) => !current);
102  };
103
104  const form = useForm<FormType>({
105    defaultValues: getDefaultValues(),
106    resolver: zodResolver(schema),
107  });
108
109  const [dataLimit] = useWatch({
110    control: form.control,
111    name: ['data_limit'],
112  });
113
114  const usageTitle = t('userDialog.total');
115  const [usage, setUsage] = useState(createUsageConfig(colorMode, usageTitle));
116  const [usageFilter, setUsageFilter] = useState('1m');
117  const fetchUsageWithFilter = (query: FilterUsageType) => {
118    fetchUserUsage(editingUser!, query).then((data: any) => {
119      const labels = [];
120      const series = [];
121      for (const key in data.usages) {
122        series.push(data.usages[key].used_traffic);
123        labels.push(data.usages[key].node_name);
124      }
125      setUsage(createUsageConfig(colorMode, usageTitle, series, labels));
126    });
127  };
128
129  useEffect(() => {
130    if (isOpen) {
131      refetchServices();
132    }
133  }, [isOpen]);
134
135
136  useEffect(() => {
137    if (editingUser) {
138      form.reset(formatUser(editingUser));
139
140      fetchUsageWithFilter({
141        start: dayjs().utc().subtract(30, 'day').format('YYYY-MM-DDTHH:00:00'),
142      });
143    }
144  }, [editingUser]);
145
146  const submit = async (values: FormType) => {
147    setLoading(true);
148    const methods = { edited: editUser, created: createUser };
149    const method = isEditing ? 'edited' : 'created';
150    setError(null);
151    const { services, username, ...rest } = values;
152
153    let body: UserCreate = {
154      ...rest,
155      username,
156      services,
157      data_limit: values.data_limit,
158      data_limit_reset_strategy:
159        values.data_limit && values.data_limit > 0
160          ? values.data_limit_reset_strategy
161          : 'no_reset',
162      status:
163        values.status === 'active' || values.status === 'disabled'
164          ? values.status
165          : 'active',
166    };
167
168    await methods[method](body)
169      .then(() => {
170        toast({
171          title: t(
172            isEditing ? 'userDialog.userEdited' : 'userDialog.userCreated',
173            { username: values.username }
174          ),
175          status: 'success',
176          isClosable: true,
177          position: 'top',
178          duration: 3000,
179        });
180        onClose();
181      })
182      .catch((err) => {
183        if (err?.response?.status === 409 || err?.response?.status === 400)
184          setError(err?.response?._data?.detail);
185        if (err?.response?.status === 422) {
186          Object.keys(err.response._data.detail).forEach((key) => {
187            setError(err?.response._data.detail[key] as string);
188            form.setError(
189              key as 'services' | 'username' | 'data_limit' | 'expire',
190              {
191                type: 'custom',
192                message: err.response._data.detail[key],
193              }
194            );
195          });
196        }
197      })
198      .finally(() => {
199        setLoading(false);
200      });
201  };
202
203  const onClose = () => {
204    form.reset(getDefaultValues());
205    onCreateUser(false);
206    onEditingUser(null);
207    setError(null);
208    setUsageVisible(false);
209    setUsageFilter('1m');
210  };
211
212  const handleResetUsage = () => {
213    useUsers.setState({ resetUsageUser: editingUser });
214  };
215
216  const handleRevokeSubscription = () => {
217    useUsers.setState({ revokeSubscriptionUser: editingUser });
218  };
219
220  const disabled = loading;
221
222  return (
223    <Modal isOpen={isOpen} onClose={onClose} size="2xl">
224      <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
225      <FormProvider {...form} formState={form.formState}>
226        <ModalContent mx="3">
227          <form onSubmit={form.handleSubmit(submit)}>
228            <DialogModalHeader HeaderIcon={isEditing ? EditIcon : AddIcon} title={isEditing ? t('userDialog.editUserTitle') : t('createNewUser')} />
229            <ModalCloseButton mt={3} disabled={disabled} />
230            <ModalBody>
231              <Grid
232                templateColumns={{
233                  base: 'repeat(1, 1fr)',
234                  md: 'repeat(2, 1fr)',
235                }}
236                gap={3}
237              >
238                <GridItem>
239                  <VStack justifyContent="space-between">
240                    <Flex
241                      flexDirection="column"
242                      gridAutoRows="min-content"
243                      w="full"
244                    >
245                      <UsernameField form={form} disabled={disabled} isEditing={isEditing} t={t} />
246                      <DataLimitField form={form} disabled={disabled} t={t} />
247
248                      <Collapse
249                        in={!!(dataLimit && dataLimit > 0)}
250                        animateOpacity
251                        style={{ width: '100%' }}
252                      >
253                        <PeriodicUsageReset form={form} t={t} />
254                      </Collapse>
255                      <ExpireDateField form={form} t={t} i18n={i18n} disabled={disabled} />
256                      <NoteField form={form} t={t} />
257                    </Flex>
258                    {error && (
259                      <Alert
260                        status="error"
261                        display={{ base: 'none', md: 'flex' }}
262                      >
263                        <AlertIcon />
264                        {error}
265                      </Alert>
266                    )}
267                  </VStack>
268                </GridItem>
269
270                <GridItem>
271                  <ServicesField t={t} services={services || []} form={form} />
272                </GridItem>
273
274                {isEditing && usageVisible && (
275                  <GridItem pt={6} colSpan={{ base: 1, md: 2 }}>
276                    <VStack gap={4}>
277                      <UsageFilter
278                        defaultValue={usageFilter}
279                        onChange={(filter, query) => {
280                          setUsageFilter(filter);
281                          fetchUsageWithFilter(query);
282                        }}
283                      />
284                      <Box
285                        width={{ base: '100%', md: '70%' }}
286                        justifySelf="center"
287                      >
288                        <ReactApexChart
289                          options={usage.options}
290                          series={usage.series}
291                          type="donut"
292                        />
293                      </Box>
294                    </VStack>
295                  </GridItem>
296                )}
297              </Grid>
298
299              {error && (
300                <Alert
301                  mt="3"
302                  status="error"
303                  display={{ base: 'flex', md: 'none' }}
304                >
305                  <AlertIcon />
306                  {error}
307                </Alert>
308              )}
309            </ModalBody>
310            <DialogModalFooter>
311              <HStack
312                justifyContent="flex-start"
313                w={{
314                  base: 'full',
315                  sm: 'unset',
316                }}
317              >
318                {isEditing && editingUser !== null && (
319                  <>
320                    <Tooltip label={t('delete')} placement="top">
321                      <IconButton
322                        aria-label="Delete"
323                        size="sm"
324                        onClick={() => {
325                          onDeletingUser(editingUser);
326                          onClose();
327                        }}
328                      >
329                        <DeleteIcon />
330                      </IconButton>
331                    </Tooltip>
332                    <Tooltip label={t('userDialog.usage')} placement="top">
333                      <IconButton
334                        aria-label="usage"
335                        size="sm"
336                        onClick={handleUsageToggle}
337                      >
338                        <UserUsageIcon />
339                      </IconButton>
340                    </Tooltip>
341                    <Button onClick={handleResetUsage} size="sm">
342                      {t('userDialog.resetUsage')}
343                    </Button>
344                    <Button onClick={handleRevokeSubscription} size="sm">
345                      {t('userDialog.revokeSubscription')}
346                    </Button>
347                  </>
348                )}
349              </HStack>
350              <HStack
351                w="full"
352                maxW={{ md: '50%', base: 'full' }}
353                justify="end"
354              >
355                <Button
356                  type="submit"
357                  size="sm"
358                  px="8"
359                  colorScheme="primary"
360                  leftIcon={loading ? <Spinner size="xs" /> : undefined}
361                  disabled={false}
362                >
363                  {isEditing ? t('userDialog.editUser') : t('createUser')}
364                </Button>
365              </HStack>
366            </DialogModalFooter>
367          </form>
368        </ModalContent>
369      </FormProvider>
370    </Modal >
371  );
372};