직접 서명 기능 추가

This commit is contained in:
dohyeons 2025-10-02 10:04:02 +09:00
parent e697acb2c9
commit c52937c22d
2 changed files with 238 additions and 40 deletions

View File

@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Trash2, Settings, Database, Link2, Upload, Loader2 } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { QueryManager } from "./QueryManager";
import { SignaturePad } from "./SignaturePad";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
@ -636,48 +637,110 @@ export function ReportDesignerRightPanel() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 파일 업로드 */}
<div>
<Label className="text-xs">
{selectedComponent.type === "signature" ? "서명 이미지" : "도장 이미지"}
</Label>
<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 ? "파일 변경" : "파일 선택"}
</>
{/* 서명란: 탭으로 직접 서명 / 이미지 업로드 선택 */}
{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>
{/* 직접 서명 탭 */}
<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}
/>
<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>
)}
</Button>
</TabsContent>
</Tabs>
) : (
// 도장란: 기존 방식 유지
<div>
<Label className="text-xs"> </Label>
<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="mt-1 text-xs text-gray-500">JPG, PNG, GIF, WEBP ( 10MB)</p>
{selectedComponent.imageUrl && !selectedComponent.imageUrl.startsWith("data:") && (
<p className="mt-2 truncate text-xs text-indigo-600">
: {selectedComponent.imageUrl}
</p>
)}
</div>
<p className="mt-1 text-xs text-gray-500">JPG, PNG, GIF, WEBP ( 10MB)</p>
{selectedComponent.imageUrl && (
<p className="mt-2 truncate text-xs text-indigo-600">
: {selectedComponent.imageUrl}
</p>
)}
</div>
)}
{/* 맞춤 방식 */}
<div>

View File

@ -0,0 +1,135 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Eraser, Pen } from "lucide-react";
interface SignaturePadProps {
onSignatureChange: (dataUrl: string) => void;
initialSignature?: string;
}
export function SignaturePad({ onSignatureChange, initialSignature }: SignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [hasDrawn, setHasDrawn] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 초기 서명이 있으면 로드
if (initialSignature) {
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
setHasDrawn(true);
};
img.src = initialSignature;
} else {
// 캔버스 초기화 (흰색 배경)
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}, [initialSignature]);
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
setIsDrawing(true);
setHasDrawn(true);
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "#000000";
ctx.lineWidth = 2;
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.lineTo(x, y);
ctx.stroke();
};
const stopDrawing = () => {
if (!isDrawing) return;
setIsDrawing(false);
const canvas = canvasRef.current;
if (!canvas) return;
// 서명 이미지를 Base64 데이터로 변환하여 콜백 호출
const dataUrl = canvas.toDataURL("image/png");
onSignatureChange(dataUrl);
};
const clearSignature = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 캔버스 클리어 (흰색 배경)
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
setHasDrawn(false);
onSignatureChange("");
};
return (
<div className="space-y-2">
<Card className="overflow-hidden border-2 border-dashed border-gray-300 bg-white p-2">
<canvas
ref={canvasRef}
width={300}
height={150}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
className="cursor-crosshair touch-none"
style={{ width: "100%", height: "auto" }}
/>
</Card>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">
<Pen className="mr-1 inline h-3 w-3" />
</p>
<Button type="button" variant="outline" size="sm" onClick={clearSignature} disabled={!hasDrawn}>
<Eraser className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
);
}