|
- "use client";
-
- import Grid from "@mui/material/Grid";
- import Card from "@mui/material/Card";
- import CardContent from "@mui/material/CardContent";
- import Typography from "@mui/material/Typography";
- import React, { SyntheticEvent, useCallback, useMemo, useState } from "react";
- import { useTranslation } from "react-i18next";
- import TextField from "@mui/material/TextField";
- import FormControl from "@mui/material/FormControl";
- import InputLabel from "@mui/material/InputLabel";
- import Select, { SelectChangeEvent } from "@mui/material/Select";
- import MenuItem from "@mui/material/MenuItem";
- import CardActions from "@mui/material/CardActions";
- import Button from "@mui/material/Button";
- import RestartAlt from "@mui/icons-material/RestartAlt";
- import Search from "@mui/icons-material/Search";
- import ExpandMore from "@mui/icons-material/ExpandMore";
- import ExpandLess from "@mui/icons-material/ExpandLess";
- import dayjs, { Dayjs } from "dayjs";
- import "dayjs/locale/zh-hk";
- import { DatePicker } from "@mui/x-date-pickers/DatePicker";
- import { TimePicker } from "@mui/x-date-pickers/TimePicker";
- import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
- import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
- import {
- Autocomplete,
- Box,
- Checkbox,
- Chip,
- ListSubheader,
- } from "@mui/material";
- import MultiSelect from "@/components/SearchBox/MultiSelect";
- import { intersectionWith } from "lodash";
- import { INPUT_DATE_FORMAT, INPUT_TIME_FORMAT, OUTPUT_DATE_FORMAT, OUTPUT_TIME_FORMAT, dayjsToDateTimeString } from "@/app/utils/formatUtil";
- import { DateTimePicker } from "@mui/x-date-pickers";
-
- interface BaseCriterion<T extends string> {
- label: string;
- label2?: string;
- paramName: T;
- paramName2?: T;
- // options?: T[] | string[];
- defaultValue?: string;
- /** 與 `defaultValue` 配對,用於 dateRange / datetimeRange 重置時的結束值 */
- defaultValueTo?: string;
- preFilledValue?: string | { from?: string; to?: string };
- filterObj?: T;
- handleSelectionChange?: (selectedOptions: T[]) => void;
- /** Hidden until the user clicks more criteria. */
- advanced?: boolean;
- /** Shown under the field in error color when set. */
- errorText?: string;
- }
-
- interface OptionWithLabel<T extends string> {
- label: string;
- value: any;
- }
-
- interface TextCriterion<T extends string> extends BaseCriterion<T> {
- type: "text";
- placeholder?: string;
- }
-
- interface SelectCriterion<T extends string> extends BaseCriterion<T> {
- type: "select";
- options: string[];
- }
-
- interface SelectWithLabelCriterion<T extends string> extends BaseCriterion<T> {
- type: "select-labelled";
- options: OptionWithLabel<T>[];
- }
-
- interface MultiSelectCriterion<T extends string> extends BaseCriterion<T> {
- type: "multi-select";
- options: T[];
- selectedOptions: T[];
- handleSelectionChange: (selectedOptions: T[]) => void;
- }
-
- interface AutocompleteOptions {
- value: string | number;
- label: string;
- group?: string;
- }
-
- interface AutocompleteCriterion<T extends string> extends BaseCriterion<T> {
- type: "autocomplete";
- options: AutocompleteOptions[];
- multiple?: boolean;
- noOptionsText?: string;
- needAll?: boolean;
- }
-
- interface DateRangeCriterion<T extends string> extends BaseCriterion<T> {
- type: "dateRange";
- }
-
- interface DatetimeRangeCriterion<T extends string> extends BaseCriterion<T> {
- type: "datetimeRange";
- }
-
- interface DateCriterion<T extends string> extends BaseCriterion<T> {
- type: "date";
- }
-
- interface TimeCriterion<T extends string> extends BaseCriterion<T> {
- type: "time";
- }
-
- export type Criterion<T extends string> =
- | TextCriterion<T>
- | SelectCriterion<T>
- | SelectWithLabelCriterion<T>
- | DateRangeCriterion<T>
- | DatetimeRangeCriterion<T>
- | DateCriterion<T>
- | TimeCriterion<T>
- | MultiSelectCriterion<T>
- | AutocompleteCriterion<T>;
-
- interface Props<T extends string> {
- criteria: Criterion<T>[];
- // TODO: may need to check the type is "autocomplete" and "multiple" = true, then allow string[].
- // TODO: may need to check the type is "dateRange", then add T and `${T}To` in the same time.
- // onSearch: (inputs: Record<T | (Criterion<T>["type"] extends "dateRange" ? `${T}To` : never), string>) => void;
- onSearch: (inputs: Record<T | `${T}To`, string>) => void;
- onReset?: () => void;
- /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */
- extraActions?: React.ReactNode;
- /** Disable inputs/actions while external task is running */
- disabled?: boolean;
- }
-
- /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.1 | 2026-09-21 */
- function SearchBox<T extends string>({
- criteria,
- onSearch,
- onReset,
- extraActions,
- disabled = false,
- }: Props<T>) {
- const { t } = useTranslation("common");
- const defaultAll: AutocompleteOptions = {
- value: "All",
- label: t("All"),
- group: t("All"),
- };
- const defaultInputs = useMemo(
- () =>
- criteria.reduce<Record<T | `${T}To`, string>>(
- (acc, c) => {
- let tempCriteria = {
- ...acc,
- [c.paramName]:
- c.type === "select" ||
- c.type === "select-labelled" ||
- (c.type === "autocomplete" && !Boolean(c.multiple))
- ? "All"
- : c.type === "autocomplete" && Boolean(c.multiple)
- ? [defaultAll.value]
- : "",
- };
-
- if (c.type === "dateRange" || c.type === "datetimeRange") {
- tempCriteria = {
- ...tempCriteria,
- [c.paramName]: c.defaultValue ?? "",
- [`${c.paramName}To`]: c.defaultValueTo ?? "",
- };
- }
- if (c.type === "date") {
- tempCriteria = {
- ...tempCriteria,
- [c.paramName]: c.defaultValue ?? "",
- };
- }
- return tempCriteria;
- },
- {} as Record<T | `${T}To`, string>,
- ),
- [criteria],
- );
- const preFilledInputs = useMemo(() => {
- const preFilledCriteria = criteria.reduce<Record<T | `${T}To`, string>>(
- (acc, c) => {
- if (c.type === "dateRange" || c.type === "datetimeRange") {
- if (typeof c.preFilledValue === "object" && c.preFilledValue !== null) {
- return {
- ...acc,
- ...(c.preFilledValue.from ? { [c.paramName]: c.preFilledValue.from } : {}),
- ...(c.preFilledValue.to ? { [`${c.paramName}To`]: c.preFilledValue.to } : {}),
- };
- }
- }
- if (typeof c.preFilledValue === "string") {
- return { ...acc, [c.paramName]: c.preFilledValue };
- }
- return acc;
- },
- {} as Record<T | `${T}To`, string>,
- );
- return {...defaultInputs, ...preFilledCriteria}
- }, [defaultInputs, criteria])
-
- const [inputs, setInputs] = useState(preFilledInputs);
- const [isReset, setIsReset] = useState(false);
- const [showAdvanced, setShowAdvanced] = useState(false);
- const hasAdvanced = criteria.some((c) => c.advanced);
- const visibleCriteria = showAdvanced ? criteria : criteria.filter((c) => !c.advanced);
-
- const makeInputChangeHandler = useCallback(
- (paramName: T): React.ChangeEventHandler<HTMLInputElement> => {
- return (e) => {
- setInputs((i) => ({ ...i, [paramName]: e.target.value }));
- };
- },
- [],
- );
-
- const makeSelectChangeHandler = useCallback((paramName: T) => {
- return (e: SelectChangeEvent) => {
- setInputs((i) => ({ ...i, [paramName]: e.target.value }));
- };
- }, []);
-
- const makeAutocompleteChangeHandler = useCallback(
- (paramName: T, multiple: boolean) => {
- return (
- e: SyntheticEvent,
- newValue: AutocompleteOptions | AutocompleteOptions[],
- ) => {
- if (multiple) {
- const multiNewValue = newValue as AutocompleteOptions[];
- setInputs((i) => ({
- ...i,
- [paramName]: multiNewValue.map(({ value }) => value),
- }));
- } else {
- const singleNewValue = newValue as AutocompleteOptions;
- setInputs((i) => ({ ...i, [paramName]: singleNewValue.value }));
- }
- };
- },
- [],
- );
-
- const makeDateChangeHandler = useCallback((paramName: T) => {
- return (e: any) => {
- setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM-DD") }));
- };
- }, []);
-
- const makeDateToChangeHandler = useCallback((paramName: T) => {
- return (e: any) => {
- setInputs((i) => ({
- ...i,
- [paramName + "To"]: dayjs(e).format("YYYY-MM-DD"),
- }));
- };
- }, []);
-
- const makeDatetimeChangeHandler = useCallback((paramName: T) => {
- return (value: Dayjs | null) => {
- setInputs((i) => ({
- ...i,
- [paramName]: value ? dayjsToDateTimeString(value) : null
- }));
- };
- }, []);
-
- const makeDatetimeToChangeHandler = useCallback((paramName: T) => {
- return (value: Dayjs | null) => {
- setInputs((i) => ({
- ...i,
- [paramName + "To"]: value ? dayjsToDateTimeString(value) : null
- }));
- };
- }, []);
-
- const makeTimeChangeHandler = useCallback((paramName: T) => {
- return (value: Dayjs | null) => {
- setInputs((i) => ({
- ...i,
- [paramName]: value ? value.format("HH:mm") : ""
- }));
- };
- }, []);
-
- const handleReset = () => {
- setInputs(preFilledInputs);
- onReset?.();
- setIsReset(!isReset);
- };
-
- const handleSearch = () => {
- onSearch(inputs);
- };
-
- return (
- <Card className="app-search-criteria" elevation={0}>
- <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
- <Typography className="app-search-criteria-label" variant="overline" sx={{ display: "block", mb: 0.5 }}>
- {t("Search Criteria")}
- </Typography>
- <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
- {visibleCriteria.map((c) => {
- return (
- <Grid key={c.paramName} item xs={6}>
- {c.type === "text" && (
- <TextField
- label={c.label}
- fullWidth
- placeholder={c.placeholder}
- onChange={makeInputChangeHandler(c.paramName)}
- value={inputs[c.paramName]}
- disabled={disabled}
- />
- )}
- {/* eslint-disable-next-line @typescript-eslint/no-unused-vars */}
- {/* {c.type === "multi-select" && (
- <MultiSelect
- label={t(c.label)}
- options={c?.options}
- selectedValues={c.filterObj?.[c.paramName] ?? []}
- onChange={c.handleSelectionChange}
- isReset={isReset}
- />
- )} */}
- {c.type === "select" && (
- <FormControl fullWidth>
- <InputLabel>{c.label}</InputLabel>
- <Select
- label={c.label}
- onChange={makeSelectChangeHandler(c.paramName)}
- value={inputs[c.paramName] ?? "All"}
- disabled={disabled}
- >
- <MenuItem value={"All"}>{t("All")}</MenuItem>
- {c.options.map((option) => (
- <MenuItem key={option} value={option}>
- {t(option)}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- )}
- {c.type === "select-labelled" && (
- <FormControl fullWidth>
- <InputLabel>{c.label}</InputLabel>
- <Select
- label={c.label}
- onChange={makeSelectChangeHandler(c.paramName)}
- value={inputs[c.paramName] ?? "All"}
- disabled={disabled}
- >
- <MenuItem value={"All"}>{t("All")}</MenuItem>
- {c.options.map((option) => (
- <MenuItem key={option.value} value={option.value}>
- {option.label}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- )}
- {c.type === "autocomplete" && (
- <Autocomplete
- groupBy={
- c.options.filter((option) => option.group !== "All")
- .length > 0 && c.options.every((option) => option.group)
- ? (option) =>
- option.group && option.group.trim() !== ""
- ? option.group
- : "Ungrouped"
- : undefined
- }
- multiple={Boolean(c.multiple)}
- noOptionsText={c.noOptionsText ?? t("No options")}
- disableClearable
- fullWidth
- disabled={disabled}
- value={
- c.multiple
- ? intersectionWith(
- [defaultAll, ...c.options],
- inputs[c.paramName],
- (option, v) => {
- return option.value === (v ?? "");
- },
- )
- : c.options.find(
- (option) => option.value === inputs[c.paramName],
- ) ?? defaultAll
- }
- onChange={makeAutocompleteChangeHandler(
- c.paramName,
- Boolean(c.multiple),
- )}
- getOptionLabel={(option) => option.label}
- options={[defaultAll, ...c.options]}
- disableCloseOnSelect={Boolean(c.multiple)}
- renderGroup={
- c.options.every((option) => option.group)
- ? (params) => (
- <React.Fragment
- key={`${params.key}-${params.group}`}
- >
- <ListSubheader>{params.group}</ListSubheader>
- {params.children}
- </React.Fragment>
- )
- : undefined
- }
- renderTags={
- c.multiple
- ? (value, getTagProps) =>
- value.map((option, index) => {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { key, ...chipProps } = getTagProps({
- index,
- });
- return (
- <Chip
- {...chipProps}
- key={`${option.value}-${option.label}`}
- label={option.label}
- />
- );
- })
- : undefined
- }
- renderOption={(
- params: React.HTMLAttributes<HTMLLIElement> & {
- key?: React.Key;
- },
- option,
- { selected },
- ) => {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { key, ...rest } = params;
- return (
- <MenuItem
- {...rest}
- disableRipple
- value={option.value}
- key={`${option.value}--${option.label}`}
- >
- {c.multiple && (
- <Checkbox
- disableRipple
- key={`checkbox-${option.value}`}
- checked={selected}
- sx={{ transform: "translate(0)" }}
- />
- )}
- {option.label}
- </MenuItem>
- );
- }}
- renderInput={(params) => (
- <TextField
- {...params}
- variant="outlined"
- label={c.label}
- />
- )}
- />
- )}
- {c.type === "dateRange" && (
- <LocalizationProvider
- dateAdapter={AdapterDayjs}
- // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
- adapterLocale="zh-hk"
- >
- <Box display="flex">
- <FormControl fullWidth>
- <DatePicker
- format={`${OUTPUT_DATE_FORMAT}`}
- label={c.label}
- onChange={makeDateChangeHandler(c.paramName)}
- disabled={disabled}
- value={
- dayjs(inputs[c.paramName]).isValid()
- ? dayjs(inputs[c.paramName])
- : null
- }
- />
- </FormControl>
- <Box
- display="flex"
- alignItems="center"
- justifyContent="center"
- marginInline={2}
- >
- {"-"}
- </Box>
- <FormControl fullWidth>
- <DatePicker
- format={`${OUTPUT_DATE_FORMAT}`}
- label={c.label2 ? c.label2 : null}
- onChange={makeDateToChangeHandler(c.paramName)}
- disabled={disabled}
- value={
- dayjs(inputs[`${c.paramName}To`]).isValid()
- ? dayjs(inputs[`${c.paramName}To`])
- : null
- }
- />
- </FormControl>
- </Box>
- </LocalizationProvider>
- )}
- {c.type === "datetimeRange" && (
- <LocalizationProvider
- dateAdapter={AdapterDayjs}
- // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
- adapterLocale="zh-hk"
- >
- <Box display="flex">
- <FormControl fullWidth>
- <DateTimePicker
- views={['year','month','day','hours', 'minutes', 'seconds']}
- format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`}
- label={c.label}
- onChange={makeDatetimeChangeHandler(c.paramName)}
- disabled={disabled}
- value={
- dayjs(inputs[c.paramName]).isValid()
- ? dayjs(inputs[c.paramName])
- : null
- }
- />
- </FormControl>
- <Box
- display="flex"
- alignItems="center"
- justifyContent="center"
- marginInline={2}
- >
- {"-"}
- </Box>
- <FormControl fullWidth>
- <DateTimePicker
- views={['year','month','day','hours', 'minutes', 'seconds']}
- format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`}
- label={c.label2 ? c.label2 : null}
- onChange={makeDatetimeToChangeHandler(c.paramName)}
- disabled={disabled}
- value={
- dayjs(inputs[`${c.paramName}To`]).isValid()
- ? dayjs(inputs[`${c.paramName}To`])
- : null
- }
- />
- </FormControl>
- </Box>
- </LocalizationProvider>
- )}
- {c.type === "date" && (
- <LocalizationProvider
- dateAdapter={AdapterDayjs}
- // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
- adapterLocale="zh-hk"
- >
- <Box display="flex">
- <FormControl fullWidth>
- <DatePicker
- format={`${OUTPUT_DATE_FORMAT}`}
- label={c.label}
- onChange={makeDateChangeHandler(c.paramName)}
- disabled={disabled}
- value={
- inputs[c.paramName] && dayjs(inputs[c.paramName]).isValid()
- ? dayjs(inputs[c.paramName])
- : null
- }
- slotProps={{
- textField: {
- fullWidth: true,
- error: Boolean(c.errorText),
- helperText: c.errorText || undefined,
- },
- }}
- />
- </FormControl>
- </Box>
- </LocalizationProvider>
- )}
- {c.type === "time" && (
- <LocalizationProvider
- dateAdapter={AdapterDayjs}
- adapterLocale="zh-hk"
- >
- <FormControl fullWidth>
- <TimePicker
- format="HH:mm"
- label={c.label}
- onChange={makeTimeChangeHandler(c.paramName)}
- disabled={disabled}
- value={
- inputs[c.paramName] && dayjs(inputs[c.paramName], "HH:mm").isValid()
- ? dayjs(inputs[c.paramName], "HH:mm")
- : null
- }
- />
- </FormControl>
- </LocalizationProvider>
- )}
- </Grid>
- );
- })}
- </Grid>
- <CardActions sx={{ justifyContent: "flex-start", gap: 1, pt: 2, flexWrap: "wrap" }}>
- <Button
- variant="outlined"
- startIcon={<RestartAlt />}
- onClick={handleReset}
- disabled={disabled}
- sx={{ borderColor: "#e2e8f0", color: "#334155" }}
- >
- {t("Reset")}
- </Button>
- <Button
- variant="contained"
- color="primary"
- startIcon={<Search />}
- onClick={handleSearch}
- disabled={disabled}
- >
- {t("Search")}
- </Button>
- {hasAdvanced && (
- <Button
- variant="text"
- startIcon={showAdvanced ? <ExpandLess /> : <ExpandMore />}
- onClick={() => setShowAdvanced((open) => !open)}
- disabled={disabled}
- >
- {showAdvanced ? t("Fewer criteria") : t("More criteria")}
- </Button>
- )}
- {extraActions}
- </CardActions>
- </CardContent>
- </Card>
- );
- }
-
- export default SearchBox;
|