"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; componentPositions: Record; 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(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(); 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 (
{ 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 && (
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
)} {/* 컴포넌트 GridLayout */} {componentIds.length > 0 && availableWidth > 0 && cols > 0 && ( {componentIds.map((compId) => { const compDef = components[compId]; if (!compDef) return null; return (
{ e.stopPropagation(); onSelectComponent(compId); }} onMouseDown={(e) => e.stopPropagation()} > {/* 드래그 핸들 바 */}
{/* 컴포넌트 내용 */}
{/* 삭제 버튼 */} {selectedComponentId === compId && isActive && ( )}
); })}
)}
); } // ======================================== // 컴포넌트 미리보기 // ======================================== interface ComponentPreviewV2Props { component: PopComponentDefinition; } function ComponentPreviewV2({ component }: ComponentPreviewV2Props) { const { type, label } = component; const renderPreview = () => { switch (type) { case "pop-field": return (
{label || "필드"}
); case "pop-button": return (
{label || "버튼"}
); case "pop-list": return (
{label || "리스트"}
); case "pop-indicator": return (
{label || "KPI"} 0
); case "pop-scanner": return (
QR
{label || "스캐너"}
); case "pop-numpad": return (
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
{key}
))}
); default: return {label || type}; } }; return
{renderPreview()}
; }