286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Loader2, PenLine, Upload } from "lucide-react";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import { SignatureGenerator } from "../SignatureGenerator";
|
|
import { reportApi } from "@/lib/api/reportApi";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type { ComponentConfig } from "@/types/report";
|
|
|
|
interface Props {
|
|
component: ComponentConfig;
|
|
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
|
section?: "style" | "data";
|
|
}
|
|
|
|
export function SignatureProperties({ component, section }: Props) {
|
|
const { updateComponent } = useReportDesigner();
|
|
const [signatureMethod, setSignatureMethod] = useState<"upload" | "generate">("upload");
|
|
const [uploadingImage, setUploadingImage] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const { toast } = useToast();
|
|
|
|
const showStyle = !section || section === "style";
|
|
const showData = !section || section === "data";
|
|
|
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.startsWith("image/")) {
|
|
toast({ title: "오류", description: "이미지 파일만 업로드 가능합니다.", variant: "destructive" });
|
|
return;
|
|
}
|
|
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
toast({ title: "오류", description: "파일 크기는 10MB 이하여야 합니다.", variant: "destructive" });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setUploadingImage(true);
|
|
const result = await reportApi.uploadImage(file);
|
|
if (result.success) {
|
|
updateComponent(component.id, { imageUrl: result.data.fileUrl });
|
|
toast({ title: "성공", description: "이미지가 업로드되었습니다." });
|
|
}
|
|
} catch {
|
|
toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive" });
|
|
} finally {
|
|
setUploadingImage(false);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* 서명란 스타일 — 우측 패널(section="style")에서 표시 */}
|
|
{showStyle && (
|
|
<div className="mt-4 space-y-3 rounded-xl border border-indigo-200 bg-indigo-50/50 p-4">
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-indigo-700">
|
|
<PenLine className="h-4 w-4" />
|
|
{component.type === "signature" ? "서명란 스타일" : "도장란 스타일"}
|
|
</div>
|
|
|
|
{/* 맞춤 방식 */}
|
|
<div>
|
|
<Label className="text-xs">맞춤 방식</Label>
|
|
<Select
|
|
value={component.objectFit || "contain"}
|
|
onValueChange={(value) =>
|
|
updateComponent(component.id, { objectFit: value as "contain" | "cover" | "fill" | "none" })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="contain">포함 (비율 유지)</SelectItem>
|
|
<SelectItem value="cover">채우기 (잘림)</SelectItem>
|
|
<SelectItem value="fill">늘리기</SelectItem>
|
|
<SelectItem value="none">원본 크기</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 레이블 표시 */}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="showLabel"
|
|
checked={component.showLabel !== false}
|
|
onChange={(e) => updateComponent(component.id, { showLabel: e.target.checked })}
|
|
className="h-4 w-4"
|
|
/>
|
|
<Label htmlFor="showLabel" className="text-xs">
|
|
레이블 표시
|
|
</Label>
|
|
</div>
|
|
|
|
{/* 레이블 텍스트 */}
|
|
{component.showLabel !== false && (
|
|
<>
|
|
<div>
|
|
<Label className="text-xs">레이블 텍스트</Label>
|
|
<Input
|
|
type="text"
|
|
value={component.labelText || (component.type === "signature" ? "서명:" : "(인)")}
|
|
onChange={(e) => updateComponent(component.id, { labelText: e.target.value })}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* 레이블 위치 (서명란만) */}
|
|
{component.type === "signature" && (
|
|
<div>
|
|
<Label className="text-xs">레이블 위치</Label>
|
|
<Select
|
|
value={component.labelPosition || "left"}
|
|
onValueChange={(value) =>
|
|
updateComponent(component.id, {
|
|
labelPosition: value as "top" | "left" | "bottom" | "right",
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="top">위</SelectItem>
|
|
<SelectItem value="left">왼쪽</SelectItem>
|
|
<SelectItem value="bottom">아래</SelectItem>
|
|
<SelectItem value="right">오른쪽</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 서명 입력 — 모달(section="data")에서 표시 */}
|
|
{showData && (
|
|
<div className="mt-4 space-y-3 rounded-xl border border-indigo-200 bg-indigo-50/50 p-4">
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-indigo-700">
|
|
<PenLine className="h-4 w-4" />
|
|
{component.type === "signature" ? "서명 입력" : "도장 이미지"}
|
|
</div>
|
|
|
|
{component.type === "signature" ? (
|
|
<>
|
|
{/* 서명 방식 선택 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">서명 방식</Label>
|
|
<Select
|
|
value={signatureMethod}
|
|
onValueChange={(value: "upload" | "generate") => setSignatureMethod(value)}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="upload">이미지 업로드</SelectItem>
|
|
<SelectItem value="generate">서명 만들기</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 이미지 업로드 */}
|
|
{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" />
|
|
{component.imageUrl ? "파일 변경" : "파일 선택"}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-gray-500">JPG, PNG, GIF, WEBP (최대 10MB)</p>
|
|
{component.imageUrl && !component.imageUrl.startsWith("data:") && (
|
|
<p className="truncate text-xs text-indigo-600">현재: {component.imageUrl}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 서명 만들기 */}
|
|
{signatureMethod === "generate" && (
|
|
<div className="mt-3">
|
|
<SignatureGenerator
|
|
onSignatureSelect={(dataUrl) => {
|
|
updateComponent(component.id, { imageUrl: dataUrl });
|
|
toast({ title: "성공", description: "서명이 적용되었습니다." });
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
// 도장란
|
|
<>
|
|
<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" />
|
|
{component.imageUrl ? "파일 변경" : "파일 선택"}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<p className="mt-1 text-xs text-gray-500">JPG, PNG, GIF, WEBP (최대 10MB)</p>
|
|
{component.imageUrl && !component.imageUrl.startsWith("data:") && (
|
|
<p className="mt-2 truncate text-xs text-indigo-600">현재: {component.imageUrl}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 이름 입력 (도장란만) */}
|
|
<div>
|
|
<Label className="text-xs">이름</Label>
|
|
<Input
|
|
type="text"
|
|
value={component.personName || ""}
|
|
onChange={(e) => updateComponent(component.id, { personName: e.target.value })}
|
|
placeholder="예: 홍길동"
|
|
className="h-9"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-500">도장 옆에 표시될 이름</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|