- Introduced a new controller for managing custom input values in report cells, allowing users to retrieve and upsert values associated with specific reports and targets. - Implemented API routes for fetching and saving report cell values, ensuring proper authentication and data handling. - Enhanced the frontend components to support the new report cell input functionality, including the ability to edit and save input values in a modal. - Updated inventory and equipment management pages to include new features for handling missing items and managing warehouse locations effectively.
176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* SmartSelect
|
|
*
|
|
* 옵션 개수에 따라 자동으로 검색 기능을 제공하는 셀렉트 컴포넌트.
|
|
* - 옵션 5개 미만: 기본 Select
|
|
* - 옵션 5개 이상: 검색 + 가상 스크롤 Combobox (대용량 옵션도 빠르게 처리)
|
|
*/
|
|
|
|
import React, { useState, useMemo, useEffect, useRef } from "react";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Check, ChevronsUpDown, Search as SearchIcon } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
|
|
const SEARCH_THRESHOLD = 5;
|
|
const ITEM_HEIGHT = 36;
|
|
const LIST_HEIGHT = 280;
|
|
|
|
export interface SmartSelectOption {
|
|
code: string;
|
|
label: string;
|
|
}
|
|
|
|
interface SmartSelectProps {
|
|
options: SmartSelectOption[];
|
|
value: string;
|
|
onValueChange: (value: string) => void;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function SmartSelect({
|
|
options,
|
|
value,
|
|
onValueChange,
|
|
placeholder = "선택",
|
|
disabled = false,
|
|
className,
|
|
}: SmartSelectProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const selectedLabel = useMemo(
|
|
() => options.find((o) => o.code === value)?.label,
|
|
[options, value],
|
|
);
|
|
|
|
// 팝오버 닫힐 때 검색어 리셋
|
|
useEffect(() => {
|
|
if (!open) setSearch("");
|
|
}, [open]);
|
|
|
|
// 검색어로 옵션 필터 (대소문자 무시)
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return options;
|
|
return options.filter((o) => o.label.toLowerCase().includes(q));
|
|
}, [options, search]);
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: filtered.length,
|
|
getScrollElement: () => scrollRef.current,
|
|
estimateSize: () => ITEM_HEIGHT,
|
|
overscan: 10,
|
|
});
|
|
|
|
// 팝오버 열릴 때 측정 강제 (Portal 렌더 타이밍 대응)
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const id = requestAnimationFrame(() => virtualizer.measure());
|
|
return () => cancelAnimationFrame(id);
|
|
}, [open, virtualizer, filtered.length]);
|
|
|
|
if (options.length < SEARCH_THRESHOLD) {
|
|
return (
|
|
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
|
<SelectTrigger className={cn("h-9", className)}>
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((o) => (
|
|
<SelectItem key={o.code} value={o.code}>
|
|
{o.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
className={cn("h-9 w-full justify-between font-normal", className)}
|
|
>
|
|
<span className="truncate">
|
|
{selectedLabel || <span className="text-muted-foreground">{placeholder}</span>}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<div className="flex items-center border-b px-2">
|
|
<SearchIcon className="h-4 w-4 text-muted-foreground mr-1 shrink-0" />
|
|
<Input
|
|
autoFocus
|
|
placeholder="검색..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
/>
|
|
</div>
|
|
{filtered.length === 0 ? (
|
|
<div className="py-6 text-center text-sm text-muted-foreground">검색 결과가 없습니다.</div>
|
|
) : (
|
|
<div
|
|
ref={scrollRef}
|
|
className="overflow-auto py-1"
|
|
style={{ height: LIST_HEIGHT }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: "100%",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((vItem) => {
|
|
const o = filtered[vItem.index];
|
|
const isSelected = value === o.code;
|
|
return (
|
|
<button
|
|
key={o.code}
|
|
type="button"
|
|
onClick={() => {
|
|
onValueChange(o.code);
|
|
setOpen(false);
|
|
}}
|
|
className={cn(
|
|
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left hover:bg-accent",
|
|
isSelected && "bg-accent/60",
|
|
)}
|
|
style={{
|
|
height: `${vItem.size}px`,
|
|
transform: `translateY(${vItem.start}px)`,
|
|
}}
|
|
>
|
|
<Check className={cn("mr-2 h-4 w-4 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
|
|
<span className="truncate">{o.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|