FPSMS-frontend
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 

224 行
6.6 KiB

  1. "use client";
  2. import { useEffect, useMemo, useState } from "react";
  3. import { Autocomplete, Chip, CircularProgress, TextField } from "@mui/material";
  4. import { useTranslation } from "react-i18next";
  5. import { searchItemCodes, type ItemCodeSearchHit } from "./itemCodeSearchApi";
  6. type Props = {
  7. label: string;
  8. value: string[];
  9. onChange: (codes: string[]) => void;
  10. placeholder?: string;
  11. disabled?: boolean;
  12. minChars?: number;
  13. };
  14. const hitLabel = (hit: ItemCodeSearchHit) =>
  15. hit.name ? `${hit.code} ${hit.name}` : hit.code;
  16. /** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.0 | 2026-09-10 */
  17. const AsyncItemCodeAutocomplete: React.FC<Props> = ({
  18. label,
  19. value,
  20. onChange,
  21. placeholder,
  22. disabled = false,
  23. minChars = 2,
  24. }) => {
  25. const { t } = useTranslation("report");
  26. const [inputValue, setInputValue] = useState("");
  27. const [suggestions, setSuggestions] = useState<ItemCodeSearchHit[]>([]);
  28. const [labelByCode, setLabelByCode] = useState<Record<string, string>>({});
  29. const [isSearching, setIsSearching] = useState(false);
  30. const trimmedInput = inputValue.trim();
  31. const needsMoreChars = trimmedInput.length > 0 && trimmedInput.length < minChars;
  32. useEffect(() => {
  33. if (trimmedInput.length < minChars) {
  34. setSuggestions([]);
  35. setIsSearching(false);
  36. return;
  37. }
  38. const controller = new AbortController();
  39. let cancelled = false;
  40. const timer = window.setTimeout(async () => {
  41. setIsSearching(true);
  42. try {
  43. const hits = await searchItemCodes(trimmedInput, controller.signal);
  44. if (cancelled) return;
  45. setSuggestions(hits);
  46. setLabelByCode((prev) => {
  47. const next = { ...prev };
  48. hits.forEach((hit) => {
  49. next[hit.code] = hitLabel(hit);
  50. });
  51. return next;
  52. });
  53. } catch (error) {
  54. if (cancelled) return;
  55. if (error instanceof DOMException && error.name === "AbortError") return;
  56. setSuggestions([]);
  57. } finally {
  58. if (!cancelled) setIsSearching(false);
  59. }
  60. }, 300);
  61. return () => {
  62. cancelled = true;
  63. window.clearTimeout(timer);
  64. controller.abort();
  65. };
  66. }, [trimmedInput, minChars]);
  67. const options = useMemo(() => {
  68. const seen = new Set<string>();
  69. const codes: string[] = [];
  70. suggestions.forEach((hit) => {
  71. if (seen.has(hit.code)) return;
  72. seen.add(hit.code);
  73. codes.push(hit.code);
  74. });
  75. value.forEach((code) => {
  76. if (seen.has(code)) return;
  77. seen.add(code);
  78. codes.push(code);
  79. });
  80. return codes;
  81. }, [suggestions, value]);
  82. const noOptionsText = needsMoreChars
  83. ? t("typeToSearchItemCode", { min: minChars })
  84. : isSearching
  85. ? t("searchingItemCodes")
  86. : trimmedInput.length < minChars
  87. ? t("typeToSearchItemCode", { min: minChars })
  88. : t("noItemCodeMatches");
  89. const hasSelection = value.length > 0;
  90. return (
  91. <Autocomplete
  92. multiple
  93. freeSolo
  94. filterSelectedOptions
  95. disabled={disabled}
  96. options={options}
  97. value={value}
  98. inputValue={inputValue}
  99. loading={isSearching}
  100. filterOptions={(opts) =>
  101. trimmedInput.length < minChars
  102. ? []
  103. : opts.filter((code) => !value.includes(code))
  104. }
  105. isOptionEqualToValue={(option, selected) => option === selected}
  106. autoHighlight
  107. noOptionsText={noOptionsText}
  108. sx={{
  109. '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot': hasSelection
  110. ? {
  111. alignItems: 'flex-start',
  112. alignContent: 'flex-start',
  113. flexWrap: 'wrap',
  114. minHeight: 56,
  115. paddingTop: '32px !important',
  116. paddingBottom: '8px !important',
  117. paddingLeft: '14px !important',
  118. }
  119. : {
  120. alignItems: 'center',
  121. height: 56,
  122. minHeight: 56,
  123. maxHeight: 56,
  124. boxSizing: 'border-box',
  125. paddingTop: '16.5px !important',
  126. paddingBottom: '16.5px !important',
  127. paddingLeft: '14px !important',
  128. },
  129. '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input': {
  130. fontSize: '1rem',
  131. padding: '0 !important',
  132. },
  133. '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input::placeholder': {
  134. color: 'text.disabled',
  135. opacity: 1,
  136. },
  137. '& .MuiAutocomplete-tag': {
  138. flex: '1 0 100%',
  139. maxWidth: '100%',
  140. width: '100%',
  141. margin: '6px 0 4px',
  142. },
  143. }}
  144. componentsProps={{
  145. popper: {
  146. placement: "top-start",
  147. modifiers: [{ name: "flip", enabled: false }],
  148. },
  149. }}
  150. onInputChange={(_, next, reason) => {
  151. if (reason === "reset") {
  152. setInputValue("");
  153. return;
  154. }
  155. setInputValue(next);
  156. }}
  157. onChange={(_, newValue) => {
  158. const codes = (Array.isArray(newValue) ? newValue : [])
  159. .map((item) => (typeof item === "string" ? item.trim() : String(item).trim()))
  160. .filter(Boolean);
  161. onChange(Array.from(new Set(codes)));
  162. setInputValue("");
  163. }}
  164. getOptionLabel={(option) => labelByCode[option] || option}
  165. renderTags={(selected, getTagProps) =>
  166. selected.map((option, index) => (
  167. <Chip
  168. variant="outlined"
  169. label={labelByCode[option] || option}
  170. {...getTagProps({ index })}
  171. key={`${option}-${index}`}
  172. sx={{
  173. height: 'auto',
  174. mt: index === 0 ? 0.5 : 0,
  175. py: 0.5,
  176. justifyContent: 'space-between',
  177. '& .MuiChip-label': {
  178. fontSize: '1rem',
  179. whiteSpace: 'normal',
  180. textAlign: 'left',
  181. lineHeight: 1.4,
  182. display: 'block',
  183. },
  184. }}
  185. />
  186. ))
  187. }
  188. renderInput={(params) => (
  189. <TextField
  190. {...params}
  191. fullWidth
  192. label={label}
  193. placeholder={hasSelection ? "" : (placeholder || "e.g. FA0591")}
  194. helperText={t("typeToSearchItemCode", { min: minChars })}
  195. InputLabelProps={params.InputLabelProps}
  196. InputProps={{
  197. ...params.InputProps,
  198. endAdornment: (
  199. <>
  200. {isSearching ? <CircularProgress color="inherit" size={18} /> : null}
  201. {params.InputProps.endAdornment}
  202. </>
  203. ),
  204. }}
  205. />
  206. )}
  207. />
  208. );
  209. };
  210. export default AsyncItemCodeAutocomplete;