374 lines
12 KiB
TypeScript
374 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 {
|
||
|
|
PopSectionDefinition,
|
||
|
|
PopComponentDefinition,
|
||
|
|
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";
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// Props
|
||
|
|
// ========================================
|
||
|
|
interface SectionGridV2Props {
|
||
|
|
sectionId: string;
|
||
|
|
sectionDef: PopSectionDefinition;
|
||
|
|
components: Record<string, PopComponentDefinition>;
|
||
|
|
componentPositions: Record<string, GridPosition>;
|
||
|
|
isActive: boolean;
|
||
|
|
selectedComponentId: string | null;
|
||
|
|
onSelectComponent: (id: string | null) => void;
|
||
|
|
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
||
|
|
onUpdateComponentPosition: (componentId: string, position: GridPosition) => void;
|
||
|
|
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 메인 컴포넌트
|
||
|
|
// ========================================
|
||
|
|
export function SectionGridV2({
|
||
|
|
sectionId,
|
||
|
|
sectionDef,
|
||
|
|
components,
|
||
|
|
componentPositions,
|
||
|
|
isActive,
|
||
|
|
selectedComponentId,
|
||
|
|
onSelectComponent,
|
||
|
|
onDropComponent,
|
||
|
|
onUpdateComponentPosition,
|
||
|
|
onDeleteComponent,
|
||
|
|
}: SectionGridV2Props) {
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
|
|
||
|
|
// 이 섹션에 포함된 컴포넌트 ID 목록
|
||
|
|
const componentIds = sectionDef.componentIds || [];
|
||
|
|
|
||
|
|
// 컨테이너 크기 측정
|
||
|
|
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용 레이아웃 변환
|
||
|
|
const gridLayoutItems: Layout[] = useMemo(() => {
|
||
|
|
return componentIds
|
||
|
|
.map((compId) => {
|
||
|
|
const pos = componentPositions[compId];
|
||
|
|
if (!pos) return null;
|
||
|
|
|
||
|
|
// 위치가 그리드 범위를 벗어나지 않도록 조정
|
||
|
|
const x = Math.min(Math.max(0, pos.col - 1), Math.max(0, cols - 1));
|
||
|
|
const y = Math.min(Math.max(0, pos.row - 1), Math.max(0, rows - 1));
|
||
|
|
const w = Math.min(Math.max(1, pos.colSpan), Math.max(1, cols - x));
|
||
|
|
const h = Math.min(Math.max(1, pos.rowSpan), Math.max(1, rows - y));
|
||
|
|
|
||
|
|
return {
|
||
|
|
i: compId,
|
||
|
|
x,
|
||
|
|
y,
|
||
|
|
w,
|
||
|
|
h,
|
||
|
|
minW: 1,
|
||
|
|
minH: 1,
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter((item): item is Layout => item !== null);
|
||
|
|
}, [componentIds, componentPositions, cols, rows]);
|
||
|
|
|
||
|
|
// 드래그 완료 핸들러
|
||
|
|
const handleDragStop = useCallback(
|
||
|
|
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
||
|
|
const newPos: GridPosition = {
|
||
|
|
col: newItem.x + 1,
|
||
|
|
row: newItem.y + 1,
|
||
|
|
colSpan: newItem.w,
|
||
|
|
rowSpan: newItem.h,
|
||
|
|
};
|
||
|
|
|
||
|
|
const oldPos = componentPositions[newItem.i];
|
||
|
|
if (!oldPos || oldPos.col !== newPos.col || oldPos.row !== newPos.row) {
|
||
|
|
onUpdateComponentPosition(newItem.i, newPos);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[componentPositions, onUpdateComponentPosition]
|
||
|
|
);
|
||
|
|
|
||
|
|
// 리사이즈 완료 핸들러
|
||
|
|
const handleResizeStop = useCallback(
|
||
|
|
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
||
|
|
const newPos: GridPosition = {
|
||
|
|
col: newItem.x + 1,
|
||
|
|
row: newItem.y + 1,
|
||
|
|
colSpan: newItem.w,
|
||
|
|
rowSpan: newItem.h,
|
||
|
|
};
|
||
|
|
|
||
|
|
const oldPos = componentPositions[newItem.i];
|
||
|
|
if (!oldPos || oldPos.colSpan !== newPos.colSpan || oldPos.rowSpan !== newPos.rowSpan) {
|
||
|
|
onUpdateComponentPosition(newItem.i, newPos);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[componentPositions, onUpdateComponentPosition]
|
||
|
|
);
|
||
|
|
|
||
|
|
// 빈 셀 찾기 (드롭 위치용)
|
||
|
|
const findEmptyCell = useCallback((): GridPosition => {
|
||
|
|
const occupied = new Set<string>();
|
||
|
|
|
||
|
|
componentIds.forEach((compId) => {
|
||
|
|
const pos = componentPositions[compId];
|
||
|
|
if (!pos) return;
|
||
|
|
|
||
|
|
for (let c = pos.col; c < pos.col + pos.colSpan; c++) {
|
||
|
|
for (let r = pos.row; r < pos.row + pos.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 };
|
||
|
|
}, [componentIds, componentPositions, cols, rows]);
|
||
|
|
|
||
|
|
// 컴포넌트 드롭 핸들러
|
||
|
|
const [{ isOver, canDrop }, drop] = useDrop(
|
||
|
|
() => ({
|
||
|
|
accept: DND_ITEM_TYPES.COMPONENT,
|
||
|
|
drop: (item: DragItemComponent) => {
|
||
|
|
if (!isActive) return;
|
||
|
|
const emptyCell = findEmptyCell();
|
||
|
|
onDropComponent(sectionId, item.componentType, emptyCell);
|
||
|
|
return { dropped: true };
|
||
|
|
},
|
||
|
|
canDrop: () => isActive,
|
||
|
|
collect: (monitor) => ({
|
||
|
|
isOver: monitor.isOver(),
|
||
|
|
canDrop: monitor.canDrop(),
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
[isActive, sectionId, 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();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* 빈 상태 안내 텍스트 */}
|
||
|
|
{componentIds.length === 0 && (
|
||
|
|
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
"rounded bg-white/80 px-2 py-1 text-xs",
|
||
|
|
isOver && canDrop ? "font-medium text-primary" : "text-gray-400"
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 컴포넌트 GridLayout */}
|
||
|
|
{componentIds.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"]}
|
||
|
|
>
|
||
|
|
{componentIds.map((compId) => {
|
||
|
|
const compDef = components[compId];
|
||
|
|
if (!compDef) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={compId}
|
||
|
|
className={cn(
|
||
|
|
"group relative flex flex-col overflow-hidden rounded border bg-white text-xs transition-all",
|
||
|
|
selectedComponentId === compId
|
||
|
|
? "border-primary ring-2 ring-primary/30"
|
||
|
|
: "border-gray-200 hover:border-gray-400"
|
||
|
|
)}
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
onSelectComponent(compId);
|
||
|
|
}}
|
||
|
|
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 transition-opacity hover:opacity-100">
|
||
|
|
<Move className="h-3 w-3 text-gray-400" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 컴포넌트 내용 */}
|
||
|
|
<div className="flex flex-1 items-center justify-center p-1">
|
||
|
|
<ComponentPreviewV2 component={compDef} />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 삭제 버튼 */}
|
||
|
|
{selectedComponentId === compId && isActive && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white text-destructive shadow hover:bg-destructive/10"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
onDeleteComponent(sectionId, compId);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-3 w-3" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</GridLayout>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ========================================
|
||
|
|
// 컴포넌트 미리보기
|
||
|
|
// ========================================
|
||
|
|
interface ComponentPreviewV2Props {
|
||
|
|
component: PopComponentDefinition;
|
||
|
|
}
|
||
|
|
|
||
|
|
function ComponentPreviewV2({ component }: ComponentPreviewV2Props) {
|
||
|
|
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 font-medium text-primary">
|
||
|
|
{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="flex h-8 w-8 items-center justify-center rounded border-2 border-dashed border-gray-300">
|
||
|
|
<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 w-full grid-cols-3 gap-0.5">
|
||
|
|
{[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>;
|
||
|
|
}
|