ERP-node/frontend/components/report/designer/modals/GridEditor.tsx

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">&mdash;</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>
);
}