diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index 124eb265..5f755947 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -27,7 +27,11 @@ import { BorderStyle, PageOrientation, convertMillimetersToTwip, + Header, + Footer, + HeadingLevel, } from "docx"; +import { WatermarkConfig } from "../types/report"; import bwipjs from "bwip-js"; export class ReportController { @@ -3063,6 +3067,36 @@ export class ReportController { children.push(new Paragraph({ children: [] })); } + // 워터마크 헤더 생성 (워터마크가 활성화된 경우) + const watermark: WatermarkConfig | undefined = page.watermark; + let headers: { default?: Header } | undefined; + + if (watermark?.enabled && watermark.type === "text" && watermark.text) { + // 워터마크 색상을 hex로 변환 (alpha 적용) + const opacity = watermark.opacity ?? 0.3; + const fontColor = watermark.fontColor || "#CCCCCC"; + // hex 색상에서 # 제거 + const cleanColor = fontColor.replace("#", ""); + + headers = { + default: new Header({ + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: watermark.text, + size: (watermark.fontSize || 48) * 2, // Word는 half-point 사용 + color: cleanColor, + bold: true, + }), + ], + }), + ], + }), + }; + } + return { properties: { page: { @@ -3082,6 +3116,7 @@ export class ReportController { }, }, }, + headers, children, }; }); diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index 23a7496d..d5641cff 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -116,6 +116,22 @@ export interface UpdateReportRequest { useYn?: string; } +// 워터마크 설정 +export interface WatermarkConfig { + enabled: boolean; + type: "text" | "image"; + // 텍스트 워터마크 + text?: string; + fontSize?: number; + fontColor?: string; + // 이미지 워터마크 + imageUrl?: string; + // 공통 설정 + opacity: number; // 0~1 + style: "diagonal" | "center" | "tile"; + rotation?: number; // 대각선일 때 각도 (기본 -45) +} + // 페이지 설정 export interface PageConfig { page_id: string; @@ -131,6 +147,7 @@ export interface PageConfig { right: number; }; components: any[]; + watermark?: WatermarkConfig; } // 레이아웃 설정 diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index f278cd97..7f63123b 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -3,15 +3,192 @@ import { useRef, useEffect } from "react"; import { useDrop } from "react-dnd"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; -import { ComponentConfig } from "@/types/report"; +import { ComponentConfig, WatermarkConfig } from "@/types/report"; import { CanvasComponent } from "./CanvasComponent"; import { Ruler } from "./Ruler"; import { v4 as uuidv4 } from "uuid"; +import { getFullImageUrl } from "@/lib/api/client"; // mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정) // A4 기준: 210mm x 297mm → 840px x 1188px export const MM_TO_PX = 4; +// 워터마크 레이어 컴포넌트 +interface WatermarkLayerProps { + watermark: WatermarkConfig; + canvasWidth: number; + canvasHeight: number; +} + +function WatermarkLayer({ watermark, canvasWidth, canvasHeight }: WatermarkLayerProps) { + // 공통 스타일 + const baseStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + pointerEvents: "none", + overflow: "hidden", + zIndex: 1, // 컴포넌트보다 낮은 z-index + }; + + // 대각선 스타일 + if (watermark.style === "diagonal") { + const rotation = watermark.rotation ?? -45; + return ( +
+
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+
+ ); + } + + // 중앙 스타일 + if (watermark.style === "center") { + return ( +
+
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+
+ ); + } + + // 타일 스타일 + if (watermark.style === "tile") { + const rotation = watermark.rotation ?? -30; + // 타일 간격 계산 + const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; + const cols = Math.ceil(canvasWidth / tileSize) + 2; + const rows = Math.ceil(canvasHeight / tileSize) + 2; + + return ( +
+
+ {Array.from({ length: rows * cols }).map((_, index) => ( +
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+ ))} +
+
+ ); + } + + return null; +} + export function ReportDesignerCanvas() { const canvasRef = useRef(null); const { @@ -431,6 +608,15 @@ export function ReportDesignerCanvas() { /> )} + {/* 워터마크 렌더링 */} + {currentPage?.watermark?.enabled && ( + + )} + {/* 정렬 가이드라인 렌더링 */} {alignmentGuides.vertical.map((x, index) => (
("properties"); const [uploadingImage, setUploadingImage] = useState(false); + const [uploadingWatermarkImage, setUploadingWatermarkImage] = useState(false); const [signatureMethod, setSignatureMethod] = useState<"draw" | "upload" | "generate">("draw"); const fileInputRef = useRef(null); + const watermarkFileInputRef = useRef(null); const { toast } = useToast(); const selectedComponent = components.find((c) => c.id === selectedComponentId); @@ -94,6 +98,65 @@ export function ReportDesignerRightPanel() { } }; + // 워터마크 이미지 업로드 핸들러 + const handleWatermarkImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !currentPageId) return; + + // 파일 타입 체크 + if (!file.type.startsWith("image/")) { + toast({ + title: "오류", + description: "이미지 파일만 업로드 가능합니다.", + variant: "destructive", + }); + return; + } + + // 파일 크기 체크 (5MB) + if (file.size > 5 * 1024 * 1024) { + toast({ + title: "오류", + description: "파일 크기는 5MB 이하여야 합니다.", + variant: "destructive", + }); + return; + } + + try { + setUploadingWatermarkImage(true); + + const result = await reportApi.uploadImage(file); + + if (result.success) { + // 업로드된 이미지 URL을 워터마크에 설정 + updatePageSettings(currentPageId, { + watermark: { + ...currentPage!.watermark!, + imageUrl: result.data.fileUrl, + }, + }); + + toast({ + title: "성공", + description: "워터마크 이미지가 업로드되었습니다.", + }); + } + } catch { + toast({ + title: "오류", + description: "이미지 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setUploadingWatermarkImage(false); + // input 초기화 + if (watermarkFileInputRef.current) { + watermarkFileInputRef.current.value = ""; + } + } + }; + // 선택된 쿼리의 결과 필드 가져오기 const getQueryFields = (queryId: string): string[] => { const result = context.getQueryResult(queryId); @@ -2626,6 +2689,352 @@ export function ReportDesignerRightPanel() {
+ + {/* 워터마크 설정 */} + + + 워터마크 + + + {/* 워터마크 활성화 */} +
+ + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark, + enabled: checked, + type: currentPage.watermark?.type ?? "text", + opacity: currentPage.watermark?.opacity ?? 0.3, + style: currentPage.watermark?.style ?? "diagonal", + }, + }) + } + /> +
+ + {currentPage.watermark?.enabled && ( + <> + {/* 워터마크 타입 */} +
+ + +
+ + {/* 텍스트 워터마크 설정 */} + {currentPage.watermark?.type === "text" && ( + <> +
+ + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + text: e.target.value, + }, + }) + } + placeholder="DRAFT, 대외비 등" + className="mt-1" + /> +
+
+
+ + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + fontSize: Number(e.target.value), + }, + }) + } + className="mt-1" + min={12} + max={200} + /> +
+
+ +
+ + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + fontColor: e.target.value, + }, + }) + } + className="h-9 w-12 cursor-pointer p-1" + /> + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + fontColor: e.target.value, + }, + }) + } + className="flex-1" + /> +
+
+
+ + )} + + {/* 이미지 워터마크 설정 */} + {currentPage.watermark?.type === "image" && ( +
+ +
+ + + {currentPage.watermark?.imageUrl && ( + + )} +
+

+ JPG, PNG, GIF, WEBP (최대 5MB) +

+ {currentPage.watermark?.imageUrl && ( +

+ 현재: ...{currentPage.watermark.imageUrl.slice(-30)} +

+ )} +
+ )} + + {/* 공통 설정 */} +
+ + +
+ + {/* 대각선/타일 회전 각도 */} + {(currentPage.watermark?.style === "diagonal" || + currentPage.watermark?.style === "tile") && ( +
+ + + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + rotation: Number(e.target.value), + }, + }) + } + className="mt-1" + min={-180} + max={180} + /> +
+ )} + + {/* 투명도 */} +
+
+ + + {Math.round((currentPage.watermark?.opacity ?? 0.3) * 100)}% + +
+ + updatePageSettings(currentPageId, { + watermark: { + ...currentPage.watermark!, + opacity: value[0] / 100, + }, + }) + } + min={5} + max={100} + step={5} + className="mt-2" + /> +
+ + {/* 프리셋 버튼 */} +
+ + + + +
+ + )} +
+
) : (
diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 0851fa92..c1b35854 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -22,6 +22,202 @@ interface ReportPreviewModalProps { onClose: () => void; } +// 미리보기용 워터마크 레이어 컴포넌트 +interface PreviewWatermarkLayerProps { + watermark: { + enabled: boolean; + type: "text" | "image"; + text?: string; + fontSize?: number; + fontColor?: string; + imageUrl?: string; + opacity: number; + style: "diagonal" | "center" | "tile"; + rotation?: number; + }; + pageWidth: number; + pageHeight: number; +} + +function PreviewWatermarkLayer({ watermark, pageWidth, pageHeight }: PreviewWatermarkLayerProps) { + const baseStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + pointerEvents: "none", + overflow: "hidden", + zIndex: 0, + }; + + const rotation = watermark.rotation ?? -45; + + // 대각선 스타일 + if (watermark.style === "diagonal") { + return ( +
+
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+
+ ); + } + + // 중앙 스타일 + if (watermark.style === "center") { + return ( +
+
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+
+ ); + } + + // 타일 스타일 + if (watermark.style === "tile") { + const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; + const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2; + const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2; + + return ( +
+
+ {Array.from({ length: rows * cols }).map((_, index) => ( +
+ {watermark.type === "text" ? ( + + {watermark.text || "WATERMARK"} + + ) : ( + watermark.imageUrl && ( + watermark + ) + )} +
+ ))} +
+
+ ); + } + + return null; +} + // 바코드/QR코드 미리보기 컴포넌트 function BarcodePreview({ component, @@ -321,6 +517,60 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) printWindow.print(); }; + // 워터마크 HTML 생성 헬퍼 함수 + const generateWatermarkHTML = (watermark: any, pageWidth: number, pageHeight: number): string => { + if (!watermark?.enabled) return ""; + + const opacity = watermark.opacity ?? 0.3; + const rotation = watermark.rotation ?? -45; + + // 공통 래퍼 스타일 + const wrapperStyle = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; z-index: 0;`; + + // 텍스트 컨텐츠 생성 + const textContent = watermark.type === "text" + ? `${watermark.text || "WATERMARK"}` + : watermark.imageUrl + ? `` + : ""; + + if (watermark.style === "diagonal") { + return ` +
+
+ ${textContent} +
+
`; + } + + if (watermark.style === "center") { + return ` +
+
+ ${textContent} +
+
`; + } + + if (watermark.style === "tile") { + const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150; + const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2; + const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2; + const tileItems = Array.from({ length: rows * cols }) + .map(() => `
${textContent}
`) + .join(""); + + return ` +
+
+ ${tileItems} +
+
`; + } + + return ""; + }; + // 페이지별 컴포넌트 HTML 생성 const generatePageHTML = ( pageComponents: any[], @@ -329,6 +579,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) backgroundColor: string, pageIndex: number = 0, totalPages: number = 1, + watermark?: any, ): string => { const componentsHTML = pageComponents .map((component) => { @@ -649,8 +900,11 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) }) .join(""); + const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight); + return `
+ ${watermarkHTML} ${componentsHTML}
`; }; @@ -670,6 +924,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) page.background_color, pageIndex, totalPages, + page.watermark, ), ) .join('
'); @@ -894,13 +1149,21 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
{/* 페이지 컨텐츠 */}
+ {/* 워터마크 렌더링 */} + {page.watermark?.enabled && ( + + )} {(Array.isArray(page.components) ? page.components : []).map((component) => { const displayValue = getComponentValue(component); const queryResult = component.queryId ? getQueryResult(component.queryId) : null; diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 5a61e5b9..6619b534 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -81,6 +81,22 @@ export interface ExternalConnection { is_active: string; } +// 워터마크 설정 +export interface WatermarkConfig { + enabled: boolean; + type: "text" | "image"; + // 텍스트 워터마크 + text?: string; + fontSize?: number; + fontColor?: string; + // 이미지 워터마크 + imageUrl?: string; + // 공통 설정 + opacity: number; // 0~1 + style: "diagonal" | "center" | "tile"; + rotation?: number; // 대각선일 때 각도 (기본 -45) +} + // 페이지 설정 export interface ReportPage { page_id: string; @@ -97,6 +113,7 @@ export interface ReportPage { }; background_color: string; components: ComponentConfig[]; + watermark?: WatermarkConfig; } // 레이아웃 설정 (페이지 기반)