diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index c9456e3c..6fd5738e 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -858,6 +858,7 @@ export default function PopDesigner({ onAddConnection={handleAddConnection} onUpdateConnection={handleUpdateConnection} onRemoveConnection={handleRemoveConnection} + modals={layout.modals} /> diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 9ab3deff..6bf0ef38 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -22,7 +22,7 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; -import { PopDataConnection } from "../types/pop-layout"; +import { PopDataConnection, PopModalDefinition } from "../types/pop-layout"; import ConnectionEditor from "./ConnectionEditor"; // ======================================== @@ -56,6 +56,8 @@ interface ComponentEditorPanelProps { onUpdateConnection?: (connectionId: string, conn: Omit) => void; /** 연결 삭제 콜백 */ onRemoveConnection?: (connectionId: string) => void; + /** 모달 정의 목록 (설정 패널에 전달) */ + modals?: PopModalDefinition[]; } // ======================================== @@ -97,6 +99,7 @@ export default function ComponentEditorPanel({ onAddConnection, onUpdateConnection, onRemoveConnection, + modals, }: ComponentEditorPanelProps) { const breakpoint = GRID_BREAKPOINTS[currentMode]; @@ -208,6 +211,7 @@ export default function ComponentEditorPanel({ onUpdate={onUpdateComponent} previewPageIndex={previewPageIndex} onPreviewPage={onPreviewPage} + modals={modals} /> @@ -397,9 +401,10 @@ interface ComponentSettingsFormProps { onUpdate?: (updates: Partial) => void; previewPageIndex?: number; onPreviewPage?: (pageIndex: number) => void; + modals?: PopModalDefinition[]; } -function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage }: ComponentSettingsFormProps) { +function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) { // PopComponentRegistry에서 configPanel 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const ConfigPanel = registeredComp?.configPanel; @@ -430,6 +435,7 @@ function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPrevie onUpdate={handleConfigUpdate} onPreviewPage={onPreviewPage} previewPageIndex={previewPageIndex} + modals={modals} /> ) : (
diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 90afa939..2e92d602 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X } from "lucide-react"; +import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -21,6 +21,7 @@ import { PopComponentRegistry, type ComponentConnectionMeta, } from "@/lib/registry/PopComponentRegistry"; +import { getTableColumns } from "@/lib/api/tableManagement"; // ======================================== // Props @@ -101,10 +102,11 @@ export default function ConnectionEditor({ } // ======================================== -// 대상 컴포넌트의 컬럼 목록 추출 +// 대상 컴포넌트에서 정보 추출 // ======================================== -function extractTargetColumns(comp: PopComponentDefinitionV5 | undefined): string[] { +/** 화면에 표시 중인 컬럼만 추출 */ +function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] { if (!comp?.config) return []; const cfg = comp.config as Record; const cols: string[] = []; @@ -124,6 +126,14 @@ function extractTargetColumns(comp: PopComponentDefinitionV5 | undefined): strin return cols; } +/** 대상 컴포넌트의 데이터소스 테이블명 추출 */ +function extractTableName(comp: PopComponentDefinitionV5 | undefined): string { + if (!comp?.config) return ""; + const cfg = comp.config as Record; + const ds = cfg.dataSource as { tableName?: string } | undefined; + return ds?.tableName || ""; +} + // ======================================== // 보내기 섹션 // ======================================== @@ -262,11 +272,47 @@ function ConnectionForm({ ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta : null; - const targetColumns = React.useMemo( - () => extractTargetColumns(targetComp || undefined), + // 화면에 표시 중인 컬럼 + const displayColumns = React.useMemo( + () => extractDisplayColumns(targetComp || undefined), [targetComp] ); + // DB 테이블 전체 컬럼 (비동기 조회) + const tableName = React.useMemo( + () => extractTableName(targetComp || undefined), + [targetComp] + ); + const [allDbColumns, setAllDbColumns] = React.useState([]); + const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false); + + React.useEffect(() => { + if (!tableName) { + setAllDbColumns([]); + return; + } + let cancelled = false; + setDbColumnsLoading(true); + getTableColumns(tableName).then((res) => { + if (cancelled) return; + if (res.success && res.data?.columns) { + setAllDbColumns(res.data.columns.map((c) => c.columnName)); + } else { + setAllDbColumns([]); + } + setDbColumnsLoading(false); + }); + return () => { cancelled = true; }; + }, [tableName]); + + // 표시 컬럼과 데이터 전용 컬럼 분리 + const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); + const dataOnlyColumns = React.useMemo( + () => allDbColumns.filter((c) => !displaySet.has(c)), + [allDbColumns, displaySet] + ); + const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0; + const toggleColumn = (col: string) => { setFilterColumns((prev) => prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col] @@ -384,25 +430,61 @@ function ConnectionForm({ {/* 필터 설정 */} {selectedTargetInput && (
- {/* 컬럼 선택 (복수) */}

필터할 컬럼

- {targetColumns.length > 0 ? ( -
- {targetColumns.map((col) => ( -
- toggleColumn(col)} - /> - + + {dbColumnsLoading ? ( +
+ + 컬럼 조회 중... +
+ ) : hasAnyColumns ? ( +
+ {/* 표시 컬럼 그룹 */} + {displayColumns.length > 0 && ( +
+

화면 표시 컬럼

+ {displayColumns.map((col) => ( +
+ toggleColumn(col)} + /> + +
+ ))}
- ))} + )} + + {/* 데이터 전용 컬럼 그룹 */} + {dataOnlyColumns.length > 0 && ( +
+ {displayColumns.length > 0 && ( +
+ )} +

데이터 전용 컬럼

+ {dataOnlyColumns.map((col) => ( +
+ toggleColumn(col)} + /> + +
+ ))} +
+ )}
) : ( ([]); - const { subscribe } = usePopEvent(screenId); + const { subscribe, publish } = usePopEvent(screenId); // 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환 useConnectionResolver({ @@ -69,34 +70,51 @@ export default function PopViewerWithModals({ connections: layout.dataFlow?.connections || [], }); - // 모달 열기 이벤트 구독 + // 모달 열기/닫기 이벤트 구독 useEffect(() => { const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => { const data = payload as { modalId?: string; title?: string; mode?: string; + returnTo?: string; }; - // fullscreen 모달: layout.modals에서 정의 찾기 if (data?.modalId) { const modalDef = layout.modals?.find(m => m.id === data.modalId); if (modalDef) { - setModalStack(prev => [...prev, { definition: modalDef }]); + setModalStack(prev => [...prev, { + definition: modalDef, + returnTo: data.returnTo, + }]); } } }); - const unsubClose = subscribe("__pop_modal_close__", () => { - // 가장 최근 모달 닫기 - setModalStack(prev => prev.slice(0, -1)); + const unsubClose = subscribe("__pop_modal_close__", (payload: unknown) => { + const data = payload as { selectedRow?: Record } | undefined; + + setModalStack(prev => { + if (prev.length === 0) return prev; + const topModal = prev[prev.length - 1]; + + // 결과 데이터가 있고, 반환 대상이 지정된 경우 결과 이벤트 발행 + if (data?.selectedRow && topModal.returnTo) { + publish("__pop_modal_result__", { + selectedRow: data.selectedRow, + returnTo: topModal.returnTo, + }); + } + + return prev.slice(0, -1); + }); }); return () => { unsubOpen(); unsubClose(); }; - }, [subscribe, layout.modals]); + }, [subscribe, publish, layout.modals]); // 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC) const handleCloseTopModal = useCallback(() => { diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts index f590c4f5..3c20acc2 100644 --- a/frontend/hooks/pop/useConnectionResolver.ts +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -48,10 +48,12 @@ export function useConnectionResolver({ for (const conn of conns) { const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; - // filterConfig가 있으면 payload에 첨부 - const enrichedPayload = conn.filterConfig - ? { value: payload, filterConfig: conn.filterConfig } - : payload; + // 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId } + const enrichedPayload = { + value: payload, + filterConfig: conn.filterConfig, + _connectionId: conn.id, + }; publish(targetEvent, enrichedPayload); } diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index a00e0dea..e1a33021 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect, useRef } from "react"; +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"; @@ -11,14 +11,31 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Switch } from "@/components/ui/switch"; -import { Search, ChevronRight } from "lucide-react"; +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"; -import { DATE_PRESET_LABELS, computeDateRange, DEFAULT_SEARCH_CONFIG } from "./types"; // ======================================== // 메인 컴포넌트 @@ -42,15 +59,18 @@ export function PopSearchComponent({ 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, @@ -58,13 +78,11 @@ export function PopSearchComponent({ }); } - // 레거시 호환 publish("filter_changed", { [fieldKey]: newValue }); }, [fieldKey, publish, setSharedData, componentId] ); - // 외부 값 수신 (스캔 결과, 모달 선택 등) useEffect(() => { if (!componentId) return; const unsub = subscribe( @@ -80,6 +98,24 @@ export function PopSearchComponent({ 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 ( @@ -101,8 +137,20 @@ export function PopSearchComponent({ config={config} value={value} onChange={emitFilterChanged} + modalDisplayText={modalDisplayText} + onModalOpen={handleModalOpen} />
+ + {isModalType && config.modalConfig && ( + + )}
); } @@ -115,80 +163,46 @@ interface InputRendererProps { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void; + modalDisplayText?: string; + onModalOpen?: () => void; } -function SearchInputRenderer({ config, value, onChange }: InputRendererProps) { - switch (config.inputType) { +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) { + const normalized = normalizeInputType(config.inputType as string); + switch (normalized) { case "text": case "number": - return ( - - ); + return ; case "select": - return ( - - ); + return ; case "date-preset": return ; case "toggle": - return ( - - ); - case "modal-table": - case "modal-card": - case "modal-icon-grid": - return ( - - ); + return ; + case "modal": + return ; default: return ; } } // ======================================== -// text 서브타입: 디바운스 + Enter +// text 서브타입 // ======================================== -interface TextInputProps { - config: PopSearchConfig; - value: string; - onChange: (v: unknown) => void; -} - -function TextSearchInput({ config, value, onChange }: TextInputProps) { +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); - }; - }, []); + 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); - } + if (ms > 0) debounceRef.current = setTimeout(() => onChange(v), ms); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -217,29 +231,18 @@ function TextSearchInput({ config, value, onChange }: TextInputProps) { } // ======================================== -// select 서브타입: 즉시 발행 +// select 서브타입 // ======================================== -interface SelectInputProps { - config: PopSearchConfig; - value: string; - onChange: (v: unknown) => void; -} - -function SelectSearchInput({ config, value, onChange }: SelectInputProps) { +function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { return ( - onChange(v)}> {(config.options || []).map((opt) => ( - - {opt.label} - + {opt.label} ))} @@ -247,29 +250,17 @@ function SelectSearchInput({ config, value, onChange }: SelectInputProps) { } // ======================================== -// date-preset 서브타입: 탭 버튼 + 즉시 발행 +// date-preset 서브타입 // ======================================== -interface DatePresetInputProps { - config: PopSearchConfig; - value: unknown; - onChange: (v: unknown) => void; -} - -function DatePresetSearchInput({ config, value, onChange }: DatePresetInputProps) { - 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; +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; - } + if (preset === "custom") { onChange({ preset: "custom", from: "", to: "" }); return; } const range = computeDateRange(preset); if (range) onChange(range); }; @@ -277,13 +268,7 @@ function DatePresetSearchInput({ config, value, onChange }: DatePresetInputProps return (
{presets.map((preset) => ( - ))} @@ -292,47 +277,32 @@ function DatePresetSearchInput({ config, value, onChange }: DatePresetInputProps } // ======================================== -// toggle 서브타입: Switch + 즉시 발행 +// toggle 서브타입 // ======================================== -interface ToggleInputProps { - value: boolean; - onChange: (v: unknown) => void; -} - -function ToggleSearchInput({ value, onChange }: ToggleInputProps) { +function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) { return (
- onChange(checked)} - /> - - {value ? "ON" : "OFF"} - + onChange(checked)} /> + {value ? "ON" : "OFF"}
); } // ======================================== -// modal-* 서브타입: readonly 입력 + 아이콘 (MVP: UI만) +// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기 // ======================================== -interface ModalInputProps { - config: PopSearchConfig; - value: string; -} - -function ModalSearchInput({ config, value }: ModalInputProps) { +function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) { return (
{ if (e.key === "Enter" || e.key === " ") onClick?.(); }} > - - {value || config.placeholder || "선택..."} - + {displayText || config.placeholder || "선택..."}
); @@ -345,9 +315,374 @@ function ModalSearchInput({ config, value }: ModalInputProps) { function PlaceholderInput({ inputType }: { inputType: string }) { return (
- - {inputType} (후속 구현 예정) - + {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(); + } + }, [open, fetchData, hasFilterTabs, filterTabs]); + + // 필터링된 행 계산 + 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))}
); } diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index af62afe4..3993dc48 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -13,13 +13,26 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react"; import type { PopSearchConfig, SearchInputType, DatePresetOption, + ModalSelectConfig, + ModalDisplayStyle, + ModalSearchMode, + ModalFilterTab, } from "./types"; -import { SEARCH_INPUT_TYPE_LABELS, DATE_PRESET_LABELS } from "./types"; +import { + SEARCH_INPUT_TYPE_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"; // ======================================== // 기본값 @@ -47,7 +60,11 @@ interface ConfigPanelProps { export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) { const [step, setStep] = useState(0); - const cfg = { ...DEFAULT_CONFIG, ...(config || {}) }; + const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) }; + const cfg: PopSearchConfig = { + ...rawCfg, + inputType: normalizeInputType(rawCfg.inputType as string), + }; const update = (partial: Partial) => { onUpdate({ ...cfg, ...partial }); @@ -79,17 +96,9 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) { ))}
- {/* STEP 1: 기본 설정 */} - {step === 0 && ( - - )} + {step === 0 && } + {step === 1 && } - {/* STEP 2: 타입별 상세 설정 */} - {step === 1 && ( - - )} - - {/* 이전/다음 버튼 */}
); } // ======================================== -// select 상세 설정: 정적 옵션 편집 +// select 상세 설정 // ======================================== function SelectDetailSettings({ cfg, update }: StepProps) { @@ -297,10 +285,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) { const addOption = () => { update({ - options: [ - ...options, - { value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` }, - ], + options: [...options, { value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` }], }); }; @@ -309,52 +294,25 @@ function SelectDetailSettings({ cfg, update }: StepProps) { }; const updateOption = (index: number, field: "value" | "label", val: string) => { - const next = options.map((opt, i) => - i === index ? { ...opt, [field]: val } : opt - ); - update({ options: next }); + 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]" - /> -
))} - - @@ -363,16 +321,11 @@ function SelectDetailSettings({ cfg, update }: StepProps) { } // ======================================== -// date-preset 상세 설정: 프리셋 선택 +// date-preset 상세 설정 // ======================================== function DatePresetDetailSettings({ cfg, update }: StepProps) { - const ALL_PRESETS: DatePresetOption[] = [ - "today", - "this-week", - "this-month", - "custom", - ]; + const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"]; const activePresets = cfg.datePresets || ["today", "this-week", "this-month"]; const togglePreset = (preset: DatePresetOption) => { @@ -385,7 +338,6 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) { return (
- {ALL_PRESETS.map((preset) => (
togglePreset(preset)} /> - +
))} - {activePresets.includes("custom") && (

"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현) @@ -409,63 +358,291 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) { } // ======================================== -// modal-* 상세 설정 +// modal 상세 설정 // ======================================== -function ModalDetailSettings({ cfg, update }: StepProps) { - const mc = cfg.modalConfig || { modalCanvasId: "", displayField: "", valueField: "" }; +const DEFAULT_MODAL_CONFIG: ModalSelectConfig = { + displayStyle: "table", + displayField: "", + valueField: "", + searchMode: "contains", +}; - const updateModal = (partial: Partial) => { +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); + + 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 (

-
-

- 모달 캔버스 연동은 모달 시스템 구현 후 활성화됩니다. - 현재는 설정값만 저장됩니다. -

-
- - {/* 모달 캔버스 ID */} + {/* 보여주기 방식 */}
- - updateModal({ modalCanvasId: e.target.value })} - placeholder="예: modal-supplier" - className="h-8 text-xs" - /> -
- - {/* 표시 필드 */} -
- - updateModal({ displayField: e.target.value })} - placeholder="예: supplier_name" - className="h-8 text-xs" - /> + +

- 선택 후 입력란에 표시할 필드명 + 테이블: 표 형태 / 아이콘: 아이콘 카드 형태

- {/* 값 필드 */} + {/* 데이터 테이블 */}
- - updateModal({ valueField: e.target.value })} - placeholder="예: supplier_code" - className="h-8 text-xs" - /> -

- 필터 값으로 사용할 필드명 -

+ + {tablesLoading ? ( +
+ + 테이블 목록 로딩... +
+ ) : ( + + )}
+ + {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) 필터 탭 표시 +

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

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

+
+ + {/* 필터에 쓸 값 */} +
+ + +

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

+
+ + )}
); } diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index fdb43cac..6c49b1c5 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -1,7 +1,7 @@ // ===== pop-search 전용 타입 ===== // 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나. -/** 검색 필드 입력 타입 (11종) */ +/** 검색 필드 입력 타입 (9종) */ export type SearchInputType = | "text" | "number" @@ -10,11 +10,18 @@ export type SearchInputType = | "select" | "multi-select" | "combo" - | "modal-table" - | "modal-card" - | "modal-icon-grid" + | "modal" | "toggle"; +/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */ +export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid"; + +/** 레거시 타입 -> modal로 정규화 */ +export function normalizeInputType(t: string): SearchInputType { + if (t === "modal-table" || t === "modal-card" || t === "modal-icon-grid") return "modal"; + return t as SearchInputType; +} + /** 날짜 프리셋 옵션 */ export type DatePresetOption = "today" | "this-week" | "this-month" | "custom"; @@ -33,17 +40,35 @@ export interface SelectDataSource { sortDirection?: "asc" | "desc"; } -/** 모달 선택 설정 (modal-table / modal-card / modal-icon-grid 서브타입 전용) */ +/** 모달 보여주기 방식: 테이블 or 아이콘 */ +export type ModalDisplayStyle = "table" | "icon"; + +/** 모달 검색 방식 */ +export type ModalSearchMode = "contains" | "starts-with" | "equals"; + +/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */ +export type ModalFilterTab = "korean" | "alphabet"; + +/** 모달 선택 설정 */ export interface ModalSelectConfig { - modalCanvasId: string; + displayStyle?: ModalDisplayStyle; + + tableName?: string; + displayColumns?: string[]; + /** 컬럼별 커스텀 헤더 라벨 { column_name: "표시 라벨" } */ + columnLabels?: Record; + searchColumns?: string[]; + searchMode?: ModalSearchMode; + /** 모달 상단 필터 탭 (가나다 / ABC) */ + filterTabs?: ModalFilterTab[]; + displayField: string; valueField: string; - returnEvent?: string; } /** pop-search 전체 설정 */ export interface PopSearchConfig { - inputType: SearchInputType; + inputType: SearchInputType | LegacySearchInputType; fieldName: string; placeholder?: string; defaultValue?: unknown; @@ -59,7 +84,7 @@ export interface PopSearchConfig { // date-preset 전용 datePresets?: DatePresetOption[]; - // modal-* 전용 + // modal 전용 modalConfig?: ModalSelectConfig; // 라벨 @@ -99,12 +124,78 @@ export const SEARCH_INPUT_TYPE_LABELS: Record = { select: "단일 선택", "multi-select": "다중 선택", combo: "자동완성", - "modal-table": "모달 (테이블)", - "modal-card": "모달 (카드)", - "modal-icon-grid": "모달 (아이콘)", + modal: "모달", toggle: "토글", }; +/** 모달 보여주기 방식 라벨 */ +export const MODAL_DISPLAY_STYLE_LABELS: Record = { + table: "테이블", + icon: "아이콘", +}; + +/** 모달 검색 방식 라벨 */ +export const MODAL_SEARCH_MODE_LABELS: Record = { + contains: "포함", + "starts-with": "시작", + equals: "같음", +}; + +/** 모달 필터 탭 라벨 */ +export const MODAL_FILTER_TAB_LABELS: Record = { + korean: "가나다", + alphabet: "ABC", +}; + +/** 한글 초성 추출 */ +const KOREAN_CONSONANTS = [ + "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", + "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ", +]; + +/** 초성 -> 대표 초성 (쌍자음 합침) */ +const CONSONANT_GROUP: Record = { + "ㄱ": "ㄱ", "ㄲ": "ㄱ", + "ㄴ": "ㄴ", + "ㄷ": "ㄷ", "ㄸ": "ㄷ", + "ㄹ": "ㄹ", + "ㅁ": "ㅁ", + "ㅂ": "ㅂ", "ㅃ": "ㅂ", + "ㅅ": "ㅅ", "ㅆ": "ㅅ", + "ㅇ": "ㅇ", + "ㅈ": "ㅈ", "ㅉ": "ㅈ", + "ㅊ": "ㅊ", + "ㅋ": "ㅋ", + "ㅌ": "ㅌ", + "ㅍ": "ㅍ", + "ㅎ": "ㅎ", +}; + +/** 문자열 첫 글자의 그룹 키 추출 (한글 초성 / 영문 대문자 / 기타) */ +export function getGroupKey( + text: string, + mode: ModalFilterTab +): string { + if (!text) return "#"; + const ch = text.charAt(0); + const code = ch.charCodeAt(0); + + if (mode === "korean") { + if (code >= 0xAC00 && code <= 0xD7A3) { + const idx = Math.floor((code - 0xAC00) / (21 * 28)); + const consonant = KOREAN_CONSONANTS[idx]; + return CONSONANT_GROUP[consonant] || consonant; + } + return "#"; + } + + // alphabet + if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) { + return ch.toUpperCase(); + } + return "#"; +} + /** 날짜 범위 계산 (date-preset -> 실제 날짜) */ export function computeDateRange( preset: DatePresetOption diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx index 7bf23365..567f6d1d 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -67,6 +67,7 @@ export function PopStringListComponent({ const dataSource = config?.dataSource; const listColumns = config?.listColumns || []; const cardGrid = config?.cardGrid; + const rowClickAction = config?.rowClickAction || "none"; // 데이터 상태 const [rows, setRows] = useState([]); @@ -83,12 +84,14 @@ export function PopStringListComponent({ // 이벤트 버스 const { publish, subscribe } = usePopEvent(screenId || ""); - // 외부 필터 조건 (연결 시스템에서 수신) - const [externalFilter, setExternalFilter] = useState<{ - fieldName: string; - value: unknown; - filterConfig?: { targetColumn: string; filterMode: string }; - } | null>(null); + // 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합) + const [externalFilters, setExternalFilters] = useState< + Map + >(new Map()); // 표준 입력 이벤트 구독 useEffect(() => { @@ -98,15 +101,23 @@ export function PopStringListComponent({ (payload: unknown) => { const data = payload as { value?: { fieldName?: string; value?: unknown }; - filterConfig?: { targetColumn: string; filterMode: string }; + filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string }; + _connectionId?: string; }; - if (data?.value) { - setExternalFilter({ - fieldName: data.value.fieldName || "", - value: data.value.value, - filterConfig: data.filterConfig, - }); - } + const connId = data?._connectionId || "default"; + setExternalFilters(prev => { + const next = new Map(prev); + if (data?.value?.value) { + next.set(connId, { + fieldName: data.value.fieldName || "", + value: data.value.value, + filterConfig: data.filterConfig, + }); + } else { + next.delete(connId); + } + return next; + }); } ); return unsub; @@ -146,6 +157,24 @@ export function PopStringListComponent({ [rows, publish, screenId] ); + // 행 클릭 핸들러 (selected_row 발행 + 모달 닫기 옵션) + const handleRowClick = useCallback( + (row: RowData) => { + if (rowClickAction === "none") return; + + // selected_row 이벤트 발행 + if (componentId) { + publish(`__comp_output__${componentId}__selected_row`, row); + } + + // 모달 내부에서 사용 시: 선택 후 모달 닫기 + 데이터 반환 + if (rowClickAction === "select-and-close-modal") { + publish("__pop_modal_close__", { selectedRow: row }); + } + }, + [rowClickAction, componentId, publish] + ); + // 오버플로우 설정 (JSON 복원 시 string 유입 방어) const overflowMode = overflow?.mode || "loadMore"; const visibleRows = Number(overflow?.visibleRows) || 5; @@ -155,45 +184,49 @@ export function PopStringListComponent({ const pageSize = Number(overflow?.pageSize) || visibleRows; const paginationStyle = overflow?.paginationStyle || "bottom"; - // --- 외부 필터 적용 --- + // --- 외부 필터 적용 (복수 필터 AND 결합) --- const filteredRows = useMemo(() => { - if (!externalFilter || !externalFilter.value) return rows; + if (externalFilters.size === 0) return rows; - const searchValue = String(externalFilter.value).toLowerCase(); - if (!searchValue) return rows; + const matchSingleFilter = ( + row: RowData, + filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } + ): boolean => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; - // 복수 컬럼 지원: targetColumns > targetColumn > fieldName - const fc = externalFilter.filterConfig; - const columns: string[] = - (fc as any)?.targetColumns?.length > 0 - ? (fc as any).targetColumns - : fc?.targetColumn - ? [fc.targetColumn] - : externalFilter.fieldName - ? [externalFilter.fieldName] - : []; + const fc = filter.filterConfig; + const columns: string[] = + fc?.targetColumns?.length + ? fc.targetColumns + : fc?.targetColumn + ? [fc.targetColumn] + : filter.fieldName + ? [filter.fieldName] + : []; - if (columns.length === 0) return rows; + if (columns.length === 0) return true; - const mode = fc?.filterMode || "contains"; + const mode = fc?.filterMode || "contains"; - const matchCell = (cellValue: string) => { - switch (mode) { - case "equals": - return cellValue === searchValue; - case "starts_with": - return cellValue.startsWith(searchValue); - case "contains": - default: - return cellValue.includes(searchValue); - } + const matchCell = (cellValue: string) => { + switch (mode) { + case "equals": + return cellValue === searchValue; + case "starts_with": + return cellValue.startsWith(searchValue); + case "contains": + default: + return cellValue.includes(searchValue); + } + }; + + return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase())); }; - // 하나라도 일치하면 표시 - return rows.filter((row) => - columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase())) - ); - }, [rows, externalFilter]); + const allFilters = [...externalFilters.values()]; + return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f))); + }, [rows, externalFilters]); // --- 더보기 모드 --- useEffect(() => { @@ -357,7 +390,7 @@ export function PopStringListComponent({ {/* 컨텐츠 */}
{displayMode === "list" ? ( - + ) : ( void; } -function ListModeView({ columns, data }: ListModeViewProps) { +function ListModeView({ columns, data, onRowClick }: ListModeViewProps) { // 런타임 컬럼 전환 상태 // key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName) const [activeColumns, setActiveColumns] = useState>({}); @@ -581,8 +615,12 @@ function ListModeView({ columns, data }: ListModeViewProps) { {data.map((row, i) => (
onRowClick?.(row)} > {columns.map((col, colIdx) => { const currentColName = activeColumns[colIdx] || col.columnName; diff --git a/frontend/lib/registry/pop-components/pop-string-list/types.ts b/frontend/lib/registry/pop-components/pop-string-list/types.ts index 6679cb54..f28a221a 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/types.ts +++ b/frontend/lib/registry/pop-components/pop-string-list/types.ts @@ -7,6 +7,9 @@ import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "../pop-butt /** 표시 모드 */ export type StringListDisplayMode = "list" | "card"; +/** 행 클릭 동작 */ +export type RowClickAction = "none" | "publish" | "select-and-close-modal"; + /** 카드 내부 셀 1개 정의 */ export interface CardCellDefinition { id: string; @@ -81,4 +84,5 @@ export interface PopStringListConfig { selectedColumns?: string[]; // 사용자가 선택한 컬럼명 목록 (모드 무관 영속) listColumns?: ListColumnConfig[]; // 리스트 모드 전용 cardGrid?: CardGridConfig; // 카드 모드 전용 + rowClickAction?: RowClickAction; // 행 클릭 시 동작 (기본: "none") }