ClientRenewalFields.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import { useEffect, useId, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useFormContext, useWatch } from 'react-hook-form';
  4. import { useQuery } from '@tanstack/react-query';
  5. import { Button, Col, Form, InputNumber, Row, Select, Space, Typography } from 'antd';
  6. import { FormField } from '@/components/form/rhf';
  7. import { ClientRenewalPreviewSchema } from '@/generated/zod';
  8. import { HttpUtil } from '@/utils';
  9. import type { ClientFormValues } from '@/schemas/client';
  10. type RenewalFields = Pick<ClientFormValues, 'reset' | 'resetDay' | 'resetWeekday' | 'resetMax'>;
  11. type RenewalMode = 'none' | 'interval' | 'weekly' | 'monthly';
  12. function RenewalDate({ value }: { value: string }) {
  13. // Keep the server's wall time and per-date offset, including across DST.
  14. // Parsing into a browser-local Date would show a different renewal boundary.
  15. const display = value
  16. .replace('T', ' ')
  17. .replace(/(Z|[+-]\d{2}:\d{2})$/, (offset) => ` UTC${offset === 'Z' ? '+00:00' : offset}`);
  18. return (
  19. <time dateTime={value} dir="ltr">
  20. {display}
  21. </time>
  22. );
  23. }
  24. export default function ClientRenewalFields({
  25. active,
  26. expiryTime,
  27. resetCount = 0,
  28. bulk = false,
  29. delayedStart = false,
  30. setExpiry,
  31. }: {
  32. active: boolean;
  33. expiryTime: number;
  34. resetCount?: number;
  35. bulk?: boolean;
  36. delayedStart?: boolean;
  37. setExpiry: (expiry: number) => void;
  38. }) {
  39. const { t, i18n } = useTranslation();
  40. const formId = useId();
  41. const modeId = 'client-renewal-mode-' + formId;
  42. const { control, setValue } = useFormContext<RenewalFields>();
  43. const [reset, resetDay, resetWeekday, resetMax] = useWatch({
  44. control,
  45. name: ['reset', 'resetDay', 'resetWeekday', 'resetMax'],
  46. });
  47. const mode: RenewalMode =
  48. resetDay > 0 ? 'monthly' : resetWeekday > 0 ? 'weekly' : reset > 0 ? 'interval' : 'none';
  49. const request = useMemo(
  50. () => ({
  51. expiryTime,
  52. reset: reset || 0,
  53. resetDay: resetDay || 0,
  54. resetWeekday: resetWeekday || 0,
  55. resetMax: resetMax || 0,
  56. resetCount,
  57. }),
  58. [expiryTime, reset, resetDay, resetWeekday, resetMax, resetCount],
  59. );
  60. const [debounced, setDebounced] = useState(request);
  61. useEffect(() => {
  62. const timer = setTimeout(() => setDebounced(request), 250);
  63. return () => clearTimeout(timer);
  64. }, [request]);
  65. const query = useQuery({
  66. queryKey: ['clients', 'renewalPreview', debounced],
  67. enabled: active && mode !== 'none' && request === debounced,
  68. retry: false,
  69. queryFn: async () => {
  70. const msg = await HttpUtil.post('/panel/api/clients/renewalPreview', debounced, {
  71. headers: { 'Content-Type': 'application/json' },
  72. silent: true,
  73. });
  74. if (!msg?.success) throw new Error(msg?.msg || 'Renewal preview failed');
  75. return ClientRenewalPreviewSchema.parse(msg.obj);
  76. },
  77. });
  78. const preview = request === debounced ? query.data : undefined;
  79. const weekdayFormatter = new Intl.DateTimeFormat(i18n.language, {
  80. weekday: 'long',
  81. timeZone: 'UTC',
  82. });
  83. function changeMode(next: RenewalMode) {
  84. setValue('reset', next === 'interval' ? Math.max(1, reset || 0) : 0);
  85. setValue('resetDay', next === 'monthly' ? Math.max(1, resetDay || 0) : 0);
  86. setValue('resetWeekday', next === 'weekly' ? Math.max(1, resetWeekday || 0) : 0);
  87. }
  88. return (
  89. <>
  90. <Row gutter={16}>
  91. <Col xs={24} md={bulk ? 24 : 8}>
  92. <Form.Item label={t('pages.clients.renewMode')} htmlFor={modeId}>
  93. <Select
  94. id={modeId}
  95. value={mode}
  96. onChange={changeMode}
  97. options={[
  98. { value: 'none', label: t('pages.clients.renewModeNone') },
  99. { value: 'interval', label: t('pages.clients.renewModeInterval') },
  100. { value: 'weekly', label: t('pages.clients.renewModeWeekly') },
  101. { value: 'monthly', label: t('pages.clients.renewModeMonthly') },
  102. ]}
  103. />
  104. </Form.Item>
  105. </Col>
  106. {mode === 'interval' && (
  107. <Col xs={24} md={bulk ? 24 : 8}>
  108. <FormField
  109. name="reset"
  110. label={bulk ? t('pages.clients.renew') : t('pages.clients.renewDays')}
  111. tooltip={t('pages.clients.renewDesc')}
  112. transform={{ output: (v) => Number(v) || 1 }}
  113. >
  114. <InputNumber
  115. id={'client-renewal-interval-' + formId}
  116. min={1}
  117. style={{ width: '100%' }}
  118. />
  119. </FormField>
  120. </Col>
  121. )}
  122. {mode === 'monthly' && (
  123. <Col xs={24} md={bulk ? 24 : 8}>
  124. <FormField
  125. name="resetDay"
  126. label={t('pages.clients.renewOnDay')}
  127. tooltip={t('pages.clients.renewOnDayDesc')}
  128. transform={{ output: (v) => Number(v) || 1 }}
  129. >
  130. <InputNumber
  131. id={'client-renewal-day-' + formId}
  132. min={1}
  133. max={31}
  134. style={{ width: '100%' }}
  135. />
  136. </FormField>
  137. </Col>
  138. )}
  139. {mode === 'weekly' && (
  140. <Col xs={24} md={bulk ? 24 : 8}>
  141. <FormField name="resetWeekday" label={t('pages.clients.renewWeekday')}>
  142. <Select
  143. id={'client-renewal-weekday-' + formId}
  144. options={Array.from({ length: 7 }, (_, i) => ({
  145. value: i + 1,
  146. label: weekdayFormatter.format(new Date(Date.UTC(2026, 0, i + 5))),
  147. }))}
  148. />
  149. </FormField>
  150. </Col>
  151. )}
  152. {mode !== 'none' && (
  153. <Col xs={24} md={bulk ? 24 : 8}>
  154. <FormField
  155. name="resetMax"
  156. label={t('pages.clients.renewMax')}
  157. tooltip={t('pages.clients.renewMaxDesc')}
  158. transform={{ output: (v) => Number(v) || 0 }}
  159. >
  160. <InputNumber min={0} style={{ width: '100%' }} />
  161. </FormField>
  162. </Col>
  163. )}
  164. </Row>
  165. {mode !== 'none' && (
  166. <>
  167. <Typography.Paragraph type="secondary">
  168. {mode === 'interval'
  169. ? t('pages.clients.renewIntervalDesc')
  170. : mode === 'weekly'
  171. ? t('pages.clients.renewWeeklyDesc')
  172. : t('pages.clients.renewScheduleDesc')}
  173. </Typography.Paragraph>
  174. {query.isError && request === debounced && (
  175. <Typography.Paragraph type="warning">
  176. {t('pages.clients.renewPreviewError')}
  177. </Typography.Paragraph>
  178. )}
  179. {preview && (
  180. <Space orientation="vertical" size={4} style={{ width: '100%', marginBottom: 16 }}>
  181. <Typography.Text>
  182. {t('pages.clients.renewPreview', {
  183. zone:
  184. preview.timeZone === 'Local'
  185. ? t('pages.clients.renewServerLocal')
  186. : preview.timeZone,
  187. })}
  188. </Typography.Text>
  189. <Typography.Text type="secondary">
  190. {t('pages.clients.renewPreviewHint')}
  191. </Typography.Text>
  192. {delayedStart || preview.delayedStart ? (
  193. <Typography.Text type="secondary">
  194. {t('pages.clients.renewFirstUse')}
  195. </Typography.Text>
  196. ) : expiryTime === 0 ? (
  197. <>
  198. <Typography.Text type="warning">
  199. {t('pages.clients.renewNeedsExpiry')}
  200. </Typography.Text>
  201. {preview.suggestedExpiryTime > 0 && (
  202. <Button
  203. style={{ maxWidth: '100%', height: 'auto', whiteSpace: 'normal' }}
  204. onClick={() => setExpiry(preview.suggestedExpiryTime)}
  205. >
  206. <span>
  207. {t('pages.clients.renewSetExpiry')}:{' '}
  208. <RenewalDate value={preview.suggestedExpiry} />
  209. </span>
  210. </Button>
  211. )}
  212. </>
  213. ) : (
  214. <>
  215. <Typography.Text>
  216. {t('pages.clients.renewAt')}: <RenewalDate value={preview.renewAt} />
  217. </Typography.Text>
  218. <Typography.Text>
  219. {t('pages.clients.renewValidThrough')}:{' '}
  220. <RenewalDate value={preview.validThrough} />
  221. </Typography.Text>
  222. {preview.nextExpiry && (
  223. <Typography.Text>
  224. {t('pages.clients.renewNextExpiry')}:{' '}
  225. <RenewalDate value={preview.nextExpiry} />
  226. </Typography.Text>
  227. )}
  228. <Typography.Text>
  229. {t('pages.clients.renewPeriods', { count: preview.renewals })}
  230. </Typography.Text>
  231. {!preview.canRenew && (
  232. <Typography.Text type="warning">
  233. {t('pages.clients.renewUnavailable')}
  234. </Typography.Text>
  235. )}
  236. </>
  237. )}
  238. </Space>
  239. )}
  240. </>
  241. )}
  242. </>
  243. );
  244. }