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

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