서명 만들기 기능 구현
This commit is contained in:
parent
7828b5e073
commit
f456ab89e8
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue