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

1503 lines
54 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";
import JsBarcode from "jsbarcode";
import QRCode from "qrcode";
// 고정 스케일 팩터 (화면 해상도와 무관)
const MM_TO_PX = 4;
// 1D 바코드 렌더러 컴포넌트
interface BarcodeRendererProps {
value: string;
format: string;
width: number;
height: number;
displayValue: boolean;
lineColor: string;
background: string;
margin: number;
}
function BarcodeRenderer({
value,
format,
width,
height,
displayValue,
lineColor,
background,
margin,
}: BarcodeRendererProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!svgRef.current || !value) return;
// 매번 에러 상태 초기화 후 재검사
setError(null);
try {
// 바코드 형식에 따른 유효성 검사
let isValid = true;
let errorMsg = "";
const trimmedValue = value.trim();
if (format === "EAN13" && !/^\d{12,13}$/.test(trimmedValue)) {
isValid = false;
errorMsg = "EAN-13: 12~13자리 숫자 필요";
} else if (format === "EAN8" && !/^\d{7,8}$/.test(trimmedValue)) {
isValid = false;
errorMsg = "EAN-8: 7~8자리 숫자 필요";
} else if (format === "UPC" && !/^\d{11,12}$/.test(trimmedValue)) {
isValid = false;
errorMsg = "UPC: 11~12자리 숫자 필요";
}
if (!isValid) {
setError(errorMsg);
return;
}
// JsBarcode는 format을 소문자로 받음
const barcodeFormat = format.toLowerCase();
// transparent는 빈 문자열로 변환 (SVG 배경 없음)
const bgColor = background === "transparent" ? "" : background;
JsBarcode(svgRef.current, trimmedValue, {
format: barcodeFormat,
width: 2,
height: Math.max(30, height - (displayValue ? 30 : 10)),
displayValue: displayValue,
lineColor: lineColor,
background: bgColor,
margin: margin,
fontSize: 12,
textMargin: 2,
});
} catch (err: any) {
// JsBarcode 체크섬 오류 등
setError(err?.message || "바코드 생성 실패");
}
}, [value, format, width, height, displayValue, lineColor, background, margin]);
return (
<div className="relative h-full w-full">
{/* SVG는 항상 렌더링 (에러 시 숨김) */}
<svg ref={svgRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
{/* 에러 메시지 오버레이 */}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
<span>{error}</span>
<span className="mt-1 text-gray-400">{value}</span>
</div>
)}
</div>
);
}
// QR코드 렌더러 컴포넌트
interface QRCodeRendererProps {
value: string;
size: number;
fgColor: string;
bgColor: string;
level: "L" | "M" | "Q" | "H";
}
function QRCodeRenderer({ value, size, fgColor, bgColor, level }: QRCodeRendererProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!canvasRef.current || !value) return;
// 매번 에러 상태 초기화 후 재시도
setError(null);
// qrcode 라이브러리는 hex 색상만 지원, transparent는 흰색으로 대체
const lightColor = bgColor === "transparent" ? "#ffffff" : bgColor;
QRCode.toCanvas(
canvasRef.current,
value,
{
width: Math.max(50, size),
margin: 2,
color: {
dark: fgColor,
light: lightColor,
},
errorCorrectionLevel: level,
},
(err) => {
if (err) {
// 실제 에러 메시지 표시
setError(err.message || "QR코드 생성 실패");
}
},
);
}, [value, size, fgColor, bgColor, level]);
return (
<div className="relative h-full w-full">
{/* Canvas는 항상 렌더링 (에러 시 숨김) */}
<canvas ref={canvasRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
{/* 에러 메시지 오버레이 */}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
<span>{error}</span>
<span className="mt-1 text-gray-400">{value}</span>
</div>
)}
</div>
);
}
interface CanvasComponentProps {
component: ComponentConfig;
}
export function CanvasComponent({ component }: CanvasComponentProps) {
const {
components,
selectedComponentId,
selectedComponentIds,
selectComponent,
selectMultipleComponents,
updateComponent,
getQueryResult,
snapValueToGrid,
calculateAlignmentGuides,
clearAlignmentGuides,
canvasWidth,
canvasHeight,
margins,
layoutConfig,
currentPageId,
duplicateAtPosition,
} = 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);
// Alt+드래그 복제를 위한 상태
const [isAltDuplicating, setIsAltDuplicating] = useState(false);
const duplicatedIdsRef = useRef<string[]>([]);
// 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
const originalPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
// 인라인 편집 상태
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isSelected = selectedComponentId === component.id;
const isMultiSelected = selectedComponentIds.includes(component.id);
const isLocked = component.locked === true;
const isGrouped = !!component.groupId;
// 표시할 값 결정
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 = "system-ui, -apple-system, sans-serif";
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),
});
};
// 더블 클릭 핸들러 (텍스트 컴포넌트: 인라인 편집 모드 진입)
const handleDoubleClick = (e: React.MouseEvent) => {
if (component.type !== "text" && component.type !== "label") return;
if (isLocked) return; // 잠긴 컴포넌트는 편집 불가
e.stopPropagation();
// 인라인 편집 모드 진입
setEditValue(component.defaultValue || "");
setIsEditing(true);
};
// 인라인 편집 시작 시 textarea에 포커스
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}, [isEditing]);
// 선택 해제 시 편집 모드 종료를 위한 ref
const editValueRef = useRef(editValue);
const isEditingRef = useRef(isEditing);
editValueRef.current = editValue;
isEditingRef.current = isEditing;
// 선택 해제 시 편집 모드 종료 (저장 후 종료)
useEffect(() => {
if (!isSelected && !isMultiSelected && isEditingRef.current) {
// 현재 편집 값으로 저장
if (editValueRef.current !== component.defaultValue) {
updateComponent(component.id, { defaultValue: editValueRef.current });
}
setIsEditing(false);
}
}, [isSelected, isMultiSelected, component.id, component.defaultValue, updateComponent]);
// 인라인 편집 저장
const handleEditSave = () => {
if (!isEditing) return;
updateComponent(component.id, {
defaultValue: editValue,
});
setIsEditing(false);
};
// 인라인 편집 취소
const handleEditCancel = () => {
setIsEditing(false);
setEditValue("");
};
// 인라인 편집 키보드 핸들러
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleEditCancel();
} else if (e.key === "Enter" && !e.shiftKey) {
// Enter: 저장 (Shift+Enter는 줄바꿈)
e.preventDefault();
handleEditSave();
}
};
// 드래그 시작
const handleMouseDown = (e: React.MouseEvent) => {
// 편집 모드에서는 드래그 비활성화
if (isEditing) return;
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;
// 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);
}
}
}
}
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로 변환
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;
// 캔버스 경계 체크 (mm를 px로 변환)
const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * MM_TO_PX;
// 컴포넌트가 여백 안에 있도록 제한
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;
// 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, {
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),
});
}
});
}
} 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 * 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 boundedWidth = Math.min(newWidth, maxWidth);
const boundedHeight = Math.min(newHeight, maxHeight);
// 구분선은 방향에 따라 한 축만 조절 가능
if (component.type === "divider") {
if (component.orientation === "vertical") {
// 세로 구분선: 높이만 조절
updateComponent(component.id, {
height: snapValueToGrid(boundedHeight),
});
} else {
// 가로 구분선: 너비만 조절
updateComponent(component.id, {
width: snapValueToGrid(boundedWidth),
});
}
} else if (component.type === "barcode" && component.barcodeType === "QR") {
// QR코드는 정사각형 유지: 더 큰 변화량 기준으로 동기화
const maxDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
const newSize = Math.max(50, resizeStart.width + maxDelta);
const maxSize = Math.min(
canvasWidthPx - marginRightPx - component.x,
canvasHeightPx - marginBottomPx - component.y,
);
const boundedSize = Math.min(newSize, maxSize);
const snappedSize = snapValueToGrid(boundedSize);
updateComponent(component.id, {
width: snappedSize,
height: snappedSize,
});
} else {
// Grid Snap 적용
updateComponent(component.id, {
width: snapValueToGrid(boundedWidth),
height: snapValueToGrid(boundedHeight),
});
}
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// Alt 복제 상태 초기화
setIsAltDuplicating(false);
duplicatedIdsRef.current = [];
originalPositionsRef.current = new Map();
// 가이드라인 초기화
clearAlignmentGuides();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [
isDragging,
isResizing,
isAltDuplicating,
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 renderContent = () => {
const displayValue = getDisplayValue();
const hasBinding = component.queryId && component.fieldName;
switch (component.type) {
case "text":
case "label":
// 인라인 편집 모드
if (isEditing) {
return (
<textarea
ref={textareaRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleEditSave}
onKeyDown={handleEditKeyDown}
className="h-full w-full resize-none border-none bg-transparent p-0 outline-none focus:ring-2 focus:ring-blue-500"
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
lineHeight: "1.5",
}}
/>
);
}
return (
<div
className="h-full w-full"
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}}
>
{displayValue}
</div>
);
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 (
<div className="h-full w-full overflow-auto">
<table
className="w-full border-collapse text-xs"
style={{
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
}}
>
<thead>
<tr
style={{
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
color: component.headerTextColor || "#111827",
}}
>
{columns.map((col) => (
<th
key={col.field}
className={component.showBorder !== false ? "border border-gray-300" : ""}
style={{
padding: "6px 8px",
textAlign: col.align || "left",
width: col.width ? `${col.width}px` : "auto",
fontWeight: "600",
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{queryResult.rows.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => (
<td
key={col.field}
className={component.showBorder !== false ? "border border-gray-300" : ""}
style={{
padding: "6px 8px",
textAlign: col.align || "left",
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
}}
>
{String(row[col.field] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
}
// 기본 테이블 (데이터 없을 때)
return (
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
</div>
);
case "image":
return (
<div className="h-full w-full overflow-hidden">
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="이미지"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div className="flex h-full 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 dividerLineWidth = component.lineWidth || 1;
const dividerLineColor = component.lineColor || "#000000";
const isHorizontal = component.orientation !== "vertical";
return (
<div className={`flex h-full w-full ${isHorizontal ? "items-center" : "justify-center"}`}>
<div
style={{
width: isHorizontal ? "100%" : `${dividerLineWidth}px`,
height: isHorizontal ? `${dividerLineWidth}px` : "100%",
backgroundColor: dividerLineColor,
...(component.lineStyle === "dashed" && {
backgroundImage: `repeating-linear-gradient(
${isHorizontal ? "90deg" : "0deg"},
${dividerLineColor} 0px,
${dividerLineColor} 10px,
transparent 10px,
transparent 20px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "dotted" && {
backgroundImage: `repeating-linear-gradient(
${isHorizontal ? "90deg" : "0deg"},
${dividerLineColor} 0px,
${dividerLineColor} 3px,
transparent 3px,
transparent 10px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "double" && {
boxShadow: isHorizontal
? `0 ${dividerLineWidth * 2}px 0 0 ${dividerLineColor}`
: `${dividerLineWidth * 2}px 0 0 0 ${dividerLineColor}`,
}),
}}
/>
</div>
);
case "signature":
const sigLabelPos = component.labelPosition || "left";
const sigShowLabel = component.showLabel !== false;
const sigLabelText = component.labelText || "서명:";
return (
<div className="h-full w-full">
<div
className={`flex h-full gap-2 ${
sigLabelPos === "top"
? "flex-col"
: sigLabelPos === "bottom"
? "flex-col-reverse"
: sigLabelPos === "right"
? "flex-row-reverse"
: "flex-row"
}`}
>
{sigShowLabel && (
<div
className="flex items-center justify-center text-xs font-medium"
style={{
width: sigLabelPos === "left" || sigLabelPos === "right" ? "auto" : "100%",
minWidth: sigLabelPos === "left" || sigLabelPos === "right" ? "40px" : "auto",
}}
>
{sigLabelText}
</div>
)}
<div className="relative flex-1">
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="서명"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
style={{
borderColor: component.borderColor || "#cccccc",
}}
>
</div>
)}
</div>
</div>
</div>
);
case "stamp":
const stampShowLabel = component.showLabel !== false;
const stampLabelText = component.labelText || "(인)";
const stampPersonName = component.personName || "";
return (
<div className="h-full w-full">
<div className="flex h-full gap-2">
{stampPersonName && <div className="flex items-center text-xs font-medium">{stampPersonName}</div>}
<div className="relative flex-1">
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="도장"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
style={{
borderColor: component.borderColor || "#cccccc",
borderRadius: "50%",
}}
>
</div>
)}
{stampShowLabel && (
<div
className="absolute inset-0 flex items-center justify-center text-xs font-medium"
style={{
pointerEvents: "none",
}}
>
{stampLabelText}
</div>
)}
</div>
</div>
</div>
);
case "pageNumber":
// 페이지 번호 포맷
const format = component.pageNumberFormat || "number";
const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
const currentPageIndex = sortedPages.findIndex((p) => p.page_id === currentPageId);
const totalPages = sortedPages.length;
const currentPageNum = currentPageIndex + 1;
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${currentPageNum}`;
break;
case "numberTotal":
pageNumberText = `${currentPageNum} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${currentPageNum} 페이지`;
break;
default:
pageNumberText = `${currentPageNum}`;
}
return (
<div
className="flex h-full w-full items-center justify-center"
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{pageNumberText}
</div>
);
case "card":
// 카드 컴포넌트: 제목 + 항목 목록
const cardTitle = component.cardTitle || "정보 카드";
const cardItems = component.cardItems || [];
const labelWidth = component.labelWidth || 80;
const showCardTitle = component.showCardTitle !== false;
const titleFontSize = component.titleFontSize || 14;
const labelFontSize = component.labelFontSize || 13;
const valueFontSize = component.valueFontSize || 13;
const titleColor = component.titleColor || "#1e40af";
const labelColor = component.labelColor || "#374151";
const valueColor = component.valueColor || "#000000";
// 쿼리 바인딩된 값 가져오기
const getCardItemValue = (item: { label: string; value: string; fieldName?: string }) => {
if (item.fieldName && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
}
}
return item.value;
};
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 제목 */}
{showCardTitle && (
<>
<div
className="flex-shrink-0 px-2 py-1 font-semibold"
style={{
fontSize: `${titleFontSize}px`,
color: titleColor,
}}
>
{cardTitle}
</div>
{/* 구분선 */}
<div
className="mx-1 flex-shrink-0 border-b"
style={{ borderColor: component.borderColor || "#e5e7eb" }}
/>
</>
)}
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => (
<div key={index} className="flex py-0.5">
<span
className="flex-shrink-0 font-medium"
style={{
width: `${labelWidth}px`,
fontSize: `${labelFontSize}px`,
color: labelColor,
}}
>
{item.label}
</span>
<span
className="flex-1"
style={{
fontSize: `${valueFontSize}px`,
color: valueColor,
}}
>
{getCardItemValue(item)}
</span>
</div>
))}
</div>
</div>
);
case "calculation":
// 계산 컴포넌트
const calcItems = component.calcItems || [];
const resultLabel = component.resultLabel || "합계";
const calcLabelWidth = component.labelWidth || 120;
const calcLabelFontSize = component.labelFontSize || 13;
const calcValueFontSize = component.valueFontSize || 13;
const calcResultFontSize = component.resultFontSize || 16;
const calcLabelColor = component.labelColor || "#374151";
const calcValueColor = component.valueColor || "#000000";
const calcResultColor = component.resultColor || "#2563eb";
const numberFormat = component.numberFormat || "currency";
const currencySuffix = component.currencySuffix || "원";
// 숫자 포맷팅 함수
const formatNumber = (num: number): string => {
if (numberFormat === "none") return String(num);
if (numberFormat === "comma") return num.toLocaleString();
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
return String(num);
};
// 쿼리 바인딩된 값 가져오기
const getCalcItemValue = (item: {
label: string;
value: number | string;
operator: string;
fieldName?: string;
}): number => {
if (item.fieldName && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
const val = row[item.fieldName];
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
}
}
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
};
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
const calculateResult = (): number => {
if (calcItems.length === 0) return 0;
// 첫 번째 항목은 기준값
let result = getCalcItemValue(
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
);
// 두 번째 항목부터 연산자 적용
for (let i = 1; i < calcItems.length; i++) {
const item = calcItems[i];
const val = getCalcItemValue(
item as { label: string; value: number | string; operator: string; fieldName?: string },
);
switch (item.operator) {
case "+":
result += val;
break;
case "-":
result -= val;
break;
case "x":
result *= val;
break;
case "÷":
result = val !== 0 ? result / val : result;
break;
}
}
return result;
};
const calcResult = calculateResult();
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{calcItems.map(
(
item: { label: string; value: number | string; operator: string; fieldName?: string },
index: number,
) => {
const itemValue = getCalcItemValue(item);
return (
<div key={index} className="flex items-center justify-between py-1">
<span
className="flex-shrink-0"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
className="text-right"
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
},
)}
</div>
{/* 구분선 */}
<div className="mx-1 flex-shrink-0 border-t" style={{ borderColor: component.borderColor || "#374151" }} />
{/* 결과 */}
<div className="flex items-center justify-between px-2 py-2">
<span
className="font-semibold"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcResultFontSize}px`,
color: calcLabelColor,
}}
>
{resultLabel}
</span>
<span
className="text-right font-bold"
style={{
fontSize: `${calcResultFontSize}px`,
color: calcResultColor,
}}
>
{formatNumber(calcResult)}
</span>
</div>
</div>
);
case "barcode":
// 바코드/QR코드 컴포넌트 렌더링
const barcodeType = component.barcodeType || "CODE128";
const showBarcodeText = component.showBarcodeText !== false;
const barcodeColor = component.barcodeColor || "#000000";
const barcodeBackground = component.barcodeBackground || "transparent";
const barcodeMargin = component.barcodeMargin ?? 10;
const qrErrorLevel = component.qrErrorCorrectionLevel || "M";
// 바코드 값 결정 (쿼리 바인딩 또는 고정값)
const getBarcodeValue = (): string => {
// QR코드 다중 필드 모드
if (
barcodeType === "QR" &&
component.qrUseMultiField &&
component.qrDataFields &&
component.qrDataFields.length > 0 &&
component.queryId
) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
// 모든 행 포함 모드
if (component.qrIncludeAllRows) {
const allRowsData: Record<string, string>[] = [];
queryResult.rows.forEach((row) => {
const rowData: Record<string, string> = {};
component.qrDataFields!.forEach((field) => {
if (field.fieldName && field.label) {
const val = row[field.fieldName];
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
}
});
allRowsData.push(rowData);
});
return JSON.stringify(allRowsData);
}
// 단일 행 (첫 번째 행만)
const row = queryResult.rows[0];
const jsonData: Record<string, string> = {};
component.qrDataFields.forEach((field) => {
if (field.fieldName && field.label) {
const val = row[field.fieldName];
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
}
});
return JSON.stringify(jsonData);
}
// 쿼리 결과가 없으면 플레이스홀더 표시
const placeholderData: Record<string, string> = {};
component.qrDataFields.forEach((field) => {
if (field.label) {
placeholderData[field.label] = `{${field.fieldName || "field"}}`;
}
});
return component.qrIncludeAllRows
? JSON.stringify([placeholderData, { "...": "..." }])
: JSON.stringify(placeholderData);
}
// 단일 필드 바인딩
if (component.barcodeFieldName && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
// QR코드 + 모든 행 포함
if (barcodeType === "QR" && component.qrIncludeAllRows) {
const allValues = queryResult.rows
.map((row) => {
const val = row[component.barcodeFieldName!];
return val !== null && val !== undefined ? String(val) : "";
})
.filter((v) => v !== "");
return JSON.stringify(allValues);
}
// 단일 행 (첫 번째 행만)
const row = queryResult.rows[0];
const val = row[component.barcodeFieldName];
if (val !== null && val !== undefined) {
return String(val);
}
}
// 플레이스홀더
if (barcodeType === "QR" && component.qrIncludeAllRows) {
return JSON.stringify([`{${component.barcodeFieldName}}`, "..."]);
}
return `{${component.barcodeFieldName}}`;
}
return component.barcodeValue || "SAMPLE123";
};
const barcodeValue = getBarcodeValue();
const isQR = barcodeType === "QR";
return (
<div
className="flex h-full w-full items-center justify-center overflow-hidden"
style={{ backgroundColor: barcodeBackground }}
>
{isQR ? (
<QRCodeRenderer
value={barcodeValue}
size={Math.min(component.width, component.height) - 10}
fgColor={barcodeColor}
bgColor={barcodeBackground}
level={qrErrorLevel}
/>
) : (
<BarcodeRenderer
value={barcodeValue}
format={barcodeType}
width={component.width}
height={component.height}
displayValue={showBarcodeText}
lineColor={barcodeColor}
background={barcodeBackground}
margin={barcodeMargin}
/>
)}
</div>
);
case "checkbox":
// 체크박스 컴포넌트 렌더링
const checkboxSize = component.checkboxSize || 18;
const checkboxColor = component.checkboxColor || "#2563eb";
const checkboxBorderColor = component.checkboxBorderColor || "#6b7280";
const checkboxLabelPosition = component.checkboxLabelPosition || "right";
const checkboxLabel = component.checkboxLabel || "";
// 체크 상태 결정 (쿼리 바인딩 또는 고정값)
const getCheckboxValue = (): boolean => {
if (component.checkboxFieldName && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
const val = row[component.checkboxFieldName];
// truthy/falsy 값 판정
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
return true;
}
return false;
}
return false;
}
return component.checkboxChecked === true;
};
const isChecked = getCheckboxValue();
return (
<div
className={`flex h-full w-full items-center gap-2 ${
checkboxLabelPosition === "left" ? "flex-row-reverse justify-end" : ""
}`}
>
{/* 체크박스 */}
<div
className="flex items-center justify-center rounded-sm border-2 transition-colors"
style={{
width: `${checkboxSize}px`,
height: `${checkboxSize}px`,
borderColor: isChecked ? checkboxColor : checkboxBorderColor,
backgroundColor: isChecked ? checkboxColor : "transparent",
}}
>
{isChecked && (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
width: `${checkboxSize * 0.7}px`,
height: `${checkboxSize * 0.7}px`,
}}
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
{/* 레이블 */}
{/* 레이블 */}
{checkboxLabel && (
<span
style={{
fontSize: `${component.fontSize || 14}px`,
color: component.fontColor || "#374151",
}}
>
{checkboxLabel}
</span>
)}
</div>
);
default:
return <div> </div>;
}
};
return (
<div
ref={componentRef}
className={`absolute ${component.type === "divider" ? "p-0" : "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}
onDoubleClick={handleDoubleClick}
>
{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 h-3 w-3 rounded-full bg-blue-500 ${
component.type === "divider"
? component.orientation === "vertical"
? "bottom-0 left-1/2 cursor-s-resize" // 세로 구분선: 하단 중앙
: "top-1/2 right-0 cursor-e-resize" // 가로 구분선: 우측 중앙
: "right-0 bottom-0 cursor-se-resize" // 일반 컴포넌트: 우하단
}`}
style={{
transform:
component.type === "divider"
? component.orientation === "vertical"
? "translate(-50%, 50%)" // 세로 구분선
: "translate(50%, -50%)" // 가로 구분선
: "translate(50%, 50%)", // 일반 컴포넌트
}}
onMouseDown={handleResizeStart}
/>
)}
</div>
);
}