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

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">&mdash;</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex items-center justify-center py-8 text-xs text-gray-400">
</div>
)}
</div>
);
}