441 lines
15 KiB
TypeScript
441 lines
15 KiB
TypeScript
"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,
|
|
} = 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<HTMLDivElement>(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);
|
|
const snappedX = snapValueToGrid(newX);
|
|
const snappedY = snapValueToGrid(newY);
|
|
|
|
// 정렬 가이드라인 계산
|
|
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) {
|
|
updateComponent(c.id, {
|
|
x: c.x + deltaX,
|
|
y: c.y + deltaY,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
} 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);
|
|
// Grid Snap 적용
|
|
updateComponent(component.id, {
|
|
width: snapValueToGrid(newWidth),
|
|
height: snapValueToGrid(newHeight),
|
|
});
|
|
}
|
|
};
|
|
|
|
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,
|
|
]);
|
|
|
|
// 표시할 값 결정
|
|
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 (
|
|
<div className="h-full w-full">
|
|
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
|
<span>텍스트 필드</span>
|
|
{hasBinding && <span className="text-blue-600">● 연결됨</span>}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: `${component.fontSize}px`,
|
|
color: component.fontColor,
|
|
fontWeight: component.fontWeight,
|
|
textAlign: component.textAlign as "left" | "center" | "right",
|
|
}}
|
|
className="w-full"
|
|
>
|
|
{displayValue}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case "label":
|
|
return (
|
|
<div className="h-full w-full">
|
|
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
|
<span>레이블</span>
|
|
{hasBinding && <span className="text-blue-600">● 연결됨</span>}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: `${component.fontSize}px`,
|
|
color: component.fontColor,
|
|
fontWeight: component.fontWeight,
|
|
textAlign: component.textAlign as "left" | "center" | "right",
|
|
}}
|
|
>
|
|
{displayValue}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case "table":
|
|
// 테이블은 쿼리 결과의 모든 행과 필드를 표시
|
|
if (component.queryId) {
|
|
const queryResult = getQueryResult(component.queryId);
|
|
|
|
if (queryResult && queryResult.rows.length > 0) {
|
|
return (
|
|
<div className="h-full w-full overflow-auto">
|
|
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
|
<span>테이블 (디테일 데이터)</span>
|
|
<span className="text-blue-600">● 연결됨</span>
|
|
</div>
|
|
<table className="w-full border-collapse text-xs">
|
|
<thead>
|
|
<tr className="bg-gray-100">
|
|
{queryResult.fields.map((field) => (
|
|
<th key={field} className="border p-1">
|
|
{field}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{queryResult.rows.slice(0, 3).map((row, idx) => (
|
|
<tr key={idx}>
|
|
{queryResult.fields.map((field) => (
|
|
<td key={field} className="border p-1">
|
|
{String(row[field] ?? "")}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
{queryResult.rows.length > 3 && (
|
|
<tr>
|
|
<td colSpan={queryResult.fields.length} className="border p-1 text-center text-gray-400">
|
|
... 외 {queryResult.rows.length - 3}건
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// 기본 테이블 (데이터 없을 때)
|
|
return (
|
|
<div className="h-full w-full overflow-auto">
|
|
<div className="mb-1 text-xs text-gray-500">테이블 (디테일 데이터)</div>
|
|
<table className="w-full border-collapse text-xs">
|
|
<thead>
|
|
<tr className="bg-gray-100">
|
|
<th className="border p-1">품목명</th>
|
|
<th className="border p-1">수량</th>
|
|
<th className="border p-1">단가</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td className="border p-1">품목1</td>
|
|
<td className="border p-1">10</td>
|
|
<td className="border p-1">50,000</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
|
|
case "image":
|
|
return (
|
|
<div className="h-full w-full overflow-hidden">
|
|
<div className="mb-1 text-xs text-gray-500">이미지</div>
|
|
{component.imageUrl ? (
|
|
<img
|
|
src={getFullImageUrl(component.imageUrl)}
|
|
alt="이미지"
|
|
style={{
|
|
width: "100%",
|
|
height: "calc(100% - 20px)",
|
|
objectFit: component.objectFit || "contain",
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="flex h-[calc(100%-20px)] w-full items-center justify-center border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
|
이미지를 업로드하세요
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
case "divider":
|
|
const lineWidth = component.lineWidth || 1;
|
|
const lineColor = component.lineColor || "#000000";
|
|
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<div
|
|
style={{
|
|
width: component.orientation === "horizontal" ? "100%" : `${lineWidth}px`,
|
|
height: component.orientation === "vertical" ? "100%" : `${lineWidth}px`,
|
|
backgroundColor: lineColor,
|
|
...(component.lineStyle === "dashed" && {
|
|
backgroundImage: `repeating-linear-gradient(
|
|
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
|
${lineColor} 0px,
|
|
${lineColor} 10px,
|
|
transparent 10px,
|
|
transparent 20px
|
|
)`,
|
|
backgroundColor: "transparent",
|
|
}),
|
|
...(component.lineStyle === "dotted" && {
|
|
backgroundImage: `repeating-linear-gradient(
|
|
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
|
${lineColor} 0px,
|
|
${lineColor} 3px,
|
|
transparent 3px,
|
|
transparent 10px
|
|
)`,
|
|
backgroundColor: "transparent",
|
|
}),
|
|
...(component.lineStyle === "double" && {
|
|
boxShadow:
|
|
component.orientation === "horizontal"
|
|
? `0 ${lineWidth * 2}px 0 0 ${lineColor}`
|
|
: `${lineWidth * 2}px 0 0 0 ${lineColor}`,
|
|
}),
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
default:
|
|
return <div>알 수 없는 컴포넌트</div>;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={componentRef}
|
|
className={`absolute p-2 shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
|
|
isSelected
|
|
? isLocked
|
|
? "ring-2 ring-red-500"
|
|
: "ring-2 ring-blue-500"
|
|
: isMultiSelected
|
|
? isLocked
|
|
? "ring-2 ring-red-300"
|
|
: "ring-2 ring-blue-300"
|
|
: ""
|
|
}`}
|
|
style={{
|
|
left: `${component.x}px`,
|
|
top: `${component.y}px`,
|
|
width: `${component.width}px`,
|
|
height: `${component.height}px`,
|
|
zIndex: component.zIndex,
|
|
backgroundColor: component.backgroundColor,
|
|
border: component.borderWidth
|
|
? `${component.borderWidth}px solid ${component.borderColor}`
|
|
: "1px solid #e5e7eb",
|
|
}}
|
|
onMouseDown={handleMouseDown}
|
|
>
|
|
{renderContent()}
|
|
|
|
{/* 잠금 표시 */}
|
|
{isLocked && (
|
|
<div className="absolute top-1 right-1 rounded bg-red-500 px-1 py-0.5 text-[10px] text-white">🔒</div>
|
|
)}
|
|
|
|
{/* 그룹화 표시 */}
|
|
{isGrouped && !isLocked && (
|
|
<div className="absolute top-1 left-1 rounded bg-purple-500 px-1 py-0.5 text-[10px] text-white">👥</div>
|
|
)}
|
|
|
|
{/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */}
|
|
{isSelected && !isLocked && (
|
|
<div
|
|
className="resize-handle absolute right-0 bottom-0 h-3 w-3 cursor-se-resize rounded-full bg-blue-500"
|
|
style={{ transform: "translate(50%, 50%)" }}
|
|
onMouseDown={handleResizeStart}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|