From f456ab89e89cc628700531f70311b889e80a2046 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 13 Oct 2025 10:32:46 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=9C=EB=AA=85=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/globals.css | 5 +- frontend/app/layout.tsx | 7 +- .../designer/ReportDesignerRightPanel.tsx | 151 +++++++----- .../report/designer/SignatureGenerator.tsx | 223 ++++++++++++++++++ 4 files changed, 322 insertions(+), 64 deletions(-) create mode 100644 frontend/components/report/designer/SignatureGenerator.tsx diff --git a/frontend/app/globals.css b/frontend/app/globals.css index fa3a934d..8352502a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,3 +1,6 @@ +/* 서명용 손글씨 폰트 - 최상단에 위치해야 함 */ +@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Gugi&family=Single+Day&family=Stylish&family=Sunflower:wght@700&family=Dokdo&display=swap"); + @import "tailwindcss"; @import "tw-animate-css"; @@ -76,7 +79,7 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); - + /* Z-Index 계층 구조 */ --z-background: 1; --z-layout: 10; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 11470e80..3709c650 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -23,6 +23,9 @@ export const metadata: Metadata = { description: "제품 수명 주기 관리(PLM) 솔루션", keywords: ["WACE", "PLM", "Product Lifecycle Management", "WACE", "제품관리"], authors: [{ name: "WACE" }], + icons: { + icon: "/favicon.ico", + }, }; export const viewport: Viewport = { @@ -37,10 +40,6 @@ export default function RootLayout({ }>) { return ( - - - -
diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index d5e231d6..bc2eea74 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -12,6 +12,7 @@ import { Trash2, Settings, Database, Link2, Upload, Loader2 } from "lucide-react import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { QueryManager } from "./QueryManager"; import { SignaturePad } from "./SignaturePad"; +import { SignatureGenerator } from "./SignatureGenerator"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; @@ -29,6 +30,7 @@ export function ReportDesignerRightPanel() { } = context; const [activeTab, setActiveTab] = useState("properties"); const [uploadingImage, setUploadingImage] = useState(false); + const [signatureMethod, setSignatureMethod] = useState<"draw" | "upload" | "generate">("draw"); const fileInputRef = useRef(null); const { toast } = useToast(); @@ -75,7 +77,7 @@ export function ReportDesignerRightPanel() { description: "이미지가 업로드되었습니다.", }); } - } catch (error) { + } catch { toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", @@ -650,68 +652,99 @@ export function ReportDesignerRightPanel() { - {/* 서명란: 탭으로 직접 서명 / 이미지 업로드 선택 */} + {/* 서명란: 드롭다운으로 직접 서명 / 이미지 업로드 / 서명 만들기 선택 */} {selectedComponent.type === "signature" ? ( - - - - 직접 서명 - - - 이미지 업로드 - - + <> +
+ + +
- {/* 직접 서명 탭 */} - - { - updateComponent(selectedComponent.id, { - imageUrl: dataUrl, - }); - }} - /> - - - {/* 이미지 업로드 탭 */} - -
- + { + updateComponent(selectedComponent.id, { + imageUrl: dataUrl, + }); + }} /> -
-

JPG, PNG, GIF, WEBP (최대 10MB)

- {selectedComponent.imageUrl && !selectedComponent.imageUrl.startsWith("data:") && ( -

현재: {selectedComponent.imageUrl}

- )} -
-
+ )} + + {/* 이미지 업로드 */} + {signatureMethod === "upload" && ( +
+
+ + +
+

JPG, PNG, GIF, WEBP (최대 10MB)

+ {selectedComponent.imageUrl && !selectedComponent.imageUrl.startsWith("data:") && ( +

+ 현재: {selectedComponent.imageUrl} +

+ )} +
+ )} + + {/* 서명 만들기 */} + {signatureMethod === "generate" && ( +
+ { + updateComponent(selectedComponent.id, { + imageUrl: dataUrl, + }); + toast({ + title: "성공", + description: "서명이 적용되었습니다.", + }); + }} + /> +
+ )} + ) : ( // 도장란: 기존 방식 유지
diff --git a/frontend/components/report/designer/SignatureGenerator.tsx b/frontend/components/report/designer/SignatureGenerator.tsx new file mode 100644 index 00000000..f54c9a7c --- /dev/null +++ b/frontend/components/report/designer/SignatureGenerator.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Card } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Wand2 } from "lucide-react"; + +interface SignatureGeneratorProps { + onSignatureSelect: (dataUrl: string) => void; +} + +// 서명용 손글씨 폰트 목록 (스타일이 확실히 구분되는 폰트들) +const SIGNATURE_FONTS = { + korean: [ + { name: "나눔손글씨 붓", style: "'Nanum Brush Script', cursive", weight: 400 }, + { name: "나눔손글씨 펜", style: "'Nanum Pen Script', cursive", weight: 400 }, + { name: "배달의민족 도현", style: "Dokdo, cursive", weight: 400 }, + { name: "귀여운", style: "Gugi, cursive", weight: 400 }, + { name: "싱글데이", style: "'Single Day', cursive", weight: 400 }, + { name: "스타일리시", style: "Stylish, cursive", weight: 400 }, + { name: "해바라기", style: "Sunflower, sans-serif", weight: 700 }, + { name: "손글씨", style: "Gaegu, cursive", weight: 700 }, + ], + english: [ + { name: "Allura (우아한)", style: "Allura, cursive", weight: 400 }, + { name: "Dancing Script (춤추는)", style: "'Dancing Script', cursive", weight: 700 }, + { name: "Great Vibes (멋진)", style: "'Great Vibes', cursive", weight: 400 }, + { name: "Pacifico (파도)", style: "Pacifico, cursive", weight: 400 }, + { name: "Satisfy (만족)", style: "Satisfy, cursive", weight: 400 }, + { name: "Caveat (거친)", style: "Caveat, cursive", weight: 700 }, + { name: "Permanent Marker", style: "'Permanent Marker', cursive", weight: 400 }, + { name: "Shadows Into Light", style: "'Shadows Into Light', cursive", weight: 400 }, + { name: "Kalam (볼드)", style: "Kalam, cursive", weight: 700 }, + { name: "Patrick Hand", style: "'Patrick Hand', cursive", weight: 400 }, + { name: "Indie Flower", style: "'Indie Flower', cursive", weight: 400 }, + { name: "Amatic SC", style: "'Amatic SC', cursive", weight: 700 }, + { name: "Covered By Your Grace", style: "'Covered By Your Grace', cursive", weight: 400 }, + ], +}; + +export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProps) { + const [language, setLanguage] = useState<"korean" | "english">("korean"); + const [name, setName] = useState(""); + const [generatedSignatures, setGeneratedSignatures] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + const [fontsLoaded, setFontsLoaded] = useState(false); + const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]); + + const fonts = SIGNATURE_FONTS[language]; + + // 컴포넌트 마운트 시 폰트 미리 로드 + useEffect(() => { + const loadAllFonts = async () => { + try { + await document.fonts.ready; + + // 모든 폰트를 명시적으로 로드 + const allFonts = [...SIGNATURE_FONTS.korean, ...SIGNATURE_FONTS.english]; + const fontLoadPromises = allFonts.map((font) => document.fonts.load(`${font.weight} 124px ${font.style}`)); + await Promise.all(fontLoadPromises); + + // 임시 Canvas를 그려서 폰트를 강제로 렌더링 (브라우저가 폰트를 실제로 사용하도록) + const tempCanvas = document.createElement("canvas"); + tempCanvas.width = 100; + tempCanvas.height = 100; + const tempCtx = tempCanvas.getContext("2d"); + + if (tempCtx) { + for (const font of allFonts) { + tempCtx.font = `${font.weight} 124px ${font.style}`; + tempCtx.fillText("테", 0, 50); + tempCtx.fillText("A", 0, 50); + } + } + + // 폰트 렌더링 후 대기 + await new Promise((resolve) => setTimeout(resolve, 500)); + + setFontsLoaded(true); + } catch (error) { + console.warn("Font preloading failed:", error); + await new Promise((resolve) => setTimeout(resolve, 1500)); + setFontsLoaded(true); + } + }; + + loadAllFonts(); + }, []); + + // 서명 생성 + const generateSignatures = async () => { + if (!name.trim()) return; + + setIsGenerating(true); + + // 폰트가 미리 로드될 때까지 대기 + if (!fontsLoaded) { + await new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (fontsLoaded) { + clearInterval(checkInterval); + resolve(true); + } + }, 100); + }); + } + + const newSignatures: string[] = []; + + // 동기적으로 하나씩 생성 + for (const font of fonts) { + const canvas = document.createElement("canvas"); + canvas.width = 500; + canvas.height = 200; + const ctx = canvas.getContext("2d"); + + if (ctx) { + // 배경 흰색 + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // 텍스트 스타일 + ctx.fillStyle = "#000000"; + ctx.font = `${font.weight} 124px ${font.style}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + // 텍스트 그리기 + ctx.fillText(name, canvas.width / 2, canvas.height / 2); + + // 데이터 URL로 변환 + newSignatures.push(canvas.toDataURL("image/png")); + } + } + + setGeneratedSignatures(newSignatures); + setIsGenerating(false); + }; + + // 서명 선택 (더블클릭) + const handleSignatureDoubleClick = (dataUrl: string) => { + onSignatureSelect(dataUrl); + }; + + return ( +
+ {/* 언어 선택 */} +
+ + +
+ + {/* 이름 입력 */} +
+ +
+ setName(e.target.value)} + placeholder={language === "korean" ? "홍길동" : "John Doe"} + className="h-8 flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") { + generateSignatures(); + } + }} + /> + +
+
+ + {/* 생성된 서명 목록 */} + {generatedSignatures.length > 0 && ( +
+ +

더블클릭하여 서명을 선택하세요

+ +
+ {generatedSignatures.map((signature, index) => ( + handleSignatureDoubleClick(signature)} + > +
+ {`서명 +

+ {fonts[index].name} +

+
+
+ ))} +
+
+
+ )} +
+ ); +}