278 lines
9.8 KiB
TypeScript
278 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* GridCellDropZone — 그리드 양식의 데이터 연결 드롭 존
|
|
*
|
|
* 모든 셀에 드롭 가능. 헤더 영역(좌측/상단)은 배경색으로 시각 구분.
|
|
* 푸터 영역(하단)은 요약 집계 셀로 시각 구분.
|
|
* - 헤더 영역 드롭 → 고정 라벨(컬럼명)로 설정
|
|
* - 데이터 영역 드롭 → 데이터 바인딩(field)으로 설정
|
|
* - 푸터 영역 → 요약(집계) 타입 설정 가능
|
|
*/
|
|
|
|
import React from "react";
|
|
import { useDrop } from "react-dnd";
|
|
import { X, Database, Type, Calculator } from "lucide-react";
|
|
import { TABLE_COLUMN_DND_TYPE } from "./TableColumnPalette";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import type { GridCell } from "@/types/report";
|
|
|
|
interface PaletteDragItem {
|
|
columnName: string;
|
|
dataType: string;
|
|
}
|
|
|
|
interface GridCellDropZoneProps {
|
|
cells: GridCell[];
|
|
rowCount: number;
|
|
colCount: number;
|
|
colWidths: number[];
|
|
rowHeights: number[];
|
|
headerRows?: number;
|
|
headerCols?: number;
|
|
footerRows?: number;
|
|
onCellDrop: (row: number, col: number, columnName: string) => void;
|
|
onHeaderDrop: (row: number, col: number, columnName: string) => void;
|
|
onCellClear: (row: number, col: number) => void;
|
|
onFooterCellClick?: (row: number, col: number) => void;
|
|
}
|
|
|
|
const SUMMARY_LABELS: Record<string, string> = {
|
|
SUM: "합계",
|
|
AVG: "평균",
|
|
COUNT: "개수",
|
|
};
|
|
|
|
// ─── 통합 드롭 셀 ───────────────────────────────────────────────────────────
|
|
|
|
interface DropCellProps {
|
|
cell: GridCell;
|
|
width: number;
|
|
height: number;
|
|
isHeader: boolean;
|
|
isFooter: boolean;
|
|
onDrop: (columnName: string) => void;
|
|
onClear: () => void;
|
|
onFooterClick?: () => void;
|
|
}
|
|
|
|
function DropCell({ cell, width, height, isHeader, isFooter, onDrop, onClear, onFooterClick }: DropCellProps) {
|
|
const isField = cell.cellType === "field" && !!cell.field;
|
|
const isLabel = cell.cellType === "static" && !!cell.value;
|
|
const hasSummary = !!cell.summaryType && cell.summaryType !== "NONE";
|
|
|
|
const [{ isOver, canDrop }, drop] = useDrop(
|
|
() => ({
|
|
accept: TABLE_COLUMN_DND_TYPE,
|
|
drop: (item: PaletteDragItem) => onDrop(item.columnName),
|
|
canDrop: () => !cell.merged,
|
|
collect: (monitor) => ({
|
|
isOver: monitor.isOver(),
|
|
canDrop: monitor.canDrop(),
|
|
}),
|
|
}),
|
|
[cell, onDrop],
|
|
);
|
|
|
|
if (cell.merged) return null;
|
|
|
|
const rSpan = cell.rowSpan ?? 1;
|
|
const cSpan = cell.colSpan ?? 1;
|
|
|
|
let bg = isHeader ? "#f3f4f6" : isFooter ? "#f8fafc" : "white";
|
|
if (isOver && canDrop) bg = "#dbeafe";
|
|
else if (hasSummary && isFooter) bg = "#eff6ff";
|
|
else if (isField) bg = "#eff6ff";
|
|
else if (isLabel && isHeader) bg = "#f0fdf4";
|
|
|
|
const renderContent = () => {
|
|
if (isFooter && hasSummary) {
|
|
return (
|
|
<div className="flex cursor-pointer items-center justify-center gap-1" onClick={onFooterClick}>
|
|
<Calculator className="h-3 w-3 shrink-0 text-blue-500" />
|
|
<span className="text-[10px] font-semibold text-blue-700">
|
|
{SUMMARY_LABELS[cell.summaryType!] || cell.summaryType}
|
|
</span>
|
|
{cell.field && <span className="truncate text-[10px] text-blue-400">({cell.field})</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isFooter && !isField && !isLabel) {
|
|
return (
|
|
<span
|
|
className="block cursor-pointer text-center text-[10px] text-gray-400 hover:text-blue-600"
|
|
onClick={onFooterClick}
|
|
>
|
|
클릭하여 설정
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (isField) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-1">
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
<Database className="h-3 w-3 shrink-0 text-blue-500" />
|
|
<span className="truncate text-xs font-medium text-blue-700">{cell.field}</span>
|
|
</div>
|
|
<button
|
|
onClick={onClear}
|
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-500"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLabel) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-1">
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
<Type className="h-3 w-3 shrink-0 text-green-600" />
|
|
<span className="truncate text-xs font-semibold text-gray-700">{cell.value}</span>
|
|
</div>
|
|
<button
|
|
onClick={onClear}
|
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-500"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <span className="block text-center text-xs text-gray-300">—</span>;
|
|
};
|
|
|
|
return (
|
|
<td
|
|
ref={(node) => {
|
|
drop(node);
|
|
}}
|
|
rowSpan={rSpan > 1 ? rSpan : undefined}
|
|
colSpan={cSpan > 1 ? cSpan : undefined}
|
|
className={`border ${isHeader ? "border-gray-300" : isFooter ? "border-blue-200" : "border-gray-200"}`}
|
|
style={{
|
|
width,
|
|
height: Math.max(height, 32),
|
|
minWidth: width,
|
|
backgroundColor: bg,
|
|
padding: "3px 6px",
|
|
verticalAlign: "middle",
|
|
transition: "background-color 150ms",
|
|
}}
|
|
>
|
|
{renderContent()}
|
|
</td>
|
|
);
|
|
}
|
|
|
|
// ─── 메인 컴포넌트 ──────────────────────────────────────────────────────────
|
|
|
|
export function GridCellDropZone({
|
|
cells,
|
|
rowCount,
|
|
colCount,
|
|
colWidths,
|
|
rowHeights,
|
|
headerRows = 1,
|
|
headerCols = 1,
|
|
footerRows = 0,
|
|
onCellDrop,
|
|
onHeaderDrop,
|
|
onCellClear,
|
|
onFooterCellClick,
|
|
}: GridCellDropZoneProps) {
|
|
const totalWidth = colWidths.reduce((a, b) => a + b, 0);
|
|
|
|
const findCell = (row: number, col: number) => cells.find((c) => c.row === row && c.col === col);
|
|
|
|
if (cells.length === 0 || rowCount === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center rounded-lg border border-dashed border-gray-300 py-8 text-xs text-gray-400">
|
|
레이아웃 탭에서 격자를 먼저 구성하세요.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fieldCount = cells.filter(
|
|
(c) => (c.cellType === "field" || (c.cellType === "static" && c.value)) && !c.merged,
|
|
).length;
|
|
const totalNonMerged = cells.filter((c) => !c.merged).length;
|
|
const footerStartRow = rowCount - footerRows;
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<div className="space-y-3">
|
|
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
|
<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>
|
|
<span className="text-[10px] text-gray-400">{fieldCount}/{totalNonMerged} 배치됨</span>
|
|
</div>
|
|
<div className="overflow-auto p-3">
|
|
<table className="border-collapse" style={{ width: totalWidth, tableLayout: "fixed" }}>
|
|
<colgroup>
|
|
{colWidths.map((w, i) => (
|
|
<col key={i} style={{ width: w }} />
|
|
))}
|
|
</colgroup>
|
|
<tbody>
|
|
{Array.from({ length: rowCount }).map((_, r) => {
|
|
const tds: React.ReactNode[] = [];
|
|
for (let c = 0; c < colCount; c++) {
|
|
const cell = findCell(r, c);
|
|
if (!cell || cell.merged) continue;
|
|
|
|
const cSpan = cell.colSpan ?? 1;
|
|
const rSpan = cell.rowSpan ?? 1;
|
|
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 isHeader = r < headerRows || c < headerCols;
|
|
const isFooter = footerRows > 0 && r >= footerStartRow;
|
|
|
|
const cellNode = (
|
|
<DropCell
|
|
key={cell.id}
|
|
cell={cell}
|
|
width={w}
|
|
height={h}
|
|
isHeader={isHeader}
|
|
isFooter={isFooter}
|
|
onDrop={(columnName) =>
|
|
isHeader ? onHeaderDrop(r, c, columnName) : onCellDrop(r, c, columnName)
|
|
}
|
|
onClear={() => onCellClear(r, c)}
|
|
onFooterClick={isFooter && onFooterCellClick ? () => onFooterCellClick(r, c) : undefined}
|
|
/>
|
|
);
|
|
|
|
if (isFooter) {
|
|
tds.push(
|
|
<Tooltip key={cell.id}>
|
|
<TooltipTrigger asChild>{cellNode}</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-[200px]">
|
|
<p className="text-xs">
|
|
{cell.summaryType && cell.summaryType !== "NONE"
|
|
? `${SUMMARY_LABELS[cell.summaryType] || cell.summaryType}${cell.field ? ` (${cell.field})` : ""}`
|
|
: "클릭하여 계산 방식을 선택하세요"}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>,
|
|
);
|
|
} else {
|
|
tds.push(cellNode);
|
|
}
|
|
}
|
|
return <tr key={`drop-row-${r}`}>{tds}</tr>;
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
}
|