GroupsPage.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Button,
  5. Card,
  6. Col,
  7. ConfigProvider,
  8. Dropdown,
  9. Form,
  10. Input,
  11. Layout,
  12. Modal,
  13. Result,
  14. Row,
  15. Space,
  16. Spin,
  17. Statistic,
  18. Table,
  19. Tag,
  20. Tooltip,
  21. message,
  22. } from 'antd';
  23. import type { MenuProps, TableColumnsType } from 'antd';
  24. import {
  25. ArrowDownOutlined,
  26. ArrowUpOutlined,
  27. ClockCircleOutlined,
  28. DeleteOutlined,
  29. EditOutlined,
  30. LinkOutlined,
  31. MoreOutlined,
  32. PieChartOutlined,
  33. PlusOutlined,
  34. RetweetOutlined,
  35. TagsOutlined,
  36. TeamOutlined,
  37. UsergroupAddOutlined,
  38. UsergroupDeleteOutlined,
  39. } from '@ant-design/icons';
  40. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  41. import { z } from 'zod';
  42. import { useTheme } from '@/hooks/useTheme';
  43. import { useMediaQuery } from '@/hooks/useMediaQuery';
  44. import { usePageTitle } from '@/hooks/usePageTitle';
  45. import { useClients } from '@/hooks/useClients';
  46. import { HttpUtil, SizeFormatter } from '@/utils';
  47. import { setMessageInstance } from '@/utils/messageBus';
  48. import AppSidebar from '@/layouts/AppSidebar';
  49. import { LazyMount } from '@/components/utility';
  50. import { keys } from '@/api/queryKeys';
  51. import {
  52. ClientRecordSchema,
  53. GroupSummaryListSchema,
  54. type ClientRecord,
  55. type GroupSummary,
  56. } from '@/schemas/client';
  57. import { parseMsg } from '@/utils/zodValidate';
  58. const ClientRecordListSchema = z.array(ClientRecordSchema).nullable().transform((v) => v ?? []);
  59. const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
  60. const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
  61. const GroupAddClientsModal = lazy(() => import('./GroupAddClientsModal'));
  62. const GroupRemoveClientsModal = lazy(() => import('./GroupRemoveClientsModal'));
  63. const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
  64. async function fetchGroups(): Promise<GroupSummary[]> {
  65. const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
  66. if (!msg?.success) throw new Error(msg?.msg || 'Failed to load groups');
  67. const validated = parseMsg(msg, GroupSummaryListSchema, 'clients/groups');
  68. return validated.obj ?? [];
  69. }
  70. async function fetchEmailsForGroup(name: string): Promise<string[]> {
  71. const msg = await HttpUtil.get<string[]>(
  72. `/panel/api/clients/groups/${encodeURIComponent(name)}/emails`,
  73. undefined,
  74. { silent: true },
  75. );
  76. if (!msg?.success || !Array.isArray(msg.obj)) return [];
  77. return msg.obj;
  78. }
  79. export default function GroupsPage() {
  80. usePageTitle();
  81. const { t } = useTranslation();
  82. const { isDark, isUltra, antdThemeConfig } = useTheme();
  83. const { isMobile } = useMediaQuery();
  84. const [modal, modalContextHolder] = Modal.useModal();
  85. const [messageApi, messageContextHolder] = message.useMessage();
  86. useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
  87. const queryClient = useQueryClient();
  88. const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
  89. const groupsQuery = useQuery({
  90. queryKey: keys.clients.groups(),
  91. queryFn: fetchGroups,
  92. });
  93. const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
  94. const loading = groupsQuery.isFetching;
  95. const fetched = groupsQuery.data !== undefined || groupsQuery.isError;
  96. const fetchError = groupsQuery.error ? (groupsQuery.error as Error).message : '';
  97. const invalidate = useCallback(() => {
  98. queryClient.invalidateQueries({ queryKey: keys.clients.root() });
  99. }, [queryClient]);
  100. const createMut = useMutation({
  101. mutationFn: (body: { name: string }) =>
  102. HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS),
  103. onSuccess: (msg) => { if (msg?.success) invalidate(); },
  104. });
  105. const renameMut = useMutation({
  106. mutationFn: (body: { oldName: string; newName: string }) =>
  107. HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS),
  108. onSuccess: (msg) => { if (msg?.success) invalidate(); },
  109. });
  110. const deleteMut = useMutation({
  111. mutationFn: (body: { name: string }) =>
  112. HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS),
  113. onSuccess: (msg) => { if (msg?.success) invalidate(); },
  114. });
  115. const bulkResetMut = useMutation({
  116. mutationFn: (body: { emails: string[] }) =>
  117. HttpUtil.post('/panel/api/clients/bulkResetTraffic', body, JSON_HEADERS),
  118. onSuccess: (msg) => { if (msg?.success) invalidate(); },
  119. });
  120. const [createOpen, setCreateOpen] = useState(false);
  121. const [createName, setCreateName] = useState('');
  122. const [renameOpen, setRenameOpen] = useState(false);
  123. const [renameTarget, setRenameTarget] = useState<GroupSummary | null>(null);
  124. const [renameValue, setRenameValue] = useState('');
  125. const [subLinksOpen, setSubLinksOpen] = useState(false);
  126. const [adjustOpen, setAdjustOpen] = useState(false);
  127. const [addClientsOpen, setAddClientsOpen] = useState(false);
  128. const [removeClientsOpen, setRemoveClientsOpen] = useState(false);
  129. const [groupEmails, setGroupEmails] = useState<string[]>([]);
  130. const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
  131. const allClientsQuery = useQuery<ClientRecord[]>({
  132. queryKey: keys.clients.all(),
  133. queryFn: async () => {
  134. const msg = await HttpUtil.get('/panel/api/clients/list', undefined, { silent: true });
  135. if (!msg?.success) throw new Error(msg?.msg || 'Failed to load clients');
  136. const validated = parseMsg(msg, ClientRecordListSchema, 'clients/list');
  137. return validated.obj ?? [];
  138. },
  139. enabled: addClientsOpen || removeClientsOpen || subLinksOpen,
  140. staleTime: 30_000,
  141. });
  142. const allClients = allClientsQuery.data ?? [];
  143. const totalGroups = groups.length;
  144. const totalClients = useMemo(
  145. () => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
  146. [groups],
  147. );
  148. const totalTraffic = useMemo(
  149. () => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
  150. [groups],
  151. );
  152. const totalUpload = useMemo(
  153. () => groups.reduce((acc, g) => acc + (g.up || 0), 0),
  154. [groups],
  155. );
  156. const totalDownload = useMemo(
  157. () => groups.reduce((acc, g) => acc + (g.down || 0), 0),
  158. [groups],
  159. );
  160. function openCreate() {
  161. setCreateName('');
  162. setCreateOpen(true);
  163. }
  164. async function confirmCreate() {
  165. const name = createName.trim();
  166. if (!name) return;
  167. if (groups.some((g) => g.name.toLowerCase() === name.toLowerCase())) {
  168. messageApi.error(t('pages.groups.renameCollision', { name }));
  169. return;
  170. }
  171. const msg = await createMut.mutateAsync({ name });
  172. if (msg?.success) {
  173. messageApi.success(t('pages.groups.createSuccess', { name }));
  174. setCreateOpen(false);
  175. }
  176. }
  177. function openRename(g: GroupSummary) {
  178. setRenameTarget(g);
  179. setRenameValue(g.name);
  180. setRenameOpen(true);
  181. }
  182. async function confirmRename() {
  183. if (!renameTarget) return;
  184. const next = renameValue.trim();
  185. if (!next || next === renameTarget.name) {
  186. setRenameOpen(false);
  187. return;
  188. }
  189. if (groups.some((g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name)) {
  190. messageApi.error(t('pages.groups.renameCollision', { name: next }));
  191. return;
  192. }
  193. const msg = await renameMut.mutateAsync({ oldName: renameTarget.name, newName: next });
  194. if (msg?.success) {
  195. const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
  196. messageApi.success(t('pages.groups.renameSuccess', { count: affected }));
  197. setRenameOpen(false);
  198. }
  199. }
  200. function onDelete(g: GroupSummary) {
  201. modal.confirm({
  202. title: t('pages.groups.deleteConfirmTitle', { name: g.name }),
  203. content: t('pages.groups.deleteConfirmContent', { count: g.clientCount }),
  204. okText: t('delete'),
  205. okType: 'danger',
  206. cancelText: t('cancel'),
  207. onOk: async () => {
  208. const msg = await deleteMut.mutateAsync({ name: g.name });
  209. if (msg?.success) {
  210. const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
  211. messageApi.success(t('pages.groups.deleteSuccess', { count: affected }));
  212. }
  213. },
  214. });
  215. }
  216. async function openSubLinksFor(g: GroupSummary) {
  217. if (!g.clientCount) {
  218. messageApi.info(t('pages.groups.emptyForAction'));
  219. return;
  220. }
  221. const emails = await fetchEmailsForGroup(g.name);
  222. if (emails.length === 0) {
  223. messageApi.info(t('pages.groups.emptyForAction'));
  224. return;
  225. }
  226. setGroupForAction(g);
  227. setGroupEmails(emails);
  228. setSubLinksOpen(true);
  229. }
  230. async function openAdjustFor(g: GroupSummary) {
  231. if (!g.clientCount) {
  232. messageApi.info(t('pages.groups.emptyForAction'));
  233. return;
  234. }
  235. const emails = await fetchEmailsForGroup(g.name);
  236. if (emails.length === 0) {
  237. messageApi.info(t('pages.groups.emptyForAction'));
  238. return;
  239. }
  240. setGroupForAction(g);
  241. setGroupEmails(emails);
  242. setAdjustOpen(true);
  243. }
  244. function openAddClientsFor(g: GroupSummary) {
  245. setGroupForAction(g);
  246. setAddClientsOpen(true);
  247. }
  248. function openRemoveClientsFor(g: GroupSummary) {
  249. if (!g.clientCount) {
  250. messageApi.info(t('pages.groups.emptyForAction'));
  251. return;
  252. }
  253. setGroupForAction(g);
  254. setRemoveClientsOpen(true);
  255. }
  256. function onDeleteClients(g: GroupSummary) {
  257. if (!g.clientCount) {
  258. messageApi.info(t('pages.groups.emptyForAction'));
  259. return;
  260. }
  261. modal.confirm({
  262. title: t('pages.groups.deleteClientsConfirmTitle', { name: g.name }),
  263. content: t('pages.groups.deleteClientsConfirmContent', { count: g.clientCount }),
  264. okText: t('delete'),
  265. okType: 'danger',
  266. cancelText: t('cancel'),
  267. onOk: async () => {
  268. const emails = await fetchEmailsForGroup(g.name);
  269. if (emails.length === 0) return;
  270. const msg = await bulkDelete(emails);
  271. if (msg?.success) {
  272. const ok = msg.obj?.deleted ?? 0;
  273. const skipped = msg.obj?.skipped ?? [];
  274. const failed = skipped.length;
  275. if (failed === 0) {
  276. messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok }));
  277. } else {
  278. const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
  279. messageApi.warning(firstError
  280. ? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}`
  281. : t('pages.groups.deleteClientsMixed', { ok, failed }));
  282. }
  283. }
  284. },
  285. });
  286. }
  287. function onResetTraffic(g: GroupSummary) {
  288. if (!g.clientCount) {
  289. messageApi.info(t('pages.groups.emptyForAction'));
  290. return;
  291. }
  292. modal.confirm({
  293. title: t('pages.groups.resetConfirmTitle', { name: g.name }),
  294. content: t('pages.groups.resetConfirmContent', { count: g.clientCount }),
  295. okText: t('reset'),
  296. okType: 'danger',
  297. cancelText: t('cancel'),
  298. onOk: async () => {
  299. const emails = await fetchEmailsForGroup(g.name);
  300. if (emails.length === 0) return;
  301. const msg = await bulkResetMut.mutateAsync({ emails });
  302. if (msg?.success) {
  303. const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
  304. messageApi.success(t('pages.groups.resetSuccess', { count: affected }));
  305. }
  306. },
  307. });
  308. }
  309. function rowActions(row: GroupSummary): MenuProps['items'] {
  310. return [
  311. {
  312. key: 'subLinks',
  313. icon: <LinkOutlined />,
  314. label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }),
  315. disabled: !row.clientCount,
  316. onClick: () => openSubLinksFor(row),
  317. },
  318. {
  319. key: 'adjust',
  320. icon: <ClockCircleOutlined />,
  321. label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }),
  322. disabled: !row.clientCount,
  323. onClick: () => openAdjustFor(row),
  324. },
  325. {
  326. key: 'reset',
  327. icon: <RetweetOutlined />,
  328. label: t('pages.groups.resetTraffic'),
  329. disabled: !row.clientCount,
  330. onClick: () => onResetTraffic(row),
  331. },
  332. {
  333. key: 'addClients',
  334. icon: <UsergroupAddOutlined />,
  335. label: t('pages.groups.addToGroup'),
  336. onClick: () => openAddClientsFor(row),
  337. },
  338. {
  339. key: 'rename',
  340. icon: <EditOutlined />,
  341. label: t('pages.groups.rename'),
  342. onClick: () => openRename(row),
  343. },
  344. { type: 'divider' },
  345. {
  346. key: 'removeClients',
  347. icon: <UsergroupDeleteOutlined />,
  348. label: t('pages.groups.removeFromGroup'),
  349. danger: true,
  350. disabled: !row.clientCount,
  351. onClick: () => openRemoveClientsFor(row),
  352. },
  353. {
  354. key: 'deleteClients',
  355. icon: <DeleteOutlined />,
  356. label: t('pages.groups.deleteClients'),
  357. danger: true,
  358. disabled: !row.clientCount,
  359. onClick: () => onDeleteClients(row),
  360. },
  361. {
  362. key: 'delete',
  363. icon: <DeleteOutlined />,
  364. label: t('pages.groups.deleteGroupOnly'),
  365. danger: true,
  366. onClick: () => onDelete(row),
  367. },
  368. ];
  369. }
  370. const columns: TableColumnsType<GroupSummary> = [
  371. {
  372. title: t('pages.clients.actions'),
  373. key: 'actions',
  374. width: 90,
  375. render: (_v, row) => (
  376. <Space size={4}>
  377. <Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
  378. <Button size="small" type="text" icon={<MoreOutlined />} />
  379. </Dropdown>
  380. <Tooltip title={t('pages.groups.rename')}>
  381. <Button size="small" type="text" icon={<EditOutlined />} onClick={() => openRename(row)} />
  382. </Tooltip>
  383. </Space>
  384. ),
  385. },
  386. {
  387. title: t('pages.groups.name'),
  388. dataIndex: 'name',
  389. key: 'name',
  390. render: (name: string) => <Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>{name}</Tag>,
  391. },
  392. {
  393. title: t('pages.groups.clientCount'),
  394. dataIndex: 'clientCount',
  395. key: 'clientCount',
  396. width: 180,
  397. render: (count: number) => <span>{count || 0}</span>,
  398. },
  399. {
  400. title: t('pages.groups.upload'),
  401. dataIndex: 'up',
  402. key: 'up',
  403. width: 140,
  404. render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
  405. },
  406. {
  407. title: t('pages.groups.download'),
  408. dataIndex: 'down',
  409. key: 'down',
  410. width: 140,
  411. render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
  412. },
  413. {
  414. title: t('pages.groups.trafficUsed'),
  415. dataIndex: 'trafficUsed',
  416. key: 'trafficUsed',
  417. width: 160,
  418. render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
  419. },
  420. ];
  421. const pageClass = useMemo(() => {
  422. const classes = ['groups-page'];
  423. if (isDark) classes.push('is-dark');
  424. if (isUltra) classes.push('is-ultra');
  425. return classes.join(' ');
  426. }, [isDark, isUltra]);
  427. return (
  428. <ConfigProvider theme={antdThemeConfig}>
  429. {messageContextHolder}
  430. {modalContextHolder}
  431. <Layout className={pageClass}>
  432. <AppSidebar />
  433. <Layout className="content-shell">
  434. <Layout.Content id="content-layout" className="content-area">
  435. <Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
  436. {!fetched ? (
  437. <div className="loading-spacer" />
  438. ) : fetchError ? (
  439. <Result
  440. status="error"
  441. title={t('somethingWentWrong')}
  442. subTitle={fetchError}
  443. extra={<Button type="primary" loading={loading} onClick={() => groupsQuery.refetch()}>{t('refresh')}</Button>}
  444. />
  445. ) : (
  446. <Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
  447. <Col span={24}>
  448. <Card size="small" hoverable className="summary-card">
  449. <Row gutter={[16, isMobile ? 16 : 12]}>
  450. <Col xs={12} sm={12} md={6}>
  451. <Statistic
  452. title={t('pages.groups.totalGroups')}
  453. value={String(totalGroups)}
  454. prefix={<TagsOutlined />}
  455. />
  456. </Col>
  457. <Col xs={12} sm={12} md={6}>
  458. <Statistic
  459. title={t('pages.groups.totalGroupedClients')}
  460. value={String(totalClients)}
  461. prefix={<TeamOutlined />}
  462. />
  463. </Col>
  464. <Col xs={12} sm={12} md={6}>
  465. <Statistic
  466. title={t('pages.groups.totalUpDown')}
  467. value={0}
  468. formatter={() => (
  469. <span>
  470. <ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)}
  471. {' / '}
  472. <ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)}
  473. </span>
  474. )}
  475. />
  476. </Col>
  477. <Col xs={12} sm={12} md={6}>
  478. <Statistic
  479. title={t('pages.groups.totalTraffic')}
  480. value={SizeFormatter.sizeFormat(totalTraffic)}
  481. prefix={<PieChartOutlined />}
  482. />
  483. </Col>
  484. </Row>
  485. </Card>
  486. </Col>
  487. <Col span={24}>
  488. <Card
  489. size="small"
  490. hoverable
  491. title={
  492. <div className="card-toolbar">
  493. <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
  494. {!isMobile && t('pages.groups.addGroup')}
  495. </Button>
  496. </div>
  497. }
  498. >
  499. <Table<GroupSummary>
  500. dataSource={groups}
  501. columns={columns}
  502. rowKey="name"
  503. size="small"
  504. pagination={false}
  505. loading={loading}
  506. locale={{
  507. emptyText: (
  508. <div className="card-empty">
  509. <TagsOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  510. <div>{t('noData')}</div>
  511. </div>
  512. ),
  513. }}
  514. />
  515. </Card>
  516. </Col>
  517. </Row>
  518. )}
  519. </Spin>
  520. </Layout.Content>
  521. </Layout>
  522. <Modal
  523. open={createOpen}
  524. title={t('pages.groups.addGroup')}
  525. okText={t('create')}
  526. cancelText={t('cancel')}
  527. confirmLoading={createMut.isPending}
  528. onCancel={() => setCreateOpen(false)}
  529. onOk={confirmCreate}
  530. destroyOnHidden
  531. >
  532. <Form layout="vertical">
  533. <Form.Item label={t('pages.groups.name')}>
  534. <Input
  535. value={createName}
  536. onChange={(e) => setCreateName(e.target.value)}
  537. onPressEnter={confirmCreate}
  538. placeholder={t('pages.clients.groupPlaceholder')}
  539. autoFocus
  540. />
  541. </Form.Item>
  542. </Form>
  543. </Modal>
  544. <Modal
  545. open={renameOpen}
  546. title={renameTarget ? t('pages.groups.renameTitle', { name: renameTarget.name }) : ''}
  547. okText={t('save')}
  548. cancelText={t('cancel')}
  549. confirmLoading={renameMut.isPending}
  550. onCancel={() => setRenameOpen(false)}
  551. onOk={confirmRename}
  552. destroyOnHidden
  553. >
  554. <Form layout="vertical">
  555. <Form.Item label={t('pages.groups.name')}>
  556. <Input
  557. value={renameValue}
  558. onChange={(e) => setRenameValue(e.target.value)}
  559. onPressEnter={confirmRename}
  560. placeholder={t('pages.clients.groupPlaceholder')}
  561. autoFocus
  562. />
  563. </Form.Item>
  564. </Form>
  565. </Modal>
  566. <LazyMount when={subLinksOpen}>
  567. <SubLinksModal
  568. open={subLinksOpen}
  569. emails={groupEmails}
  570. clients={allClients}
  571. subSettings={subSettings}
  572. onOpenChange={setSubLinksOpen}
  573. />
  574. </LazyMount>
  575. <LazyMount when={adjustOpen}>
  576. <ClientBulkAdjustModal
  577. open={adjustOpen}
  578. count={groupEmails.length}
  579. onOpenChange={setAdjustOpen}
  580. onSubmit={async (addDays, addBytes) => {
  581. const msg = await bulkAdjust(groupEmails, addDays, addBytes);
  582. if (msg?.success) {
  583. const obj = msg.obj ?? { adjusted: 0 };
  584. messageApi.success(
  585. t('pages.groups.adjustSuccess', {
  586. count: obj.adjusted ?? 0,
  587. name: groupForAction?.name ?? '',
  588. }),
  589. );
  590. return obj;
  591. }
  592. return null;
  593. }}
  594. />
  595. </LazyMount>
  596. <LazyMount when={addClientsOpen}>
  597. <GroupAddClientsModal
  598. open={addClientsOpen}
  599. groupName={groupForAction?.name ?? null}
  600. candidates={allClients.filter((c) => c.group !== groupForAction?.name)}
  601. onClose={() => setAddClientsOpen(false)}
  602. onSubmit={async (emails) => {
  603. const msg = await bulkAddToGroup(emails, groupForAction?.name ?? '');
  604. if (msg?.success) {
  605. return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
  606. }
  607. return null;
  608. }}
  609. />
  610. </LazyMount>
  611. <LazyMount when={removeClientsOpen}>
  612. <GroupRemoveClientsModal
  613. open={removeClientsOpen}
  614. groupName={groupForAction?.name ?? null}
  615. members={allClients.filter((c) => c.group === groupForAction?.name)}
  616. onClose={() => setRemoveClientsOpen(false)}
  617. onSubmit={async (emails) => {
  618. const msg = await bulkRemoveFromGroup(emails);
  619. if (msg?.success) {
  620. return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
  621. }
  622. return null;
  623. }}
  624. />
  625. </LazyMount>
  626. </Layout>
  627. </ConfigProvider>
  628. );
  629. }