"use client"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; import { Search, ChevronRight, Loader2, X, CalendarDays } from "lucide-react"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns"; import { ko } from "date-fns/locale"; import { usePopEvent } from "@/hooks/pop"; import { dataApi } from "@/lib/api/data"; import type { PopSearchConfig, DatePresetOption, DateSelectionMode, CalendarDisplayMode, ModalSelectConfig, ModalSearchMode, ModalFilterTab, } from "./types"; import { DATE_PRESET_LABELS, computeDateRange, DEFAULT_SEARCH_CONFIG, normalizeInputType, MODAL_FILTER_TAB_LABELS, getGroupKey, } from "./types"; // ======================================== // 메인 컴포넌트 // ======================================== interface PopSearchComponentProps { config: PopSearchConfig; label?: string; screenId?: string; componentId?: string; } const DEFAULT_CONFIG = DEFAULT_SEARCH_CONFIG; export function PopSearchComponent({ config: rawConfig, label, screenId, componentId, }: PopSearchComponentProps) { const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) }; const { publish, subscribe, setSharedData } = usePopEvent(screenId || ""); const [value, setValue] = useState(config.defaultValue ?? ""); const [modalDisplayText, setModalDisplayText] = useState(""); const [simpleModalOpen, setSimpleModalOpen] = useState(false); const normalizedType = normalizeInputType(config.inputType as string); const isModalType = normalizedType === "modal"; const fieldKey = isModalType ? (config.modalConfig?.valueField || config.fieldName || componentId || "search") : (config.fieldName || componentId || "search"); const resolveFilterMode = useCallback(() => { if (config.filterMode) return config.filterMode; if (normalizedType === "date") { const mode: DateSelectionMode = config.dateSelectionMode || "single"; return mode === "range" ? "range" : "equals"; } return "contains"; }, [config.filterMode, config.dateSelectionMode, normalizedType]); const emitFilterChanged = useCallback( (newValue: unknown) => { setValue(newValue); setSharedData(`search_${fieldKey}`, newValue); if (componentId) { const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; publish(`__comp_output__${componentId}__filter_value`, { fieldName: fieldKey, filterColumns, value: newValue, filterMode: resolveFilterMode(), }); } publish("filter_changed", { [fieldKey]: newValue }); }, [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] ); useEffect(() => { if (!componentId) return; const unsub = subscribe( `__comp_input__${componentId}__set_value`, (payload: unknown) => { const data = payload as { value?: unknown } | unknown; const incoming = typeof data === "object" && data && "value" in data ? (data as { value: unknown }).value : data; emitFilterChanged(incoming); } ); return unsub; }, [componentId, subscribe, emitFilterChanged]); const handleModalOpen = useCallback(() => { if (!config.modalConfig) return; setSimpleModalOpen(true); }, [config.modalConfig]); const handleSimpleModalSelect = useCallback( (row: Record) => { const mc = config.modalConfig; const display = mc?.displayField ? String(row[mc.displayField] ?? "") : ""; const filterVal = mc?.valueField ? String(row[mc.valueField] ?? "") : ""; setModalDisplayText(display); emitFilterChanged(filterVal); setSimpleModalOpen(false); }, [config.modalConfig, emitFilterChanged] ); const showLabel = config.labelVisible !== false && !!config.labelText; return (
{showLabel && ( {config.labelText} )}
{isModalType && config.modalConfig && ( )}
); } // ======================================== // 서브타입 분기 렌더러 // ======================================== interface InputRendererProps { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void; modalDisplayText?: string; onModalOpen?: () => void; } function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": case "number": return ; case "select": return ; case "date": { const dateMode: DateSelectionMode = config.dateSelectionMode || "single"; return dateMode === "range" ? : ; } case "date-preset": return ; case "toggle": return ; case "modal": return ; default: return ; } } // ======================================== // text 서브타입 // ======================================== function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { const [inputValue, setInputValue] = useState(value); const debounceRef = useRef | null>(null); useEffect(() => { setInputValue(value); }, [value]); useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []); const handleChange = (e: React.ChangeEvent) => { const v = e.target.value; setInputValue(v); if (debounceRef.current) clearTimeout(debounceRef.current); const ms = config.debounceMs ?? 500; if (ms > 0) debounceRef.current = setTimeout(() => onChange(v), ms); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && config.triggerOnEnter !== false) { if (debounceRef.current) clearTimeout(debounceRef.current); onChange(inputValue); } }; const isNumber = config.inputType === "number"; return (
); } // ======================================== // date 서브타입 - 단일 날짜 // ======================================== function DateSingleInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { const [open, setOpen] = useState(false); const useModal = config.calendarDisplay === "modal"; const selected = value ? new Date(value + "T00:00:00") : undefined; const handleSelect = useCallback( (day: Date | undefined) => { if (!day) return; onChange(format(day, "yyyy-MM-dd")); setOpen(false); }, [onChange] ); const handleClear = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onChange(""); }, [onChange] ); const triggerButton = ( ); if (useModal) { return ( <> {triggerButton} 날짜 선택
); } return ( {triggerButton} ); } // ======================================== // date 서브타입 - 기간 선택 (프리셋 + Calendar Range) // ======================================== interface DateRangeValue { from?: string; to?: string } const RANGE_PRESETS = [ { key: "today", label: "오늘" }, { key: "this-week", label: "이번주" }, { key: "this-month", label: "이번달" }, ] as const; function computeRangePreset(key: string): DateRangeValue { const now = new Date(); const fmt = (d: Date) => format(d, "yyyy-MM-dd"); switch (key) { case "today": return { from: fmt(now), to: fmt(now) }; case "this-week": return { from: fmt(startOfWeek(now, { weekStartsOn: 1 })), to: fmt(endOfWeek(now, { weekStartsOn: 1 })) }; case "this-month": return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) }; default: return {}; } } function DateRangeInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) { const [open, setOpen] = useState(false); const useModal = config.calendarDisplay === "modal"; const rangeVal: DateRangeValue = (typeof value === "object" && value !== null) ? value as DateRangeValue : (typeof value === "string" && value ? { from: value, to: value } : {}); const calendarRange = useMemo(() => { if (!rangeVal.from) return undefined; return { from: new Date(rangeVal.from + "T00:00:00"), to: rangeVal.to ? new Date(rangeVal.to + "T00:00:00") : undefined, }; }, [rangeVal.from, rangeVal.to]); const activePreset = RANGE_PRESETS.find((p) => { const preset = computeRangePreset(p.key); return preset.from === rangeVal.from && preset.to === rangeVal.to; })?.key ?? null; const handlePreset = useCallback( (key: string) => { const preset = computeRangePreset(key); onChange(preset); }, [onChange] ); const handleRangeSelect = useCallback( (range: { from?: Date; to?: Date } | undefined) => { if (!range?.from) return; const from = format(range.from, "yyyy-MM-dd"); const to = range.to ? format(range.to, "yyyy-MM-dd") : from; onChange({ from, to }); if (range.to) setOpen(false); }, [onChange] ); const handleClear = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); onChange({}); }, [onChange] ); const displayText = rangeVal.from ? rangeVal.from === rangeVal.to ? format(new Date(rangeVal.from + "T00:00:00"), "MM/dd (EEE)", { locale: ko }) : `${format(new Date(rangeVal.from + "T00:00:00"), "MM/dd", { locale: ko })} ~ ${rangeVal.to ? format(new Date(rangeVal.to + "T00:00:00"), "MM/dd", { locale: ko }) : ""}` : ""; const presetBar = (
{RANGE_PRESETS.map((p) => ( ))}
); const calendarEl = ( ); const triggerButton = ( ); if (useModal) { return ( <> {triggerButton} 기간 선택
{presetBar}
{calendarEl}
); } return ( {triggerButton}
{presetBar} {calendarEl}
); } // ======================================== // select 서브타입 // ======================================== function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { return ( ); } // ======================================== // date-preset 서브타입 // ======================================== function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) { const presets: DatePresetOption[] = config.datePresets || ["today", "this-week", "this-month"]; const currentPreset = value && typeof value === "object" && "preset" in (value as Record) ? (value as Record).preset : value; const handleSelect = (preset: DatePresetOption) => { if (preset === "custom") { onChange({ preset: "custom", from: "", to: "" }); return; } const range = computeDateRange(preset); if (range) onChange(range); }; return (
{presets.map((preset) => ( ))}
); } // ======================================== // toggle 서브타입 // ======================================== function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) { return (
onChange(checked)} /> {value ? "ON" : "OFF"}
); } // ======================================== // modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기 // ======================================== function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) { return (
{ if (e.key === "Enter" || e.key === " ") onClick?.(); }} > {displayText || config.placeholder || "선택..."}
); } // ======================================== // 미구현 서브타입 플레이스홀더 // ======================================== function PlaceholderInput({ inputType }: { inputType: string }) { return (
{inputType} (후속 구현 예정)
); } // ======================================== // 검색 방식별 문자열 매칭 // ======================================== function matchSearchMode(cellValue: string, term: string, mode: ModalSearchMode): boolean { const lower = cellValue.toLowerCase(); const tLower = term.toLowerCase(); switch (mode) { case "starts-with": return lower.startsWith(tLower); case "equals": return lower === tLower; case "contains": default: return lower.includes(tLower); } } // ======================================== // 아이콘 색상 생성 (이름 기반 결정적 색상) // ======================================== const ICON_COLORS = [ "bg-red-500", "bg-orange-500", "bg-amber-500", "bg-yellow-500", "bg-lime-500", "bg-green-500", "bg-emerald-500", "bg-teal-500", "bg-cyan-500", "bg-sky-500", "bg-blue-500", "bg-indigo-500", "bg-violet-500", "bg-purple-500", "bg-fuchsia-500", "bg-pink-500", ]; function getIconColor(text: string): string { let hash = 0; for (let i = 0; i < text.length; i++) { hash = text.charCodeAt(i) + ((hash << 5) - hash); } return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length]; } // ======================================== // 모달 Dialog: 테이블 / 아이콘 뷰 + 필터 탭 // ======================================== interface ModalDialogProps { open: boolean; onOpenChange: (open: boolean) => void; modalConfig: ModalSelectConfig; title: string; onSelect: (row: Record) => void; } function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: ModalDialogProps) { const [searchText, setSearchText] = useState(""); const [allRows, setAllRows] = useState[]>([]); const [loading, setLoading] = useState(false); const [activeFilterTab, setActiveFilterTab] = useState(null); const debounceRef = useRef | null>(null); const { tableName, displayColumns, searchColumns, searchMode = "contains", filterTabs, columnLabels, displayStyle = "table", displayField, } = modalConfig; const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : []; const hasFilterTabs = filterTabs && filterTabs.length > 0; // 데이터 로드 const fetchData = useCallback(async () => { if (!tableName) return; setLoading(true); try { const result = await dataApi.getTableData(tableName, { page: 1, size: 200 }); setAllRows(result.data || []); } catch { setAllRows([]); } finally { setLoading(false); } }, [tableName]); useEffect(() => { if (open) { setSearchText(""); setActiveFilterTab(hasFilterTabs ? filterTabs![0] : null); fetchData(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, fetchData, hasFilterTabs]); // 필터링된 행 계산 const filteredRows = useMemo(() => { let items = allRows; // 텍스트 검색 필터 if (searchText.trim()) { const cols = searchColumns && searchColumns.length > 0 ? searchColumns : colsToShow; items = items.filter((row) => cols.some((col) => { const val = row[col]; return val != null && matchSearchMode(String(val), searchText, searchMode); }) ); } // 필터 탭 (초성/알파벳) 적용 if (activeFilterTab && displayField) { items = items.filter((row) => { const val = row[displayField]; if (val == null) return false; const key = getGroupKey(String(val), activeFilterTab); return key !== "#"; }); } return items; }, [allRows, searchText, searchColumns, colsToShow, searchMode, activeFilterTab, displayField]); // 그룹화 (필터 탭 활성화 시) const groupedRows = useMemo(() => { if (!activeFilterTab || !displayField) return null; const groups = new Map[]>(); for (const row of filteredRows) { const val = row[displayField]; const key = val != null ? getGroupKey(String(val), activeFilterTab) : "#"; if (key === "#") continue; if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(row); } // 정렬 const sorted = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "ko")); return sorted; }, [filteredRows, activeFilterTab, displayField]); const handleSearchChange = (e: React.ChangeEvent) => { const v = e.target.value; setSearchText(v); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => {}, 300); }; const getColLabel = (colName: string) => columnLabels?.[colName] || colName; return ( {title} 선택 {/* 필터 탭 버튼 */} {hasFilterTabs && (
{filterTabs!.map((tab) => ( ))}
)}
{/* 검색 입력 */}
{searchText && ( )}
{/* 결과 영역 */}
{loading ? (
) : filteredRows.length === 0 ? (
{searchText ? "검색 결과가 없습니다" : "데이터가 없습니다"}
) : displayStyle === "icon" ? ( ) : ( )}

{filteredRows.length}건 표시 / {displayStyle === "icon" ? "아이콘" : "행"}을 클릭하면 선택됩니다

); } // ======================================== // 테이블 뷰 // ======================================== function TableView({ rows, groupedRows, colsToShow, displayField, getColLabel, activeFilterTab, onSelect, }: { rows: Record[]; groupedRows: [string, Record[]][] | null; colsToShow: string[]; displayField: string; getColLabel: (col: string) => string; activeFilterTab: ModalFilterTab | null; onSelect: (row: Record) => void; }) { const renderRow = (row: Record, i: number) => ( onSelect(row)}> {colsToShow.length > 0 ? colsToShow.map((col) => ( {String(row[col] ?? "")} )) : Object.entries(row).slice(0, 3).map(([k, v]) => ( {String(v ?? "")} ))} ); if (groupedRows && activeFilterTab) { return (
{colsToShow.length > 0 && (
{colsToShow.map((col) => (
{getColLabel(col)}
))}
)} {groupedRows.map(([groupKey, groupRows]) => (
{groupKey}
{groupRows.map((row, i) => renderRow(row, i))}
))}
); } return ( {colsToShow.length > 0 && ( {colsToShow.map((col) => ( ))} )} {rows.map((row, i) => renderRow(row, i))}
{getColLabel(col)}
); } // ======================================== // 아이콘 뷰 // ======================================== function IconView({ rows, groupedRows, displayField, onSelect, }: { rows: Record[]; groupedRows: [string, Record[]][] | null; displayField: string; onSelect: (row: Record) => void; }) { const renderIconCard = (row: Record, i: number) => { const text = displayField ? String(row[displayField] ?? "") : ""; const firstChar = text.charAt(0) || "?"; const color = getIconColor(text); return (
onSelect(row)} >
{firstChar}
{text}
); }; if (groupedRows) { return (
{groupedRows.map(([groupKey, groupRows]) => (
{groupKey}
{groupRows.map((row, i) => renderIconCard(row, i))}
))}
); } return (
{rows.map((row, i) => renderIconCard(row, i))}
); }