워터마크 기능 추가

This commit is contained in:
dohyeons 2025-12-22 15:40:31 +09:00
parent 002c71f9e8
commit d90e68905e
6 changed files with 929 additions and 2 deletions

View File

@ -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,
};
});

View File

@ -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;
}
// 레이아웃 설정

View File

@ -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 (
<div style={baseStyle}>
<div
className="absolute flex items-center justify-center"
style={{
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
opacity: watermark.opacity,
whiteSpace: "nowrap",
}}
>
{watermark.type === "text" ? (
<span
style={{
fontSize: `${watermark.fontSize || 48}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : (
watermark.imageUrl && (
<img
src={getFullImageUrl(watermark.imageUrl)}
alt="watermark"
style={{
maxWidth: "50%",
maxHeight: "50%",
objectFit: "contain",
}}
/>
)
)}
</div>
</div>
);
}
// 중앙 스타일
if (watermark.style === "center") {
return (
<div style={baseStyle}>
<div
className="absolute flex items-center justify-center"
style={{
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
opacity: watermark.opacity,
}}
>
{watermark.type === "text" ? (
<span
style={{
fontSize: `${watermark.fontSize || 48}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : (
watermark.imageUrl && (
<img
src={getFullImageUrl(watermark.imageUrl)}
alt="watermark"
style={{
maxWidth: "50%",
maxHeight: "50%",
objectFit: "contain",
}}
/>
)
)}
</div>
</div>
);
}
// 타일 스타일
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 (
<div style={baseStyle}>
<div
style={{
position: "absolute",
top: "-50%",
left: "-50%",
width: "200%",
height: "200%",
display: "flex",
flexWrap: "wrap",
alignContent: "flex-start",
transform: `rotate(${rotation}deg)`,
opacity: watermark.opacity,
}}
>
{Array.from({ length: rows * cols }).map((_, index) => (
<div
key={index}
style={{
width: `${tileSize}px`,
height: `${tileSize}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{watermark.type === "text" ? (
<span
style={{
fontSize: `${watermark.fontSize || 24}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
whiteSpace: "nowrap",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : (
watermark.imageUrl && (
<img
src={getFullImageUrl(watermark.imageUrl)}
alt="watermark"
style={{
width: `${tileSize * 0.6}px`,
height: `${tileSize * 0.6}px`,
objectFit: "contain",
}}
/>
)
)}
</div>
))}
</div>
</div>
);
}
return null;
}
export function ReportDesignerCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
const {
@ -431,6 +608,15 @@ export function ReportDesignerCanvas() {
/>
)}
{/* 워터마크 렌더링 */}
{currentPage?.watermark?.enabled && (
<WatermarkLayer
watermark={currentPage.watermark}
canvasWidth={canvasWidth * MM_TO_PX}
canvasHeight={canvasHeight * MM_TO_PX}
/>
)}
{/* 정렬 가이드라인 렌더링 */}
{alignmentGuides.vertical.map((x, index) => (
<div

View File

@ -9,6 +9,8 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import { Trash2, Settings, Database, Link2, Upload, Loader2, X } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { QueryManager } from "./QueryManager";
@ -32,8 +34,10 @@ export function ReportDesignerRightPanel() {
} = context;
const [activeTab, setActiveTab] = useState<string>("properties");
const [uploadingImage, setUploadingImage] = useState(false);
const [uploadingWatermarkImage, setUploadingWatermarkImage] = useState(false);
const [signatureMethod, setSignatureMethod] = useState<"draw" | "upload" | "generate">("draw");
const fileInputRef = useRef<HTMLInputElement>(null);
const watermarkFileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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() {
</div>
</CardContent>
</Card>
{/* 워터마크 설정 */}
<Card>
<CardHeader>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 워터마크 활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentPage.watermark?.enabled ?? false}
onCheckedChange={(checked) =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark,
enabled: checked,
type: currentPage.watermark?.type ?? "text",
opacity: currentPage.watermark?.opacity ?? 0.3,
style: currentPage.watermark?.style ?? "diagonal",
},
})
}
/>
</div>
{currentPage.watermark?.enabled && (
<>
{/* 워터마크 타입 */}
<div>
<Label className="text-xs"></Label>
<Select
value={currentPage.watermark?.type ?? "text"}
onValueChange={(value: "text" | "image") =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
type: value,
},
})
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="image"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 텍스트 워터마크 설정 */}
{currentPage.watermark?.type === "text" && (
<>
<div>
<Label className="text-xs"></Label>
<Input
value={currentPage.watermark?.text ?? ""}
onChange={(e) =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
text: e.target.value,
},
})
}
placeholder="DRAFT, 대외비 등"
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={currentPage.watermark?.fontSize ?? 48}
onChange={(e) =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
fontSize: Number(e.target.value),
},
})
}
className="mt-1"
min={12}
max={200}
/>
</div>
<div>
<Label className="text-xs"></Label>
<div className="mt-1 flex gap-1">
<Input
type="color"
value={currentPage.watermark?.fontColor ?? "#cccccc"}
onChange={(e) =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
fontColor: e.target.value,
},
})
}
className="h-9 w-12 cursor-pointer p-1"
/>
<Input
type="text"
value={currentPage.watermark?.fontColor ?? "#cccccc"}
onChange={(e) =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
fontColor: e.target.value,
},
})
}
className="flex-1"
/>
</div>
</div>
</div>
</>
)}
{/* 이미지 워터마크 설정 */}
{currentPage.watermark?.type === "image" && (
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-2">
<input
ref={watermarkFileInputRef}
type="file"
accept="image/*"
onChange={handleWatermarkImageUpload}
className="hidden"
disabled={uploadingWatermarkImage}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => watermarkFileInputRef.current?.click()}
disabled={uploadingWatermarkImage}
className="flex-1"
>
{uploadingWatermarkImage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
{currentPage.watermark?.imageUrl ? "이미지 변경" : "이미지 선택"}
</>
)}
</Button>
{currentPage.watermark?.imageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
imageUrl: "",
},
})
}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
<p className="text-muted-foreground mt-1 text-[10px]">
JPG, PNG, GIF, WEBP ( 5MB)
</p>
{currentPage.watermark?.imageUrl && (
<p className="mt-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-xs text-indigo-600">
현재: ...{currentPage.watermark.imageUrl.slice(-30)}
</p>
)}
</div>
)}
{/* 공통 설정 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={currentPage.watermark?.style ?? "diagonal"}
onValueChange={(value: "diagonal" | "center" | "tile") =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
style: value,
},
})
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="diagonal"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="tile"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* 대각선/타일 회전 각도 */}
{(currentPage.watermark?.style === "diagonal" ||
currentPage.watermark?.style === "tile") && (
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={currentPage.watermark?.rotation ?? -45}
onChange={(e) =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
rotation: Number(e.target.value),
},
})
}
className="mt-1"
min={-180}
max={180}
/>
</div>
)}
{/* 투명도 */}
<div>
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<span className="text-muted-foreground text-xs">
{Math.round((currentPage.watermark?.opacity ?? 0.3) * 100)}%
</span>
</div>
<Slider
value={[(currentPage.watermark?.opacity ?? 0.3) * 100]}
onValueChange={(value) =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
opacity: value[0] / 100,
},
})
}
min={5}
max={100}
step={5}
className="mt-2"
/>
</div>
{/* 프리셋 버튼 */}
<div className="grid grid-cols-2 gap-2 pt-2">
<Button
size="sm"
variant="outline"
onClick={() =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
type: "text",
text: "DRAFT",
fontSize: 64,
fontColor: "#cccccc",
style: "diagonal",
opacity: 0.2,
rotation: -45,
},
})
}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
type: "text",
text: "대외비",
fontSize: 64,
fontColor: "#ff0000",
style: "diagonal",
opacity: 0.15,
rotation: -45,
},
})
}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
type: "text",
text: "SAMPLE",
fontSize: 48,
fontColor: "#888888",
style: "tile",
opacity: 0.1,
rotation: -30,
},
})
}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
updatePageSettings(currentPageId, {
watermark: {
...currentPage.watermark!,
type: "text",
text: "COPY",
fontSize: 56,
fontColor: "#aaaaaa",
style: "center",
opacity: 0.25,
},
})
}
>
</Button>
</div>
</>
)}
</CardContent>
</Card>
</>
) : (
<div className="flex h-full items-center justify-center">

View File

@ -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 (
<div style={baseStyle}>
<div
className="absolute flex items-center justify-center"
style={{
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
opacity: watermark.opacity,
whiteSpace: "nowrap",
}}
>
{watermark.type === "text" ? (
<span
style={{
fontSize: `${watermark.fontSize || 48}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : (
watermark.imageUrl && (
<img
src={
watermark.imageUrl.startsWith("data:")
? watermark.imageUrl
: getFullImageUrl(watermark.imageUrl)
}
alt="watermark"
style={{
maxWidth: "50%",
maxHeight: "50%",
objectFit: "contain",
}}
/>
)
)}
</div>
</div>
);
}
// 중앙 스타일
if (watermark.style === "center") {
return (
<div style={baseStyle}>
<div
className="absolute flex items-center justify-center"
style={{
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
opacity: watermark.opacity,
}}
>
{watermark.type === "text" ? (
<span
style={{
fontSize: `${watermark.fontSize || 48}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : (
watermark.imageUrl && (
<img
src={
watermark.imageUrl.startsWith("data:")
? watermark.imageUrl
: getFullImageUrl(watermark.imageUrl)
}
alt="watermark"
style={{
maxWidth: "50%",
maxHeight: "50%",
objectFit: "contain",
}}
/>
)
)}
</div>
</div>
);
}
// 타일 스타일
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 (
<div style={baseStyle}>
<div
style={{
position: "absolute",
top: "-50%",
left: "-50%",
width: "200%",
height: "200%",
display: "flex",
flexWrap: "wrap",
alignContent: "flex-start",
transform: `rotate(${rotation}deg)`,
opacity: watermark.opacity,
}}
>
{Array.from({ length: rows * cols }).map((_, index) => (
<div
key={index}
style={{
width: `${tileSize}px`,
height: `${tileSize}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{watermark.type === "text" ? (
<span
style={{
fontSize: `${watermark.fontSize || 24}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
whiteSpace: "nowrap",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : (
watermark.imageUrl && (
<img
src={
watermark.imageUrl.startsWith("data:")
? watermark.imageUrl
: getFullImageUrl(watermark.imageUrl)
}
alt="watermark"
style={{
width: `${tileSize * 0.6}px`,
height: `${tileSize * 0.6}px`,
objectFit: "contain",
}}
/>
)
)}
</div>
))}
</div>
</div>
);
}
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"
? `<span style="font-size: ${watermark.fontSize || 48}px; color: ${watermark.fontColor || "#cccccc"}; font-weight: bold; white-space: nowrap;">${watermark.text || "WATERMARK"}</span>`
: watermark.imageUrl
? `<img src="${watermark.imageUrl.startsWith("data:") ? watermark.imageUrl : getFullImageUrl(watermark.imageUrl)}" style="max-width: 50%; max-height: 50%; object-fit: contain;" />`
: "";
if (watermark.style === "diagonal") {
return `
<div style="${wrapperStyle}">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(${rotation}deg); opacity: ${opacity};">
${textContent}
</div>
</div>`;
}
if (watermark.style === "center") {
return `
<div style="${wrapperStyle}">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); opacity: ${opacity};">
${textContent}
</div>
</div>`;
}
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(() => `<div style="width: ${tileSize}px; height: ${tileSize}px; display: flex; align-items: center; justify-content: center;">${textContent}</div>`)
.join("");
return `
<div style="${wrapperStyle}">
<div style="position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; display: flex; flex-wrap: wrap; align-content: flex-start; transform: rotate(${rotation}deg); opacity: ${opacity};">
${tileItems}
</div>
</div>`;
}
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 `
<div style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor}; margin: 0 auto;">
${watermarkHTML}
${componentsHTML}
</div>`;
};
@ -670,6 +924,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
page.background_color,
pageIndex,
totalPages,
page.watermark,
),
)
.join('<div style="page-break-after: always;"></div>');
@ -894,13 +1149,21 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
<div key={page.page_id} className="relative">
{/* 페이지 컨텐츠 */}
<div
className="relative mx-auto shadow-lg"
className="relative mx-auto overflow-hidden shadow-lg"
style={{
width: `${page.width}mm`,
minHeight: `${page.height}mm`,
backgroundColor: page.background_color,
}}
>
{/* 워터마크 렌더링 */}
{page.watermark?.enabled && (
<PreviewWatermarkLayer
watermark={page.watermark}
pageWidth={page.width}
pageHeight={page.height}
/>
)}
{(Array.isArray(page.components) ? page.components : []).map((component) => {
const displayValue = getComponentValue(component);
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;

View File

@ -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;
}
// 레이아웃 설정 (페이지 기반)