"use client"; import { useRef, useState, useEffect } from "react"; import { ComponentConfig } from "@/types/report"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { getFullImageUrl } from "@/lib/api/client"; interface CanvasComponentProps { component: ComponentConfig; } export function CanvasComponent({ component }: CanvasComponentProps) { const { components, selectedComponentId, selectedComponentIds, selectComponent, updateComponent, getQueryResult, snapValueToGrid, calculateAlignmentGuides, clearAlignmentGuides, canvasWidth, canvasHeight, margins, } = useReportDesigner(); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 }); const componentRef = useRef(null); const isSelected = selectedComponentId === component.id; const isMultiSelected = selectedComponentIds.includes(component.id); const isLocked = component.locked === true; const isGrouped = !!component.groupId; // 드래그 시작 const handleMouseDown = (e: React.MouseEvent) => { if ((e.target as HTMLElement).classList.contains("resize-handle")) { return; } // 잠긴 컴포넌트는 드래그 불가 if (isLocked) { e.stopPropagation(); // Ctrl/Cmd 키 감지 (다중 선택) const isMultiSelect = e.ctrlKey || e.metaKey; selectComponent(component.id, isMultiSelect); return; } e.stopPropagation(); // Ctrl/Cmd 키 감지 (다중 선택) const isMultiSelect = e.ctrlKey || e.metaKey; // 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택 if (isGrouped && !isMultiSelect) { const groupMembers = components.filter((c) => c.groupId === component.groupId); const groupMemberIds = groupMembers.map((c) => c.id); // 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가 selectComponent(groupMemberIds[0], false); groupMemberIds.slice(1).forEach((id) => selectComponent(id, true)); } else { selectComponent(component.id, isMultiSelect); } setIsDragging(true); setDragStart({ x: e.clientX - component.x, y: e.clientY - component.y, }); }; // 리사이즈 시작 const handleResizeStart = (e: React.MouseEvent) => { // 잠긴 컴포넌트는 리사이즈 불가 if (isLocked) { e.stopPropagation(); return; } e.stopPropagation(); setIsResizing(true); setResizeStart({ x: e.clientX, y: e.clientY, width: component.width, height: component.height, }); }; // 마우스 이동 핸들러 (전역) useEffect(() => { if (!isDragging && !isResizing) return; const handleMouseMove = (e: MouseEvent) => { if (isDragging) { const newX = Math.max(0, e.clientX - dragStart.x); const newY = Math.max(0, e.clientY - dragStart.y); // 여백을 px로 변환 (1mm ≈ 3.7795px) const marginTopPx = margins.top * 3.7795; const marginBottomPx = margins.bottom * 3.7795; const marginLeftPx = margins.left * 3.7795; const marginRightPx = margins.right * 3.7795; // 캔버스 경계 체크 (mm를 px로 변환) const canvasWidthPx = canvasWidth * 3.7795; const canvasHeightPx = canvasHeight * 3.7795; // 컴포넌트가 여백 안에 있도록 제한 const minX = marginLeftPx; const minY = marginTopPx; const maxX = canvasWidthPx - marginRightPx - component.width; const maxY = canvasHeightPx - marginBottomPx - component.height; const boundedX = Math.min(Math.max(minX, newX), maxX); const boundedY = Math.min(Math.max(minY, newY), maxY); const snappedX = snapValueToGrid(boundedX); const snappedY = snapValueToGrid(boundedY); // 정렬 가이드라인 계산 calculateAlignmentGuides(component.id, snappedX, snappedY, component.width, component.height); // 이동 거리 계산 const deltaX = snappedX - component.x; const deltaY = snappedY - component.y; // 현재 컴포넌트 이동 updateComponent(component.id, { x: snappedX, y: snappedY, }); // 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동 if (isGrouped) { components.forEach((c) => { if (c.groupId === component.groupId && c.id !== component.id) { const newGroupX = c.x + deltaX; const newGroupY = c.y + deltaY; // 그룹 컴포넌트도 경계 체크 const groupMaxX = canvasWidthPx - c.width; const groupMaxY = canvasHeightPx - c.height; updateComponent(c.id, { x: Math.min(Math.max(0, newGroupX), groupMaxX), y: Math.min(Math.max(0, newGroupY), groupMaxY), }); } }); } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; const deltaY = e.clientY - resizeStart.y; const newWidth = Math.max(50, resizeStart.width + deltaX); const newHeight = Math.max(30, resizeStart.height + deltaY); // 여백을 px로 변환 const marginRightPx = margins.right * 3.7795; const marginBottomPx = margins.bottom * 3.7795; // 캔버스 경계 체크 const canvasWidthPx = canvasWidth * 3.7795; const canvasHeightPx = canvasHeight * 3.7795; // 컴포넌트가 여백을 벗어나지 않도록 최대 크기 제한 const maxWidth = canvasWidthPx - marginRightPx - component.x; const maxHeight = canvasHeightPx - marginBottomPx - component.y; const boundedWidth = Math.min(newWidth, maxWidth); const boundedHeight = Math.min(newHeight, maxHeight); // Grid Snap 적용 updateComponent(component.id, { width: snapValueToGrid(boundedWidth), height: snapValueToGrid(boundedHeight), }); } }; const handleMouseUp = () => { setIsDragging(false); setIsResizing(false); // 가이드라인 초기화 clearAlignmentGuides(); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [ isDragging, isResizing, dragStart.x, dragStart.y, resizeStart.x, resizeStart.y, resizeStart.width, resizeStart.height, component.id, component.x, component.y, component.width, component.height, component.groupId, isGrouped, components, updateComponent, snapValueToGrid, calculateAlignmentGuides, clearAlignmentGuides, canvasWidth, canvasHeight, ]); // 표시할 값 결정 const getDisplayValue = (): string => { // 쿼리와 필드가 연결되어 있으면 실제 데이터 조회 if (component.queryId && component.fieldName) { const queryResult = getQueryResult(component.queryId); // 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시 if (queryResult && queryResult.rows.length > 0) { const firstRow = queryResult.rows[0]; const value = firstRow[component.fieldName]; // 값이 있으면 문자열로 변환하여 반환 if (value !== null && value !== undefined) { return String(value); } } // 실행 결과가 없거나 값이 없으면 필드명 표시 return `{${component.fieldName}}`; } // 기본값이 있으면 기본값 표시 if (component.defaultValue) { return component.defaultValue; } // 둘 다 없으면 타입에 따라 기본 텍스트 return component.type === "text" ? "텍스트 입력" : "레이블 텍스트"; }; // 컴포넌트 타입별 렌더링 const renderContent = () => { const displayValue = getDisplayValue(); const hasBinding = component.queryId && component.fieldName; switch (component.type) { case "text": return (
텍스트 필드 {hasBinding && ● 연결됨}
{displayValue}
); case "label": return (
레이블 {hasBinding && ● 연결됨}
{displayValue}
); case "table": // 테이블은 쿼리 결과의 모든 행과 필드를 표시 if (component.queryId) { const queryResult = getQueryResult(component.queryId); if (queryResult && queryResult.rows.length > 0) { // tableColumns가 없으면 자동 생성 const columns = component.tableColumns && component.tableColumns.length > 0 ? component.tableColumns : queryResult.fields.map((field) => ({ field, header: field, width: undefined, align: "left" as const, })); return (
테이블 ● 연결됨 ({queryResult.rows.length}행)
{columns.map((col) => ( ))} {queryResult.rows.map((row, idx) => ( {columns.map((col) => ( ))} ))}
{col.header}
{String(row[col.field] ?? "")}
); } } // 기본 테이블 (데이터 없을 때) return (
테이블
쿼리를 연결하세요
); case "image": return (
이미지
{component.imageUrl ? ( 이미지 ) : (
이미지를 업로드하세요
)}
); case "divider": const lineWidth = component.lineWidth || 1; const lineColor = component.lineColor || "#000000"; return (
); case "signature": const sigLabelPos = component.labelPosition || "left"; const sigShowLabel = component.showLabel !== false; const sigLabelText = component.labelText || "서명:"; const sigShowUnderline = component.showUnderline !== false; return (
서명란
{sigShowLabel && (
{sigLabelText}
)}
{component.imageUrl ? ( 서명 ) : (
서명 이미지
)} {sigShowUnderline && (
)}
); case "stamp": const stampShowLabel = component.showLabel !== false; const stampLabelText = component.labelText || "(인)"; const stampPersonName = component.personName || ""; return (
도장란
{stampPersonName &&
{stampPersonName}
}
{component.imageUrl ? ( 도장 ) : (
도장 이미지
)} {stampShowLabel && (
{stampLabelText}
)}
); default: return
알 수 없는 컴포넌트
; } }; return (
{renderContent()} {/* 잠금 표시 */} {isLocked && (
🔒
)} {/* 그룹화 표시 */} {isGrouped && !isLocked && (
👥
)} {/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */} {isSelected && !isLocked && (
)}
); }