FPSMS-frontend
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 

458 Zeilen
16 KiB

  1. "use client";
  2. import Grid from "@mui/material/Grid";
  3. import Card from "@mui/material/Card";
  4. import CardContent from "@mui/material/CardContent";
  5. import Typography from "@mui/material/Typography";
  6. import React, { SyntheticEvent, useCallback, useMemo, useState } from "react";
  7. import { useTranslation } from "react-i18next";
  8. import TextField from "@mui/material/TextField";
  9. import FormControl from "@mui/material/FormControl";
  10. import InputLabel from "@mui/material/InputLabel";
  11. import Select, { SelectChangeEvent } from "@mui/material/Select";
  12. import MenuItem from "@mui/material/MenuItem";
  13. import CardActions from "@mui/material/CardActions";
  14. import Button from "@mui/material/Button";
  15. import RestartAlt from "@mui/icons-material/RestartAlt";
  16. import Search from "@mui/icons-material/Search";
  17. import dayjs from "dayjs";
  18. import "dayjs/locale/zh-hk";
  19. import { DatePicker } from "@mui/x-date-pickers/DatePicker";
  20. import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
  21. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  22. import {
  23. Autocomplete,
  24. Box,
  25. Checkbox,
  26. Chip,
  27. ListSubheader,
  28. } from "@mui/material";
  29. import MultiSelect from "@/components/SearchBox/MultiSelect";
  30. import { intersectionWith } from "lodash";
  31. interface BaseCriterion<T extends string> {
  32. label: string;
  33. label2?: string;
  34. paramName: T;
  35. paramName2?: T;
  36. // options?: T[] | string[];
  37. filterObj?: T;
  38. handleSelectionChange?: (selectedOptions: T[]) => void;
  39. }
  40. interface OptionWithLabel<T extends string> {
  41. label: string;
  42. value: any;
  43. }
  44. interface TextCriterion<T extends string> extends BaseCriterion<T> {
  45. type: "text";
  46. }
  47. interface SelectCriterion<T extends string> extends BaseCriterion<T> {
  48. type: "select";
  49. options: string[];
  50. }
  51. interface SelectWithLabelCriterion<T extends string> extends BaseCriterion<T> {
  52. type: "select-labelled";
  53. options: OptionWithLabel<T>[];
  54. }
  55. interface MultiSelectCriterion<T extends string> extends BaseCriterion<T> {
  56. type: "multi-select";
  57. options: T[];
  58. selectedOptions: T[];
  59. handleSelectionChange: (selectedOptions: T[]) => void;
  60. }
  61. interface AutocompleteOptions {
  62. value: string | number;
  63. label: string;
  64. group?: string;
  65. }
  66. interface AutocompleteCriterion<T extends string> extends BaseCriterion<T> {
  67. type: "autocomplete";
  68. options: AutocompleteOptions[];
  69. multiple?: boolean;
  70. noOptionsText?: string;
  71. needAll?: boolean;
  72. }
  73. interface DateRangeCriterion<T extends string> extends BaseCriterion<T> {
  74. type: "dateRange";
  75. }
  76. interface DateCriterion<T extends string> extends BaseCriterion<T> {
  77. type: "date";
  78. }
  79. export type Criterion<T extends string> =
  80. | TextCriterion<T>
  81. | SelectCriterion<T>
  82. | SelectWithLabelCriterion<T>
  83. | DateRangeCriterion<T>
  84. | DateCriterion<T>
  85. | MultiSelectCriterion<T>
  86. | AutocompleteCriterion<T>;
  87. interface Props<T extends string> {
  88. criteria: Criterion<T>[];
  89. // TODO: may need to check the type is "autocomplete" and "multiple" = true, then allow string[].
  90. // TODO: may need to check the type is "dateRange", then add T and `${T}To` in the same time.
  91. // onSearch: (inputs: Record<T | (Criterion<T>["type"] extends "dateRange" ? `${T}To` : never), string>) => void;
  92. onSearch: (inputs: Record<T | `${T}To`, string>) => void;
  93. onReset?: () => void;
  94. }
  95. function SearchBox<T extends string>({
  96. criteria,
  97. onSearch,
  98. onReset,
  99. }: Props<T>) {
  100. const { t } = useTranslation("common");
  101. const defaultAll: AutocompleteOptions = {
  102. value: "All",
  103. label: t("All"),
  104. group: t("All"),
  105. };
  106. const defaultInputs = useMemo(
  107. () =>
  108. criteria.reduce<Record<T | `${T}To`, string>>(
  109. (acc, c) => {
  110. let tempCriteria = {
  111. ...acc,
  112. [c.paramName]:
  113. c.type === "select" ||
  114. c.type === "select-labelled" ||
  115. (c.type === "autocomplete" && !Boolean(c.multiple))
  116. ? "All"
  117. : c.type === "autocomplete" && Boolean(c.multiple)
  118. ? [defaultAll.value]
  119. : "",
  120. };
  121. if (c.type === "dateRange") {
  122. tempCriteria = {
  123. ...tempCriteria,
  124. [c.paramName]: "",
  125. [`${c.paramName}To`]: "",
  126. };
  127. }
  128. return tempCriteria;
  129. },
  130. {} as Record<T | `${T}To`, string>,
  131. ),
  132. [criteria],
  133. );
  134. const [inputs, setInputs] = useState(defaultInputs);
  135. const [isReset, setIsReset] = useState(false);
  136. const makeInputChangeHandler = useCallback(
  137. (paramName: T): React.ChangeEventHandler<HTMLInputElement> => {
  138. return (e) => {
  139. setInputs((i) => ({ ...i, [paramName]: e.target.value }));
  140. };
  141. },
  142. [],
  143. );
  144. const makeSelectChangeHandler = useCallback((paramName: T) => {
  145. return (e: SelectChangeEvent) => {
  146. setInputs((i) => ({ ...i, [paramName]: e.target.value }));
  147. };
  148. }, []);
  149. const makeAutocompleteChangeHandler = useCallback(
  150. (paramName: T, multiple: boolean) => {
  151. return (
  152. e: SyntheticEvent,
  153. newValue: AutocompleteOptions | AutocompleteOptions[],
  154. ) => {
  155. if (multiple) {
  156. const multiNewValue = newValue as AutocompleteOptions[];
  157. setInputs((i) => ({
  158. ...i,
  159. [paramName]: multiNewValue.map(({ value }) => value),
  160. }));
  161. } else {
  162. const singleNewValue = newValue as AutocompleteOptions;
  163. setInputs((i) => ({ ...i, [paramName]: singleNewValue.value }));
  164. }
  165. };
  166. },
  167. [],
  168. );
  169. const makeDateChangeHandler = useCallback((paramName: T) => {
  170. return (e: any) => {
  171. setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM-DD") }));
  172. };
  173. }, []);
  174. const makeDateToChangeHandler = useCallback((paramName: T) => {
  175. return (e: any) => {
  176. setInputs((i) => ({
  177. ...i,
  178. [paramName + "To"]: dayjs(e).format("YYYY-MM-DD"),
  179. }));
  180. };
  181. }, []);
  182. const handleReset = () => {
  183. setInputs(defaultInputs);
  184. onReset?.();
  185. setIsReset(!isReset);
  186. };
  187. const handleSearch = () => {
  188. onSearch(inputs);
  189. };
  190. return (
  191. <Card>
  192. <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
  193. <Typography variant="overline">{t("Search Criteria")}</Typography>
  194. <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
  195. {criteria.map((c) => {
  196. return (
  197. <Grid key={c.paramName} item xs={6}>
  198. {c.type === "text" && (
  199. <TextField
  200. label={t(c.label)}
  201. fullWidth
  202. onChange={makeInputChangeHandler(c.paramName)}
  203. value={inputs[c.paramName]}
  204. />
  205. )}
  206. {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */}
  207. {/* {c.type === "multi-select" && (
  208. <MultiSelect
  209. label={t(c.label)}
  210. options={c?.options}
  211. selectedValues={c.filterObj?.[c.paramName] ?? []}
  212. onChange={c.handleSelectionChange}
  213. isReset={isReset}
  214. />
  215. )} */}
  216. {c.type === "select" && (
  217. <FormControl fullWidth>
  218. <InputLabel>{t(c.label)}</InputLabel>
  219. <Select
  220. label={t(c.label)}
  221. onChange={makeSelectChangeHandler(c.paramName)}
  222. value={inputs[c.paramName]}
  223. >
  224. <MenuItem value={"All"}>{t("All")}</MenuItem>
  225. {c.options.map((option) => (
  226. <MenuItem key={option} value={option}>
  227. {option}
  228. </MenuItem>
  229. ))}
  230. </Select>
  231. </FormControl>
  232. )}
  233. {c.type === "select-labelled" && (
  234. <FormControl fullWidth>
  235. <InputLabel>{t(c.label)}</InputLabel>
  236. <Select
  237. label={t(c.label)}
  238. onChange={makeSelectChangeHandler(c.paramName)}
  239. value={inputs[c.paramName]}
  240. >
  241. <MenuItem value={"All"}>{t("All")}</MenuItem>
  242. {c.options.map((option) => (
  243. <MenuItem key={option.value} value={option.value}>
  244. {option.label}
  245. </MenuItem>
  246. ))}
  247. </Select>
  248. </FormControl>
  249. )}
  250. {c.type === "autocomplete" && (
  251. <Autocomplete
  252. groupBy={
  253. c.options.filter((option) => option.group !== "All")
  254. .length > 0 && c.options.every((option) => option.group)
  255. ? (option) =>
  256. option.group && option.group.trim() !== ""
  257. ? option.group
  258. : "Ungrouped"
  259. : undefined
  260. }
  261. multiple={Boolean(c.multiple)}
  262. noOptionsText={c.noOptionsText ?? t("No options")}
  263. disableClearable
  264. fullWidth
  265. value={
  266. c.multiple
  267. ? intersectionWith(
  268. [defaultAll, ...c.options],
  269. inputs[c.paramName],
  270. (option, v) => {
  271. return option.value === (v ?? "");
  272. },
  273. )
  274. : c.options.find(
  275. (option) => option.value === inputs[c.paramName],
  276. ) ?? defaultAll
  277. }
  278. onChange={makeAutocompleteChangeHandler(
  279. c.paramName,
  280. Boolean(c.multiple),
  281. )}
  282. getOptionLabel={(option) => option.label}
  283. options={[defaultAll, ...c.options]}
  284. disableCloseOnSelect={Boolean(c.multiple)}
  285. renderGroup={
  286. c.options.every((option) => option.group)
  287. ? (params) => (
  288. <React.Fragment
  289. key={`${params.key}-${params.group}`}
  290. >
  291. <ListSubheader>{params.group}</ListSubheader>
  292. {params.children}
  293. </React.Fragment>
  294. )
  295. : undefined
  296. }
  297. renderTags={
  298. c.multiple
  299. ? (value, getTagProps) =>
  300. value.map((option, index) => {
  301. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  302. const { key, ...chipProps } = getTagProps({
  303. index,
  304. });
  305. return (
  306. <Chip
  307. {...chipProps}
  308. key={`${option.value}-${option.label}`}
  309. label={option.label}
  310. />
  311. );
  312. })
  313. : undefined
  314. }
  315. renderOption={(
  316. params: React.HTMLAttributes<HTMLLIElement> & {
  317. key?: React.Key;
  318. },
  319. option,
  320. { selected },
  321. ) => {
  322. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  323. const { key, ...rest } = params;
  324. return (
  325. <MenuItem
  326. {...rest}
  327. disableRipple
  328. value={option.value}
  329. key={`${option.value}--${option.label}`}
  330. >
  331. {c.multiple && (
  332. <Checkbox
  333. disableRipple
  334. key={`checkbox-${option.value}`}
  335. checked={selected}
  336. sx={{ transform: "translate(0)" }}
  337. />
  338. )}
  339. {option.label}
  340. </MenuItem>
  341. );
  342. }}
  343. renderInput={(params) => (
  344. <TextField
  345. {...params}
  346. variant="outlined"
  347. label={t(c.label)}
  348. />
  349. )}
  350. />
  351. )}
  352. {c.type === "dateRange" && (
  353. <LocalizationProvider
  354. dateAdapter={AdapterDayjs}
  355. // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
  356. adapterLocale="zh-hk"
  357. >
  358. <Box display="flex">
  359. <FormControl fullWidth>
  360. <DatePicker
  361. label={t(c.label)}
  362. onChange={makeDateChangeHandler(c.paramName)}
  363. value={
  364. dayjs(inputs[c.paramName]).isValid()
  365. ? dayjs(inputs[c.paramName])
  366. : null
  367. }
  368. />
  369. </FormControl>
  370. <Box
  371. display="flex"
  372. alignItems="center"
  373. justifyContent="center"
  374. marginInline={2}
  375. >
  376. {"-"}
  377. </Box>
  378. <FormControl fullWidth>
  379. <DatePicker
  380. label={c.label2 ? t(c.label2) : null}
  381. onChange={makeDateToChangeHandler(c.paramName)}
  382. value={
  383. dayjs(inputs[`${c.paramName}To`]).isValid()
  384. ? dayjs(inputs[`${c.paramName}To`])
  385. : null
  386. }
  387. />
  388. </FormControl>
  389. </Box>
  390. </LocalizationProvider>
  391. )}
  392. {c.type === "date" && (
  393. <LocalizationProvider
  394. dateAdapter={AdapterDayjs}
  395. // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
  396. adapterLocale="zh-hk"
  397. >
  398. <Box display="flex">
  399. <FormControl fullWidth>
  400. <DatePicker
  401. label={t(c.label)}
  402. onChange={makeDateChangeHandler(c.paramName)}
  403. />
  404. </FormControl>
  405. </Box>
  406. </LocalizationProvider>
  407. )}
  408. </Grid>
  409. );
  410. })}
  411. </Grid>
  412. <CardActions sx={{ justifyContent: "flex-end" }}>
  413. <Button
  414. variant="text"
  415. startIcon={<RestartAlt />}
  416. onClick={handleReset}
  417. >
  418. {t("Reset")}
  419. </Button>
  420. <Button
  421. variant="outlined"
  422. startIcon={<Search />}
  423. onClick={handleSearch}
  424. >
  425. {t("Search")}
  426. </Button>
  427. </CardActions>
  428. </CardContent>
  429. </Card>
  430. );
  431. }
  432. export default SearchBox;