|
- "use client";
-
- import { useEffect, useMemo, useState } from "react";
- import { Autocomplete, Chip, CircularProgress, TextField } from "@mui/material";
- import { useTranslation } from "react-i18next";
- import { searchItemCodes, type ItemCodeSearchHit } from "./itemCodeSearchApi";
-
- type Props = {
- label: string;
- value: string[];
- onChange: (codes: 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.0 | 2026-09-10 */
- const AsyncItemCodeAutocomplete: React.FC<Props> = ({
- label,
- value,
- onChange,
- placeholder,
- disabled = false,
- minChars = 2,
- }) => {
- const { t } = useTranslation("report");
- const [inputValue, setInputValue] = useState("");
- const [suggestions, setSuggestions] = useState<ItemCodeSearchHit[]>([]);
- const [labelByCode, setLabelByCode] = useState<Record<string, string>>({});
- 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 options = useMemo(() => {
- const seen = new Set<string>();
- const codes: string[] = [];
- suggestions.forEach((hit) => {
- if (seen.has(hit.code)) return;
- seen.add(hit.code);
- codes.push(hit.code);
- });
- value.forEach((code) => {
- if (seen.has(code)) return;
- seen.add(code);
- codes.push(code);
- });
- return codes;
- }, [suggestions, value]);
-
- 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 (
- <Autocomplete
- multiple
- freeSolo
- filterSelectedOptions
- disabled={disabled}
- options={options}
- value={value}
- inputValue={inputValue}
- loading={isSearching}
- filterOptions={(opts) =>
- trimmedInput.length < minChars
- ? []
- : opts.filter((code) => !value.includes(code))
- }
- isOptionEqualToValue={(option, selected) => option === selected}
- 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("");
- return;
- }
- setInputValue(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) => (
- <Chip
- variant="outlined"
- label={labelByCode[option] || option}
- {...getTagProps({ index })}
- key={`${option}-${index}`}
- sx={{
- height: 'auto',
- mt: index === 0 ? 0.5 : 0,
- py: 0.5,
- justifyContent: 'space-between',
- '& .MuiChip-label': {
- fontSize: '1rem',
- whiteSpace: 'normal',
- textAlign: 'left',
- lineHeight: 1.4,
- display: 'block',
- },
- }}
- />
- ))
- }
- renderInput={(params) => (
- <TextField
- {...params}
- fullWidth
- label={label}
- placeholder={hasSelection ? "" : (placeholder || "e.g. FA0591")}
- helperText={t("typeToSearchItemCode", { min: minChars })}
- InputLabelProps={params.InputLabelProps}
- InputProps={{
- ...params.InputProps,
- endAdornment: (
- <>
- {isSearching ? <CircularProgress color="inherit" size={18} /> : null}
- {params.InputProps.endAdornment}
- </>
- ),
- }}
- />
- )}
- />
- );
- };
-
- export default AsyncItemCodeAutocomplete;
|