898 lines
34 KiB
TypeScript
898 lines
34 KiB
TypeScript
"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<ComponentConfig>) => 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<SelectionRange | null>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragStartRef = useRef<{ row: number; col: number } | null>(null);
|
|
|
|
// 열/행 리사이즈
|
|
const [resizingColIdx, setResizingColIdx] = useState<number | null>(null);
|
|
const [resizingRowIdx, setResizingRowIdx] = useState<number | null>(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<GridCell>) => {
|
|
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<string | null>(null);
|
|
const editInputRef = useRef<HTMLInputElement>(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(
|
|
<td
|
|
key={cell.id}
|
|
rowSpan={rSpan > 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 ? (
|
|
<input
|
|
ref={editInputRef}
|
|
className="h-full w-full border-none bg-transparent text-center text-xs outline-none"
|
|
defaultValue={cell.cellType === "field" ? cell.field ?? "" : cell.value ?? ""}
|
|
onBlur={(e) => handleEditBlur(cell, e.target.value)}
|
|
onKeyDown={(e) => handleEditKeyDown(e, cell, (e.target as HTMLInputElement).value)}
|
|
style={{ fontSize: cell.fontSize ?? 12 }}
|
|
/>
|
|
) : (
|
|
<span className="pointer-events-none block truncate text-xs">
|
|
{displayValue || <span className="text-gray-300">—</span>}
|
|
</span>
|
|
)}
|
|
{cell.cellType === "field" && !isEditing && (
|
|
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-blue-500" />
|
|
)}
|
|
</td>,
|
|
);
|
|
}
|
|
|
|
return tds;
|
|
};
|
|
|
|
// ─── 메인 렌더링 ──────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div
|
|
className="rounded-xl border border-border bg-white shadow-sm"
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
>
|
|
{/* 컨트롤 바 */}
|
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
|
<span className="text-sm font-bold text-gray-800">격자 양식</span>
|
|
<div className="flex items-center gap-1">
|
|
{/* 행 조절 */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
|
onClick={handleRemoveRow}
|
|
disabled={rowCount <= MIN_ROWS}
|
|
>
|
|
<Minus className="h-3 w-3" />
|
|
</Button>
|
|
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
|
<Rows3 className="h-3 w-3 text-gray-400" />{rowCount}행
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
|
onClick={handleAddRow}
|
|
disabled={rowCount >= MAX_ROWS}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
|
|
<div className="mx-1.5 h-4 w-px bg-gray-300" />
|
|
|
|
{/* 열 조절 */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
|
onClick={handleRemoveCol}
|
|
disabled={colCount <= MIN_COLS}
|
|
>
|
|
<Minus className="h-3 w-3" />
|
|
</Button>
|
|
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
|
<Columns3 className="h-3 w-3 text-gray-400" />{colCount}열
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
|
onClick={handleAddCol}
|
|
disabled={colCount >= MAX_COLS}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
|
|
<div className="mx-1.5 h-4 w-px bg-gray-300" />
|
|
|
|
{/* 병합 / 해제 */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1 border-gray-300 px-2 text-xs text-blue-600 hover:bg-blue-50 disabled:opacity-30 disabled:text-gray-400"
|
|
onClick={handleMerge}
|
|
disabled={!canMerge}
|
|
>
|
|
<Merge className="h-3 w-3" />
|
|
병합
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1 border-gray-300 px-2 text-xs text-orange-600 hover:bg-orange-50 disabled:opacity-30 disabled:text-gray-400"
|
|
onClick={handleUnmerge}
|
|
disabled={!canUnmerge}
|
|
>
|
|
<SplitSquareHorizontal className="h-3 w-3" />
|
|
해제
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 그리드 캔버스 */}
|
|
<div className="overflow-auto p-3" style={{ maxHeight: 360 }}>
|
|
<table
|
|
className="select-none border-collapse"
|
|
style={{ width: totalWidth + 28, tableLayout: "fixed" }}
|
|
>
|
|
<colgroup>
|
|
<col style={{ width: 28, minWidth: 28 }} />
|
|
{colWidths.map((w, i) => (
|
|
<col key={i} style={{ width: w, minWidth: 40 }} />
|
|
))}
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th className="border border-gray-200 bg-gray-100" />
|
|
{colWidths.map((_, i) => (
|
|
<th
|
|
key={i}
|
|
className="group/colhdr relative border border-gray-200 bg-gray-100 px-1 py-1 text-[10px] font-medium text-gray-400"
|
|
>
|
|
<span>{i + 1}</span>
|
|
{colCount > MIN_COLS && (
|
|
<button
|
|
onClick={() => handleRemoveColAt(i)}
|
|
className="absolute -top-0.5 right-0.5 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-white shadow-sm transition-colors hover:bg-red-500 group-hover/colhdr:flex"
|
|
title={`${i + 1}열 삭제`}
|
|
>
|
|
<Trash2 className="h-2 w-2" />
|
|
</button>
|
|
)}
|
|
{i < colCount - 1 && (
|
|
<div
|
|
className={`absolute -right-[3px] top-0 z-10 h-full w-[5px] cursor-col-resize transition-colors ${
|
|
resizingColIdx === i ? "bg-blue-400" : "hover:bg-blue-300"
|
|
}`}
|
|
onMouseDown={(e) => handleColResizeStart(i, e)}
|
|
/>
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Array.from({ length: rowCount }).map((_, r) => {
|
|
const gridRow = renderGridRow(r);
|
|
return (
|
|
<tr key={`row-${r}`}>
|
|
<td
|
|
className="group/rowhdr relative border border-gray-200 bg-gray-100 text-center text-[10px] font-medium text-gray-400"
|
|
style={{ width: 28, minWidth: 28, height: rowHeights[r] ?? DEFAULT_ROW_HEIGHT, minHeight: rowHeights[r] ?? DEFAULT_ROW_HEIGHT }}
|
|
>
|
|
<span>{r + 1}</span>
|
|
{rowCount > MIN_ROWS && (
|
|
<button
|
|
onClick={() => handleRemoveRowAt(r)}
|
|
className="absolute -left-0.5 top-0.5 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-white shadow-sm transition-colors hover:bg-red-500 group-hover/rowhdr:flex"
|
|
title={`${r + 1}행 삭제`}
|
|
>
|
|
<Trash2 className="h-2 w-2" />
|
|
</button>
|
|
)}
|
|
{r < rowCount - 1 && (
|
|
<div
|
|
className={`absolute -bottom-[3px] left-0 z-10 h-[5px] w-full cursor-row-resize transition-colors ${
|
|
resizingRowIdx === r ? "bg-blue-400" : "hover:bg-blue-300"
|
|
}`}
|
|
onMouseDown={(e) => handleRowResizeStart(r, e)}
|
|
/>
|
|
)}
|
|
</td>
|
|
{gridRow}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* 셀 설정 패널 */}
|
|
<div className="border-t border-gray-200 bg-gray-50/50 px-4 py-3">
|
|
{!selectedCell ? (
|
|
<div className="flex items-center justify-center py-3 text-xs text-gray-400">
|
|
셀을 클릭하여 선택하세요
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{/* 셀 위치 */}
|
|
<span className="inline-block rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-500">
|
|
{selectedCell.row + 1}행 {selectedCell.col + 1}열
|
|
{((selectedCell.rowSpan ?? 1) > 1 || (selectedCell.colSpan ?? 1) > 1)
|
|
? ` (${selectedCell.rowSpan ?? 1}x${selectedCell.colSpan ?? 1})`
|
|
: ""}
|
|
</span>
|
|
|
|
{/* 셀 타입 */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-medium text-gray-500">셀 타입</label>
|
|
<Select
|
|
value={selectedCell.cellType}
|
|
onValueChange={(v) => updateCellProps(selectedCell.row, selectedCell.col, { cellType: v as GridCell["cellType"] })}
|
|
>
|
|
<SelectTrigger className="h-8 w-full text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="static">
|
|
<span className="flex items-center gap-1"><Type className="h-3 w-3" /> 고정 텍스트</span>
|
|
</SelectItem>
|
|
<SelectItem value="field">
|
|
<span className="flex items-center gap-1"><Database className="h-3 w-3" /> 데이터 연결</span>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 셀 값 */}
|
|
{selectedCell.cellType === "static" && (
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-medium text-gray-500">텍스트</label>
|
|
<Input
|
|
className="h-8 w-full text-xs"
|
|
value={selectedCell.value ?? ""}
|
|
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { value: e.target.value })}
|
|
placeholder="텍스트 입력"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedCell.cellType === "field" && (
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-medium text-gray-500">연결 필드</label>
|
|
{selectedCell.field ? (
|
|
<span className="inline-flex items-center gap-1 rounded bg-blue-50 px-2 py-1.5 text-xs font-medium text-blue-700">
|
|
<Database className="h-3 w-3" />{selectedCell.field}
|
|
</span>
|
|
) : (
|
|
<p className="text-[10px] text-gray-400">데이터 연결 탭에서 배치</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 구분선 */}
|
|
<div className="h-px bg-gray-200" />
|
|
|
|
{/* 스타일 설정 — 1행 5열 */}
|
|
<div className="flex items-end gap-2">
|
|
{/* 정렬 */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-medium text-gray-500">정렬</label>
|
|
<div className="flex items-center gap-0.5 rounded border border-gray-200 bg-white p-0.5">
|
|
{(["left", "center", "right"] as const).map((a) => (
|
|
<button
|
|
key={a}
|
|
className={`flex h-6 w-6 items-center justify-center rounded ${
|
|
selectedCell.align === a ? "bg-blue-100 text-blue-700" : "text-gray-400 hover:bg-gray-100"
|
|
}`}
|
|
onClick={() => updateCellProps(selectedCell.row, selectedCell.col, { align: a })}
|
|
>
|
|
{a === "left" ? <AlignLeft className="h-3 w-3" /> : a === "center" ? <AlignCenter className="h-3 w-3" /> : <AlignRight className="h-3 w-3" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 굵기 */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-medium text-gray-500">굵기</label>
|
|
<button
|
|
className={`flex h-7 w-7 items-center justify-center rounded border ${
|
|
selectedCell.fontWeight === "bold" ? "border-blue-300 bg-blue-100 text-blue-700" : "border-gray-200 text-gray-400 hover:bg-gray-100"
|
|
}`}
|
|
onClick={() => updateCellProps(selectedCell.row, selectedCell.col, { fontWeight: selectedCell.fontWeight === "bold" ? "normal" : "bold" })}
|
|
>
|
|
<Bold className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 글자 크기 */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-medium text-gray-500">크기</label>
|
|
<Input
|
|
type="number"
|
|
className="h-7 w-12 text-center text-xs"
|
|
value={selectedCell.fontSize ?? 12}
|
|
min={8}
|
|
max={36}
|
|
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { fontSize: parseInt(e.target.value) || 12 })}
|
|
/>
|
|
</div>
|
|
|
|
{/* 배경색 */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-medium text-gray-500">배경색</label>
|
|
<div className="flex h-7 items-center gap-1 rounded border border-gray-200 bg-white px-1.5">
|
|
<Paintbrush className="h-3 w-3 shrink-0 text-gray-400" />
|
|
<input
|
|
type="color"
|
|
className="h-5 w-6 cursor-pointer rounded border-none"
|
|
value={selectedCell.backgroundColor || "#ffffff"}
|
|
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { backgroundColor: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 글자색 */}
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-medium text-gray-500">글자색</label>
|
|
<div className="flex h-7 items-center gap-1 rounded border border-gray-200 bg-white px-1.5">
|
|
<Type className="h-3 w-3 shrink-0 text-gray-400" />
|
|
<input
|
|
type="color"
|
|
className="h-5 w-6 cursor-pointer rounded border-none"
|
|
value={selectedCell.textColor || "#111827"}
|
|
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { textColor: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|