ERP-node/frontend/components/pop/designer/SectionGrid.tsx

353 lines
12 KiB
TypeScript

"use client";
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
import { useDrop } from "react-dnd";
import GridLayout, { Layout } from "react-grid-layout";
import { cn } from "@/lib/utils";
import {
PopSectionData,
PopComponentData,
PopComponentType,
GridPosition,
} from "./types/pop-layout";
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
import { Trash2, Move } from "lucide-react";
import { Button } from "@/components/ui/button";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
interface SectionGridProps {
section: PopSectionData;
isActive: boolean;
selectedComponentId: string | null;
onSelectComponent: (id: string | null) => void;
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
onUpdateComponent: (sectionId: string, componentId: string, updates: Partial<PopComponentData>) => void;
onDeleteComponent: (sectionId: string, componentId: string) => void;
}
export function SectionGrid({
section,
isActive,
selectedComponentId,
onSelectComponent,
onDropComponent,
onUpdateComponent,
onDeleteComponent,
}: SectionGridProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { components } = section;
// 컨테이너 크기 측정
const [containerSize, setContainerSize] = useState({ width: 300, height: 200 });
useEffect(() => {
const updateSize = () => {
if (containerRef.current) {
setContainerSize({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
}
};
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, []);
// 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산
const padding = 8; // p-2 = 8px
const gap = 4; // 고정 간격
const availableWidth = containerSize.width - padding * 2;
const availableHeight = containerSize.height - padding * 2;
// 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산
const CELL_SIZE = 40;
const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap)));
const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap)));
const cellHeight = CELL_SIZE;
// GridLayout용 레이아웃 변환 (자동 계산된 cols/rows 사용)
const gridLayoutItems: Layout[] = useMemo(() => {
return components.map((comp) => {
// 컴포넌트 위치가 그리드 범위를 벗어나지 않도록 조정
const x = Math.min(Math.max(0, comp.grid.col - 1), Math.max(0, cols - 1));
const y = Math.min(Math.max(0, comp.grid.row - 1), Math.max(0, rows - 1));
// colSpan/rowSpan도 범위 제한
const w = Math.min(Math.max(1, comp.grid.colSpan), Math.max(1, cols - x));
const h = Math.min(Math.max(1, comp.grid.rowSpan), Math.max(1, rows - y));
return {
i: comp.id,
x,
y,
w,
h,
minW: 1,
minH: 1,
};
});
}, [components, cols, rows]);
// 드래그/리사이즈 완료 핸들러 (onDragStop, onResizeStop 사용)
const handleDragStop = useCallback(
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
const comp = components.find((c) => c.id === newItem.i);
if (!comp) return;
const newGrid: GridPosition = {
col: newItem.x + 1,
row: newItem.y + 1,
colSpan: newItem.w,
rowSpan: newItem.h,
};
if (
comp.grid.col !== newGrid.col ||
comp.grid.row !== newGrid.row
) {
onUpdateComponent(section.id, comp.id, { grid: newGrid });
}
},
[components, section.id, onUpdateComponent]
);
const handleResizeStop = useCallback(
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
const comp = components.find((c) => c.id === newItem.i);
if (!comp) return;
const newGrid: GridPosition = {
col: newItem.x + 1,
row: newItem.y + 1,
colSpan: newItem.w,
rowSpan: newItem.h,
};
if (
comp.grid.colSpan !== newGrid.colSpan ||
comp.grid.rowSpan !== newGrid.rowSpan
) {
onUpdateComponent(section.id, comp.id, { grid: newGrid });
}
},
[components, section.id, onUpdateComponent]
);
// 빈 셀 찾기 (드롭 위치용) - 자동 계산된 cols/rows 사용
const findEmptyCell = useCallback((): GridPosition => {
const occupied = new Set<string>();
components.forEach((comp) => {
for (let c = comp.grid.col; c < comp.grid.col + comp.grid.colSpan; c++) {
for (let r = comp.grid.row; r < comp.grid.row + comp.grid.rowSpan; r++) {
occupied.add(`${c}-${r}`);
}
}
});
// 빈 셀 찾기
for (let r = 1; r <= rows; r++) {
for (let c = 1; c <= cols; c++) {
if (!occupied.has(`${c}-${r}`)) {
return { col: c, row: r, colSpan: 1, rowSpan: 1 };
}
}
}
// 빈 셀 없으면 첫 번째 위치에
return { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
}, [components, cols, rows]);
// 컴포넌트 드롭 핸들러
const [{ isOver, canDrop }, drop] = useDrop(() => ({
accept: DND_ITEM_TYPES.COMPONENT,
drop: (item: DragItemComponent) => {
if (!isActive) return;
const emptyCell = findEmptyCell();
onDropComponent(section.id, item.componentType, emptyCell);
return { dropped: true };
},
canDrop: () => isActive,
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}), [isActive, section.id, findEmptyCell, onDropComponent]);
return (
<div
ref={(node) => {
containerRef.current = node;
drop(node);
}}
className={cn(
"relative h-full w-full p-2 transition-colors",
isOver && canDrop && "bg-blue-50"
)}
onClick={(e) => {
e.stopPropagation();
onSelectComponent(null);
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
{/* 빈 상태 안내 텍스트 */}
{components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center z-10">
<span className={cn(
"rounded bg-white/80 px-2 py-1 text-xs",
isOver && canDrop ? "text-primary font-medium" : "text-gray-400"
)}>
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
</span>
</div>
)}
{/* 컴포넌트 GridLayout */}
{components.length > 0 && availableWidth > 0 && cols > 0 && (
<GridLayout
className="layout relative z-10"
layout={gridLayoutItems}
cols={cols}
rowHeight={cellHeight}
width={availableWidth}
margin={[gap, gap]}
containerPadding={[0, 0]}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
isDraggable={isActive}
isResizable={isActive}
compactType={null}
preventCollision={false}
useCSSTransforms={true}
draggableHandle=".component-drag-handle"
resizeHandles={["se", "e", "s"]}
>
{components.map((comp) => (
<div
key={comp.id}
className={cn(
"group relative flex flex-col rounded border bg-white text-xs transition-all overflow-hidden",
selectedComponentId === comp.id
? "border-primary ring-2 ring-primary/30"
: "border-gray-200 hover:border-gray-400"
)}
onClick={(e) => {
e.stopPropagation();
onSelectComponent(comp.id);
}}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 드래그 핸들 바 */}
<div className="component-drag-handle flex h-5 cursor-move items-center justify-center border-b bg-gray-50 opacity-60 hover:opacity-100 transition-opacity">
<Move className="h-3 w-3 text-gray-400" />
</div>
{/* 컴포넌트 내용 */}
<div className="flex flex-1 items-center justify-center p-1">
<ComponentPreview component={comp} />
</div>
{/* 삭제 버튼 */}
{selectedComponentId === comp.id && (
<Button
variant="ghost"
size="icon"
className="absolute -right-2 -top-2 h-5 w-5 rounded-full bg-white shadow text-destructive hover:bg-destructive/10 z-10"
onClick={(e) => {
e.stopPropagation();
onDeleteComponent(section.id, comp.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
))}
</GridLayout>
)}
</div>
);
}
// 컴포넌트 미리보기
interface ComponentPreviewProps {
component: PopComponentData;
}
function ComponentPreview({ component }: ComponentPreviewProps) {
const { type, label } = component;
// 타입별 미리보기 렌더링
const renderPreview = () => {
switch (type) {
case "pop-field":
return (
<div className="flex w-full flex-col gap-1">
<span className="text-[10px] text-gray-500">{label || "필드"}</span>
<div className="h-6 w-full rounded border border-gray-200 bg-gray-50" />
</div>
);
case "pop-button":
return (
<div className="flex h-8 w-full items-center justify-center rounded bg-primary/10 text-primary font-medium">
{label || "버튼"}
</div>
);
case "pop-list":
return (
<div className="flex w-full flex-col gap-0.5">
<span className="text-[10px] text-gray-500">{label || "리스트"}</span>
<div className="h-3 w-full rounded bg-gray-100" />
<div className="h-3 w-3/4 rounded bg-gray-100" />
<div className="h-3 w-full rounded bg-gray-100" />
</div>
);
case "pop-indicator":
return (
<div className="flex w-full flex-col items-center gap-1">
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
<span className="text-lg font-bold text-primary">0</span>
</div>
);
case "pop-scanner":
return (
<div className="flex w-full flex-col items-center gap-1">
<div className="h-8 w-8 rounded border-2 border-dashed border-gray-300 flex items-center justify-center">
<span className="text-[8px] text-gray-400">QR</span>
</div>
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
</div>
);
case "pop-numpad":
return (
<div className="grid grid-cols-3 gap-0.5 w-full">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
<div
key={key}
className="flex h-4 items-center justify-center rounded bg-gray-100 text-[8px]"
>
{key}
</div>
))}
</div>
);
default:
return <span className="text-gray-500">{label || type}</span>;
}
};
return <div className="w-full overflow-hidden">{renderPreview()}</div>;
}