직접 서명 기능 추가
This commit is contained in:
parent
e697acb2c9
commit
c52937c22d
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue