"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 } from "lucide-react"; import { usePopEvent } from "@/hooks/pop"; import { dataApi } from "@/lib/api/data"; import type { PopSearchConfig, DatePresetOption, 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 fieldKey = config.fieldName || componentId || "search"; const normalizedType = normalizeInputType(config.inputType as string); const isModalType = normalizedType === "modal"; const emitFilterChanged = useCallback( (newValue: unknown) => { setValue(newValue); setSharedData(`search_${fieldKey}`, newValue); if (componentId) { publish(`__comp_output__${componentId}__filter_value`, { fieldName: fieldKey, value: newValue, }); } publish("filter_changed", { [fieldKey]: newValue }); }, [fieldKey, publish, setSharedData, componentId] ); 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-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 (
); } // ======================================== // 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))}
); }