"use client"; /** * GridEditor — 복잡한 테이블 양식 편집기 * * 셀 병합(rowspan/colspan), 고정 텍스트/데이터 바인딩 혼합, * 셀별 스타일 설정이 가능한 그리드 에디터. */ import React, { useState, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Plus, Minus, Merge, SplitSquareHorizontal, Type, Database, Paintbrush, Bold, AlignLeft, AlignCenter, AlignRight, Rows3, Columns3, Trash2, } from "lucide-react"; import type { GridCell, ComponentConfig } from "@/types/report"; // ─── 상수 ──────────────────────────────────────────────────────────────────── const DEFAULT_COL_WIDTH = 100; const DEFAULT_ROW_HEIGHT = 32; const MIN_ROWS = 1; const MIN_COLS = 1; const MAX_ROWS = 100; const MAX_COLS = 30; const INITIAL_ROWS = 4; const INITIAL_COLS = 6; // ─── 유틸 함수 ────────────────────────────────────────────────────────────── function cellId(row: number, col: number): string { return `r${row}c${col}`; } function createEmptyCell(row: number, col: number): GridCell { return { id: cellId(row, col), row, col, rowSpan: 1, colSpan: 1, cellType: "static", value: "", align: "center", verticalAlign: "middle", fontWeight: "normal", fontSize: 12, borderStyle: "thin", }; } function initGrid(rows: number, cols: number): GridCell[] { const cells: GridCell[] = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { cells.push(createEmptyCell(r, c)); } } return cells; } function getCell(cells: GridCell[], row: number, col: number): GridCell | undefined { return cells.find((c) => c.row === row && c.col === col); } interface SelectionRange { startRow: number; startCol: number; endRow: number; endCol: number; } function normalizeRange(range: SelectionRange): SelectionRange { return { startRow: Math.min(range.startRow, range.endRow), startCol: Math.min(range.startCol, range.endCol), endRow: Math.max(range.startRow, range.endRow), endCol: Math.max(range.startCol, range.endCol), }; } function isCellInRange(row: number, col: number, range: SelectionRange | null): boolean { if (!range) return false; const n = normalizeRange(range); return row >= n.startRow && row <= n.endRow && col >= n.startCol && col <= n.endCol; } // ─── Props ─────────────────────────────────────────────────────────────────── interface GridEditorProps { component: ComponentConfig; onUpdate: (updates: Partial) => void; schemaColumns?: Array<{ column_name: string; data_type: string }>; } // ─── 메인 컴포넌트 ────────────────────────────────────────────────────────── export function GridEditor({ component, onUpdate, schemaColumns = [] }: GridEditorProps) { const rowCount = component.gridRowCount ?? INITIAL_ROWS; const colCount = component.gridColCount ?? INITIAL_COLS; const cells = component.gridCells ?? initGrid(rowCount, colCount); const colWidths = component.gridColWidths ?? Array(colCount).fill(DEFAULT_COL_WIDTH); const rowHeights = component.gridRowHeights ?? Array(rowCount).fill(DEFAULT_ROW_HEIGHT); const [selection, setSelection] = useState(null); const [isDragging, setIsDragging] = useState(false); const dragStartRef = useRef<{ row: number; col: number } | null>(null); // 열/행 리사이즈 const [resizingColIdx, setResizingColIdx] = useState(null); const [resizingRowIdx, setResizingRowIdx] = useState(null); const resizeStartRef = useRef(0); const resizeStartSizeRef = useRef(0); // 선택된 단일 셀 (설정 패널용) const selectedCell = useMemo(() => { if (!selection) return null; const n = normalizeRange(selection); return getCell(cells, n.startRow, n.startCol); }, [selection, cells]); // 선택 범위 내 셀 개수 const selectedCellCount = useMemo(() => { if (!selection) return 0; const n = normalizeRange(selection); let count = 0; for (let r = n.startRow; r <= n.endRow; r++) { for (let c = n.startCol; c <= n.endCol; c++) { const cell = getCell(cells, r, c); if (cell && !cell.merged) count++; } } return count; }, [selection, cells]); // ─── 그리드 업데이트 헬퍼 ───────────────────────────────────────────────── const updateGrid = useCallback( (newCells: GridCell[], newRowCount?: number, newColCount?: number, newColWidths?: number[], newRowHeights?: number[]) => { onUpdate({ gridCells: newCells, gridRowCount: newRowCount ?? rowCount, gridColCount: newColCount ?? colCount, gridColWidths: newColWidths ?? colWidths, gridRowHeights: newRowHeights ?? rowHeights, }); }, [onUpdate, rowCount, colCount, colWidths, rowHeights], ); const updateCellProps = useCallback( (row: number, col: number, updates: Partial) => { const newCells = cells.map((c) => c.row === row && c.col === col ? { ...c, ...updates } : c, ); updateGrid(newCells); }, [cells, updateGrid], ); // ─── 셀 선택 핸들러 ────────────────────────────────────────────────────── const handleCellMouseDown = useCallback( (row: number, col: number, e: React.MouseEvent) => { e.preventDefault(); // 병합된 셀 클릭 시 주체 셀로 리다이렉트 const cell = getCell(cells, row, col); if (cell?.merged && cell.mergedBy) { const master = cells.find((c) => c.id === cell.mergedBy); if (master) { row = master.row; col = master.col; } } dragStartRef.current = { row, col }; setIsDragging(true); setSelection({ startRow: row, startCol: col, endRow: row, endCol: col }); }, [cells], ); const handleCellMouseEnter = useCallback( (row: number, col: number) => { if (!isDragging || !dragStartRef.current) return; setSelection({ startRow: dragStartRef.current.row, startCol: dragStartRef.current.col, endRow: row, endCol: col, }); }, [isDragging], ); const handleMouseUp = useCallback(() => { setIsDragging(false); dragStartRef.current = null; }, []); // ─── 행/열 추가/삭제 ───────────────────────────────────────────────────── const handleAddRow = useCallback(() => { if (rowCount >= MAX_ROWS) return; const newRow = rowCount; const newCells = [ ...cells, ...Array.from({ length: colCount }, (_, c) => createEmptyCell(newRow, c)), ]; updateGrid(newCells, newRow + 1, colCount, colWidths, [...rowHeights, DEFAULT_ROW_HEIGHT]); }, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]); const handleAddCol = useCallback(() => { if (colCount >= MAX_COLS) return; const newCol = colCount; const newCells = [ ...cells, ...Array.from({ length: rowCount }, (_, r) => createEmptyCell(r, newCol)), ]; updateGrid(newCells, rowCount, newCol + 1, [...colWidths, DEFAULT_COL_WIDTH], rowHeights); }, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]); const handleRemoveRow = useCallback(() => { if (rowCount <= MIN_ROWS) return; const lastRow = rowCount - 1; // 병합이 마지막 행을 포함하면 제거 불가 const hasMergeConflict = cells.some( (c) => !c.merged && c.row < lastRow && c.row + (c.rowSpan ?? 1) - 1 >= lastRow, ); if (hasMergeConflict) return; const newCells = cells.filter((c) => c.row < lastRow); updateGrid(newCells, lastRow, colCount, colWidths, rowHeights.slice(0, lastRow)); }, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]); const handleRemoveCol = useCallback(() => { if (colCount <= MIN_COLS) return; const lastCol = colCount - 1; const hasMergeConflict = cells.some( (c) => !c.merged && c.col < lastCol && c.col + (c.colSpan ?? 1) - 1 >= lastCol, ); if (hasMergeConflict) return; const newCells = cells.filter((c) => c.col < lastCol); updateGrid(newCells, rowCount, lastCol, colWidths.slice(0, lastCol), rowHeights); }, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]); const handleRemoveColAt = useCallback( (targetCol: number) => { if (colCount <= MIN_COLS) return; const hasMergeConflict = cells.some((c) => { if (c.merged) return false; const cStart = c.col; const cEnd = c.col + (c.colSpan ?? 1) - 1; return cStart !== targetCol && cEnd >= targetCol && cStart < targetCol; }); if (hasMergeConflict) return; const newCells = cells .filter((c) => c.col !== targetCol) .map((c) => { const newCol = c.col > targetCol ? c.col - 1 : c.col; return { ...c, col: newCol, id: cellId(c.row, newCol), ...(c.mergedBy ? { mergedBy: cells.find((m) => m.id === c.mergedBy) ? cellId( cells.find((m) => m.id === c.mergedBy)!.row, cells.find((m) => m.id === c.mergedBy)!.col > targetCol ? cells.find((m) => m.id === c.mergedBy)!.col - 1 : cells.find((m) => m.id === c.mergedBy)!.col, ) : undefined, } : {}), }; }); const newColWidths = colWidths.filter((_, i) => i !== targetCol); updateGrid(newCells, rowCount, colCount - 1, newColWidths, rowHeights); setSelection(null); }, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid], ); const handleRemoveRowAt = useCallback( (targetRow: number) => { if (rowCount <= MIN_ROWS) return; const hasMergeConflict = cells.some((c) => { if (c.merged) return false; const rStart = c.row; const rEnd = c.row + (c.rowSpan ?? 1) - 1; return rStart !== targetRow && rEnd >= targetRow && rStart < targetRow; }); if (hasMergeConflict) return; const newCells = cells .filter((c) => c.row !== targetRow) .map((c) => { const newRow = c.row > targetRow ? c.row - 1 : c.row; return { ...c, row: newRow, id: cellId(newRow, c.col), ...(c.mergedBy ? { mergedBy: cells.find((m) => m.id === c.mergedBy) ? cellId( cells.find((m) => m.id === c.mergedBy)!.row > targetRow ? cells.find((m) => m.id === c.mergedBy)!.row - 1 : cells.find((m) => m.id === c.mergedBy)!.row, cells.find((m) => m.id === c.mergedBy)!.col, ) : undefined, } : {}), }; }); const newRowHeights = rowHeights.filter((_, i) => i !== targetRow); updateGrid(newCells, rowCount - 1, colCount, colWidths, newRowHeights); setSelection(null); }, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid], ); // ─── 셀 병합 / 해제 ────────────────────────────────────────────────────── const canMerge = useMemo(() => { if (!selection) return false; const n = normalizeRange(selection); if (n.startRow === n.endRow && n.startCol === n.endCol) return false; // 선택 범위 안에 이미 병합된 셀이 있으면 불가 for (let r = n.startRow; r <= n.endRow; r++) { for (let c = n.startCol; c <= n.endCol; c++) { const cell = getCell(cells, r, c); if (!cell) return false; if (cell.merged) return false; if ((cell.rowSpan ?? 1) > 1 || (cell.colSpan ?? 1) > 1) return false; } } return true; }, [selection, cells]); const canUnmerge = useMemo(() => { if (!selectedCell) return false; return (selectedCell.rowSpan ?? 1) > 1 || (selectedCell.colSpan ?? 1) > 1; }, [selectedCell]); const handleMerge = useCallback(() => { if (!selection || !canMerge) return; const n = normalizeRange(selection); const rSpan = n.endRow - n.startRow + 1; const cSpan = n.endCol - n.startCol + 1; const masterId = cellId(n.startRow, n.startCol); const newCells = cells.map((cell) => { if (cell.row === n.startRow && cell.col === n.startCol) { return { ...cell, rowSpan: rSpan, colSpan: cSpan }; } if (isCellInRange(cell.row, cell.col, n) && !(cell.row === n.startRow && cell.col === n.startCol)) { return { ...cell, merged: true, mergedBy: masterId, value: "", field: "", formula: "" }; } return cell; }); updateGrid(newCells); setSelection({ startRow: n.startRow, startCol: n.startCol, endRow: n.startRow, endCol: n.startCol }); }, [selection, canMerge, cells, updateGrid]); const handleUnmerge = useCallback(() => { if (!selectedCell || !canUnmerge) return; const masterId = selectedCell.id; const newCells = cells.map((cell) => { if (cell.id === masterId) { return { ...cell, rowSpan: 1, colSpan: 1 }; } if (cell.mergedBy === masterId) { return { ...cell, merged: false, mergedBy: undefined }; } return cell; }); updateGrid(newCells); }, [selectedCell, canUnmerge, cells, updateGrid]); // ─── 열/행 리사이즈 ──────────────────────────────────────────────────────── const MIN_COL_WIDTH = 40; const MIN_ROW_HEIGHT = 20; const handleColResizeStart = useCallback( (colIdx: number, e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setResizingColIdx(colIdx); resizeStartRef.current = e.clientX; resizeStartSizeRef.current = colWidths[colIdx] ?? DEFAULT_COL_WIDTH; const onMove = (moveEvent: MouseEvent) => { const delta = moveEvent.clientX - resizeStartRef.current; const newWidth = Math.max(MIN_COL_WIDTH, resizeStartSizeRef.current + delta); const newColWidths = colWidths.map((w, i) => (i === colIdx ? newWidth : w)); updateGrid(cells, rowCount, colCount, newColWidths, rowHeights); }; const onUp = () => { setResizingColIdx(null); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [colWidths, cells, rowCount, colCount, rowHeights, updateGrid], ); const handleRowResizeStart = useCallback( (rowIdx: number, e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setResizingRowIdx(rowIdx); resizeStartRef.current = e.clientY; resizeStartSizeRef.current = rowHeights[rowIdx] ?? DEFAULT_ROW_HEIGHT; const onMove = (moveEvent: MouseEvent) => { const delta = moveEvent.clientY - resizeStartRef.current; const newHeight = Math.max(MIN_ROW_HEIGHT, resizeStartSizeRef.current + delta); const newRowHeights = rowHeights.map((h, i) => (i === rowIdx ? newHeight : h)); updateGrid(cells, rowCount, colCount, colWidths, newRowHeights); }; const onUp = () => { setResizingRowIdx(null); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [rowHeights, cells, rowCount, colCount, colWidths, updateGrid], ); // ─── 셀 직접 편집 (더블클릭) ────────────────────────────────────────────── const [editingCell, setEditingCell] = useState(null); const editInputRef = useRef(null); const handleCellDoubleClick = useCallback((cell: GridCell) => { if (cell.merged) return; setEditingCell(cell.id); setTimeout(() => editInputRef.current?.focus(), 0); }, []); const handleEditBlur = useCallback( (cell: GridCell, newValue: string) => { setEditingCell(null); if (cell.cellType === "static") { updateCellProps(cell.row, cell.col, { value: newValue }); } else if (cell.cellType === "field") { updateCellProps(cell.row, cell.col, { field: newValue }); } }, [updateCellProps], ); const handleEditKeyDown = useCallback( (e: React.KeyboardEvent, cell: GridCell, value: string) => { if (e.key === "Enter") { handleEditBlur(cell, value); } else if (e.key === "Escape") { setEditingCell(null); } }, [handleEditBlur], ); // ─── 그리드 렌더링 ──────────────────────────────────────────────────────── const totalWidth = colWidths.reduce((a, b) => a + b, 0); const renderGridRow = (r: number): React.ReactNode[] => { const tds: React.ReactNode[] = []; for (let c = 0; c < colCount; c++) { const cell = getCell(cells, r, c); if (!cell || cell.merged) continue; const rSpan = cell.rowSpan ?? 1; const cSpan = cell.colSpan ?? 1; const isSelected = isCellInRange(r, c, selection); const isEditing = editingCell === cell.id; const w = colWidths.slice(c, c + cSpan).reduce((a, b) => a + b, 0); const h = rowHeights.slice(r, r + rSpan).reduce((a, b) => a + b, 0); const cellBg = cell.backgroundColor || (isSelected ? "#dbeafe" : "white"); const borderW = cell.borderStyle === "medium" ? 2 : cell.borderStyle === "thick" ? 3 : cell.borderStyle === "none" ? 0 : 1; const displayValue = cell.cellType === "field" && cell.field ? `{${cell.field}}` : cell.cellType === "formula" && cell.formula ? `=${cell.formula}` : cell.value ?? ""; tds.push( 1 ? rSpan : undefined} colSpan={cSpan > 1 ? cSpan : undefined} className="relative cursor-pointer select-none" style={{ width: w, height: h, minWidth: w, minHeight: h, backgroundColor: cellBg, border: `${borderW}px solid ${isSelected ? "#3b82f6" : "#e5e7eb"}`, padding: "2px 4px", fontSize: cell.fontSize ?? 12, fontWeight: cell.fontWeight === "bold" ? 700 : 400, color: cell.textColor || "#111827", textAlign: cell.align || "center", verticalAlign: cell.verticalAlign || "middle", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", outline: isSelected ? "2px solid #3b82f6" : "none", outlineOffset: "-2px", }} onMouseDown={(e) => handleCellMouseDown(r, c, e)} onMouseEnter={() => handleCellMouseEnter(r, c)} onDoubleClick={() => handleCellDoubleClick(cell)} > {isEditing ? ( handleEditBlur(cell, e.target.value)} onKeyDown={(e) => handleEditKeyDown(e, cell, (e.target as HTMLInputElement).value)} style={{ fontSize: cell.fontSize ?? 12 }} /> ) : ( {displayValue || } )} {cell.cellType === "field" && !isEditing && ( )} , ); } return tds; }; // ─── 메인 렌더링 ────────────────────────────────────────────────────────── return (
{/* 컨트롤 바 */}
격자 양식
{/* 행 조절 */} {rowCount}행
{/* 열 조절 */} {colCount}열
{/* 병합 / 해제 */}
{/* 그리드 캔버스 */}
{colWidths.map((w, i) => ( ))} ))} {Array.from({ length: rowCount }).map((_, r) => { const gridRow = renderGridRow(r); return ( {gridRow} ); })}
{colWidths.map((_, i) => ( {i + 1} {colCount > MIN_COLS && ( )} {i < colCount - 1 && (
handleColResizeStart(i, e)} /> )}
{r + 1} {rowCount > MIN_ROWS && ( )} {r < rowCount - 1 && (
handleRowResizeStart(r, e)} /> )}
{/* 셀 설정 패널 */}
{!selectedCell ? (
셀을 클릭하여 선택하세요
) : (
{/* 셀 위치 */} {selectedCell.row + 1}행 {selectedCell.col + 1}열 {((selectedCell.rowSpan ?? 1) > 1 || (selectedCell.colSpan ?? 1) > 1) ? ` (${selectedCell.rowSpan ?? 1}x${selectedCell.colSpan ?? 1})` : ""} {/* 셀 타입 */}
{/* 셀 값 */} {selectedCell.cellType === "static" && (
updateCellProps(selectedCell.row, selectedCell.col, { value: e.target.value })} placeholder="텍스트 입력" />
)} {selectedCell.cellType === "field" && (
{selectedCell.field ? ( {selectedCell.field} ) : (

데이터 연결 탭에서 배치

)}
)} {/* 구분선 */}
{/* 스타일 설정 — 1행 5열 */}
{/* 정렬 */}
{(["left", "center", "right"] as const).map((a) => ( ))}
{/* 굵기 */}
{/* 글자 크기 */}
updateCellProps(selectedCell.row, selectedCell.col, { fontSize: parseInt(e.target.value) || 12 })} />
{/* 배경색 */}
updateCellProps(selectedCell.row, selectedCell.col, { backgroundColor: e.target.value })} />
{/* 글자색 */}
updateCellProps(selectedCell.row, selectedCell.col, { textColor: e.target.value })} />
)}
); }