"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, mode: "loadMore", showExpandButton: true, loadMoreCount: 5, maxExpandRows: 50, pageSize: 5, paginationStyle: "bottom" }, 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 currentList = cfg.listColumns || []; // 기존 리스트에서: 체크 해제된 메인 컬럼만 제거 // 조인 컬럼 (이름에 "."이 포함)은 항상 보존 const preserved = currentList.filter( (lc) => cols.includes(lc.columnName) || lc.columnName.includes(".") ); // 새로 체크된 메인 컬럼만 리스트 끝에 추가 const existingNames = new Set(preserved.map((lc) => lc.columnName)); const added = cols .filter((colName) => !existingNames.has(colName)) .map((colName) => ({ columnName: colName, label: colName } as ListColumnConfig)); update({ selectedColumns: cols, listColumns: [...preserved, ...added] }); } else { update({ selectedColumns: cols }); } }} /> )} {step === 4 && ( { // 조인 변경 후: 유효한 조인 컬럼명 셋 계산 const validJoinColNames = new Set( (dataSource.joins || []).flatMap((j) => (j.selectedTargetColumns || []).map((col) => `${j.targetTable}.${col}`) ) ); // listColumns에서 고아 조인 컬럼 제거 + alternateColumns 정리 const currentList = cfg.listColumns || []; const cleanedList = currentList .filter((lc) => { if (!lc.columnName.includes(".")) return true; // 메인 컬럼: 유지 return validJoinColNames.has(lc.columnName); // 조인 컬럼: 유효한 것만 }) .map((lc) => { const alts = lc.alternateColumns; if (!alts) return lc; const cleanedAlts = alts.filter((a) => { if (!a.includes(".")) return true; // 메인 컬럼: 유지 return validJoinColNames.has(a); // 조인 컬럼: 유효한 것만 }); return { ...lc, alternateColumns: cleanedAlts.length > 0 ? cleanedAlts : undefined, }; }); update({ dataSource, listColumns: cleanedList }); }} /> )} {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; }) { const mode = overflow.mode || "loadMore"; return (
onChange({ ...overflow, visibleRows: Number(e.target.value) || 5 }) } className="mt-1 h-8 text-xs" />
{mode === "loadMore" && ( <>
onChange({ ...overflow, showExpandButton }) } />
{overflow.showExpandButton && ( <>
onChange({ ...overflow, loadMoreCount: Number(e.target.value) || 5, }) } className="mt-1 h-8 text-xs" />

클릭할 때마다 추가로 표시할 행 수

onChange({ ...overflow, maxExpandRows: Number(e.target.value) || 50, }) } className="mt-1 h-8 text-xs" />
)} )} {mode === "pagination" && ( <>
onChange({ ...overflow, pageSize: Number(e.target.value) || 5, }) } className="mt-1 h-8 text-xs" />

{(overflow.paginationStyle || "bottom") === "bottom" ? "컴포넌트 하단에 이전/다음 버튼과 페이지 번호 표시" : "컴포넌트 좌우에 화살표 버튼 표시"}

)}
); } // ===== 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 listColumnNames = new Set(listColumns.map((c) => c.columnName)); // 추가 가능한 컬럼: (메인 + 조인) 중 현재 리스트에 없는 것 const addableColumns = [ ...availableColumns .filter((c) => !listColumnNames.has(c.name)) .map((c) => ({ value: c.name, label: c.name, source: "main" as const })), ...joinedColumns .filter((c) => !listColumnNames.has(c.name)) .map((c) => ({ value: c.name, label: `${c.displayName} (${c.sourceTable})`, source: "join" as const, })), ]; // 컬럼 추가 (독립 헤더로 추가 시 다른 컬럼의 alternateColumns에서 제거) const addColumn = (columnValue: string) => { const joinCol = joinedColumns.find((c) => c.name === columnValue); const newCol: ListColumnConfig = { columnName: columnValue, label: joinCol?.displayName || columnValue, }; // 다른 컬럼의 alternateColumns에서 이 컬럼 제거 (독립 헤더가 되므로) const cleaned = listColumns.map((col) => { const alts = col.alternateColumns; if (!alts || !alts.includes(columnValue)) return col; const newAlts = alts.filter((a) => a !== columnValue); return { ...col, alternateColumns: newAlts.length > 0 ? newAlts : undefined }; }); onChange([...cleaned, newCol]); }; // 컬럼 삭제 (리스트에서만 삭제, STEP 3 체크 유지) const removeColumn = (index: number) => { const next = listColumns.filter((_, i) => i !== index); onChange(next); // 펼침 인덱스 초기화 (삭제로 인덱스가 밀리므로) setExpandedAltIdx(null); }; // 전환 후보: (메인 + 조인) - 자기 자신 - 리스트에 독립 헤더로 있는 것 const getAlternateCandidates = (currentColumnName: string) => { return [ ...availableColumns .filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name)) .map((c) => ({ value: c.name, label: c.name, source: "main" as const })), ...joinedColumns .filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name)) .map((c) => ({ value: c.name, label: c.displayName, source: "join" as const, sourceTable: c.sourceTable, })), ]; }; 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 && addableColumns.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]" /> {/* 정렬 */} {/* 컬럼 전환 버튼 (전환 후보가 있을 때만) */} {getAlternateCandidates(col.columnName).length > 0 && ( )} {/* 컬럼 삭제 버튼 */}
{/* 전환 가능 컬럼 (펼침 시만 표시, 메인+조인 중 리스트 미포함분) */} {expandedAltIdx === i && (() => { const candidates = getAlternateCandidates(col.columnName); if (candidates.length === 0) return null; return (
전환: {candidates.map((cand) => { const alts = col.alternateColumns || []; const isAlt = alts.includes(cand.value); return ( ); })}
); })()} ))}
{/* 컬럼 추가 */} {addableColumns.length > 0 && ( )}

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

); } // ===== 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}) | 상단/좌측: 일괄 | 셀 경계: 개별 조정

); }