GroupsPage.tsx 22 KB

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