ERP-node/frontend/components/report/designer/CanvasComponent.tsx

789 lines
28 KiB
TypeScript
Raw Normal View History

2025-10-01 12:00:13 +09:00
"use client";
import { useRef, useState, useEffect } from "react";
2025-10-01 12:00:13 +09:00
import { ComponentConfig } from "@/types/report";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
const MM_TO_PX = 4;
const HANDLE_SIZE = 8;
const HANDLE_PX = `${HANDLE_SIZE}px`;
type HandleDef = {
dir: string;
cursor: string;
size: string;
top?: string;
left?: string;
right?: string;
bottom?: string;
transform: string;
};
const RESIZE_HANDLES_ALL: HandleDef[] = [
{ dir: "nw", cursor: "cursor-nw-resize", size: HANDLE_PX, top: "0", left: "0", transform: "translate(-50%,-50%)" },
{ dir: "n", cursor: "cursor-n-resize", size: HANDLE_PX, top: "0", left: "50%", transform: "translate(-50%,-50%)" },
{ dir: "ne", cursor: "cursor-ne-resize", size: HANDLE_PX, top: "0", right: "0", transform: "translate(50%,-50%)" },
{ dir: "w", cursor: "cursor-w-resize", size: HANDLE_PX, top: "50%", left: "0", transform: "translate(-50%,-50%)" },
{ dir: "e", cursor: "cursor-e-resize", size: HANDLE_PX, top: "50%", right: "0", transform: "translate(50%,-50%)" },
{ dir: "sw", cursor: "cursor-sw-resize", size: HANDLE_PX, bottom: "0", left: "0", transform: "translate(-50%,50%)" },
{ dir: "s", cursor: "cursor-s-resize", size: HANDLE_PX, bottom: "0", left: "50%", transform: "translate(-50%,50%)" },
{ dir: "se", cursor: "cursor-se-resize", size: HANDLE_PX, bottom: "0", right: "0", transform: "translate(50%,50%)" },
];
const RESIZE_HANDLES_HORIZONTAL_DIVIDER: HandleDef[] = [
{ dir: "w", cursor: "cursor-w-resize", size: HANDLE_PX, top: "50%", left: "0", transform: "translate(-50%,-50%)" },
{ dir: "e", cursor: "cursor-e-resize", size: HANDLE_PX, top: "50%", right: "0", transform: "translate(50%,-50%)" },
];
const RESIZE_HANDLES_VERTICAL_DIVIDER: HandleDef[] = [
{ dir: "n", cursor: "cursor-n-resize", size: HANDLE_PX, top: "0", left: "50%", transform: "translate(-50%,-50%)" },
{ dir: "s", cursor: "cursor-s-resize", size: HANDLE_PX, bottom: "0", left: "50%", transform: "translate(-50%,50%)" },
];
import {
TextRenderer,
TableRenderer,
ImageRenderer,
DividerRenderer,
SignatureRenderer,
StampRenderer,
PageNumberRenderer,
CardRenderer,
CalculationRenderer,
BarcodeCanvasRenderer,
CheckboxRenderer,
} from "./renderers";
2025-10-01 12:00:13 +09:00
interface CanvasComponentProps {
component: ComponentConfig;
}
export function CanvasComponent({ component }: CanvasComponentProps) {
2025-10-01 15:35:16 +09:00
const {
components,
2025-10-01 15:35:16 +09:00
selectedComponentId,
selectedComponentIds,
2025-10-01 15:35:16 +09:00
selectComponent,
selectMultipleComponents,
2025-10-01 15:35:16 +09:00
updateComponent,
getQueryResult,
snapValueToGrid,
calculateAlignmentGuides,
clearAlignmentGuides,
canvasWidth,
canvasHeight,
2025-10-02 14:19:38 +09:00
margins,
layoutConfig,
currentPageId,
duplicateAtPosition,
openComponentModal,
removeComponent,
2025-10-01 15:35:16 +09:00
} = useReportDesigner();
2025-10-01 12:00:13 +09:00
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState("");
2025-10-01 12:00:13 +09:00
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0, compX: 0, compY: 0 });
2025-10-01 12:00:13 +09:00
const componentRef = useRef<HTMLDivElement>(null);
// Alt+드래그 복제를 위한 상태
const [isAltDuplicating, setIsAltDuplicating] = useState(false);
const duplicatedIdsRef = useRef<string[]>([]);
// 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
const originalPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
2025-10-01 12:00:13 +09:00
const isSelected = selectedComponentId === component.id;
const isMultiSelected = selectedComponentIds.includes(component.id);
2025-10-01 16:23:20 +09:00
const isLocked = component.locked === true;
const isGrouped = !!component.groupId;
2025-10-01 12:00:13 +09:00
// 표시할 값 결정
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 fitTextToContent = () => {
if (isLocked) return;
if (component.type !== "text" && component.type !== "label") return;
const minWidth = 50;
const minHeight = 30;
// 여백을 px로 변환
const marginRightPx = margins.right * MM_TO_PX;
const marginBottomPx = margins.bottom * MM_TO_PX;
const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * MM_TO_PX;
// 최대 크기 (여백 고려)
const maxWidth = canvasWidthPx - marginRightPx - component.x;
const maxHeight = canvasHeightPx - marginBottomPx - component.y;
const displayValue = getDisplayValue();
const fontSize = component.fontSize || 14;
// 줄바꿈으로 분리하여 각 줄의 너비 측정
const lines = displayValue.split("\n");
let maxLineWidth = 0;
lines.forEach((line) => {
const measureEl = document.createElement("span");
measureEl.style.position = "absolute";
measureEl.style.visibility = "hidden";
measureEl.style.whiteSpace = "nowrap";
measureEl.style.fontSize = `${fontSize}px`;
measureEl.style.fontWeight = component.fontWeight || "normal";
measureEl.style.fontFamily = component.fontFamily || "Malgun Gothic";
measureEl.textContent = line || " "; // 빈 줄은 공백으로
document.body.appendChild(measureEl);
const lineWidth = measureEl.getBoundingClientRect().width;
maxLineWidth = Math.max(maxLineWidth, lineWidth);
document.body.removeChild(measureEl);
});
// 컴포넌트 padding (p-2 = 8px * 2) + 여유분
const horizontalPadding = 24;
const verticalPadding = 20;
// 줄 높이 계산 (font-size * line-height 약 1.5)
const lineHeight = fontSize * 1.5;
const totalHeight = lines.length * lineHeight;
const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth);
const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight);
const newWidth = Math.max(minWidth, finalWidth);
const newHeight = Math.max(minHeight, finalHeight);
// 크기 업데이트
updateComponent(component.id, {
width: snapValueToGrid(newWidth),
height: snapValueToGrid(newHeight),
});
};
// 더블 클릭 → 인캔버스 설정 모달 오픈 (Phase 5)
const handleDoubleClick = (e: React.MouseEvent) => {
if (isLocked) return;
if (component.type === "divider") return;
e.stopPropagation();
openComponentModal(component.id);
};
2025-10-01 12:00:13 +09:00
// 드래그 시작
const handleMouseDown = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
return;
}
2025-10-01 16:23:20 +09:00
// 잠긴 컴포넌트는 드래그 불가
if (isLocked) {
e.stopPropagation();
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
selectComponent(component.id, isMultiSelect);
return;
}
2025-10-01 12:00:13 +09:00
e.stopPropagation();
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
// Alt 키 감지 (복제 드래그)
const isAltPressed = e.altKey;
// 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작)
const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id);
if (!isPartOfMultiSelection) {
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
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);
}
}
// Alt+드래그: 복제 모드
if (isAltPressed) {
// 복제할 컴포넌트 ID 목록 결정
let idsToClone: string[] = [];
if (isPartOfMultiSelection) {
// 다중 선택된 경우: 잠기지 않은 선택된 모든 컴포넌트 복제
idsToClone = selectedComponentIds.filter((id) => {
const c = components.find((comp) => comp.id === id);
return c && !c.locked;
});
} else if (isGrouped) {
// 그룹화된 경우: 같은 그룹의 모든 컴포넌트 복제
idsToClone = components.filter((c) => c.groupId === component.groupId && !c.locked).map((c) => c.id);
} else {
// 단일 컴포넌트
idsToClone = [component.id];
}
if (idsToClone.length > 0) {
// 원본 컴포넌트들의 위치 저장 (복제본 ID -> 원본 위치 매핑용)
const positionsMap = new Map<string, { x: number; y: number }>();
idsToClone.forEach((id) => {
const comp = components.find((c) => c.id === id);
if (comp) {
positionsMap.set(id, { x: comp.x, y: comp.y });
}
});
// 복제 생성 (오프셋 없이 원래 위치에)
const newIds = duplicateAtPosition(idsToClone, 0, 0);
if (newIds.length > 0) {
// 복제된 컴포넌트 ID와 원본 위치 매핑
// newIds[i]는 idsToClone[i]에서 복제됨
const dupPositionsMap = new Map<string, { x: number; y: number }>();
newIds.forEach((newId, index) => {
const originalId = idsToClone[index];
const originalPos = positionsMap.get(originalId);
if (originalPos) {
dupPositionsMap.set(newId, originalPos);
}
});
originalPositionsRef.current = dupPositionsMap;
// 복제된 컴포넌트들을 선택하고 드래그 시작
duplicatedIdsRef.current = newIds;
setIsAltDuplicating(true);
// 복제된 컴포넌트들 선택
if (newIds.length === 1) {
selectComponent(newIds[0], false);
} else {
selectMultipleComponents(newIds);
}
}
}
}
2025-10-01 12:00:13 +09:00
setIsDragging(true);
const hitAreaOffsetY = isHorizontalDivider ? (DIVIDER_HIT_AREA - component.height) / 2 : 0;
const hitAreaOffsetX = isVerticalDivider ? (DIVIDER_HIT_AREA - component.width) / 2 : 0;
2025-10-01 12:00:13 +09:00
setDragStart({
x: e.clientX - component.x + hitAreaOffsetX,
y: e.clientY - component.y + hitAreaOffsetY,
2025-10-01 12:00:13 +09:00
});
};
// 리사이즈 시작 (방향 인자)
const handleResizeStart = (e: React.MouseEvent, direction: string) => {
2025-10-01 16:23:20 +09:00
if (isLocked) {
e.stopPropagation();
return;
}
2025-10-01 12:00:13 +09:00
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
2025-10-01 12:00:13 +09:00
setResizeStart({
x: e.clientX,
y: e.clientY,
width: component.width,
height: component.height,
compX: component.x,
compY: component.y,
2025-10-01 12:00:13 +09:00
});
};
// 마우스 이동 핸들러 (전역)
2025-10-01 14:14:06 +09:00
useEffect(() => {
2025-10-01 14:23:00 +09:00
if (!isDragging && !isResizing) return;
2025-10-01 12:00:13 +09:00
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로 변환
const marginTopPx = margins.top * MM_TO_PX;
const marginBottomPx = margins.bottom * MM_TO_PX;
const marginLeftPx = margins.left * MM_TO_PX;
const marginRightPx = margins.right * MM_TO_PX;
2025-10-02 14:19:38 +09:00
// 캔버스 경계 체크 (mm를 px로 변환)
const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * MM_TO_PX;
2025-10-02 14:19:38 +09:00
// 컴포넌트가 여백 안에 있도록 제한
const minX = marginLeftPx;
const minY = marginTopPx;
const maxX = canvasWidthPx - marginRightPx - component.width;
const maxY = canvasHeightPx - marginBottomPx - component.height;
2025-10-02 14:19:38 +09:00
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);
2025-10-01 15:35:16 +09:00
// 정렬 가이드라인 계산
calculateAlignmentGuides(component.id, snappedX, snappedY, component.width, component.height);
// 이동 거리 계산
const deltaX = snappedX - component.x;
const deltaY = snappedY - component.y;
// Alt+드래그 복제 모드: 원본은 이동하지 않고 복제본만 이동
if (isAltDuplicating && duplicatedIdsRef.current.length > 0) {
// 복제된 컴포넌트들 이동 (각각의 원본 위치 기준으로 절대 위치 설정)
duplicatedIdsRef.current.forEach((dupId) => {
const dupComp = components.find((c) => c.id === dupId);
const originalPos = originalPositionsRef.current.get(dupId);
if (dupComp && originalPos) {
// 각 복제본의 원본 위치에서 delta만큼 이동
const targetX = originalPos.x + deltaX;
const targetY = originalPos.y + deltaY;
// 경계 체크
const dupMaxX = canvasWidthPx - marginRightPx - dupComp.width;
const dupMaxY = canvasHeightPx - marginBottomPx - dupComp.height;
updateComponent(dupId, {
x: Math.min(Math.max(marginLeftPx, targetX), dupMaxX),
y: Math.min(Math.max(marginTopPx, targetY), dupMaxY),
});
}
});
return; // 원본 컴포넌트는 이동하지 않음
}
// 현재 컴포넌트 이동
updateComponent(component.id, {
2025-10-01 15:35:16 +09:00
x: snappedX,
y: snappedY,
});
// 다중 선택된 경우: 선택된 다른 컴포넌트들도 함께 이동
if (selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id)) {
components.forEach((c) => {
// 현재 컴포넌트는 이미 이동됨, 잠긴 컴포넌트는 제외
if (c.id !== component.id && selectedComponentIds.includes(c.id) && !c.locked) {
const newMultiX = c.x + deltaX;
const newMultiY = c.y + deltaY;
// 경계 체크
const multiMaxX = canvasWidthPx - marginRightPx - c.width;
const multiMaxY = canvasHeightPx - marginBottomPx - c.height;
updateComponent(c.id, {
x: Math.min(Math.max(marginLeftPx, newMultiX), multiMaxX),
y: Math.min(Math.max(marginTopPx, newMultiY), multiMaxY),
});
}
});
}
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
else 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),
});
}
});
}
2025-10-01 12:00:13 +09:00
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
const deltaY = e.clientY - resizeStart.y;
const marginTopPx = margins.top * MM_TO_PX;
const marginLeftPx = margins.left * MM_TO_PX;
const marginRightPx = margins.right * MM_TO_PX;
const marginBottomPx = margins.bottom * MM_TO_PX;
const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * MM_TO_PX;
const dir = resizeDirection;
const resizesLeft = dir.includes("w");
const resizesRight = dir.includes("e");
const resizesTop = dir.includes("n");
const resizesBottom = dir.includes("s");
let newX = resizeStart.compX;
let newY = resizeStart.compY;
let newW = resizeStart.width;
let newH = resizeStart.height;
if (resizesRight) {
newW = resizeStart.width + deltaX;
}
if (resizesLeft) {
newW = resizeStart.width - deltaX;
newX = resizeStart.compX + deltaX;
}
if (resizesBottom) {
newH = resizeStart.height + deltaY;
}
if (resizesTop) {
newH = resizeStart.height - deltaY;
newY = resizeStart.compY + deltaY;
}
const MIN_W = 20;
const MIN_H = 10;
// 최소 크기 제한 + 위치 보정
if (newW < MIN_W) {
if (resizesLeft) newX = resizeStart.compX + resizeStart.width - MIN_W;
newW = MIN_W;
}
if (newH < MIN_H) {
if (resizesTop) newY = resizeStart.compY + resizeStart.height - MIN_H;
newH = MIN_H;
}
// 캔버스 경계 제한
if (newX < marginLeftPx) {
newW -= marginLeftPx - newX;
newX = marginLeftPx;
}
if (newY < marginTopPx) {
newH -= marginTopPx - newY;
newY = marginTopPx;
}
const maxRight = canvasWidthPx - marginRightPx;
const maxBottom = canvasHeightPx - marginBottomPx;
if (newX + newW > maxRight) newW = maxRight - newX;
if (newY + newH > maxBottom) newH = maxBottom - newY;
// 구분선: 방향에 따라 한 축만 조절
2025-12-19 18:19:29 +09:00
if (component.type === "divider") {
if (component.orientation === "vertical") {
updateComponent(component.id, {
y: snapValueToGrid(newY),
height: snapValueToGrid(newH),
2025-12-19 18:19:29 +09:00
});
} else {
updateComponent(component.id, {
x: snapValueToGrid(newX),
width: snapValueToGrid(newW),
2025-12-19 18:19:29 +09:00
});
}
2025-12-22 11:51:19 +09:00
} else if (component.type === "barcode" && component.barcodeType === "QR") {
const maxDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
const newSize = Math.max(50, resizeStart.width + maxDelta);
const maxSize = Math.min(maxRight - component.x, maxBottom - component.y);
const snappedSize = snapValueToGrid(Math.min(newSize, maxSize));
updateComponent(component.id, { width: snappedSize, height: snappedSize });
} else {
2025-12-22 11:51:19 +09:00
updateComponent(component.id, {
x: snapValueToGrid(newX),
y: snapValueToGrid(newY),
width: snapValueToGrid(newW),
height: snapValueToGrid(newH),
2025-12-22 11:51:19 +09:00
});
2025-12-19 18:19:29 +09:00
}
2025-10-01 12:00:13 +09:00
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// Alt 복제 상태 초기화
setIsAltDuplicating(false);
duplicatedIdsRef.current = [];
originalPositionsRef.current = new Map();
2025-10-01 15:35:16 +09:00
// 가이드라인 초기화
clearAlignmentGuides();
2025-10-01 12:00:13 +09:00
};
2025-10-01 14:23:00 +09:00
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [
isDragging,
isResizing,
isAltDuplicating,
resizeDirection,
2025-10-01 14:23:00 +09:00
dragStart.x,
dragStart.y,
resizeStart.x,
resizeStart.y,
resizeStart.width,
resizeStart.height,
resizeStart.compX,
resizeStart.compY,
2025-10-01 14:23:00 +09:00
component.id,
component.x,
component.y,
2025-10-01 15:35:16 +09:00
component.width,
component.height,
component.groupId,
isGrouped,
components,
2025-10-01 14:23:00 +09:00
updateComponent,
snapValueToGrid,
2025-10-01 15:35:16 +09:00
calculateAlignmentGuides,
clearAlignmentGuides,
canvasWidth,
canvasHeight,
2025-10-01 14:23:00 +09:00
]);
2025-10-01 12:00:13 +09:00
// 단일 조건 평가
const evaluateSingleRule = (rule: { queryId: string; field: string; operator: string; value: string }): boolean => {
const queryResult = getQueryResult(rule.queryId);
if (!queryResult?.rows?.length) return false;
const rawValue = queryResult.rows[0][rule.field];
const fieldStr = rawValue !== null && rawValue !== undefined ? String(rawValue) : "";
const fieldNum = parseFloat(fieldStr);
const compareNum = parseFloat(rule.value);
switch (rule.operator) {
case "eq": return fieldStr === rule.value;
case "ne": return fieldStr !== rule.value;
case "gt": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum > compareNum;
case "lt": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum < compareNum;
case "gte": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum >= compareNum;
case "lte": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum <= compareNum;
case "contains": return fieldStr.includes(rule.value);
case "notEmpty": return fieldStr !== "";
case "empty": return fieldStr === "";
default: return false;
}
};
// 조건부 표시 규칙 평가 (다중 AND 조건 지원)
// 격자 모드일 때는 gridConditionalRules를, 일반 테이블일 때는 conditionalRules를 사용
const evaluateConditional = (): boolean => {
const isGrid = component.gridMode === true;
const rulesArr = isGrid ? component.gridConditionalRules : component.conditionalRules;
const singleRule = isGrid ? component.gridConditionalRule : component.conditionalRule;
2025-10-01 12:00:13 +09:00
const rules = rulesArr?.length
? rulesArr
: singleRule
? [singleRule]
: [];
if (rules.length === 0) return true;
2025-10-01 12:00:13 +09:00
const validRules = rules.filter((r) => r.queryId && r.field);
if (validRules.length === 0) return true;
2025-10-01 16:53:35 +09:00
const action = validRules[0].action;
const allMet = validRules.every((rule) => evaluateSingleRule(rule));
2025-10-01 17:31:15 +09:00
return action === "show" ? allMet : !allMet;
};
2025-10-01 17:31:15 +09:00
const isConditionalVisible = evaluateConditional();
2025-10-01 17:31:15 +09:00
const isDivider = component.type === "divider";
const isHorizontalDivider = isDivider && component.orientation !== "vertical";
const isVerticalDivider = isDivider && component.orientation === "vertical";
const DIVIDER_HIT_AREA = 24;
2025-10-01 17:31:15 +09:00
// 컴포넌트 타입별 렌더링
const renderContent = () => {
const displayValue = getDisplayValue();
switch (component.type) {
case "text":
case "label":
return <TextRenderer component={component} getQueryResult={getQueryResult} displayValue={displayValue} />;
case "table":
return <TableRenderer component={component} getQueryResult={getQueryResult} />;
case "image":
return <ImageRenderer component={component} />;
case "divider":
return (
<div
className="flex items-center justify-center"
style={{
width: `${component.width}px`,
height: `${component.height}px`,
position: "absolute",
top: isHorizontalDivider ? "50%" : 0,
left: isVerticalDivider ? "50%" : 0,
transform: `translate(${isVerticalDivider ? "-50%" : "0"}, ${isHorizontalDivider ? "-50%" : "0"})`,
}}
>
<DividerRenderer component={component} />
</div>
);
case "signature":
return <SignatureRenderer component={component} />;
case "stamp":
return <StampRenderer component={component} />;
case "pageNumber":
return <PageNumberRenderer component={component} currentPageId={currentPageId} layoutConfig={layoutConfig} />;
case "card":
return <CardRenderer component={component} getQueryResult={getQueryResult} />;
case "calculation":
return <CalculationRenderer component={component} getQueryResult={getQueryResult} />;
case "barcode":
return <BarcodeCanvasRenderer component={component} getQueryResult={getQueryResult} />;
2025-12-19 18:06:25 +09:00
case "checkbox":
return <CheckboxRenderer component={component} getQueryResult={getQueryResult} />;
2025-10-01 12:00:13 +09:00
default:
return <div> </div>;
}
};
return (
<div
ref={componentRef}
className={`absolute ${isDivider ? "p-0" : "p-2"} shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
2025-10-01 16:23:20 +09:00
isSelected
? isLocked
? "ring-2 ring-red-500"
: "ring-2 ring-blue-500"
: isMultiSelected
? isLocked
? "ring-2 ring-red-300"
: "ring-2 ring-blue-300"
: ""
}`}
2025-10-01 12:00:13 +09:00
style={{
left: `${component.x}px`,
top: isHorizontalDivider
? `${component.y - (DIVIDER_HIT_AREA - component.height) / 2}px`
: `${component.y}px`,
width: isVerticalDivider
? `${Math.max(component.width, DIVIDER_HIT_AREA)}px`
: `${component.width}px`,
height: isHorizontalDivider
? `${Math.max(component.height, DIVIDER_HIT_AREA)}px`
: `${component.height}px`,
2025-10-01 12:00:13 +09:00
zIndex: component.zIndex,
backgroundColor: isDivider ? "transparent" : component.backgroundColor,
opacity: isConditionalVisible ? undefined : 0.35,
border: isDivider
? isSelected || isMultiSelected
? undefined
: "none"
: component.borderWidth
? `${component.borderWidth}px solid ${component.borderColor}`
: "1px solid #e5e7eb",
borderRadius: component.borderRadius ? `${component.borderRadius}px` : undefined,
2025-10-01 12:00:13 +09:00
}}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
2025-10-01 12:00:13 +09:00
>
{renderContent()}
{/* 조건부 표시 미충족 오버레이 (디자인 모드에서는 숨기지 않고 표시) */}
{!isConditionalVisible && (
<div
className="pointer-events-none absolute inset-0 border-2 border-dashed border-amber-400"
title="표시 조건: 조건 미충족"
>
<div className="absolute top-0 right-0 rounded-bl bg-amber-400 px-1 py-0.5 text-[9px] font-medium text-amber-900">
</div>
</div>
)}
2025-10-01 16:23:20 +09:00
{/* 잠금 표시 */}
{isLocked && (
<div className="absolute top-1 right-1 rounded bg-red-500 px-1 py-0.5 text-[10px] text-white">🔒</div>
)}
{/* 삭제 버튼 (선택 + 잠금 해제 상태에서만 표시) */}
{isSelected && !isLocked && (
<button
className={`absolute z-20 flex items-center justify-center rounded-full bg-red-500 text-white shadow hover:bg-red-600 transition-colors ${
isDivider
? "h-[18px] w-[18px] -top-5 right-1"
: "h-5 w-5 -top-2.5 -right-2.5"
}`}
title="컴포넌트 삭제"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
removeComponent(component.id);
}}
>
<span className={`font-bold leading-none ${isDivider ? "text-[10px]" : "text-xs"}`}>×</span>
</button>
)}
{/* 그룹화 표시 */}
{isGrouped && !isLocked && (
<div className="absolute top-1 left-1 rounded bg-purple-500 px-1 py-0.5 text-[10px] text-white">👥</div>
)}
{/* 8방향 리사이즈 핸들 */}
2025-10-01 16:23:20 +09:00
{isSelected && !isLocked && (
<>
{(isDivider
? component.orientation === "vertical"
? RESIZE_HANDLES_VERTICAL_DIVIDER
: RESIZE_HANDLES_HORIZONTAL_DIVIDER
: RESIZE_HANDLES_ALL
).map((h) => (
<div
key={h.dir}
className={`resize-handle absolute z-10 ${h.cursor}`}
style={{
width: h.size,
height: h.size,
top: h.top,
left: h.left,
right: h.right,
bottom: h.bottom,
transform: h.transform,
backgroundColor: "#3b82f6",
borderRadius: "50%",
}}
onMouseDown={(e) => handleResizeStart(e, h.dir)}
/>
))}
</>
2025-10-01 12:00:13 +09:00
)}
</div>
);
}