FPSMS-frontend
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 

652 рядки
24 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 ExpandMore from "@mui/icons-material/ExpandMore";
  18. import ExpandLess from "@mui/icons-material/ExpandLess";
  19. import dayjs, { Dayjs } from "dayjs";
  20. import "dayjs/locale/zh-hk";
  21. import { DatePicker } from "@mui/x-date-pickers/DatePicker";
  22. import { TimePicker } from "@mui/x-date-pickers/TimePicker";
  23. import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
  24. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  25. import {
  26. Autocomplete,
  27. Box,
  28. Checkbox,
  29. Chip,
  30. ListSubheader,
  31. } from "@mui/material";
  32. import MultiSelect from "@/components/SearchBox/MultiSelect";
  33. import { intersectionWith } from "lodash";
  34. import { INPUT_DATE_FORMAT, INPUT_TIME_FORMAT, OUTPUT_DATE_FORMAT, OUTPUT_TIME_FORMAT, dayjsToDateTimeString } from "@/app/utils/formatUtil";
  35. import { DateTimePicker } from "@mui/x-date-pickers";
  36. interface BaseCriterion<T extends string> {
  37. label: string;
  38. label2?: string;
  39. paramName: T;
  40. paramName2?: T;
  41. // options?: T[] | string[];
  42. defaultValue?: string;
  43. /** 與 `defaultValue` 配對,用於 dateRange / datetimeRange 重置時的結束值 */
  44. defaultValueTo?: string;
  45. preFilledValue?: string | { from?: string; to?: string };
  46. filterObj?: T;
  47. handleSelectionChange?: (selectedOptions: T[]) => void;
  48. /** Hidden until the user clicks more criteria. */
  49. advanced?: boolean;
  50. /** Shown under the field in error color when set. */
  51. errorText?: string;
  52. }
  53. interface OptionWithLabel<T extends string> {
  54. label: string;
  55. value: any;
  56. }
  57. interface TextCriterion<T extends string> extends BaseCriterion<T> {
  58. type: "text";
  59. placeholder?: string;
  60. }
  61. interface SelectCriterion<T extends string> extends BaseCriterion<T> {
  62. type: "select";
  63. options: string[];
  64. }
  65. interface SelectWithLabelCriterion<T extends string> extends BaseCriterion<T> {
  66. type: "select-labelled";
  67. options: OptionWithLabel<T>[];
  68. }
  69. interface MultiSelectCriterion<T extends string> extends BaseCriterion<T> {
  70. type: "multi-select";
  71. options: T[];
  72. selectedOptions: T[];
  73. handleSelectionChange: (selectedOptions: T[]) => void;
  74. }
  75. interface AutocompleteOptions {
  76. value: string | number;
  77. label: string;
  78. group?: string;
  79. }
  80. interface AutocompleteCriterion<T extends string> extends BaseCriterion<T> {
  81. type: "autocomplete";
  82. options: AutocompleteOptions[];
  83. multiple?: boolean;
  84. noOptionsText?: string;
  85. needAll?: boolean;
  86. }
  87. interface DateRangeCriterion<T extends string> extends BaseCriterion<T> {
  88. type: "dateRange";
  89. }
  90. interface DatetimeRangeCriterion<T extends string> extends BaseCriterion<T> {
  91. type: "datetimeRange";
  92. }
  93. interface DateCriterion<T extends string> extends BaseCriterion<T> {
  94. type: "date";
  95. }
  96. interface TimeCriterion<T extends string> extends BaseCriterion<T> {
  97. type: "time";
  98. }
  99. export type Criterion<T extends string> =
  100. | TextCriterion<T>
  101. | SelectCriterion<T>
  102. | SelectWithLabelCriterion<T>
  103. | DateRangeCriterion<T>
  104. | DatetimeRangeCriterion<T>
  105. | DateCriterion<T>
  106. | TimeCriterion<T>
  107. | MultiSelectCriterion<T>
  108. | AutocompleteCriterion<T>;
  109. interface Props<T extends string> {
  110. criteria: Criterion<T>[];
  111. // TODO: may need to check the type is "autocomplete" and "multiple" = true, then allow string[].
  112. // TODO: may need to check the type is "dateRange", then add T and `${T}To` in the same time.
  113. // onSearch: (inputs: Record<T | (Criterion<T>["type"] extends "dateRange" ? `${T}To` : never), string>) => void;
  114. onSearch: (inputs: Record<T | `${T}To`, string>) => void;
  115. onReset?: () => void;
  116. /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */
  117. extraActions?: React.ReactNode;
  118. /** Disable inputs/actions while external task is running */
  119. disabled?: boolean;
  120. }
  121. /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.1 | 2026-09-21 */
  122. function SearchBox<T extends string>({
  123. criteria,
  124. onSearch,
  125. onReset,
  126. extraActions,
  127. disabled = false,
  128. }: Props<T>) {
  129. const { t } = useTranslation("common");
  130. const defaultAll: AutocompleteOptions = {
  131. value: "All",
  132. label: t("All"),
  133. group: t("All"),
  134. };
  135. const defaultInputs = useMemo(
  136. () =>
  137. criteria.reduce<Record<T | `${T}To`, string>>(
  138. (acc, c) => {
  139. let tempCriteria = {
  140. ...acc,
  141. [c.paramName]:
  142. c.type === "select" ||
  143. c.type === "select-labelled" ||
  144. (c.type === "autocomplete" && !Boolean(c.multiple))
  145. ? "All"
  146. : c.type === "autocomplete" && Boolean(c.multiple)
  147. ? [defaultAll.value]
  148. : "",
  149. };
  150. if (c.type === "dateRange" || c.type === "datetimeRange") {
  151. tempCriteria = {
  152. ...tempCriteria,
  153. [c.paramName]: c.defaultValue ?? "",
  154. [`${c.paramName}To`]: c.defaultValueTo ?? "",
  155. };
  156. }
  157. if (c.type === "date") {
  158. tempCriteria = {
  159. ...tempCriteria,
  160. [c.paramName]: c.defaultValue ?? "",
  161. };
  162. }
  163. return tempCriteria;
  164. },
  165. {} as Record<T | `${T}To`, string>,
  166. ),
  167. [criteria],
  168. );
  169. const preFilledInputs = useMemo(() => {
  170. const preFilledCriteria = criteria.reduce<Record<T | `${T}To`, string>>(
  171. (acc, c) => {
  172. if (c.type === "dateRange" || c.type === "datetimeRange") {
  173. if (typeof c.preFilledValue === "object" && c.preFilledValue !== null) {
  174. return {
  175. ...acc,
  176. ...(c.preFilledValue.from ? { [c.paramName]: c.preFilledValue.from } : {}),
  177. ...(c.preFilledValue.to ? { [`${c.paramName}To`]: c.preFilledValue.to } : {}),
  178. };
  179. }
  180. }
  181. if (typeof c.preFilledValue === "string") {
  182. return { ...acc, [c.paramName]: c.preFilledValue };
  183. }
  184. return acc;
  185. },
  186. {} as Record<T | `${T}To`, string>,
  187. );
  188. return {...defaultInputs, ...preFilledCriteria}
  189. }, [defaultInputs, criteria])
  190. const [inputs, setInputs] = useState(preFilledInputs);
  191. const [isReset, setIsReset] = useState(false);
  192. const [showAdvanced, setShowAdvanced] = useState(false);
  193. const hasAdvanced = criteria.some((c) => c.advanced);
  194. const visibleCriteria = showAdvanced ? criteria : criteria.filter((c) => !c.advanced);
  195. const makeInputChangeHandler = useCallback(
  196. (paramName: T): React.ChangeEventHandler<HTMLInputElement> => {
  197. return (e) => {
  198. setInputs((i) => ({ ...i, [paramName]: e.target.value }));
  199. };
  200. },
  201. [],
  202. );
  203. const makeSelectChangeHandler = useCallback((paramName: T) => {
  204. return (e: SelectChangeEvent) => {
  205. setInputs((i) => ({ ...i, [paramName]: e.target.value }));
  206. };
  207. }, []);
  208. const makeAutocompleteChangeHandler = useCallback(
  209. (paramName: T, multiple: boolean) => {
  210. return (
  211. e: SyntheticEvent,
  212. newValue: AutocompleteOptions | AutocompleteOptions[],
  213. ) => {
  214. if (multiple) {
  215. const multiNewValue = newValue as AutocompleteOptions[];
  216. setInputs((i) => ({
  217. ...i,
  218. [paramName]: multiNewValue.map(({ value }) => value),
  219. }));
  220. } else {
  221. const singleNewValue = newValue as AutocompleteOptions;
  222. setInputs((i) => ({ ...i, [paramName]: singleNewValue.value }));
  223. }
  224. };
  225. },
  226. [],
  227. );
  228. const makeDateChangeHandler = useCallback((paramName: T) => {
  229. return (e: any) => {
  230. setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM-DD") }));
  231. };
  232. }, []);
  233. const makeDateToChangeHandler = useCallback((paramName: T) => {
  234. return (e: any) => {
  235. setInputs((i) => ({
  236. ...i,
  237. [paramName + "To"]: dayjs(e).format("YYYY-MM-DD"),
  238. }));
  239. };
  240. }, []);
  241. const makeDatetimeChangeHandler = useCallback((paramName: T) => {
  242. return (value: Dayjs | null) => {
  243. setInputs((i) => ({
  244. ...i,
  245. [paramName]: value ? dayjsToDateTimeString(value) : null
  246. }));
  247. };
  248. }, []);
  249. const makeDatetimeToChangeHandler = useCallback((paramName: T) => {
  250. return (value: Dayjs | null) => {
  251. setInputs((i) => ({
  252. ...i,
  253. [paramName + "To"]: value ? dayjsToDateTimeString(value) : null
  254. }));
  255. };
  256. }, []);
  257. const makeTimeChangeHandler = useCallback((paramName: T) => {
  258. return (value: Dayjs | null) => {
  259. setInputs((i) => ({
  260. ...i,
  261. [paramName]: value ? value.format("HH:mm") : ""
  262. }));
  263. };
  264. }, []);
  265. const handleReset = () => {
  266. setInputs(preFilledInputs);
  267. onReset?.();
  268. setIsReset(!isReset);
  269. };
  270. const handleSearch = () => {
  271. onSearch(inputs);
  272. };
  273. return (
  274. <Card className="app-search-criteria" elevation={0}>
  275. <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
  276. <Typography className="app-search-criteria-label" variant="overline" sx={{ display: "block", mb: 0.5 }}>
  277. {t("Search Criteria")}
  278. </Typography>
  279. <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
  280. {visibleCriteria.map((c) => {
  281. return (
  282. <Grid key={c.paramName} item xs={6}>
  283. {c.type === "text" && (
  284. <TextField
  285. label={c.label}
  286. fullWidth
  287. placeholder={c.placeholder}
  288. onChange={makeInputChangeHandler(c.paramName)}
  289. value={inputs[c.paramName]}
  290. disabled={disabled}
  291. />
  292. )}
  293. {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */}
  294. {/* {c.type === "multi-select" && (
  295. <MultiSelect
  296. label={t(c.label)}
  297. options={c?.options}
  298. selectedValues={c.filterObj?.[c.paramName] ?? []}
  299. onChange={c.handleSelectionChange}
  300. isReset={isReset}
  301. />
  302. )} */}
  303. {c.type === "select" && (
  304. <FormControl fullWidth>
  305. <InputLabel>{c.label}</InputLabel>
  306. <Select
  307. label={c.label}
  308. onChange={makeSelectChangeHandler(c.paramName)}
  309. value={inputs[c.paramName] ?? "All"}
  310. disabled={disabled}
  311. >
  312. <MenuItem value={"All"}>{t("All")}</MenuItem>
  313. {c.options.map((option) => (
  314. <MenuItem key={option} value={option}>
  315. {t(option)}
  316. </MenuItem>
  317. ))}
  318. </Select>
  319. </FormControl>
  320. )}
  321. {c.type === "select-labelled" && (
  322. <FormControl fullWidth>
  323. <InputLabel>{c.label}</InputLabel>
  324. <Select
  325. label={c.label}
  326. onChange={makeSelectChangeHandler(c.paramName)}
  327. value={inputs[c.paramName] ?? "All"}
  328. disabled={disabled}
  329. >
  330. <MenuItem value={"All"}>{t("All")}</MenuItem>
  331. {c.options.map((option) => (
  332. <MenuItem key={option.value} value={option.value}>
  333. {option.label}
  334. </MenuItem>
  335. ))}
  336. </Select>
  337. </FormControl>
  338. )}
  339. {c.type === "autocomplete" && (
  340. <Autocomplete
  341. groupBy={
  342. c.options.filter((option) => option.group !== "All")
  343. .length > 0 && c.options.every((option) => option.group)
  344. ? (option) =>
  345. option.group && option.group.trim() !== ""
  346. ? option.group
  347. : "Ungrouped"
  348. : undefined
  349. }
  350. multiple={Boolean(c.multiple)}
  351. noOptionsText={c.noOptionsText ?? t("No options")}
  352. disableClearable
  353. fullWidth
  354. disabled={disabled}
  355. value={
  356. c.multiple
  357. ? intersectionWith(
  358. [defaultAll, ...c.options],
  359. inputs[c.paramName],
  360. (option, v) => {
  361. return option.value === (v ?? "");
  362. },
  363. )
  364. : c.options.find(
  365. (option) => option.value === inputs[c.paramName],
  366. ) ?? defaultAll
  367. }
  368. onChange={makeAutocompleteChangeHandler(
  369. c.paramName,
  370. Boolean(c.multiple),
  371. )}
  372. getOptionLabel={(option) => option.label}
  373. options={[defaultAll, ...c.options]}
  374. disableCloseOnSelect={Boolean(c.multiple)}
  375. renderGroup={
  376. c.options.every((option) => option.group)
  377. ? (params) => (
  378. <React.Fragment
  379. key={`${params.key}-${params.group}`}
  380. >
  381. <ListSubheader>{params.group}</ListSubheader>
  382. {params.children}
  383. </React.Fragment>
  384. )
  385. : undefined
  386. }
  387. renderTags={
  388. c.multiple
  389. ? (value, getTagProps) =>
  390. value.map((option, index) => {
  391. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  392. const { key, ...chipProps } = getTagProps({
  393. index,
  394. });
  395. return (
  396. <Chip
  397. {...chipProps}
  398. key={`${option.value}-${option.label}`}
  399. label={option.label}
  400. />
  401. );
  402. })
  403. : undefined
  404. }
  405. renderOption={(
  406. params: React.HTMLAttributes<HTMLLIElement> & {
  407. key?: React.Key;
  408. },
  409. option,
  410. { selected },
  411. ) => {
  412. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  413. const { key, ...rest } = params;
  414. return (
  415. <MenuItem
  416. {...rest}
  417. disableRipple
  418. value={option.value}
  419. key={`${option.value}--${option.label}`}
  420. >
  421. {c.multiple && (
  422. <Checkbox
  423. disableRipple
  424. key={`checkbox-${option.value}`}
  425. checked={selected}
  426. sx={{ transform: "translate(0)" }}
  427. />
  428. )}
  429. {option.label}
  430. </MenuItem>
  431. );
  432. }}
  433. renderInput={(params) => (
  434. <TextField
  435. {...params}
  436. variant="outlined"
  437. label={c.label}
  438. />
  439. )}
  440. />
  441. )}
  442. {c.type === "dateRange" && (
  443. <LocalizationProvider
  444. dateAdapter={AdapterDayjs}
  445. // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
  446. adapterLocale="zh-hk"
  447. >
  448. <Box display="flex">
  449. <FormControl fullWidth>
  450. <DatePicker
  451. format={`${OUTPUT_DATE_FORMAT}`}
  452. label={c.label}
  453. onChange={makeDateChangeHandler(c.paramName)}
  454. disabled={disabled}
  455. value={
  456. dayjs(inputs[c.paramName]).isValid()
  457. ? dayjs(inputs[c.paramName])
  458. : null
  459. }
  460. />
  461. </FormControl>
  462. <Box
  463. display="flex"
  464. alignItems="center"
  465. justifyContent="center"
  466. marginInline={2}
  467. >
  468. {"-"}
  469. </Box>
  470. <FormControl fullWidth>
  471. <DatePicker
  472. format={`${OUTPUT_DATE_FORMAT}`}
  473. label={c.label2 ? c.label2 : null}
  474. onChange={makeDateToChangeHandler(c.paramName)}
  475. disabled={disabled}
  476. value={
  477. dayjs(inputs[`${c.paramName}To`]).isValid()
  478. ? dayjs(inputs[`${c.paramName}To`])
  479. : null
  480. }
  481. />
  482. </FormControl>
  483. </Box>
  484. </LocalizationProvider>
  485. )}
  486. {c.type === "datetimeRange" && (
  487. <LocalizationProvider
  488. dateAdapter={AdapterDayjs}
  489. // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
  490. adapterLocale="zh-hk"
  491. >
  492. <Box display="flex">
  493. <FormControl fullWidth>
  494. <DateTimePicker
  495. views={['year','month','day','hours', 'minutes', 'seconds']}
  496. format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`}
  497. label={c.label}
  498. onChange={makeDatetimeChangeHandler(c.paramName)}
  499. disabled={disabled}
  500. value={
  501. dayjs(inputs[c.paramName]).isValid()
  502. ? dayjs(inputs[c.paramName])
  503. : null
  504. }
  505. />
  506. </FormControl>
  507. <Box
  508. display="flex"
  509. alignItems="center"
  510. justifyContent="center"
  511. marginInline={2}
  512. >
  513. {"-"}
  514. </Box>
  515. <FormControl fullWidth>
  516. <DateTimePicker
  517. views={['year','month','day','hours', 'minutes', 'seconds']}
  518. format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`}
  519. label={c.label2 ? c.label2 : null}
  520. onChange={makeDatetimeToChangeHandler(c.paramName)}
  521. disabled={disabled}
  522. value={
  523. dayjs(inputs[`${c.paramName}To`]).isValid()
  524. ? dayjs(inputs[`${c.paramName}To`])
  525. : null
  526. }
  527. />
  528. </FormControl>
  529. </Box>
  530. </LocalizationProvider>
  531. )}
  532. {c.type === "date" && (
  533. <LocalizationProvider
  534. dateAdapter={AdapterDayjs}
  535. // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
  536. adapterLocale="zh-hk"
  537. >
  538. <Box display="flex">
  539. <FormControl fullWidth>
  540. <DatePicker
  541. format={`${OUTPUT_DATE_FORMAT}`}
  542. label={c.label}
  543. onChange={makeDateChangeHandler(c.paramName)}
  544. disabled={disabled}
  545. value={
  546. inputs[c.paramName] && dayjs(inputs[c.paramName]).isValid()
  547. ? dayjs(inputs[c.paramName])
  548. : null
  549. }
  550. slotProps={{
  551. textField: {
  552. fullWidth: true,
  553. error: Boolean(c.errorText),
  554. helperText: c.errorText || undefined,
  555. },
  556. }}
  557. />
  558. </FormControl>
  559. </Box>
  560. </LocalizationProvider>
  561. )}
  562. {c.type === "time" && (
  563. <LocalizationProvider
  564. dateAdapter={AdapterDayjs}
  565. adapterLocale="zh-hk"
  566. >
  567. <FormControl fullWidth>
  568. <TimePicker
  569. format="HH:mm"
  570. label={c.label}
  571. onChange={makeTimeChangeHandler(c.paramName)}
  572. disabled={disabled}
  573. value={
  574. inputs[c.paramName] && dayjs(inputs[c.paramName], "HH:mm").isValid()
  575. ? dayjs(inputs[c.paramName], "HH:mm")
  576. : null
  577. }
  578. />
  579. </FormControl>
  580. </LocalizationProvider>
  581. )}
  582. </Grid>
  583. );
  584. })}
  585. </Grid>
  586. <CardActions sx={{ justifyContent: "flex-start", gap: 1, pt: 2, flexWrap: "wrap" }}>
  587. <Button
  588. variant="outlined"
  589. startIcon={<RestartAlt />}
  590. onClick={handleReset}
  591. disabled={disabled}
  592. sx={{ borderColor: "#e2e8f0", color: "#334155" }}
  593. >
  594. {t("Reset")}
  595. </Button>
  596. <Button
  597. variant="contained"
  598. color="primary"
  599. startIcon={<Search />}
  600. onClick={handleSearch}
  601. disabled={disabled}
  602. >
  603. {t("Search")}
  604. </Button>
  605. {hasAdvanced && (
  606. <Button
  607. variant="text"
  608. startIcon={showAdvanced ? <ExpandLess /> : <ExpandMore />}
  609. onClick={() => setShowAdvanced((open) => !open)}
  610. disabled={disabled}
  611. >
  612. {showAdvanced ? t("Fewer criteria") : t("More criteria")}
  613. </Button>
  614. )}
  615. {extraActions}
  616. </CardActions>
  617. </CardContent>
  618. </Card>
  619. );
  620. }
  621. export default SearchBox;