diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index bc779848..1b339825 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -56,8 +56,10 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-text": "텍스트", "pop-icon": "아이콘", "pop-dashboard": "대시보드", + "pop-card-list": "카드 목록", "pop-field": "필드", "pop-button": "버튼", + "pop-string-list": "리스트 목록", "pop-list": "리스트", "pop-indicator": "인디케이터", "pop-scanner": "스캐너", diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 47993653..1be9bcba 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -51,6 +51,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: MousePointerClick, description: "액션 버튼 (저장/삭제/API/모달)", }, + { + type: "pop-string-list", + label: "리스트 목록", + icon: List, + description: "테이블 데이터를 리스트/카드로 표시", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index f67a7d7c..d4933443 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -69,6 +69,9 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-text": "텍스트", "pop-icon": "아이콘", "pop-dashboard": "대시보드", + "pop-card-list": "카드 목록", + "pop-button": "버튼", + "pop-string-list": "리스트 목록", }; // ======================================== diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 928ab506..c173c579 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list"; /** * 데이터 흐름 정의 @@ -346,6 +346,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record; + +// ===== 메인 컴포넌트 ===== + +export function PopStringListComponent({ + config, + className, +}: PopStringListComponentProps) { + const displayMode = config?.displayMode || "list"; + const header = config?.header; + const overflow = config?.overflow; + const dataSource = config?.dataSource; + const listColumns = config?.listColumns || []; + const cardGrid = config?.cardGrid; + + // 데이터 상태 + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(false); + + // 오버플로우 계산 (JSON 복원 시 string 유입 방어) + const visibleRows = Number(overflow?.visibleRows) || 5; + const maxExpandRows = Number(overflow?.maxExpandRows) || 20; + const showExpandButton = overflow?.showExpandButton ?? true; + + // 표시할 데이터 슬라이스 + const visibleData = expanded + ? rows.slice(0, maxExpandRows) + : rows.slice(0, visibleRows); + const hasMore = rows.length > visibleRows; + + // 확장/축소 토글 + const toggleExpanded = useCallback(() => { + setExpanded((prev) => !prev); + }, []); + + // 데이터 조회 + useEffect(() => { + if (!dataSource?.tableName) { + setLoading(false); + setRows([]); + return; + } + + const fetchData = async () => { + setLoading(true); + setError(null); + + try { + // 필터 조건 구성 + const filters: Record = {}; + if (dataSource.filters && dataSource.filters.length > 0) { + dataSource.filters.forEach((f) => { + if (f.column && f.value) { + filters[f.column] = f.value; + } + }); + } + + // 정렬 조건 + const sortBy = dataSource.sort?.column; + const sortOrder = dataSource.sort?.direction; + + // 개수 제한 (string 유입 방어: Number 캐스팅) + const size = + dataSource.limit?.mode === "limited" && dataSource.limit?.count + ? Number(dataSource.limit.count) + : maxExpandRows; + + const result = await dataApi.getTableData(dataSource.tableName, { + page: 1, + size, + sortBy: sortOrder ? sortBy : undefined, + sortOrder, + filters: Object.keys(filters).length > 0 ? filters : undefined, + }); + + setRows(result.data || []); + } catch (err) { + const message = + err instanceof Error ? err.message : "데이터 조회 실패"; + setError(message); + setRows([]); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [dataSource, maxExpandRows]); + + // 로딩 상태 + if (loading) { + return ( +
+ +
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+ + {error} +
+ ); + } + + // 테이블 미선택 + if (!dataSource?.tableName) { + return ( +
+ + 테이블을 선택하세요 + +
+ ); + } + + // 데이터 없음 + if (rows.length === 0) { + return ( +
+ + 데이터가 없습니다 + +
+ ); + } + + return ( +
+ {/* 헤더 */} + {header?.enabled && header.label && ( +
+ {header.label} +
+ )} + + {/* 컨텐츠 */} +
+ {displayMode === "list" ? ( + + ) : ( + + )} +
+ + {/* 전체보기 버튼 */} + {showExpandButton && hasMore && ( +
+ +
+ )} +
+ ); +} + +// ===== 리스트 모드 ===== + +interface ListModeViewProps { + columns: ListColumnConfig[]; + data: RowData[]; +} + +function ListModeView({ columns, data }: ListModeViewProps) { + if (columns.length === 0) { + return ( +
+ + 컬럼을 설정하세요 + +
+ ); + } + + const gridCols = columns.map((c) => c.width || "1fr").join(" "); + + return ( +
+ {/* 헤더 행 */} +
+ {columns.map((col) => ( +
+ {col.label} +
+ ))} +
+ + {/* 데이터 행 */} + {data.map((row, i) => ( +
+ {columns.map((col) => ( +
+ {String(row[col.columnName] ?? "")} +
+ ))} +
+ ))} +
+ ); +} + +// ===== 카드 모드 ===== + +interface CardModeViewProps { + cardGrid?: CardGridConfig; + data: RowData[]; +} + +function CardModeView({ cardGrid, data }: CardModeViewProps) { + if (!cardGrid || (cardGrid.cells || []).length === 0) { + return ( +
+ + 카드 레이아웃을 설정하세요 + +
+ ); + } + + return ( +
+ {data.map((row, i) => ( +
0 + ? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ") + : "1fr", + gridTemplateRows: + cardGrid.rowHeights && cardGrid.rowHeights.length > 0 + ? cardGrid.rowHeights + .map((h) => { + if (!h) return "32px"; + // px 값은 직접 사용, fr 값은 마이그레이션 호환 + return h.endsWith("px") + ? h + : `${Math.round(parseFloat(h) * 32) || 32}px`; + }) + .join(" ") + : `repeat(${Number(cardGrid.rows) || 1}, 32px)`, + gap: `${Number(cardGrid.gap) || 0}px`, + }} + > + {(cardGrid.cells || []).map((cell) => { + // 가로 정렬 매핑 + const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const; + const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const; + return ( +
+ {renderCellContent(cell, row)} +
+ ); + })} +
+ ))} +
+ ); +} + +// ===== 셀 컨텐츠 렌더링 ===== + +function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactNode { + const value = row[cell.columnName]; + const displayValue = value != null ? String(value) : ""; + + switch (cell.type) { + case "image": + return displayValue ? ( + {cell.label + ) : ( +
+ No Image +
+ ); + + case "badge": + return ( + + {displayValue} + + ); + + case "button": + return ( + + ); + + case "text": + default: { + // 글자 크기 매핑 + const fontSizeClass = + cell.fontSize === "sm" + ? "text-[10px]" + : cell.fontSize === "lg" + ? "text-sm" + : "text-xs"; // md (기본) + const isLabelLeft = cell.labelPosition === "left"; + + return ( +
+ {cell.label && ( + + {cell.label}{isLabelLeft ? ":" : ""} + + )} + {displayValue} +
+ ); + } + } +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx new file mode 100644 index 00000000..8da80c8d --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx @@ -0,0 +1,2568 @@ +"use client"; + +/** + * pop-string-list 설정 패널 (Stepper/Wizard 방식) + * + * 6단계 순차 진행: + * 1) 모드 선택 (리스트/카드) + * 2) 헤더 설정 + * 3) 오버플로우 설정 + * 4) 데이터 선택 (테이블 + 컬럼 통합) + * 5) 조인 설정 (선택) + * 6-A) 리스트 컬럼 배치 (리스트 모드) + * 6-B) 카드 그리드 디자이너 (카드 모드) + */ + +import { useState, useEffect, useRef, useCallback, Fragment } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Check, ChevronsUpDown, ChevronLeft, ChevronRight, Plus, Minus, Trash2 } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import type { + PopStringListConfig, + StringListDisplayMode, + ListColumnConfig, + CardGridConfig, + CardCellDefinition, +} from "./types"; +import type { CardListDataSource, CardColumnJoin } from "../types"; +import { + fetchTableList, + fetchTableColumns, + type TableInfo, + type ColumnInfo, +} from "../pop-dashboard/utils/dataFetcher"; + +// ===== Props ===== + +interface ConfigPanelProps { + config: PopStringListConfig | undefined; + onUpdate: (config: PopStringListConfig) => void; +} + +// ===== 기본 설정값 ===== + +const DEFAULT_CONFIG: PopStringListConfig = { + displayMode: "list", + header: { enabled: true, label: "" }, + overflow: { visibleRows: 5, showExpandButton: true, maxExpandRows: 20 }, + dataSource: { tableName: "" }, + listColumns: [], + cardGrid: undefined, +}; + +// Stepper 단계 정의 +const STEP_LABELS = [ + "모드 선택", + "헤더 설정", + "오버플로우", + "데이터 선택", + "조인 설정", + "레이아웃", +] as const; + +const TOTAL_STEPS = STEP_LABELS.length; + +// ===== 메인 컴포넌트 ===== + +export function PopStringListConfigPanel({ config, onUpdate }: ConfigPanelProps) { + const [step, setStep] = useState(0); + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [selectedColumns, setSelectedColumns] = useState([]); + + // 설정값 (undefined 대비 기본값 병합) + const cfg: PopStringListConfig = { + ...DEFAULT_CONFIG, + ...config, + header: { ...DEFAULT_CONFIG.header, ...config?.header }, + overflow: { ...DEFAULT_CONFIG.overflow, ...config?.overflow }, + dataSource: { ...DEFAULT_CONFIG.dataSource, ...config?.dataSource }, + }; + + // 설정 업데이트 헬퍼 + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + // 테이블 목록 로드 + useEffect(() => { + fetchTableList() + .then(setTables) + .catch(() => setTables([])); // 네트워크 오류 시 빈 배열 + }, []); + + // 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (!cfg.dataSource.tableName) { + setColumns([]); + return; + } + fetchTableColumns(cfg.dataSource.tableName) + .then(setColumns) + .catch(() => setColumns([])); // 네트워크 오류 시 빈 배열 + }, [cfg.dataSource.tableName]); + + // 선택된 컬럼 복원 (config에 저장된 값 우선) + useEffect(() => { + if (cfg.selectedColumns && cfg.selectedColumns.length > 0) { + setSelectedColumns(cfg.selectedColumns); + } else if (cfg.displayMode === "list" && cfg.listColumns) { + setSelectedColumns(cfg.listColumns.map((c) => c.columnName)); + } else if (cfg.displayMode === "card" && cfg.cardGrid) { + setSelectedColumns((cfg.cardGrid.cells || []).map((c) => c.columnName)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cfg.dataSource.tableName]); // 테이블 변경 시에만 복원 + + // 다음/이전 단계 + const canGoNext = (): boolean => { + switch (step) { + case 0: return true; // 모드 선택 (기본값 있음) + case 1: return true; // 헤더 (선택사항) + case 2: return true; // 오버플로우 (기본값 있음) + case 3: return !!cfg.dataSource.tableName && selectedColumns.length > 0; // 테이블 + 컬럼 + case 4: return true; // 조인 (선택사항) + case 5: return true; // 레이아웃 + default: return false; + } + }; + + const goNext = () => { + if (step < TOTAL_STEPS - 1 && canGoNext()) setStep(step + 1); + }; + + const goPrev = () => { + if (step > 0) setStep(step - 1); + }; + + return ( +
+ {/* Stepper 인디케이터 */} +
+ {STEP_LABELS.map((label, i) => ( + + ))} +
+
{STEP_LABELS[step]}
+ + {/* 단계별 컨텐츠 */} +
+ {step === 0 && ( + update({ displayMode })} + /> + )} + {step === 1 && ( + update({ header })} + /> + )} + {step === 2 && ( + update({ overflow })} + /> + )} + {step === 3 && ( + { + setSelectedColumns([]); + update({ + dataSource: { ...cfg.dataSource, tableName }, + selectedColumns: [], + listColumns: [], + cardGrid: undefined, + }); + }} + columns={columns} + selectedColumns={selectedColumns} + onColumnsChange={(cols) => { + setSelectedColumns(cols); + if (cfg.displayMode === "list") { + const listColumns: ListColumnConfig[] = cols.map((colName) => { + const existing = cfg.listColumns?.find( + (lc) => lc.columnName === colName + ); + return existing || { columnName: colName, label: colName }; + }); + update({ selectedColumns: cols, listColumns }); + } else { + update({ selectedColumns: cols }); + } + }} + /> + )} + {step === 4 && ( + update({ dataSource })} + /> + )} + {step === 5 && + (cfg.displayMode === "list" ? ( + + selectedColumns.includes(c.name) + )} + joinedColumns={ + // 조인에서 선택된 대상 컬럼들을 {테이블명.컬럼명} 형태로 수집 + (cfg.dataSource.joins || []).flatMap((j) => + (j.selectedTargetColumns || []).map((col) => ({ + name: `${j.targetTable}.${col}`, + displayName: col, + sourceTable: j.targetTable, + })) + ) + } + onChange={(listColumns) => update({ listColumns })} + /> + ) : ( + update({ cardGrid })} + /> + ))} +
+ + {/* 이전/다음 버튼 */} +
+ + + {step + 1} / {TOTAL_STEPS} + + +
+
+ ); +} + +// ===== STEP 0: 모드 선택 ===== + +function StepModeSelect({ + displayMode, + onChange, +}: { + displayMode: StringListDisplayMode; + onChange: (mode: StringListDisplayMode) => void; +}) { + return ( +
+ + +
+ ); +} + +// ===== STEP 1: 헤더 설정 ===== + +function StepHeader({ + header, + onChange, +}: { + header: PopStringListConfig["header"]; + onChange: (header: PopStringListConfig["header"]) => void; +}) { + return ( +
+
+ + onChange({ ...header, enabled })} + /> +
+ {header.enabled && ( +
+ + onChange({ ...header, label: e.target.value })} + placeholder="리스트 제목 입력" + className="mt-1 h-8 text-xs" + /> +
+ )} +
+ ); +} + +// ===== STEP 2: 오버플로우 설정 ===== + +function StepOverflow({ + overflow, + onChange, +}: { + overflow: PopStringListConfig["overflow"]; + onChange: (overflow: PopStringListConfig["overflow"]) => void; +}) { + return ( +
+
+ + + onChange({ ...overflow, visibleRows: Number(e.target.value) || 5 }) + } + className="mt-1 h-8 text-xs" + /> +
+
+ + + onChange({ ...overflow, showExpandButton }) + } + /> +
+ {overflow.showExpandButton && ( +
+ + + onChange({ + ...overflow, + maxExpandRows: Number(e.target.value) || 20, + }) + } + className="mt-1 h-8 text-xs" + /> +
+ )} +
+ ); +} + +// ===== STEP 3: 데이터 선택 (테이블 + 컬럼 통합) ===== + +function StepDataSelect({ + tables, + tableName, + onTableChange, + columns, + selectedColumns, + onColumnsChange, +}: { + tables: TableInfo[]; + tableName: string; + onTableChange: (tableName: string) => void; + columns: ColumnInfo[]; + selectedColumns: string[]; + onColumnsChange: (selected: string[]) => void; +}) { + const [open, setOpen] = useState(false); + + const selectedDisplay = tableName + ? tables.find((t) => t.tableName === tableName)?.displayName || tableName + : ""; + + const toggleColumn = (colName: string) => { + if (selectedColumns.includes(colName)) { + onColumnsChange(selectedColumns.filter((c) => c !== colName)); + } else { + onColumnsChange([...selectedColumns, colName]); + } + }; + + return ( +
+ {/* 테이블 선택 */} +
+ + + + + + + + + + + 검색 결과가 없습니다 + + + { + onTableChange(""); + setOpen(false); + }} + className="text-xs" + > + + 선택 안 함 + + {tables.map((t) => ( + { + onTableChange(t.tableName); + setOpen(false); + }} + className="text-xs" + > + +
+ {t.displayName || t.tableName} + {t.displayName && t.displayName !== t.tableName && ( + + {t.tableName} + + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 컬럼 선택 (테이블 선택 후 표시) */} + {tableName && columns.length > 0 && ( +
+ +
+ {columns.map((col) => ( + + ))} +
+
+ )} + + {tableName && columns.length === 0 && ( +

+ 컬럼 로딩 중... +

+ )} +
+ ); +} + +// ===== STEP 4: 조인 설정 (UX 개선 - 자동매칭 + 타입필터링) ===== + +// DB 타입을 짧은 약어로 변환 +const shortType = (t: string): string => { + const lower = t.toLowerCase(); + if (lower.includes("character varying") || lower === "varchar") return "varchar"; + if (lower === "text") return "text"; + if (lower.includes("timestamp")) return "timestamp"; + if (lower === "integer" || lower === "int4") return "int"; + if (lower === "bigint" || lower === "int8") return "bigint"; + if (lower === "numeric" || lower === "decimal") return "numeric"; + if (lower === "boolean" || lower === "bool") return "bool"; + if (lower === "date") return "date"; + if (lower === "uuid") return "uuid"; + if (lower === "jsonb" || lower === "json") return "json"; + return t.length > 12 ? t.slice(0, 10) + ".." : t; +}; + +// 조인 항목 하나를 관리하는 서브 컴포넌트 +function JoinItem({ + join, + index, + tables, + mainColumns, + mainTableName, + onUpdate, + onRemove, +}: { + join: CardColumnJoin; + index: number; + tables: TableInfo[]; + mainColumns: ColumnInfo[]; + mainTableName: string; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + const [targetColumns, setTargetColumns] = useState([]); + const [tableOpen, setTableOpen] = useState(false); + const [loading, setLoading] = useState(false); + + // 대상 테이블 변경 시 컬럼 로딩 + useEffect(() => { + if (!join.targetTable) { + setTargetColumns([]); + return; + } + setLoading(true); + fetchTableColumns(join.targetTable) + .then(setTargetColumns) + .catch(() => setTargetColumns([])) + .finally(() => setLoading(false)); + }, [join.targetTable]); + + // 자동 매칭: 이름 + 타입이 모두 같은 컬럼 쌍 찾기 + const autoMatches = mainColumns.filter((mc) => + targetColumns.some((tc) => tc.name === mc.name && tc.type === mc.type) + ); + + // 현재 연결된 쌍이 자동매칭 항목인지 확인 + const isAutoMatch = + join.sourceColumn !== "" && + join.sourceColumn === join.targetColumn && + autoMatches.some((m) => m.name === join.sourceColumn); + + // 수동 매칭: 소스 컬럼 선택 시 같은 타입의 대상 컬럼만 필터 + const compatibleTargetCols = join.sourceColumn + ? targetColumns.filter((tc) => { + const srcCol = mainColumns.find((mc) => mc.name === join.sourceColumn); + return srcCol ? tc.type === srcCol.type : true; + }) + : targetColumns; + + // 메인 테이블 제외한 테이블 목록 + const selectableTables = tables.filter((t) => t.tableName !== mainTableName); + + // 연결 조건이 설정되었는지 여부 + const hasJoinCondition = join.sourceColumn !== "" && join.targetColumn !== ""; + + // 선택된 대상 컬럼 관리 (연결 조건 컬럼은 제외한 나머지) + const selectedTargetCols = join.selectedTargetColumns || []; + + // 가져올 수 있는 대상 컬럼 (연결 조건으로 사용된 컬럼 제외) + const pickableTargetCols = targetColumns.filter( + (tc) => tc.name !== join.targetColumn + ); + + const toggleTargetCol = (colName: string) => { + const prev = selectedTargetCols; + const next = prev.includes(colName) + ? prev.filter((c) => c !== colName) + : [...prev, colName]; + onUpdate({ selectedTargetColumns: next }); + }; + + return ( +
+ {/* 헤더 */} +
+ 연결 #{index + 1} + +
+ + {/* 대상 테이블 선택 (검색 가능 Combobox) */} +
+ 연결할 테이블 + + + + + + + + + + 테이블을 찾을 수 없습니다 + + + {selectableTables.map((t) => ( + { + onUpdate({ + targetTable: t.tableName, + sourceColumn: "", + targetColumn: "", + selectedTargetColumns: [], + }); + setTableOpen(false); + }} + className="text-[10px]" + > + +
+ {t.tableName} + {(t.displayName || t.description) && ( + + {t.displayName || t.description} + + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 대상 테이블 선택 후 컬럼 매칭 영역 */} + {join.targetTable && ( + <> + {loading ? ( +

컬럼 불러오는 중...

+ ) : ( + <> + {/* 자동 매칭 결과 - 테이블 헤더 + 컬럼명만 표시 */} + {autoMatches.length > 0 && ( +
+ + 연결 조건 선택 + + {/* 테이블명 헤더 */} +
+
+ {mainTableName} + + {join.targetTable} + +
+ {/* 매칭 행 */} +
+ {autoMatches.map((mc) => { + const isSelected = + join.sourceColumn === mc.name && join.targetColumn === mc.name; + return ( + + ); + })} +
+
+ )} + + {autoMatches.length === 0 && ( +

+ 이름이 같은 컬럼이 없습니다. 아래에서 직접 지정하세요. +

+ )} + + {/* 수동 매칭 (고급) */} + {!isAutoMatch && ( +
+ + 직접 지정 + +
+ + + = + + +
+
+ )} + + )} + + {/* 표시 방식 (JOIN 타입) - 자연어 + 설명 */} +
+ 표시 방식 +
+ + +
+
+ + {/* 가져올 컬럼 선택 (연결 조건 설정 후 활성화) */} + {hasJoinCondition && !loading && ( +
+ + 가져올 컬럼 ({selectedTargetCols.length}개 선택) + + {pickableTargetCols.length > 0 ? ( +
+ {pickableTargetCols.map((tc) => { + const isChecked = selectedTargetCols.includes(tc.name); + return ( + + ); + })} +
+ ) : ( +

+ 가져올 수 있는 컬럼이 없습니다 +

+ )} +
+ )} + + )} +
+ ); +} + +function StepJoinConfig({ + dataSource, + tables, + mainColumns, + onChange, +}: { + dataSource: CardListDataSource; + tables: TableInfo[]; + mainColumns: ColumnInfo[]; + onChange: (dataSource: CardListDataSource) => void; +}) { + const joins = dataSource.joins || []; + + const addJoin = () => { + const newJoin: CardColumnJoin = { + targetTable: "", + joinType: "LEFT", + sourceColumn: "", + targetColumn: "", + }; + onChange({ ...dataSource, joins: [...joins, newJoin] }); + }; + + const removeJoin = (index: number) => { + const next = joins.filter((_, i) => i !== index); + onChange({ ...dataSource, joins: next }); + }; + + const updateJoin = (index: number, partial: Partial) => { + const next = joins.map((j, i) => + i === index ? { ...j, ...partial } : j + ); + onChange({ ...dataSource, joins: next }); + }; + + return ( +
+

+ 다른 테이블의 데이터를 연결하여 함께 표시할 수 있습니다 (선택사항) +

+ {joins.map((join, i) => ( + updateJoin(i, partial)} + onRemove={() => removeJoin(i)} + /> + ))} + +
+ ); +} + +// ===== STEP 6-A: 리스트 컬럼 배치 ===== + +// 조인 테이블 컬럼 정보 +interface JoinedColumnInfo { + name: string; // "테이블명.컬럼명" 형태 + displayName: string; // 컬럼명만 + sourceTable: string; // 테이블명 +} + +function StepListLayout({ + listColumns, + availableColumns, + joinedColumns, + onChange, +}: { + listColumns: ListColumnConfig[]; + availableColumns: ColumnInfo[]; + joinedColumns: JoinedColumnInfo[]; + onChange: (listColumns: ListColumnConfig[]) => void; +}) { + const widthBarRef = useRef(null); + const isDraggingRef = useRef(false); + const columnsRef = useRef(listColumns); + columnsRef.current = listColumns; + const [dragIdx, setDragIdx] = useState(null); + const [dragOverIdx, setDragOverIdx] = useState(null); + // 드래그 핸들에서만 draggable 활성화 (Select/Input 충돌 방지) + const [draggableRow, setDraggableRow] = useState(null); + // 컬럼 전환 설정 펼침 인덱스 + const [expandedAltIdx, setExpandedAltIdx] = useState(null); + + const updateColumn = (index: number, partial: Partial) => { + const next = listColumns.map((col, i) => + i === index ? { ...col, ...partial } : col + ); + onChange(next); + }; + + // 너비 드래그 핸들러 + const handleWidthDrag = useCallback( + (e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startX = e.clientX; + const bar = widthBarRef.current; + if (!bar) return; + const barWidth = bar.offsetWidth; + if (barWidth === 0) return; + const cols = columnsRef.current; + const startFrs = cols.map((c) => { + const num = parseFloat(c.width || "1"); + return isNaN(num) || num <= 0 ? 1 : num; + }); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const frDelta = (delta / barWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[dividerIndex] = Math.max(0.3, startFrs[dividerIndex] + frDelta); + newFrs[dividerIndex + 1] = Math.max( + 0.3, + startFrs[dividerIndex + 1] - frDelta + ); + const next = columnsRef.current.map((col, i) => ({ + ...col, + width: `${Math.round(newFrs[i] * 10) / 10}fr`, + })); + onChange(next); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // 순서 드래그앤드롭 - 핸들에서 mousedown 시에만 draggable 활성화 + const handleDragStart = (e: React.DragEvent, idx: number) => { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + setDragIdx(idx); + }; + + const handleDragOver = (e: React.DragEvent, idx: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverIdx(idx); + }; + + const handleDrop = (e: React.DragEvent, idx: number) => { + e.preventDefault(); + if (dragIdx === null || dragIdx === idx) { + setDragIdx(null); + setDragOverIdx(null); + setDraggableRow(null); + return; + } + const next = [...listColumns]; + const [moved] = next.splice(dragIdx, 1); + next.splice(idx, 0, moved); + onChange(next); + setDragIdx(null); + setDragOverIdx(null); + setDraggableRow(null); + }; + + const handleDragEnd = () => { + setDragIdx(null); + setDragOverIdx(null); + setDraggableRow(null); + }; + + if (listColumns.length === 0) { + return ( +

+ 컬럼을 먼저 선택하세요 +

+ ); + } + + return ( +
+ {/* 컬럼 너비 드래그 바 */} +
+ {listColumns.map((col, i) => { + const fr = parseFloat(col.width || "1") || 1; + return ( + +
+ {col.label || col.columnName} +
+ {i < listColumns.length - 1 && ( +
handleWidthDrag(e, i)} + title="드래그하여 너비 조정" + /> + )} + + ); + })} +
+ + {/* 컬럼별 설정 (드래그 순서 + 컬럼 선택 + 라벨 + 정렬) */} +
+ {listColumns.map((col, i) => ( + +
handleDragStart(e, i)} + onDragOver={(e) => handleDragOver(e, i)} + onDrop={(e) => handleDrop(e, i)} + onDragEnd={handleDragEnd} + className={cn( + "flex items-center gap-1 rounded px-1 py-0.5 transition-colors", + dragIdx === i && "opacity-40", + dragOverIdx === i && dragIdx !== i && "bg-primary/10 border-t-2 border-primary" + )} + > + {/* 드래그 핸들 - mousedown 시에만 행 draggable 활성화 */} +
setDraggableRow(i)} + onMouseUp={() => setDraggableRow(null)} + > +
+
+
+
+
+
+ + {/* 컬럼 선택 드롭다운 (메인 + 조인 테이블 컬럼) */} + + + {/* 라벨 */} + updateColumn(i, { label: e.target.value })} + placeholder="라벨" + className="h-7 flex-1 text-[10px]" + /> + + {/* 정렬 */} + + + {/* 컬럼 전환 버튼 (조인 컬럼 있을 때만) */} + {joinedColumns.length > 0 && ( + + )} +
+ + {/* 전환 가능 컬럼 (펼침 시만 표시, 조인 컬럼만) */} + {expandedAltIdx === i && joinedColumns.length > 0 && ( +
+ 전환: + {joinedColumns.map((jc) => { + const alts = col.alternateColumns || []; + const isAlt = alts.includes(jc.name); + return ( + + ); + })} +
+ )} + + ))} +
+ +

+ 행을 드래그하여 순서 변경 | 상단 바 경계를 드래그하여 너비 조정 +

+
+ ); +} + +// ===== STEP 6-B: 시각적 카드 그리드 디자이너 ===== + +// fr 문자열을 숫자로 파싱 (예: "2fr" -> 2, "1fr" -> 1) +const parseFr = (v: string): number => { + const num = parseFloat(v); + return isNaN(num) || num <= 0 ? 1 : num; +}; + +// 카드 그리드 반응형 안전 제약 +// - 6열 초과: 모바일(320px)에서 셀 30px 미만 → 텍스트 깨짐 +// - 6행 초과: 카드 1장 높이 과도 → 스크롤 과다 +// - gap 16px 초과: 셀 공간 부족 +// - fr 0.3 미만: 셀 보이지 않음 +const GRID_LIMITS = { + cols: { min: 1, max: 6 }, + rows: { min: 1, max: 6 }, + gap: { min: 0, max: 16 }, + minFr: 0.3, +} as const; + +// 행 높이 기본값 (px 기반 고정 높이) +const DEFAULT_ROW_HEIGHT = 32; +const MIN_ROW_HEIGHT = 24; + +// px 문자열에서 숫자 추출 (예: "32px" → 32) +const parsePx = (v: string): number => { + const num = parseInt(v); + return isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num; +}; + +// fr → px 마이그레이션 (기존 저장 데이터 호환) +const migrateRowHeight = (v: string): string => { + if (!v || v.endsWith("fr")) { + return `${Math.round(parseFr(v) * DEFAULT_ROW_HEIGHT)}px`; + } + if (v.endsWith("px")) return v; + // 단위 없는 숫자인 경우 + const num = parseInt(v); + return `${isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num}px`; +}; + +function StepCardDesigner({ + cardGrid, + columns, + selectedColumns, + onChange, +}: { + cardGrid: CardGridConfig | undefined; + columns: ColumnInfo[]; + selectedColumns: string[]; + onChange: (cardGrid: CardGridConfig) => void; +}) { + // 셀에서 컬럼 선택 시 사용자가 선택한 컬럼만 표시 + const availableColumns = columns.filter((c) => + selectedColumns.includes(c.name) + ); + const [selectedCellId, setSelectedCellId] = useState(null); + const [mergeMode, setMergeMode] = useState(false); + const [mergeCellKeys, setMergeCellKeys] = useState>(new Set()); + const widthBarRef = useRef(null); + const rowBarRef = useRef(null); + const gridRef = useRef(null); + const gridConfigRef = useRef(undefined); + const isDraggingRef = useRef(false); + const [gridLines, setGridLines] = useState<{ + colLines: number[]; + rowLines: number[]; + }>({ colLines: [], rowLines: [] }); + + // 기본 카드 그리드 (rowHeights는 px 기반 고정 높이) + const rawGrid: CardGridConfig = cardGrid || { + rows: 1, + cols: 1, + colWidths: ["1fr"], + rowHeights: [`${DEFAULT_ROW_HEIGHT}px`], + gap: 4, + showBorder: true, + cells: [], + }; + + // 기존 fr 데이터 → px 자동 마이그레이션 + 길이 정규화 + const migratedRowHeights = ( + rawGrid.rowHeights || Array(rawGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`) + ).map(migrateRowHeight); + + // colWidths/rowHeights 배열 길이와 cols/rows 수 불일치 보정 + const safeColWidths = rawGrid.colWidths || []; + const normalizedColWidths = + safeColWidths.length >= rawGrid.cols + ? safeColWidths.slice(0, rawGrid.cols) + : [ + ...safeColWidths, + ...Array(rawGrid.cols - safeColWidths.length).fill("1fr"), + ]; + const normalizedRowHeights = + migratedRowHeights.length >= rawGrid.rows + ? migratedRowHeights.slice(0, rawGrid.rows) + : [ + ...migratedRowHeights, + ...Array(rawGrid.rows - migratedRowHeights.length).fill( + `${DEFAULT_ROW_HEIGHT}px` + ), + ]; + + const grid: CardGridConfig = { + ...rawGrid, + colWidths: normalizedColWidths, + rowHeights: normalizedRowHeights, + }; + + gridConfigRef.current = grid; + + const updateGrid = (partial: Partial) => { + onChange({ ...grid, ...partial }); + }; + + // ---- 점유 맵 ---- + + const buildOccupationMap = (): Record => { + const map: Record = {}; + grid.cells.forEach((cell) => { + const rs = Number(cell.rowSpan) || 1; + const cs = Number(cell.colSpan) || 1; + for (let r = cell.row; r < cell.row + rs; r++) { + for (let c = cell.col; c < cell.col + cs; c++) { + map[`${r}-${c}`] = cell.id; + } + } + }); + return map; + }; + + const occupationMap = buildOccupationMap(); + + const getCellByOrigin = (r: number, c: number) => + grid.cells.find((cell) => cell.row === r && cell.col === c); + + // ---- 셀 CRUD ---- + + const addCellAt = (row: number, col: number) => { + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row, + col, + rowSpan: 1, + colSpan: 1, + columnName: "", + type: "text", + }; + updateGrid({ cells: [...grid.cells, newCell] }); + setSelectedCellId(newCell.id); + }; + + const removeCell = (id: string) => { + updateGrid({ cells: grid.cells.filter((c) => c.id !== id) }); + if (selectedCellId === id) setSelectedCellId(null); + }; + + const updateCell = (id: string, partial: Partial) => { + updateGrid({ + cells: grid.cells.map((c) => (c.id === id ? { ...c, ...partial } : c)), + }); + }; + + // ---- 병합 모드 ---- + + const toggleMergeMode = () => { + if (mergeMode) { + setMergeMode(false); + setMergeCellKeys(new Set()); + } else { + setMergeMode(true); + setMergeCellKeys(new Set()); + setSelectedCellId(null); + } + }; + + const toggleMergeCell = (row: number, col: number) => { + const key = `${row}-${col}`; + if (occupationMap[key]) return; // 점유된 위치 무시 + const next = new Set(mergeCellKeys); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + setMergeCellKeys(next); + }; + + const validateMergeSelection = (): { + minRow: number; + maxRow: number; + minCol: number; + maxCol: number; + } | null => { + if (mergeCellKeys.size < 2) return null; + const positions = Array.from(mergeCellKeys).map((key) => { + const [r, c] = key.split("-").map(Number); + return { row: r, col: c }; + }); + const minRow = Math.min(...positions.map((p) => p.row)); + const maxRow = Math.max(...positions.map((p) => p.row)); + const minCol = Math.min(...positions.map((p) => p.col)); + const maxCol = Math.max(...positions.map((p) => p.col)); + const expectedCount = (maxRow - minRow + 1) * (maxCol - minCol + 1); + if (mergeCellKeys.size !== expectedCount) return null; + for (const key of mergeCellKeys) { + if (occupationMap[key]) return null; + } + return { minRow, maxRow, minCol, maxCol }; + }; + + const confirmMerge = () => { + const bbox = validateMergeSelection(); + if (!bbox) return; + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: bbox.minRow, + col: bbox.minCol, + rowSpan: bbox.maxRow - bbox.minRow + 1, + colSpan: bbox.maxCol - bbox.minCol + 1, + columnName: "", + type: "text", + }; + updateGrid({ cells: [...grid.cells, newCell] }); + setSelectedCellId(newCell.id); + setMergeMode(false); + setMergeCellKeys(new Set()); + }; + + const cancelMerge = () => { + setMergeMode(false); + setMergeCellKeys(new Set()); + }; + + const mergeValid = validateMergeSelection(); + + // ---- 셀 분할 ---- + + // 칸 나누기 (좌/우 분할 = 열 방향) + const splitCellHorizontally = (cell: CardCellDefinition) => { + const cs = Number(cell.colSpan) || 1; + const rs = Number(cell.rowSpan) || 1; + + if (cs >= 2) { + // colSpan 2 이상: 그리드 변경 없이 셀만 분할 + const leftSpan = Math.ceil(cs / 2); + const rightSpan = cs - leftSpan; + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: cell.row, + col: cell.col + leftSpan, + rowSpan: rs, + colSpan: rightSpan, + columnName: "", + type: "text", + }; + const updatedCells = grid.cells.map((c) => + c.id === cell.id ? { ...c, colSpan: leftSpan } : c + ); + updateGrid({ cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } else { + // colSpan 1: 새 열 삽입하여 분할 + if (grid.cols >= GRID_LIMITS.cols.max) return; + const insertPos = cell.col + 1; + const updatedCells = grid.cells.map((c) => { + if (c.id === cell.id) return c; // 원본 유지 + const cEnd = c.col + (Number(c.colSpan) || 1) - 1; + if (c.col >= insertPos) return { ...c, col: c.col + 1 }; + if (cEnd >= insertPos) + return { ...c, colSpan: (Number(c.colSpan) || 1) + 1 }; + return c; + }); + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: cell.row, + col: insertPos, + rowSpan: rs, + colSpan: 1, + columnName: "", + type: "text", + }; + // 열 너비: 기존 열을 반으로 분할 + const colIdx = cell.col - 1; + if (colIdx < 0 || colIdx >= grid.colWidths.length) return; // 범위 초과 방어 + const currentFr = parseFr(grid.colWidths[colIdx]); + const halfFr = Math.max(GRID_LIMITS.minFr, currentFr / 2); + const frStr = `${Math.round(halfFr * 10) / 10}fr`; + const newWidths = [...grid.colWidths]; + newWidths[colIdx] = frStr; + newWidths.splice(colIdx + 1, 0, frStr); + updateGrid({ + cols: grid.cols + 1, + colWidths: newWidths, + cells: [...updatedCells, newCell], + }); + setSelectedCellId(newCell.id); + } + }; + + // 줄 나누기 (위/아래 분할 = 행 방향) + const splitCellVertically = (cell: CardCellDefinition) => { + const rs = Number(cell.rowSpan) || 1; + const cs = Number(cell.colSpan) || 1; + const heights = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); + + if (rs >= 2) { + // rowSpan 2 이상: 그리드 변경 없이 셀만 분할 + const topSpan = Math.ceil(rs / 2); + const bottomSpan = rs - topSpan; + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: cell.row + topSpan, + col: cell.col, + rowSpan: bottomSpan, + colSpan: cs, + columnName: "", + type: "text", + }; + const updatedCells = grid.cells.map((c) => + c.id === cell.id ? { ...c, rowSpan: topSpan } : c + ); + updateGrid({ cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } else { + // rowSpan 1: 새 행 삽입하여 분할 (기존 행 높이 유지, 새 행은 기본 높이) + if (grid.rows >= GRID_LIMITS.rows.max) return; + const insertPos = cell.row + 1; + const updatedCells = grid.cells.map((c) => { + if (c.id === cell.id) return c; + const cEnd = c.row + (Number(c.rowSpan) || 1) - 1; + if (c.row >= insertPos) return { ...c, row: c.row + 1 }; + if (cEnd >= insertPos) + return { ...c, rowSpan: (Number(c.rowSpan) || 1) + 1 }; + return c; + }); + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: insertPos, + col: cell.col, + rowSpan: 1, + colSpan: cs, + columnName: "", + type: "text", + }; + // 기존 행 높이 유지, 새 행은 기본 px 높이로 삽입 + const rowIdx = cell.row - 1; + const newHeights = [...heights]; + newHeights.splice(rowIdx + 1, 0, `${DEFAULT_ROW_HEIGHT}px`); + updateGrid({ + rows: grid.rows + 1, + rowHeights: newHeights, + cells: [...updatedCells, newCell], + }); + setSelectedCellId(newCell.id); + } + }; + + // ---- 클릭 핸들러 ---- + + const handleEmptyCellClick = (row: number, col: number) => { + if (mergeMode) { + toggleMergeCell(row, col); + } else { + addCellAt(row, col); + } + }; + + const handleCellClick = (cell: CardCellDefinition) => { + if (mergeMode) return; // 병합 모드에서 기존 셀 클릭 무시 + setSelectedCellId(selectedCellId === cell.id ? null : cell.id); + }; + + // ---- 열 너비 드래그 (상단 바 - 일괄) ---- + + const handleColDragStart = useCallback( + (e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startX = e.clientX; + const bar = widthBarRef.current; + if (!bar) return; + const barWidth = bar.offsetWidth; + if (barWidth === 0) return; // 0으로 나누기 방어 + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const startFrs = (currentGrid.colWidths || []).map(parseFr); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const frDelta = (delta / barWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[dividerIndex] = Math.max( + GRID_LIMITS.minFr, + startFrs[dividerIndex] + frDelta + ); + newFrs[dividerIndex + 1] = Math.max( + GRID_LIMITS.minFr, + startFrs[dividerIndex + 1] - frDelta + ); + const newWidths = newFrs.map( + (fr) => `${Math.round(fr * 10) / 10}fr` + ); + onChange({ ...currentGrid, colWidths: newWidths }); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // ---- 행 높이 드래그 (좌측 바 - 일괄) ---- + + const handleRowDragStart = useCallback( + (e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startY = e.clientY; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + // px 기반: 픽셀 델타를 직접 적용 (fr 변환 불필요 → 안정적) + const heights = ( + currentGrid.rowHeights || + Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`) + ).map(parsePx); + if (dividerIndex < 0 || dividerIndex + 1 >= heights.length) return; + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientY - startY; + const newHeights = [...heights]; + newHeights[dividerIndex] = Math.max( + MIN_ROW_HEIGHT, + heights[dividerIndex] + delta + ); + newHeights[dividerIndex + 1] = Math.max( + MIN_ROW_HEIGHT, + heights[dividerIndex + 1] - delta + ); + const newRowHeights = newHeights.map((h) => `${Math.round(h)}px`); + onChange({ ...currentGrid, rowHeights: newRowHeights }); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // ---- 내부 셀 경계 드래그 (개별) ---- + + // 그리드 라인 위치 측정 (ResizeObserver) + useEffect(() => { + const gridEl = gridRef.current; + if (!gridEl) return; + + const measure = () => { + if (isDraggingRef.current) return; + const style = window.getComputedStyle(gridEl); + const colSizes = style.gridTemplateColumns + .split(" ") + .map(parseFloat) + .filter((v) => !isNaN(v)); + const rowSizes = style.gridTemplateRows + .split(" ") + .map(parseFloat) + .filter((v) => !isNaN(v)); + const gapSize = + parseFloat(style.gap) || parseFloat(style.columnGap) || 0; + + const colLines: number[] = []; + let x = 0; + for (let i = 0; i < colSizes.length - 1; i++) { + x += colSizes[i] + gapSize; + colLines.push(x - gapSize / 2); + } + + const rowLines: number[] = []; + let y = 0; + for (let i = 0; i < rowSizes.length - 1; i++) { + y += rowSizes[i] + gapSize; + rowLines.push(y - gapSize / 2); + } + + setGridLines({ colLines, rowLines }); + }; + + const observer = new ResizeObserver(measure); + observer.observe(gridEl); + measure(); + + return () => observer.disconnect(); + // 배열 참조가 매 렌더 변경되므로, join으로 안정적인 값 비교 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [grid.colWidths.join(","), grid.rowHeights?.join(","), grid.gap, grid.cols, grid.rows]); + + // 경계선 가시성 (병합 셀 내부는 숨김) + const isColLineVisible = (lineIdx: number): boolean => { + const leftCol = lineIdx + 1; + const rightCol = lineIdx + 2; + for (let r = 1; r <= grid.rows; r++) { + const left = occupationMap[`${r}-${leftCol}`]; + const right = occupationMap[`${r}-${rightCol}`]; + if (left !== right) return true; + if (!left && !right) return true; + } + return false; + }; + + const isRowLineVisible = (lineIdx: number): boolean => { + const topRow = lineIdx + 1; + const bottomRow = lineIdx + 2; + for (let c = 1; c <= grid.cols; c++) { + const top = occupationMap[`${topRow}-${c}`]; + const bottom = occupationMap[`${bottomRow}-${c}`]; + if (top !== bottom) return true; + if (!top && !bottom) return true; + } + return false; + }; + + // 내부 열 경계 드래그 + const handleInternalColDrag = useCallback( + (e: React.MouseEvent, lineIdx: number) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingRef.current = true; + const startX = e.clientX; + const gridEl = gridRef.current; + if (!gridEl) return; + const gridWidth = gridEl.offsetWidth; + if (gridWidth === 0) return; // 0으로 나누기 방어 + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const startFrs = (currentGrid.colWidths || []).map(parseFr); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const frDelta = (delta / gridWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[lineIdx] = Math.max( + GRID_LIMITS.minFr, + startFrs[lineIdx] + frDelta + ); + newFrs[lineIdx + 1] = Math.max( + GRID_LIMITS.minFr, + startFrs[lineIdx + 1] - frDelta + ); + const newWidths = newFrs.map( + (fr) => `${Math.round(fr * 10) / 10}fr` + ); + onChange({ ...currentGrid, colWidths: newWidths }); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // 내부 행 경계 드래그 (px 기반 직접 조정) + const handleInternalRowDrag = useCallback( + (e: React.MouseEvent, lineIdx: number) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingRef.current = true; + const startY = e.clientY; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + // px 기반: 픽셀 델타를 직접 적용 + const heights = ( + currentGrid.rowHeights || + Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`) + ).map(parsePx); + if (lineIdx < 0 || lineIdx + 1 >= heights.length) return; + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientY - startY; + const newHeights = [...heights]; + newHeights[lineIdx] = Math.max( + MIN_ROW_HEIGHT, + heights[lineIdx] + delta + ); + newHeights[lineIdx + 1] = Math.max( + MIN_ROW_HEIGHT, + heights[lineIdx + 1] - delta + ); + const newRowHeights = newHeights.map((h) => `${Math.round(h)}px`); + onChange({ ...currentGrid, rowHeights: newRowHeights }); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // ---- 선택된 셀 ---- + + const selectedCell = selectedCellId + ? grid.cells.find((c) => c.id === selectedCellId) + : null; + + useEffect(() => { + if (selectedCellId && !grid.cells.find((c) => c.id === selectedCellId)) { + setSelectedCellId(null); + } + }, [grid.cells, selectedCellId]); + + // ---- 그리드 위치 ---- + + const gridPositions: { row: number; col: number }[] = []; + for (let r = 1; r <= grid.rows; r++) { + for (let c = 1; c <= grid.cols; c++) { + gridPositions.push({ row: r, col: c }); + } + } + + const rowHeightsArr = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); + + // ---- 바 그룹핑 (병합 셀 내부 경계는 하나로 묶음) ---- + + type BarGroup = { startIdx: number; count: number; totalFr: number }; + + const colGroups: BarGroup[] = (() => { + const groups: BarGroup[] = []; + if (grid.colWidths.length === 0) return groups; // 빈 배열 방어 + let cur: BarGroup = { + startIdx: 0, + count: 1, + totalFr: parseFr(grid.colWidths[0]), + }; + for (let i = 0; i < grid.cols - 1; i++) { + if (isColLineVisible(i)) { + groups.push(cur); + cur = { + startIdx: i + 1, + count: 1, + totalFr: parseFr(grid.colWidths[i + 1]), + }; + } else { + cur.count++; + cur.totalFr += parseFr(grid.colWidths[i + 1]); + } + } + groups.push(cur); + return groups; + })(); + + const rowGroups: BarGroup[] = (() => { + const groups: BarGroup[] = []; + if (rowHeightsArr.length === 0) return groups; // 빈 배열 방어 + // totalFr 필드를 px 값의 합산으로 사용 (flex 비율로 활용) + let cur: BarGroup = { + startIdx: 0, + count: 1, + totalFr: parsePx(rowHeightsArr[0]), + }; + for (let i = 0; i < grid.rows - 1; i++) { + if (isRowLineVisible(i)) { + groups.push(cur); + cur = { + startIdx: i + 1, + count: 1, + totalFr: parsePx(rowHeightsArr[i + 1]), + }; + } else { + cur.count++; + cur.totalFr += parsePx(rowHeightsArr[i + 1]); + } + } + groups.push(cur); + return groups; + })(); + + return ( +
+ {/* 인라인 툴바: 보더 + 간격 + 병합 + 나누기 */} +
+
+ +
+ 간격 + + {grid.gap}px + +
+
+ +
+ + +
+ + {/* 병합 모드 안내 */} + {mergeMode && ( +
+ + {mergeCellKeys.size > 0 + ? `${mergeCellKeys.size}칸 선택됨${mergeValid ? " (병합 가능)" : " (직사각형으로 선택)"}` + : "빈 셀을 클릭하여 선택"} + + + +
+ )} + + {/* 열 너비 드래그 바 (일괄 조정, 병합 트랙 묶음) */} +
+
+
+ {colGroups.map((group, gi) => ( + +
+ {group.count > 1 + ? `${Math.round(group.totalFr * 10) / 10}fr` + : grid.colWidths[group.startIdx]} +
+ {gi < colGroups.length - 1 && ( +
+ handleColDragStart(e, group.startIdx + group.count - 1) + } + title="드래그하여 열 너비 일괄 조정" + /> + )} + + ))} +
+
+ + {/* 메인: 행 높이 바 (왼쪽) + 그리드 (오른쪽) */} +
+ {/* 행 높이 드래그 바 (일괄 조정, 병합 트랙 묶음) */} +
+ {rowGroups.map((group, gi) => ( + +
1 + ? `${Math.round(group.totalFr)}px` + : rowHeightsArr[group.startIdx] + } + > + {Math.round(group.totalFr)} +
+ {gi < rowGroups.length - 1 && ( +
+ handleRowDragStart(e, group.startIdx + group.count - 1) + } + title="드래그하여 행 높이 일괄 조정" + /> + )} + + ))} +
+ + {/* 인터랙티브 그리드 + 내부 드래그 오버레이 */} +
+
0 + ? grid.colWidths + .map((w) => `minmax(30px, ${w})`) + .join(" ") + : "1fr", + gridTemplateRows: rowHeightsArr.join(" "), + gap: `${Number(grid.gap) || 0}px`, + }} + > + {gridPositions.map(({ row, col }) => { + const cellAtOrigin = getCellByOrigin(row, col); + const occupiedBy = occupationMap[`${row}-${col}`]; + const isMergeSelected = mergeCellKeys.has(`${row}-${col}`); + + // span으로 점유된 위치 + if (occupiedBy && !cellAtOrigin) return null; + + // 셀 원점 + if (cellAtOrigin) { + const isSelected = selectedCellId === cellAtOrigin.id; + return ( +
handleCellClick(cellAtOrigin)} + > +
+ + {cellAtOrigin.columnName || "미지정"} + + + {cellAtOrigin.type} + +
+
+ ); + } + + // 빈 위치 + return ( +
handleEmptyCellClick(row, col)} + title={ + mergeMode ? "클릭하여 병합 선택" : "클릭하여 셀 추가" + } + > + {isMergeSelected ? ( + + ) : ( + + )} +
+ ); + })} +
+ + {/* 내부 경계 드래그 오버레이 (개별 조정) */} +
+ {gridLines.colLines.map((x, i) => { + if (!isColLineVisible(i)) return null; + return ( +
handleInternalColDrag(e, i)} + title="드래그하여 열 너비 개별 조정" + /> + ); + })} + {gridLines.rowLines.map((y, i) => { + if (!isRowLineVisible(i)) return null; + return ( +
handleInternalRowDrag(e, i)} + title="드래그하여 행 높이 개별 조정" + /> + ); + })} +
+
+
+ + {/* 선택된 셀 설정 패널 */} + {selectedCell && !mergeMode && ( +
+
+ + 셀 (행{selectedCell.row} 열{selectedCell.col} + {((Number(selectedCell.colSpan) || 1) > 1 || + (Number(selectedCell.rowSpan) || 1) > 1) && + `, ${Number(selectedCell.colSpan) || 1}x${Number(selectedCell.rowSpan) || 1}`} + ) + + +
+ + {/* 컬럼 + 타입 */} +
+ + +
+ + {/* 라벨 + 라벨 위치 */} +
+ + updateCell(selectedCell.id, { label: e.target.value }) + } + placeholder="라벨 (선택)" + className="h-7 flex-1 text-[10px]" + /> + +
+ + {/* 글자 크기 + 가로 정렬 + 세로 정렬 */} +
+ + + +
+ +
+ )} + + {/* 반응형 안내 */} +

+ {grid.cols}열 x {grid.rows}행 (최대 {GRID_LIMITS.cols.max}x + {GRID_LIMITS.rows.max}) | 상단/좌측: 일괄 | 셀 경계: 개별 조정 +

+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListPreview.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListPreview.tsx new file mode 100644 index 00000000..26e7cc67 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListPreview.tsx @@ -0,0 +1,176 @@ +"use client"; + +/** + * pop-string-list 디자이너 미리보기 + * + * 디자인 모드에서 캔버스에 표시되는 간략한 미리보기. + * 실제 데이터는 가져오지 않고 더미 데이터로 레이아웃만 시각화. + */ + +import type { PopStringListConfig } from "./types"; + +interface PopStringListPreviewProps { + config?: PopStringListConfig; +} + +export function PopStringListPreviewComponent({ + config, +}: PopStringListPreviewProps) { + const displayMode = config?.displayMode || "list"; + const header = config?.header; + const tableName = config?.dataSource?.tableName; + const listColumns = config?.listColumns || []; + const cardGrid = config?.cardGrid; + + // 테이블 미선택 + if (!tableName) { + return ( +
+ + 테이블을 선택하세요 + +
+ ); + } + + return ( +
+ {/* 헤더 */} + {header?.enabled && ( +
+ + {header.label || "리스트 목록"} + +
+ )} + + {/* 모드별 미리보기 */} +
+ {displayMode === "list" ? ( + + ) : ( + + )} +
+ + {/* 모드 라벨 */} +
+ + {displayMode === "list" ? "리스트" : "카드"} | {tableName} + +
+
+ ); +} + +// ===== 리스트 미리보기 ===== + +function ListPreview({ + columns, +}: { + columns: PopStringListConfig["listColumns"]; +}) { + const cols = columns || []; + + if (cols.length === 0) { + return ( +
+ 컬럼 미설정 +
+ ); + } + + const gridCols = cols.map((c) => c.width || "1fr").join(" "); + const dummyRows = 3; + + return ( +
+ {/* 헤더 */} +
+ {cols.map((col) => ( +
+ {col.label} +
+ ))} +
+ {/* 더미 행 */} + {Array.from({ length: dummyRows }).map((_, i) => ( +
+ {cols.map((col) => ( +
+
+
+ ))} +
+ ))} +
+ ); +} + +// ===== 카드 미리보기 ===== + +function CardPreview({ + cardGrid, +}: { + cardGrid: PopStringListConfig["cardGrid"]; +}) { + if (!cardGrid || cardGrid.cells.length === 0) { + return ( +
+ + 카드 레이아웃 미설정 + +
+ ); + } + + // 더미 카드 2장 + return ( +
+ {[0, 1].map((i) => ( +
+ {cardGrid.cells.map((cell) => ( +
+
+
+ ))} +
+ ))} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/index.tsx b/frontend/lib/registry/pop-components/pop-string-list/index.tsx new file mode 100644 index 00000000..3d148c57 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-string-list/index.tsx @@ -0,0 +1,38 @@ +"use client"; + +/** + * pop-string-list 컴포넌트 레지스트리 등록 진입점 + * + * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨 + */ + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopStringListComponent } from "./PopStringListComponent"; +import { PopStringListConfigPanel } from "./PopStringListConfig"; +import { PopStringListPreviewComponent } from "./PopStringListPreview"; +import type { PopStringListConfig } from "./types"; + +// 기본 설정값 +const defaultConfig: PopStringListConfig = { + displayMode: "list", + header: { enabled: true, label: "" }, + overflow: { visibleRows: 5, showExpandButton: true, maxExpandRows: 20 }, + dataSource: { tableName: "" }, + listColumns: [], + cardGrid: undefined, +}; + +// 레지스트리 등록 +PopComponentRegistry.registerComponent({ + id: "pop-string-list", + name: "리스트 목록", + description: "테이블 데이터를 리스트 또는 카드 형태로 표시", + category: "display", + icon: "List", + component: PopStringListComponent, + configPanel: PopStringListConfigPanel, + preview: PopStringListPreviewComponent, + defaultProps: defaultConfig, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-string-list/types.ts b/frontend/lib/registry/pop-components/pop-string-list/types.ts new file mode 100644 index 00000000..a7eeb482 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-string-list/types.ts @@ -0,0 +1,67 @@ +// ===== pop-string-list 전용 타입 ===== +// pop-card-list와 완전 독립. 공유하는 것은 CardListDataSource 타입만 import. + +import type { CardListDataSource } from "../types"; + +/** 표시 모드 */ +export type StringListDisplayMode = "list" | "card"; + +/** 카드 내부 셀 1개 정의 */ +export interface CardCellDefinition { + id: string; + row: number; // 1부터 + col: number; // 1부터 + rowSpan: number; // 행 병합 (기본 1) + colSpan: number; // 열 병합 (기본 1) + columnName: string; // 바인딩할 DB 컬럼명 + label?: string; // 셀 위에 표시할 라벨 (선택) + labelPosition?: "top" | "left"; // 라벨 위치 (기본 top) + type: "text" | "image" | "badge" | "button"; // 셀 렌더링 타입 + fontSize?: "sm" | "md" | "lg"; // 글자 크기: 작게(10px) / 보통(12px) / 크게(14px) + align?: "left" | "center" | "right"; // 가로 정렬 (기본 left) + verticalAlign?: "top" | "middle" | "bottom"; // 세로 정렬 (기본 top) +} + +/** 카드 그리드 레이아웃 설정 */ +export interface CardGridConfig { + rows: number; // 행 수 + cols: number; // 열 수 + colWidths: string[]; // fr 단위 배열 (예: ["2fr", "1fr", "1fr"]) + rowHeights?: string[]; // px 단위 배열 (예: ["32px", "48px"], 기본 32px 균등) + gap: number; // 셀 간격 (px, 2~8 권장) + showBorder: boolean; // 셀 보더 표시 + cells: CardCellDefinition[]; // 셀 목록 +} + +/** 리스트 모드 컬럼 1개 설정 */ +export interface ListColumnConfig { + columnName: string; // DB 컬럼명 + label: string; // 헤더 라벨 + width?: string; // fr 단위 (기본 "1fr") + align?: "left" | "center" | "right"; // 정렬 + alternateColumns?: string[]; // 런타임에서 전환 가능한 대체 컬럼 목록 +} + +/** 오버플로우 설정 */ +export interface OverflowConfig { + visibleRows: number; // 기본 표시 행 수 + showExpandButton: boolean; // "전체보기" 버튼 표시 + maxExpandRows: number; // 확장 시 최대 행 수 +} + +/** 헤더 설정 */ +export interface StringListHeaderConfig { + enabled: boolean; // 헤더 표시 여부 + label?: string; // 헤더 라벨 텍스트 +} + +/** pop-string-list 전체 설정 */ +export interface PopStringListConfig { + displayMode: StringListDisplayMode; + header: StringListHeaderConfig; + overflow: OverflowConfig; + dataSource: CardListDataSource; // 기존 타입 재활용 + selectedColumns?: string[]; // 사용자가 선택한 컬럼명 목록 (모드 무관 영속) + listColumns?: ListColumnConfig[]; // 리스트 모드 전용 + cardGrid?: CardGridConfig; // 카드 모드 전용 +} diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index ae70aed1..d8650bec 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -354,6 +354,7 @@ export interface CardColumnJoin { joinType: "LEFT" | "INNER" | "RIGHT"; sourceColumn: string; // 메인 테이블 컬럼 targetColumn: string; // 조인 테이블 컬럼 + selectedTargetColumns?: string[]; // 가져올 대상 테이블 컬럼 목록 } // ----- 필터 설정 -----