From f51d5fcba48960360ab58e6fecca7a6ed5482942 Mon Sep 17 00:00:00 2001 From: BOHA Date: Sun, 7 Jun 2026 00:21:17 +0200 Subject: [PATCH] feat(mui): rewrite warehouse ItemPicker on MUI Autocomplete (same props) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/admin/components/warehouse/ItemPicker.tsx | 293 +++++------------- 1 file changed, 84 insertions(+), 209 deletions(-) diff --git a/src/admin/components/warehouse/ItemPicker.tsx b/src/admin/components/warehouse/ItemPicker.tsx index c544758..cbdc3cc 100644 --- a/src/admin/components/warehouse/ItemPicker.tsx +++ b/src/admin/components/warehouse/ItemPicker.tsx @@ -1,7 +1,14 @@ -import { useState, useRef, useEffect, useCallback, useId } from "react"; -import { createPortal } from "react-dom"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { warehouseItemListOptions } from "../../lib/queries/warehouse"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import CircularProgress from "@mui/material/CircularProgress"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { + warehouseItemListOptions, + type WarehouseItem, +} from "../../lib/queries/warehouse"; interface ItemPickerProps { value: number | null; @@ -9,220 +16,88 @@ interface ItemPickerProps { itemName?: string; } +/** Minimal option shape used to display a selected value that isn't in the + * current search results (edit/prefill via `itemName`). */ +type ItemOption = Pick & Partial; + export default function ItemPicker({ value, onChange, itemName, }: ItemPickerProps) { - const [search, setSearch] = useState(itemName ?? ""); - const [open, setOpen] = useState(false); - const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 })); - const items = data?.data ?? []; + const [inputValue, setInputValue] = useState(itemName ?? ""); - const containerRef = useRef(null); - const activeIndexRef = useRef(-1); - const listboxId = useId(); - const [activeIndex, setActiveIndex] = useState(-1); + const { data, isFetching } = useQuery( + warehouseItemListOptions({ search: inputValue, perPage: 20 }), + ); + const options: ItemOption[] = data?.data ?? []; - const [dropdownStyle, setDropdownStyle] = useState<{ - position: "fixed"; - top: number; - left: number; - width: number; - zIndex: number; - } | null>(null); - - const updatePosition = useCallback(() => { - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - setDropdownStyle({ - position: "fixed", - top: rect.bottom + 2, - left: rect.left, - width: rect.width, - zIndex: 100, - }); - }, []); - - // Reset active index when items change - useEffect(() => { - if (activeIndex >= items.length) { - activeIndexRef.current = -1; - setActiveIndex(-1); - } - }, [items, activeIndex]); - - useEffect(() => { - if (open) { - updatePosition(); - const onScroll = () => updatePosition(); - const onClose = () => setOpen(false); - window.addEventListener("scroll", onScroll, true); - window.addEventListener("resize", onClose); - return () => { - window.removeEventListener("scroll", onScroll, true); - window.removeEventListener("resize", onClose); - }; - } else { - setDropdownStyle(null); - activeIndexRef.current = -1; - setActiveIndex(-1); - } - }, [open, updatePosition]); - - // Close on click-outside via mousedown on document - useEffect(() => { - if (!open) return; - const onDocMouseDown = (e: MouseEvent) => { - const target = e.target; - if (containerRef.current && target instanceof Node) { - if (!containerRef.current.contains(target)) { - // Don't close if click landed on an option in the portal - const listEl = document.getElementById(listboxId); - if (listEl && listEl.contains(target)) return; - setOpen(false); - } - } - }; - document.addEventListener("mousedown", onDocMouseDown); - return () => document.removeEventListener("mousedown", onDocMouseDown); - }, [open, listboxId]); - - const handleSelect = (itemId: number, name: string) => { - onChange(itemId); - setOpen(false); - setSearch(name); - }; - - const setActive = (index: number) => { - activeIndexRef.current = index; - setActiveIndex(index); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "ArrowDown") { - e.preventDefault(); - if (!open) { - setOpen(true); - if (items.length > 0) setActive(0); - return; - } - const next = - activeIndexRef.current < items.length - 1 - ? activeIndexRef.current + 1 - : 0; - setActive(next); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - if (!open) { - setOpen(true); - if (items.length > 0) setActive(items.length - 1); - return; - } - const prev = - activeIndexRef.current > 0 - ? activeIndexRef.current - 1 - : items.length - 1; - setActive(prev); - } else if (e.key === "Enter") { - if ( - open && - activeIndexRef.current >= 0 && - activeIndexRef.current < items.length - ) { - e.preventDefault(); - const item = items[activeIndexRef.current]; - handleSelect(item.id, item.name); - } - } else if (e.key === "Escape") { - if (open) { - e.preventDefault(); - setOpen(false); - } - } else if (e.key === "Home") { - if (open && items.length > 0) { - e.preventDefault(); - setActive(0); - } - } else if (e.key === "End") { - if (open && items.length > 0) { - e.preventDefault(); - setActive(items.length - 1); - } - } else if (e.key === "Tab") { - setOpen(false); - } - }; - - const activeId = - activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined; + // Show the selected value even when it isn't in the current search results + // (e.g. prefilled via `itemName`) by synthesizing a fallback option. + const selectedOption: ItemOption | null = + value == null + ? null + : (options.find((o) => o.id === value) ?? { + id: value, + name: itemName ?? "", + }); return ( -
- { - setSearch(e.target.value); - setOpen(true); - }} - onFocus={() => { - setOpen(true); - updatePosition(); - }} - onKeyDown={handleKeyDown} - /> - {open && - items.length > 0 && - dropdownStyle && - createPortal( -
    - {items.map((item, index) => ( -
  • { - e.preventDefault(); - handleSelect(item.id, item.name); - }} - > - {item.name} - {item.item_number && ( - - {item.item_number} - - )} - {item.available_quantity !== undefined && ( - - {item.available_quantity} {item.unit} - - )} -
  • - ))} -
, - document.body, - )} -
+ + value={selectedOption} + inputValue={inputValue} + onInputChange={(_, newInput) => setInputValue(newInput)} + onChange={(_, opt) => { + if (opt) onChange(opt.id); + }} + options={options} + loading={isFetching} + filterOptions={(x) => x} + getOptionLabel={(o) => o.name} + isOptionEqualToValue={(o, v) => o.id === v.id} + noOptionsText="Žádné položky" + renderOption={(props, option) => { + const { key, ...rest } = props; + return ( + + + {option.name} + {(option.item_number || + option.available_quantity !== undefined) && ( + + {option.item_number} + {option.item_number && option.available_quantity !== undefined + ? " · " + : ""} + {option.available_quantity !== undefined + ? `${option.available_quantity} ${option.unit ?? ""}` + : ""} + + )} + + + ); + }} + renderInput={(params) => ( + + {isFetching ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + /> ); }