257 lines
9.7 KiB
TypeScript
257 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* TableCanvasEditor — 테이블 레이아웃 탭의 시각적 편집기
|
|
*
|
|
* - 컨트롤 바(열 추가, 행 조절)는 고정
|
|
* - 테이블 미리보기만 가로/세로 스크롤
|
|
*/
|
|
|
|
import React, { useState, useRef, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Plus, Trash2, Minus, Rows3, Columns3 } from "lucide-react";
|
|
import type { ComponentConfig } from "@/types/report";
|
|
|
|
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
|
|
|
const MIN_COL_WIDTH = 60;
|
|
const DEFAULT_COL_WIDTH = 120;
|
|
const MIN_ROWS = 1;
|
|
const MAX_ROWS = 50;
|
|
const MAX_COLUMNS = 14;
|
|
const DEFAULT_ROWS = 3;
|
|
|
|
interface TableCanvasEditorProps {
|
|
columns: TableColumn[];
|
|
onColumnsChange: (columns: TableColumn[]) => void;
|
|
rowCount?: number;
|
|
onRowCountChange?: (count: number) => void;
|
|
}
|
|
|
|
export function TableCanvasEditor({ columns, onColumnsChange, rowCount, onRowCountChange }: TableCanvasEditorProps) {
|
|
const displayRows = rowCount ?? DEFAULT_ROWS;
|
|
const [resizingIdx, setResizingIdx] = useState<number | null>(null);
|
|
const startXRef = useRef(0);
|
|
const startWidthRef = useRef(0);
|
|
|
|
const canAddColumn = columns.length < MAX_COLUMNS;
|
|
|
|
const handleAddColumn = useCallback(() => {
|
|
if (!canAddColumn) return;
|
|
onColumnsChange([
|
|
...columns,
|
|
{
|
|
field: "",
|
|
header: `열 ${columns.length + 1}`,
|
|
width: DEFAULT_COL_WIDTH,
|
|
align: "left",
|
|
mappingType: "field",
|
|
summaryType: "NONE",
|
|
visible: true,
|
|
numberFormat: "none",
|
|
},
|
|
]);
|
|
}, [columns, onColumnsChange, canAddColumn]);
|
|
|
|
const handleRemoveColumn = useCallback(
|
|
(idx: number) => {
|
|
if (columns.length <= 1) return;
|
|
const remaining = columns.filter((_, i) => i !== idx);
|
|
const renumbered = remaining.map((col, i) => {
|
|
const isDefaultHeader = /^열 \d+$/.test(col.header);
|
|
return isDefaultHeader ? { ...col, header: `열 ${i + 1}` } : col;
|
|
});
|
|
onColumnsChange(renumbered);
|
|
},
|
|
[columns, onColumnsChange],
|
|
);
|
|
|
|
const handleRemoveLastColumn = useCallback(() => {
|
|
if (columns.length <= 1) return;
|
|
handleRemoveColumn(columns.length - 1);
|
|
}, [columns, handleRemoveColumn]);
|
|
|
|
const handleResizeStart = useCallback(
|
|
(idx: number, e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setResizingIdx(idx);
|
|
startXRef.current = e.clientX;
|
|
startWidthRef.current = columns[idx]?.width || DEFAULT_COL_WIDTH;
|
|
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
|
const delta = moveEvent.clientX - startXRef.current;
|
|
const newWidth = Math.max(MIN_COL_WIDTH, startWidthRef.current + delta);
|
|
onColumnsChange(
|
|
columns.map((col, i) => (i === idx ? { ...col, width: newWidth } : col)),
|
|
);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setResizingIdx(null);
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
},
|
|
[columns, onColumnsChange],
|
|
);
|
|
|
|
const totalWidth = columns.reduce((sum, col) => sum + (col.width || DEFAULT_COL_WIDTH), 0);
|
|
|
|
return (
|
|
<div className="rounded-xl border border-border bg-white shadow-sm">
|
|
{/* 컨트롤 바 */}
|
|
<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={() => onRowCountChange?.(Math.max(MIN_ROWS, displayRows - 1))}
|
|
disabled={displayRows <= 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" />{displayRows}행
|
|
</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={() => onRowCountChange?.(Math.min(MAX_ROWS, displayRows + 1))}
|
|
disabled={displayRows >= 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={handleRemoveLastColumn}
|
|
disabled={columns.length <= 1}
|
|
>
|
|
<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" />{columns.length}열
|
|
</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={handleAddColumn}
|
|
disabled={!canAddColumn}
|
|
title={canAddColumn ? "열 추가" : `최대 ${MAX_COLUMNS}개까지 추가 가능합니다`}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 미리보기 */}
|
|
{columns.length > 0 ? (
|
|
<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 }} />
|
|
{columns.map((col, idx) => (
|
|
<col key={idx} style={{ width: col.width || DEFAULT_COL_WIDTH, minWidth: MIN_COL_WIDTH }} />
|
|
))}
|
|
</colgroup>
|
|
<thead>
|
|
{/* 열 번호 헤더 */}
|
|
<tr>
|
|
<th className="border border-gray-200 bg-gray-100" />
|
|
{columns.map((col, idx) => (
|
|
<th
|
|
key={idx}
|
|
className="group/colhdr relative border border-gray-200 bg-gray-100 px-1 py-1 text-[10px] font-medium text-gray-400"
|
|
>
|
|
<span>{idx + 1}</span>
|
|
{columns.length > 1 && (
|
|
<button
|
|
onClick={() => handleRemoveColumn(idx)}
|
|
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={`${idx + 1}열 삭제`}
|
|
>
|
|
<Trash2 className="h-2 w-2" />
|
|
</button>
|
|
)}
|
|
{idx < columns.length - 1 && (
|
|
<div
|
|
className={`absolute -right-[3px] top-0 z-10 h-full w-[5px] cursor-col-resize transition-colors ${
|
|
resizingIdx === idx ? "bg-blue-400" : "hover:bg-blue-300"
|
|
}`}
|
|
onMouseDown={(e) => handleResizeStart(idx, e)}
|
|
/>
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
{/* 열 이름 헤더 */}
|
|
<tr>
|
|
<td className="border border-gray-200 bg-gray-100" />
|
|
{columns.map((col, idx) => (
|
|
<th
|
|
key={idx}
|
|
className="border border-gray-200 bg-gray-50 px-1 text-center text-xs font-semibold text-gray-700"
|
|
style={{ height: 32, minHeight: 32 }}
|
|
>
|
|
<span className="block truncate">{col.header || `열 ${idx + 1}`}</span>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{Array.from({ length: displayRows }).map((_, rowIdx) => (
|
|
<tr key={rowIdx}>
|
|
<td
|
|
className="border border-gray-200 bg-gray-100 text-center text-[10px] font-medium text-gray-400"
|
|
style={{ width: 28, minWidth: 28, height: 32, minHeight: 32 }}
|
|
>
|
|
{rowIdx + 1}
|
|
</td>
|
|
{columns.map((col, colIdx) => (
|
|
<td
|
|
key={colIdx}
|
|
className="select-none border border-gray-200"
|
|
style={{
|
|
height: 32,
|
|
minHeight: 32,
|
|
padding: "2px 4px",
|
|
textAlign: col.align || "center",
|
|
verticalAlign: "middle",
|
|
fontSize: 12,
|
|
color: "#9ca3af",
|
|
overflow: "hidden",
|
|
whiteSpace: "nowrap" as const,
|
|
textOverflow: "ellipsis",
|
|
}}
|
|
>
|
|
<span className="pointer-events-none block truncate text-xs text-gray-300">—</span>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center py-8 text-xs text-gray-400">
|
|
셀을 클릭하여 선택하세요
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|