FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 

260 行
8.1 KiB

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