서명 만들기 기능 구현

This commit is contained in:
dohyeons 2025-10-13 10:32:46 +09:00
parent 7828b5e073
commit f456ab89e8
4 changed files with 322 additions and 64 deletions

View File

@ -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;

View File

@ -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 (
<html lang="ko" className="h-full">
<head>
<link rel="icon" href="/favicon.ico" />
<meta name="theme-color" content="#0f172a" />
</head>
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
<div id="root" className="h-full">
<QueryProvider>

View File

@ -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<string>("properties");
const [uploadingImage, setUploadingImage] = useState(false);
const [signatureMethod, setSignatureMethod] = useState<"draw" | "upload" | "generate">("draw");
const fileInputRef = useRef<HTMLInputElement>(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() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 서명란: 탭으로 직접 서명 / 이미지 업로드 선택 */}
{/* 서명란: 드롭다운으로 직접 서명 / 이미지 업로드 / 서명 만들기 선택 */}
{selectedComponent.type === "signature" ? (
<Tabs defaultValue="draw" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="draw" className="text-xs">
</TabsTrigger>
<TabsTrigger value="upload" className="text-xs">
</TabsTrigger>
</TabsList>
<>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={signatureMethod}
onValueChange={(value: "draw" | "upload" | "generate") => setSignatureMethod(value)}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draw"> </SelectItem>
<SelectItem value="upload"> </SelectItem>
<SelectItem value="generate"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 직접 서명 탭 */}
<TabsContent value="draw" className="mt-3 space-y-2">
<SignaturePad
initialSignature={selectedComponent.imageUrl}
onSignatureChange={(dataUrl) => {
updateComponent(selectedComponent.id, {
imageUrl: dataUrl,
});
}}
/>
</TabsContent>
{/* 이미지 업로드 탭 */}
<TabsContent value="upload" className="mt-3 space-y-2">
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
disabled={uploadingImage}
{/* 직접 서명 */}
{signatureMethod === "draw" && (
<div className="mt-3 space-y-2">
<SignaturePad
initialSignature={selectedComponent.imageUrl}
onSignatureChange={(dataUrl) => {
updateComponent(selectedComponent.id, {
imageUrl: dataUrl,
});
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
className="flex-1"
>
{uploadingImage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
{selectedComponent.imageUrl ? "파일 변경" : "파일 선택"}
</>
)}
</Button>
</div>
<p className="text-xs text-gray-500">JPG, PNG, GIF, WEBP ( 10MB)</p>
{selectedComponent.imageUrl && !selectedComponent.imageUrl.startsWith("data:") && (
<p className="truncate text-xs text-indigo-600">: {selectedComponent.imageUrl}</p>
)}
</TabsContent>
</Tabs>
)}
{/* 이미지 업로드 */}
{signatureMethod === "upload" && (
<div className="mt-3 space-y-2">
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
disabled={uploadingImage}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
className="flex-1"
>
{uploadingImage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
{selectedComponent.imageUrl ? "파일 변경" : "파일 선택"}
</>
)}
</Button>
</div>
<p className="text-xs text-gray-500">JPG, PNG, GIF, WEBP ( 10MB)</p>
{selectedComponent.imageUrl && !selectedComponent.imageUrl.startsWith("data:") && (
<p className="truncate text-xs text-indigo-600">
: {selectedComponent.imageUrl}
</p>
)}
</div>
)}
{/* 서명 만들기 */}
{signatureMethod === "generate" && (
<div className="mt-3">
<SignatureGenerator
onSignatureSelect={(dataUrl) => {
updateComponent(selectedComponent.id, {
imageUrl: dataUrl,
});
toast({
title: "성공",
description: "서명이 적용되었습니다.",
});
}}
/>
</div>
)}
</>
) : (
// 도장란: 기존 방식 유지
<div>

View File

@ -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<string[]>([]);
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 (
<div className="space-y-3">
{/* 언어 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select value={language} onValueChange={(value: "korean" | "english") => setLanguage(value)}>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="korean"></SelectItem>
<SelectItem value="english"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 이름 입력 */}
<div className="space-y-2">
<Label className="text-xs">:</Label>
<div className="flex gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={language === "korean" ? "홍길동" : "John Doe"}
className="h-8 flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
generateSignatures();
}
}}
/>
<Button
type="button"
size="sm"
onClick={generateSignatures}
disabled={!name.trim() || isGenerating || !fontsLoaded}
>
<Wand2 className="mr-1 h-3 w-3" />
{!fontsLoaded ? "폰트 로딩 중..." : isGenerating ? "생성 중..." : "만들기"}
</Button>
</div>
</div>
{/* 생성된 서명 목록 */}
{generatedSignatures.length > 0 && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<p className="text-xs text-gray-500"> </p>
<ScrollArea className="h-[300px] rounded-md border bg-white">
<div className="space-y-2 p-2">
{generatedSignatures.map((signature, index) => (
<Card
key={index}
className="group hover:border-primary cursor-pointer border-2 border-transparent p-3 transition-all"
onDoubleClick={() => handleSignatureDoubleClick(signature)}
>
<div className="flex items-center justify-between">
<img
src={signature}
alt={`서명 ${index + 1}`}
className="h-auto max-h-[60px] w-auto max-w-full object-contain"
/>
<p className="ml-2 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
{fonts[index].name}
</p>
</div>
</Card>
))}
</div>
</ScrollArea>
</div>
)}
</div>
);
}