"use client"; import { useState, useEffect, useMemo } from "react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown, AlertTriangle } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import type { PopSearchConfig, SearchInputType, SearchFilterMode, DateSelectionMode, CalendarDisplayMode, DatePresetOption, ModalSelectConfig, ModalDisplayStyle, ModalSearchMode, ModalFilterTab, } from "./types"; import { SEARCH_INPUT_TYPE_LABELS, SEARCH_FILTER_MODE_LABELS, DATE_PRESET_LABELS, MODAL_DISPLAY_STYLE_LABELS, MODAL_SEARCH_MODE_LABELS, MODAL_FILTER_TAB_LABELS, normalizeInputType, } from "./types"; import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; import type { TableInfo, ColumnTypeInfo } from "@/lib/api/tableManagement"; // ======================================== // 기본값 // ======================================== const DEFAULT_CONFIG: PopSearchConfig = { inputType: "text", fieldName: "", placeholder: "검색어 입력", debounceMs: 500, triggerOnEnter: true, labelText: "", labelVisible: true, }; // ======================================== // 설정 패널 메인 // ======================================== interface ConfigPanelProps { config: PopSearchConfig | undefined; onUpdate: (config: PopSearchConfig) => void; allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } export function PopSearchConfigPanel({ config, onUpdate, allComponents, connections, componentId }: ConfigPanelProps) { const [step, setStep] = useState(0); const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) }; const cfg: PopSearchConfig = { ...rawCfg, inputType: normalizeInputType(rawCfg.inputType as string), }; const update = (partial: Partial) => { onUpdate({ ...cfg, ...partial }); }; const STEPS = ["기본 설정", "상세 설정"]; return (
{/* Stepper 헤더 */}
{STEPS.map((s, i) => ( ))}
{step === 0 && } {step === 1 && }
); } // ======================================== // STEP 1: 기본 설정 // ======================================== interface StepProps { cfg: PopSearchConfig; update: (partial: Partial) => void; allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } function StepBasicSettings({ cfg, update }: StepProps) { return (
update({ placeholder: e.target.value })} placeholder="입력 힌트 텍스트" className="h-8 text-xs" />
update({ labelVisible: Boolean(checked) })} />
{cfg.labelVisible !== false && (
update({ labelText: e.target.value })} placeholder="예: 거래처명" className="h-8 text-xs" />
)}
); } // ======================================== // STEP 2: 타입별 상세 설정 // ======================================== function StepDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const normalized = normalizeInputType(cfg.inputType as string); switch (normalized) { case "text": case "number": return ; case "select": return ; case "date": return ; case "date-preset": return ; case "modal": return ; case "toggle": return (

토글은 추가 설정이 없습니다. ON/OFF 값이 바로 전달됩니다.

); default: return (

{cfg.inputType} 타입의 상세 설정은 후속 구현 예정입니다.

); } } // ======================================== // 공통: 필터 연결 설정 섹션 // ======================================== interface FilterConnectionSectionProps { cfg: PopSearchConfig; update: (partial: Partial) => void; showFieldName: boolean; fixedFilterMode?: SearchFilterMode; allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; componentId?: string; } interface ConnectedComponentInfo { tableNames: string[]; displayedColumns: Set; } /** * 연결된 대상 컴포넌트의 tableName과 카드에서 표시 중인 컬럼을 추출한다. */ function getConnectedComponentInfo( componentId?: string, connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[], allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[], ): ConnectedComponentInfo { const empty: ConnectedComponentInfo = { tableNames: [], displayedColumns: new Set() }; if (!componentId || !connections || !allComponents) return empty; const targetIds = connections .filter((c) => c.sourceComponent === componentId) .map((c) => c.targetComponent); const tableNames = new Set(); const displayedColumns = new Set(); for (const tid of targetIds) { const comp = allComponents.find((c) => c.id === tid); if (!comp?.config) continue; const compCfg = comp.config as Record; const tn = compCfg.dataSource?.tableName; if (tn) tableNames.add(tn); // pop-card-list: cardTemplate에서 사용 중인 컬럼 수집 const tpl = compCfg.cardTemplate; if (tpl) { if (tpl.header?.codeField) displayedColumns.add(tpl.header.codeField); if (tpl.header?.titleField) displayedColumns.add(tpl.header.titleField); if (tpl.image?.imageColumn) displayedColumns.add(tpl.image.imageColumn); if (Array.isArray(tpl.body?.fields)) { for (const f of tpl.body.fields) { if (f.columnName) displayedColumns.add(f.columnName); } } } // pop-string-list: selectedColumns / listColumns if (Array.isArray(compCfg.selectedColumns)) { for (const col of compCfg.selectedColumns) displayedColumns.add(col); } if (Array.isArray(compCfg.listColumns)) { for (const lc of compCfg.listColumns) { if (lc.columnName) displayedColumns.add(lc.columnName); } } } return { tableNames: Array.from(tableNames), displayedColumns }; } function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode, allComponents, connections, componentId }: FilterConnectionSectionProps) { const connInfo = useMemo( () => getConnectedComponentInfo(componentId, connections, allComponents), [componentId, connections, allComponents], ); const [targetColumns, setTargetColumns] = useState([]); const [columnsLoading, setColumnsLoading] = useState(false); const connectedTablesKey = connInfo.tableNames.join(","); useEffect(() => { if (connInfo.tableNames.length === 0) { setTargetColumns([]); return; } let cancelled = false; setColumnsLoading(true); Promise.all(connInfo.tableNames.map((t) => getTableColumns(t))) .then((results) => { if (cancelled) return; const allCols: ColumnTypeInfo[] = []; const seen = new Set(); for (const res of results) { if (res.success && res.data?.columns) { for (const col of res.data.columns) { if (!seen.has(col.columnName)) { seen.add(col.columnName); allCols.push(col); } } } } setTargetColumns(allCols); }) .finally(() => { if (!cancelled) setColumnsLoading(false); }); return () => { cancelled = true; }; }, [connectedTablesKey]); // eslint-disable-line react-hooks/exhaustive-deps const hasConnection = connInfo.tableNames.length > 0; const { displayedCols, otherCols } = useMemo(() => { if (connInfo.displayedColumns.size === 0) { return { displayedCols: [] as ColumnTypeInfo[], otherCols: targetColumns }; } const displayed: ColumnTypeInfo[] = []; const others: ColumnTypeInfo[] = []; for (const col of targetColumns) { if (connInfo.displayedColumns.has(col.columnName)) { displayed.push(col); } else { others.push(col); } } return { displayedCols: displayed, otherCols: others }; }, [targetColumns, connInfo.displayedColumns]); const selectedFilterCols = cfg.filterColumns || (cfg.fieldName ? [cfg.fieldName] : []); const toggleFilterColumn = (colName: string) => { const current = new Set(selectedFilterCols); if (current.has(colName)) { current.delete(colName); } else { current.add(colName); } const next = Array.from(current); update({ filterColumns: next, fieldName: next[0] || "", }); }; const renderColumnCheckbox = (col: ColumnTypeInfo) => (
toggleFilterColumn(col.columnName)} />
); return (
필터 연결 설정
{!hasConnection && (

연결 탭에서 대상 컴포넌트를 먼저 연결해주세요. 연결된 리스트의 컬럼 목록이 여기에 표시됩니다.

)} {hasConnection && showFieldName && (
{columnsLoading ? (
컬럼 로딩...
) : targetColumns.length > 0 ? (
{displayedCols.length > 0 && (

카드에서 표시 중

{displayedCols.map(renderColumnCheckbox)}
)} {displayedCols.length > 0 && otherCols.length > 0 && (
)} {otherCols.length > 0 && (

기타 컬럼

{otherCols.map(renderColumnCheckbox)}
)}
) : (

연결된 테이블에서 컬럼을 찾을 수 없습니다

)} {selectedFilterCols.length === 0 && hasConnection && !columnsLoading && targetColumns.length > 0 && (

필터 대상 컬럼을 선택해야 연결된 리스트에서 검색이 작동합니다

)} {selectedFilterCols.length > 0 && (

{selectedFilterCols.length}개 컬럼 선택됨 - 검색어가 선택된 모든 컬럼에서 매칭됩니다

)} {selectedFilterCols.length === 0 && (

연결된 리스트에서 이 검색값과 매칭할 컬럼 (복수 선택 가능)

)}
)} {fixedFilterMode ? (
{SEARCH_FILTER_MODE_LABELS[fixedFilterMode]}

이 입력 타입은 {SEARCH_FILTER_MODE_LABELS[fixedFilterMode]} 방식이 자동 적용됩니다

) : (

연결된 리스트에 값을 보낼 때 적용되는 매칭 방식

)}
); } // ======================================== // text/number 상세 설정 // ======================================== function TextDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { return (
update({ debounceMs: Math.max(0, Number(e.target.value)) })} min={0} max={5000} step={100} className="h-8 text-xs" />

입력 후 대기 시간. 0이면 즉시 발행 (권장: 300~500)

update({ triggerOnEnter: Boolean(checked) })} />
); } // ======================================== // select 상세 설정 // ======================================== function SelectDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const options = cfg.options || []; const addOption = () => { update({ options: [...options, { value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` }], }); }; const removeOption = (index: number) => { update({ options: options.filter((_, i) => i !== index) }); }; const updateOption = (index: number, field: "value" | "label", val: string) => { update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) }); }; return (
{options.length === 0 && (

옵션이 없습니다. 아래 버튼으로 추가하세요.

)} {options.map((opt, i) => (
updateOption(i, "value", e.target.value)} placeholder="값" className="h-7 flex-1 text-[10px]" /> updateOption(i, "label", e.target.value)} placeholder="라벨" className="h-7 flex-1 text-[10px]" />
))}
); } // ======================================== // date 상세 설정 // ======================================== const DATE_SELECTION_MODE_LABELS: Record = { single: "단일 날짜", range: "기간 선택", }; const CALENDAR_DISPLAY_LABELS: Record = { popover: "팝오버 (PC용)", modal: "모달 (터치/POP용)", }; function DateDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const mode: DateSelectionMode = cfg.dateSelectionMode || "single"; const calDisplay: CalendarDisplayMode = cfg.calendarDisplay || "modal"; const autoFilterMode = mode === "range" ? "range" : "equals"; return (

{mode === "single" ? "캘린더에서 날짜 하나를 선택합니다" : "프리셋(오늘/이번주/이번달) + 캘린더 기간 선택"}

{calDisplay === "modal" ? "터치 친화적인 큰 모달로 캘린더가 열립니다" : "입력란 아래에 작은 팝오버로 열립니다"}

); } // ======================================== // date-preset 상세 설정 // ======================================== function DatePresetDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"]; const activePresets = cfg.datePresets || ["today", "this-week", "this-month"]; const togglePreset = (preset: DatePresetOption) => { const next = activePresets.includes(preset) ? activePresets.filter((p) => p !== preset) : [...activePresets, preset]; update({ datePresets: next.length > 0 ? next : ["today"] }); }; return (
{ALL_PRESETS.map((preset) => (
togglePreset(preset)} />
))} {activePresets.includes("custom") && (

"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)

)}
); } // ======================================== // modal 상세 설정 // ======================================== const DEFAULT_MODAL_CONFIG: ModalSelectConfig = { displayStyle: "table", displayField: "", valueField: "", searchMode: "contains", }; function ModalDetailSettings({ cfg, update }: StepProps) { const mc: ModalSelectConfig = { ...DEFAULT_MODAL_CONFIG, ...(cfg.modalConfig || {}) }; const updateModal = (partial: Partial) => { update({ modalConfig: { ...mc, ...partial } }); }; const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); const [tablesLoading, setTablesLoading] = useState(false); const [columnsLoading, setColumnsLoading] = useState(false); const [openTableCombo, setOpenTableCombo] = useState(false); useEffect(() => { let cancelled = false; setTablesLoading(true); tableManagementApi.getTableList().then((res) => { if (!cancelled && res.success && res.data) setTables(res.data); }).finally(() => !cancelled && setTablesLoading(false)); return () => { cancelled = true; }; }, []); useEffect(() => { if (!mc.tableName) { setColumns([]); return; } let cancelled = false; setColumnsLoading(true); getTableColumns(mc.tableName).then((res) => { if (!cancelled && res.success && res.data?.columns) setColumns(res.data.columns); }).finally(() => !cancelled && setColumnsLoading(false)); return () => { cancelled = true; }; }, [mc.tableName]); const toggleArrayItem = (field: "displayColumns" | "searchColumns", col: string) => { const current = mc[field] || []; const next = current.includes(col) ? current.filter((c) => c !== col) : [...current, col]; updateModal({ [field]: next }); }; const toggleFilterTab = (tab: ModalFilterTab) => { const current = mc.filterTabs || []; const next = current.includes(tab) ? current.filter((t) => t !== tab) : [...current, tab]; updateModal({ filterTabs: next }); }; const updateColumnLabel = (colName: string, label: string) => { const current = mc.columnLabels || {}; if (!label.trim()) { const { [colName]: _, ...rest } = current; updateModal({ columnLabels: Object.keys(rest).length > 0 ? rest : undefined }); } else { updateModal({ columnLabels: { ...current, [colName]: label } }); } }; const selectedDisplayCols = mc.displayColumns || []; return (
{/* 보여주기 방식 */}

테이블: 표 형태 / 아이콘: 아이콘 카드 형태

{/* 데이터 테이블 */}
{tablesLoading ? (
테이블 목록 로딩...
) : ( 테이블을 찾을 수 없습니다. {tables.map((t) => ( { updateModal({ tableName: t.tableName, displayColumns: [], searchColumns: [], displayField: "", valueField: "", columnLabels: undefined, }); setOpenTableCombo(false); }} className="text-xs" >
{t.displayName || t.tableName} {t.displayName && t.displayName !== t.tableName && ( {t.tableName} )}
))}
)}
{mc.tableName && ( <> {/* 표시할 컬럼 */}
{columnsLoading ? (
컬럼 로딩...
) : (
{columns.map((col) => (
toggleArrayItem("displayColumns", col.columnName)} />
))}
)}
{/* 컬럼 헤더 라벨 편집 (표시할 컬럼이 선택된 경우만) */} {selectedDisplayCols.length > 0 && (
{selectedDisplayCols.map((colName) => { const colInfo = columns.find((c) => c.columnName === colName); const defaultLabel = colInfo?.displayName || colName; return (
{colName} updateColumnLabel(colName, e.target.value)} placeholder={defaultLabel} className="h-6 flex-1 text-[10px]" />
); })}

비워두면 기본 컬럼명이 사용됩니다

)} {/* 검색 대상 컬럼 */}
{columns.map((col) => (
toggleArrayItem("searchColumns", col.columnName)} />
))}
{/* 검색 방식 */}

포함: 어디든 일치 / 시작: 앞에서 일치 / 같음: 정확히 일치

{/* 필터 탭 (가나다/ABC) */}
{(Object.entries(MODAL_FILTER_TAB_LABELS) as [ModalFilterTab, string][]).map(([key, label]) => (
toggleFilterTab(key)} />
))}

모달 상단에 초성(가나다) / 알파벳(ABC) 필터 탭 표시

{/* 중복 제거 (Distinct) */}
updateModal({ distinct: !!checked })} />

표시 필드 기준으로 동일한 값이 여러 건이면 하나만 표시

{/* 검색창에 보일 값 */}

선택 후 검색 입력란에 표시될 값 (예: 회사명)

{/* 필터에 쓸 값 */}

연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)

)}
); }