"use client"; import { useState, useCallback, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Palette, Grid3X3, Type, Calendar, Hash, CheckSquare, Radio, Save, Undo, Redo, Group, Database, Trash2, Settings, ChevronDown, Code, Building, File, List, AlignLeft, ChevronRight, } from "lucide-react"; import { ScreenDefinition, ComponentData, LayoutData, GroupState, WebType, TableInfo, GroupComponent, } from "@/types/screen"; import { generateComponentId } from "@/lib/utils/generateId"; import { createGroupComponent, calculateBoundingBox, calculateRelativePositions, restoreAbsolutePositions, getGroupChildren, } from "@/lib/utils/groupingUtils"; import { GroupingToolbar } from "./GroupingToolbar"; import StyleEditor from "./StyleEditor"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { RealtimePreview } from "./RealtimePreview"; interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; } interface ComponentMoveState { isMoving: boolean; movingComponent: ComponentData | null; originalPosition: { x: number; y: number }; currentPosition: { x: number; y: number }; } export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { const [layout, setLayout] = useState({ components: [], gridSettings: { columns: 12, gap: 16, padding: 16 }, }); const [selectedComponent, setSelectedComponent] = useState(null); // 실행취소/다시실행을 위한 히스토리 상태 const [history, setHistory] = useState([ { components: [], gridSettings: { columns: 12, gap: 16, padding: 16 }, }, ]); const [historyIndex, setHistoryIndex] = useState(0); // 히스토리에 상태 저장 const saveToHistory = useCallback( (newLayout: LayoutData) => { setHistory((prevHistory) => { const newHistory = prevHistory.slice(0, historyIndex + 1); newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사 return newHistory.slice(-50); // 최대 50개 히스토리 유지 }); setHistoryIndex((prevIndex) => Math.min(prevIndex + 1, 49)); }, [historyIndex], ); // 실행취소 const undo = useCallback(() => { if (historyIndex > 0) { const newIndex = historyIndex - 1; setHistoryIndex(newIndex); setLayout(JSON.parse(JSON.stringify(history[newIndex]))); setSelectedComponent(null); // 선택 해제 } }, [historyIndex, history]); // 다시실행 const redo = useCallback(() => { if (historyIndex < history.length - 1) { const newIndex = historyIndex + 1; setHistoryIndex(newIndex); setLayout(JSON.parse(JSON.stringify(history[newIndex]))); setSelectedComponent(null); // 선택 해제 } }, [historyIndex, history]); // 키보드 단축키 지원 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey) { switch (e.key) { case "z": e.preventDefault(); if (e.shiftKey) { redo(); // Ctrl+Shift+Z 또는 Cmd+Shift+Z } else { undo(); // Ctrl+Z 또는 Cmd+Z } break; case "y": e.preventDefault(); redo(); // Ctrl+Y 또는 Cmd+Y break; } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [undo, redo]); const [dragState, setDragState] = useState({ isDragging: false, draggedComponent: null as ComponentData | null, originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, }); const [groupState, setGroupState] = useState({ isGrouping: false, selectedComponents: [], groupTarget: null, groupMode: "create", }); const [tables, setTables] = useState([]); const [expandedTables, setExpandedTables] = useState>(new Set()); // 테이블 검색 및 페이징 상태 추가 const [searchTerm, setSearchTerm] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage] = useState(10); // 테이블 데이터 로드 (실제로는 API에서 가져와야 함) useEffect(() => { const fetchTables = async () => { try { const response = await fetch("http://localhost:8080/api/screen-management/tables", { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}`, }, }); if (response.ok) { const data = await response.json(); if (data.success) { setTables(data.data); } else { console.error("테이블 조회 실패:", data.message); // 임시 데이터로 폴백 setTables(getMockTables()); } } else { console.error("테이블 조회 실패:", response.status); // 임시 데이터로 폴백 setTables(getMockTables()); } } catch (error) { console.error("테이블 조회 중 오류:", error); // 임시 데이터로 폴백 setTables(getMockTables()); } }; fetchTables(); }, []); // 검색된 테이블 필터링 const filteredTables = useMemo(() => { if (!searchTerm.trim()) return tables; return tables.filter( (table) => table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || table.tableLabel.toLowerCase().includes(searchTerm.toLowerCase()) || table.columns.some( (column) => column.columnName.toLowerCase().includes(searchTerm.toLowerCase()) || (column.columnLabel || column.columnName).toLowerCase().includes(searchTerm.toLowerCase()), ), ); }, [tables, searchTerm]); // 페이징된 테이블 const paginatedTables = useMemo(() => { const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; return filteredTables.slice(startIndex, endIndex); }, [filteredTables, currentPage, itemsPerPage]); // 총 페이지 수 계산 const totalPages = Math.ceil(filteredTables.length / itemsPerPage); // 페이지 변경 핸들러 const handlePageChange = (page: number) => { setCurrentPage(page); setExpandedTables(new Set()); // 페이지 변경 시 확장 상태 초기화 }; // 검색어 변경 핸들러 const handleSearchChange = (value: string) => { setSearchTerm(value); setCurrentPage(1); // 검색 시 첫 페이지로 이동 setExpandedTables(new Set()); // 검색 시 확장 상태 초기화 }; // 임시 테이블 데이터 (API 실패 시 사용) const getMockTables = (): TableInfo[] => [ { tableName: "user_info", tableLabel: "사용자 정보", columns: [ { tableName: "user_info", columnName: "user_id", columnLabel: "사용자 ID", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "user_info", columnName: "user_name", columnLabel: "사용자명", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "user_info", columnName: "email", columnLabel: "이메일", webType: "email", dataType: "VARCHAR", isNullable: "YES", }, { tableName: "user_info", columnName: "phone", columnLabel: "전화번호", webType: "tel", dataType: "VARCHAR", isNullable: "YES", }, { tableName: "user_info", columnName: "birth_date", columnLabel: "생년월일", webType: "date", dataType: "DATE", isNullable: "YES", }, { tableName: "user_info", columnName: "is_active", columnLabel: "활성화", webType: "checkbox", dataType: "BOOLEAN", isNullable: "NO", }, { tableName: "user_info", columnName: "profile_code", columnLabel: "프로필 코드", webType: "code", dataType: "TEXT", isNullable: "YES", }, { tableName: "user_info", columnName: "department", columnLabel: "부서", webType: "entity", dataType: "VARCHAR", isNullable: "YES", }, { tableName: "user_info", columnName: "profile_image", columnLabel: "프로필 이미지", webType: "file", dataType: "VARCHAR", isNullable: "YES", }, ], }, { tableName: "product_info", tableLabel: "제품 정보", columns: [ { tableName: "product_info", columnName: "product_id", columnLabel: "제품 ID", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "product_info", columnName: "product_name", columnLabel: "제품명", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "product_info", columnName: "category", columnLabel: "카테고리", webType: "select", dataType: "VARCHAR", isNullable: "YES", }, { tableName: "product_info", columnName: "price", columnLabel: "가격", webType: "number", dataType: "DECIMAL", isNullable: "YES", }, { tableName: "product_info", columnName: "description", columnLabel: "설명", webType: "textarea", dataType: "TEXT", isNullable: "YES", }, { tableName: "product_info", columnName: "created_date", columnLabel: "생성일", webType: "date", dataType: "TIMESTAMP", isNullable: "NO", }, ], }, { tableName: "order_info", tableLabel: "주문 정보", columns: [ { tableName: "order_info", columnName: "order_id", columnLabel: "주문 ID", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "order_info", columnName: "customer_name", columnLabel: "고객명", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "order_info", columnName: "order_date", columnLabel: "주문일", webType: "date", dataType: "DATE", isNullable: "NO", }, { tableName: "order_info", columnName: "total_amount", columnLabel: "총 금액", webType: "number", dataType: "DECIMAL", isNullable: "NO", }, { tableName: "order_info", columnName: "status", columnLabel: "상태", webType: "select", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "order_info", columnName: "notes", columnLabel: "비고", webType: "textarea", dataType: "TEXT", isNullable: "YES", }, ], }, ]; // 테이블 확장/축소 토글 const toggleTableExpansion = useCallback((tableName: string) => { setExpandedTables((prev) => { const newSet = new Set(prev); if (newSet.has(tableName)) { newSet.delete(tableName); } else { newSet.add(tableName); } return newSet; }); }, []); // 웹타입에 따른 위젯 타입 매핑 const getWidgetTypeFromWebType = useCallback((webType: string): string => { console.log("getWidgetTypeFromWebType - input webType:", webType); switch (webType) { case "text": return "text"; case "email": return "email"; case "tel": return "tel"; case "number": return "number"; case "decimal": return "decimal"; case "date": return "date"; case "datetime": return "datetime"; case "select": return "select"; case "dropdown": return "dropdown"; case "textarea": return "textarea"; case "text_area": return "text_area"; case "checkbox": return "checkbox"; case "boolean": return "boolean"; case "radio": return "radio"; case "code": return "code"; case "entity": return "entity"; case "file": return "file"; default: console.log("getWidgetTypeFromWebType - default case, returning text for:", webType); return "text"; } }, []); // 컴포넌트 제거 함수 const removeComponent = useCallback( (componentId: string) => { const newLayout = { ...layout, components: layout.components.filter((comp) => comp.id !== componentId), }; setLayout(newLayout); saveToHistory(newLayout); if (selectedComponent?.id === componentId) { setSelectedComponent(null); } }, [layout, selectedComponent, saveToHistory], ); // 컴포넌트 속성 업데이트 함수 const updateComponentProperty = useCallback( (componentId: string, propertyPath: string, value: any) => { const newLayout = { ...layout, components: layout.components.map((comp) => { if (comp.id === componentId) { const newComp = { ...comp }; const pathParts = propertyPath.split("."); let current: any = newComp; for (let i = 0; i < pathParts.length - 1; i++) { current = current[pathParts[i]]; } current[pathParts[pathParts.length - 1]] = value; return newComp; } return comp; }), }; setLayout(newLayout); saveToHistory(newLayout); }, [layout, saveToHistory], ); // 그룹 생성 함수 const handleGroupCreate = useCallback( (componentIds: string[], title: string, style?: any) => { const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id)); if (selectedComponents.length < 2) { return; } // 경계 박스 계산 const boundingBox = calculateBoundingBox(selectedComponents); // 그룹 컴포넌트 생성 const groupComponent = createGroupComponent( componentIds, title, { x: boundingBox.minX, y: boundingBox.minY }, style, ); // 자식 컴포넌트들의 상대 위치 계산 const relativeChildren = calculateRelativePositions(selectedComponents, { x: boundingBox.minX, y: boundingBox.minY, }); // 새 레이아웃 생성 const newLayout = { ...layout, components: [ // 그룹이 아닌 기존 컴포넌트들 ...layout.components.filter((comp) => !componentIds.includes(comp.id) && comp.type !== "group"), // 그룹 컴포넌트 groupComponent, // 상대 위치로 업데이트된 자식 컴포넌트들 ...relativeChildren, ], }; setLayout(newLayout); saveToHistory(newLayout); }, [layout, saveToHistory], ); // 그룹 해제 함수 const handleGroupUngroup = useCallback( (groupId: string) => { const group = layout.components.find((comp) => comp.id === groupId) as GroupComponent; if (!group || group.type !== "group") { return; } const groupChildren = getGroupChildren(layout.components, groupId); // 자식 컴포넌트들의 절대 위치 복원 const absoluteChildren = restoreAbsolutePositions(groupChildren, group.position); // 새 레이아웃 생성 const newLayout = { ...layout, components: [ // 그룹이 아닌 기존 컴포넌트들 ...layout.components.filter((comp) => comp.id !== groupId), // 절대 위치로 복원된 자식 컴포넌트들 ...absoluteChildren, ], }; setLayout(newLayout); saveToHistory(newLayout); }, [layout, saveToHistory], ); // 레이아웃 저장 함수 const saveLayout = useCallback(async () => { try { // TODO: 실제 API 호출로 변경 console.log("레이아웃 저장:", layout); // await saveLayoutAPI(selectedScreen.screenId, layout); } catch (error) { console.error("레이아웃 저장 실패:", error); } }, [layout, selectedScreen]); // 드래그 시작 (새 컴포넌트 추가) const startDrag = useCallback((component: Partial, e: React.DragEvent) => { setDragState({ isDragging: true, draggedComponent: component as ComponentData, originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, }); e.dataTransfer.setData("application/json", JSON.stringify(component)); }, []); // 기존 컴포넌트 드래그 시작 (재배치) const startComponentDrag = useCallback((component: ComponentData, e: React.DragEvent) => { e.stopPropagation(); setDragState({ isDragging: true, draggedComponent: component, originalPosition: component.position, currentPosition: component.position, }); e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true })); }, []); // 드래그 중 const onDragOver = useCallback( (e: React.DragEvent) => { e.preventDefault(); if (dragState.isDragging) { const rect = e.currentTarget.getBoundingClientRect(); const x = Math.floor((e.clientX - rect.left) / 80) * 80; const y = Math.floor((e.clientY - rect.top) / 60) * 60; setDragState((prev) => ({ ...prev, currentPosition: { x, y }, })); } }, [dragState.isDragging], ); // 드롭 처리 const onDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); try { const data = JSON.parse(e.dataTransfer.getData("application/json")); if (data.isMoving) { // 기존 컴포넌트 재배치 const rect = e.currentTarget.getBoundingClientRect(); const x = Math.floor((e.clientX - rect.left) / 80) * 80; const y = Math.floor((e.clientY - rect.top) / 60) * 60; const newLayout = { ...layout, components: layout.components.map((comp) => (comp.id === data.id ? { ...comp, position: { x, y } } : comp)), }; setLayout(newLayout); saveToHistory(newLayout); } else { // 새 컴포넌트 추가 const rect = e.currentTarget.getBoundingClientRect(); const x = Math.floor((e.clientX - rect.left) / 80) * 80; const y = Math.floor((e.clientY - rect.top) / 60) * 60; const newComponent: ComponentData = { ...data, id: generateComponentId(), position: { x, y }, } as ComponentData; const newLayout = { ...layout, components: [...layout.components, newComponent], }; setLayout(newLayout); saveToHistory(newLayout); } } catch (error) { console.error("드롭 처리 중 오류:", error); } setDragState({ isDragging: false, draggedComponent: null, originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, }); }, [layout, saveToHistory], ); // 드래그 종료 const endDrag = useCallback(() => { setDragState({ isDragging: false, draggedComponent: null, originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, }); }, []); // 컴포넌트 클릭 (선택) const handleComponentClick = useCallback( (component: ComponentData) => { if (groupState.isGrouping) { // 그룹화 모드에서는 다중 선택 const isSelected = groupState.selectedComponents.includes(component.id); setGroupState((prev) => ({ ...prev, selectedComponents: isSelected ? prev.selectedComponents.filter((id) => id !== component.id) : [...prev.selectedComponents, component.id], })); } else { // 일반 모드에서는 단일 선택 setSelectedComponent(component); setGroupState((prev) => ({ ...prev, selectedComponents: [component.id], })); } }, [groupState.isGrouping], ); // 화면이 선택되지 않았을 때 처리 if (!selectedScreen) { return (

설계할 화면을 선택해주세요

화면 목록에서 화면을 선택한 후 설계기를 사용하세요

); } return (
{/* 상단 헤더 */}

{selectedScreen.screenName} - 화면 설계

{selectedScreen.tableName}
{/* 그룹화 툴바 */} groupState.selectedComponents.includes(comp.id))} allComponents={layout.components} /> {/* 메인 컨텐츠 영역 */}
{/* 좌측 사이드바 - 테이블 타입 */}

테이블 타입

{/* 검색 입력창 */}
handleSearchChange(e.target.value)} className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none" />
{/* 검색 결과 정보 */}
총 {filteredTables.length}개 테이블 중 {(currentPage - 1) * itemsPerPage + 1}- {Math.min(currentPage * itemsPerPage, filteredTables.length)}번째

테이블과 컬럼을 드래그하여 캔버스에 배치하세요.

{/* 테이블 목록 */}
{paginatedTables.map((table) => (
{/* 테이블 헤더 */}
startDrag( { type: "container", tableName: table.tableName, label: table.tableLabel, size: { width: 12, height: 80 }, }, e, ) } >
{table.tableLabel}
{table.tableName}
{/* 컬럼 목록 */} {expandedTables.has(table.tableName) && (
{table.columns.map((column) => (
{ console.log("Drag start - column:", column.columnName, "webType:", column.webType); const widgetType = getWidgetTypeFromWebType(column.webType || "text"); console.log("Drag start - widgetType:", widgetType); startDrag( { type: "widget", tableName: table.tableName, columnName: column.columnName, widgetType: widgetType as WebType, label: column.columnLabel || column.columnName, size: { width: 6, height: 40 }, }, e, ); }} >
{column.webType === "text" && } {column.webType === "email" && } {column.webType === "tel" && } {column.webType === "number" && } {column.webType === "decimal" && } {column.webType === "date" && } {column.webType === "datetime" && } {column.webType === "select" && } {column.webType === "dropdown" && } {column.webType === "textarea" && } {column.webType === "text_area" && } {column.webType === "checkbox" && } {column.webType === "boolean" && } {column.webType === "radio" && } {column.webType === "code" && } {column.webType === "entity" && } {column.webType === "file" && }
{column.columnLabel || column.columnName}
{column.columnName}
))}
)}
))}
{/* 페이징 컨트롤 */} {totalPages > 1 && (
{currentPage} / {totalPages}
)}
{/* 중앙: 캔버스 영역 */}
{layout.components.length === 0 ? (

빈 캔버스

좌측에서 테이블이나 컬럼을 드래그하여 배치하세요

) : (
{/* 그리드 가이드 */}
{Array.from({ length: 12 }).map((_, i) => (
))}
{/* 컴포넌트들 - 실시간 미리보기 */} {layout.components.map((component) => { // 그룹 컴포넌트인 경우 자식 컴포넌트들 가져오기 const children = component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : []; return ( handleComponentClick(component)} onDragStart={(e) => startComponentDrag(component, e)} onDragEnd={endDrag} onGroupToggle={(groupId) => { // 그룹 접기/펼치기 토글 const groupComp = component as GroupComponent; updateComponentProperty(groupId, "collapsed", !groupComp.collapsed); }} > {children.map((child) => (
{child.label || (child as any).columnName || child.id}
{child.type}
))}
); })}
)}
{/* 우측: 컴포넌트 스타일 편집 */}

컴포넌트 속성

{selectedComponent ? (
{selectedComponent.type === "container" && "테이블 속성"} {selectedComponent.type === "widget" && "위젯 속성"} {/* 위치 속성 */}
updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value)) } />
updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value)) } />
{/* 크기 속성 */}
updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value)) } />
updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value)) } />
{/* 테이블 정보 */}
{/* 위젯 전용 속성 */} {selectedComponent.type === "widget" && ( <>
updateComponentProperty(selectedComponent.id, "label", e.target.value)} />
updateComponentProperty(selectedComponent.id, "placeholder", e.target.value) } />
updateComponentProperty(selectedComponent.id, "required", e.target.checked) } />
updateComponentProperty(selectedComponent.id, "readonly", e.target.checked) } />
)} {/* 스타일 속성 */}
updateComponentProperty(selectedComponent.id, "style", newStyle)} />
{/* 고급 속성 */}
) : (

컴포넌트를 선택하여 속성을 편집하세요

)}
); }