워터마크 기능 추가
This commit is contained in:
parent
002c71f9e8
commit
d90e68905e
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// 레이아웃 설정
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// 레이아웃 설정 (페이지 기반)
|
||||
|
|
|
|||
Loading…
Reference in New Issue