"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { Autocomplete, Chip, CircularProgress, TextField } from "@mui/material"; import { useTranslation } from "react-i18next"; import { searchItemCodes, type ItemCodeSearchHit } from "./itemCodeSearchApi"; import { parseItemCodeTokens } from "./parseItemCodeTokens"; type Props = { label: string; value: string[]; onChange: (codes: string[]) => void; /** Live text in the box, including text that has not been picked from the list. */ onInputValueChange?: (value: string) => void; placeholder?: string; disabled?: boolean; minChars?: number; }; const hitLabel = (hit: ItemCodeSearchHit) => hit.name ? `${hit.code} ${hit.name}` : hit.code; /** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.1 | 2026-09-28 */ const AsyncItemCodeAutocomplete: React.FC = ({ label, value, onChange, onInputValueChange, placeholder, disabled = false, minChars = 2, }) => { const { t } = useTranslation("report"); const [inputValue, setInputValue] = useState(""); const [suggestions, setSuggestions] = useState([]); const [labelByCode, setLabelByCode] = useState>({}); const [isSearching, setIsSearching] = useState(false); const trimmedInput = inputValue.trim(); const needsMoreChars = trimmedInput.length > 0 && trimmedInput.length < minChars; useEffect(() => { if (trimmedInput.length < minChars) { setSuggestions([]); setIsSearching(false); return; } const controller = new AbortController(); let cancelled = false; const timer = window.setTimeout(async () => { setIsSearching(true); try { const hits = await searchItemCodes(trimmedInput, controller.signal); if (cancelled) return; setSuggestions(hits); setLabelByCode((prev) => { const next = { ...prev }; hits.forEach((hit) => { next[hit.code] = hitLabel(hit); }); return next; }); } catch (error) { if (cancelled) return; if (error instanceof DOMException && error.name === "AbortError") return; setSuggestions([]); } finally { if (!cancelled) setIsSearching(false); } }, 300); return () => { cancelled = true; window.clearTimeout(timer); controller.abort(); }; }, [trimmedInput, minChars]); const selectedCodes = useMemo( () => new Set(value.map((code) => code.toUpperCase())), [value], ); const options = useMemo(() => { const seen = new Set(); const codes: string[] = []; suggestions.forEach((hit) => { const key = hit.code.toUpperCase(); if (seen.has(key) || selectedCodes.has(key)) return; seen.add(key); codes.push(hit.code); }); return codes; }, [suggestions, selectedCodes]); useEffect(() => { const trimmed = inputValue.trim(); if (!trimmed) return; const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); if (hit) onInputValueChange?.(hit.code); }, [suggestions, inputValue, onInputValueChange]); const inputValueRef = useRef(inputValue); inputValueRef.current = inputValue; const previousValueRef = useRef(value); useEffect(() => { const previous = previousValueRef.current; previousValueRef.current = value; const tokens = parseItemCodeTokens(inputValueRef.current); if (tokens.length === 0) return; const previousKeys = new Set(previous.map((code) => code.toUpperCase())); const nextKeys = new Set(value.map((code) => code.toUpperCase())); const justCommitted = tokens.every( (token) => nextKeys.has(token.toUpperCase()) && !previousKeys.has(token.toUpperCase()), ); if (!justCommitted) return; setInputValue(""); onInputValueChange?.(""); }, [value, onInputValueChange]); const noOptionsText = needsMoreChars ? t("typeToSearchItemCode", { min: minChars }) : isSearching ? t("searchingItemCodes") : trimmedInput.length < minChars ? t("typeToSearchItemCode", { min: minChars }) : t("noItemCodeMatches"); const hasSelection = value.length > 0; return ( trimmedInput.length < minChars ? [] : opts.filter((code) => !selectedCodes.has(code.toUpperCase())) } isOptionEqualToValue={(option, selected) => option.toUpperCase() === selected.toUpperCase() } autoHighlight noOptionsText={noOptionsText} sx={{ '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot': hasSelection ? { alignItems: 'flex-start', alignContent: 'flex-start', flexWrap: 'wrap', minHeight: 56, paddingTop: '32px !important', paddingBottom: '8px !important', paddingLeft: '14px !important', } : { alignItems: 'center', height: 56, minHeight: 56, maxHeight: 56, boxSizing: 'border-box', paddingTop: '16.5px !important', paddingBottom: '16.5px !important', paddingLeft: '14px !important', }, '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input': { fontSize: '1rem', padding: '0 !important', }, '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input::placeholder': { color: 'text.disabled', opacity: 1, }, '& .MuiAutocomplete-tag': { flex: '1 0 100%', maxWidth: '100%', width: '100%', margin: '6px 0 4px', }, }} componentsProps={{ popper: { placement: "top-start", modifiers: [{ name: "flip", enabled: false }], }, }} onInputChange={(_, next, reason) => { if (reason === "reset") { setInputValue(""); onInputValueChange?.(""); return; } setInputValue(next); const trimmed = next.trim(); const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); onInputValueChange?.(hit?.code ?? next); }} onChange={(_, newValue) => { const codes = (Array.isArray(newValue) ? newValue : []) .map((item) => (typeof item === "string" ? item.trim() : String(item).trim())) .filter(Boolean); onChange(Array.from(new Set(codes))); setInputValue(""); }} getOptionLabel={(option) => labelByCode[option] || option} renderTags={(selected, getTagProps) => selected.map((option, index) => ( )) } renderInput={(params) => ( {isSearching ? : null} {params.InputProps.endAdornment} ), }} /> )} /> ); }; export default AsyncItemCodeAutocomplete;