도장, 서명 컴포넌트 구현

This commit is contained in:
dohyeons 2025-10-01 17:31:15 +09:00
parent d83264181c
commit dfa642798e
6 changed files with 449 additions and 2 deletions

View File

@ -383,6 +383,118 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
</div>
);
case "signature":
const sigLabelPos = component.labelPosition || "left";
const sigShowLabel = component.showLabel !== false;
const sigLabelText = component.labelText || "서명:";
const sigShowUnderline = component.showUnderline !== false;
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div
className={`flex h-[calc(100%-20px)] gap-2 ${
sigLabelPos === "top"
? "flex-col"
: sigLabelPos === "bottom"
? "flex-col-reverse"
: sigLabelPos === "right"
? "flex-row-reverse"
: "flex-row"
}`}
>
{sigShowLabel && (
<div
className="flex items-center justify-center text-xs font-medium"
style={{
width: sigLabelPos === "left" || sigLabelPos === "right" ? "auto" : "100%",
minWidth: sigLabelPos === "left" || sigLabelPos === "right" ? "40px" : "auto",
}}
>
{sigLabelText}
</div>
)}
<div className="relative flex-1">
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="서명"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
style={{
borderColor: component.borderColor || "#cccccc",
}}
>
</div>
)}
{sigShowUnderline && (
<div
className="absolute right-0 bottom-0 left-0"
style={{
borderBottom: "2px solid #000000",
}}
/>
)}
</div>
</div>
</div>
);
case "stamp":
const stampShowLabel = component.showLabel !== false;
const stampLabelText = component.labelText || "(인)";
const stampPersonName = component.personName || "";
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div className="flex h-[calc(100%-20px)] gap-2">
{stampPersonName && <div className="flex items-center text-xs font-medium">{stampPersonName}</div>}
<div className="relative flex-1">
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="도장"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
style={{
borderColor: component.borderColor || "#cccccc",
borderRadius: "50%",
}}
>
</div>
)}
{stampShowLabel && (
<div
className="absolute inset-0 flex items-center justify-center text-xs font-medium"
style={{
pointerEvents: "none",
}}
>
{stampLabelText}
</div>
)}
</div>
</div>
</div>
);
default:
return <div> </div>;
}

View File

@ -1,7 +1,7 @@
"use client";
import { useDrag } from "react-dnd";
import { Type, Table, Tag, Image, Minus } from "lucide-react";
import { Type, Table, Tag, Image, Minus, PenLine, Stamp as StampIcon } from "lucide-react";
interface ComponentItem {
type: string;
@ -15,6 +15,8 @@ const COMPONENTS: ComponentItem[] = [
{ type: "label", label: "레이블", icon: <Tag className="h-4 w-4" /> },
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
{ type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> },
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {

View File

@ -56,6 +56,12 @@ export function ReportDesignerCanvas() {
} else if (item.componentType === "divider") {
width = 300;
height = 2;
} else if (item.componentType === "signature") {
width = 120;
height = 70;
} else if (item.componentType === "stamp") {
width = 70;
height = 70;
}
// 새 컴포넌트 생성 (Grid Snap 적용)
@ -91,6 +97,28 @@ export function ReportDesignerCanvas() {
lineWidth: 1,
lineColor: "#000000",
}),
// 서명란 전용
...(item.componentType === "signature" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "서명:",
labelPosition: "left" as const,
showUnderline: true,
borderWidth: 1,
borderColor: "#cccccc",
}),
// 도장란 전용
...(item.componentType === "stamp" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "(인)",
labelPosition: "top" as const,
personName: "",
borderWidth: 1,
borderColor: "#cccccc",
}),
};
addComponent(newComponent);

View File

@ -528,6 +528,188 @@ export function ReportDesignerRightPanel() {
</Card>
)}
{/* 서명/도장 속성 */}
{(selectedComponent.type === "signature" || selectedComponent.type === "stamp") && (
<Card className="mt-4 border-indigo-200 bg-indigo-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-indigo-900">
{selectedComponent.type === "signature" ? "서명란 설정" : "도장란 설정"}
</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 ? "파일 변경" : "파일 선택"}
</>
)}
</Button>
</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>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.objectFit || "contain"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
objectFit: value as "contain" | "cover" | "fill" | "none",
})
}
>
<SelectTrigger className="h-8">
<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={selectedComponent.showLabel !== false}
onChange={(e) =>
updateComponent(selectedComponent.id, {
showLabel: e.target.checked,
})
}
className="h-4 w-4"
/>
<Label htmlFor="showLabel" className="text-xs">
</Label>
</div>
{/* 레이블 텍스트 */}
{selectedComponent.showLabel !== false && (
<>
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={
selectedComponent.labelText ||
(selectedComponent.type === "signature" ? "서명:" : "(인)")
}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelText: e.target.value,
})
}
className="h-8"
/>
</div>
{/* 레이블 위치 (서명란만) */}
{selectedComponent.type === "signature" && (
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.labelPosition || "left"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
labelPosition: value as "top" | "left" | "bottom" | "right",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="left"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
)}
</>
)}
{/* 밑줄 표시 (서명란만) */}
{selectedComponent.type === "signature" && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showUnderline"
checked={selectedComponent.showUnderline !== false}
onChange={(e) =>
updateComponent(selectedComponent.id, {
showUnderline: e.target.checked,
})
}
className="h-4 w-4"
/>
<Label htmlFor="showUnderline" className="text-xs">
</Label>
</div>
)}
{/* 이름 입력 (도장란만) */}
{selectedComponent.type === "stamp" && (
<div>
<Label className="text-xs"></Label>
<Input
type="text"
value={selectedComponent.personName || ""}
onChange={(e) =>
updateComponent(selectedComponent.id, {
personName: e.target.value,
})
}
placeholder="예: 홍길동"
className="h-8"
/>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
)}
</CardContent>
</Card>
)}
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(selectedComponent.type === "text" ||
selectedComponent.type === "label" ||

View File

@ -370,6 +370,123 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
}}
/>
)}
{component.type === "signature" && (
<div
style={{
display: "flex",
gap: "8px",
flexDirection:
component.labelPosition === "top" || component.labelPosition === "bottom" ? "column" : "row",
...(component.labelPosition === "right" || component.labelPosition === "bottom"
? { flexDirection: component.labelPosition === "right" ? "row-reverse" : "column-reverse" }
: {}),
}}
>
{component.showLabel !== false && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: "500",
minWidth:
component.labelPosition === "left" || component.labelPosition === "right"
? "40px"
: "auto",
}}
>
{component.labelText || "서명:"}
</div>
)}
<div style={{ flex: 1, position: "relative" }}>
{component.imageUrl && (
<img
src={getFullImageUrl(component.imageUrl)}
alt="서명"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
)}
{component.showUnderline !== false && (
<div
style={{
position: "absolute",
bottom: "0",
left: "0",
right: "0",
borderBottom: "2px solid #000000",
}}
/>
)}
</div>
</div>
)}
{component.type === "stamp" && (
<div
style={{
display: "flex",
gap: "8px",
width: "100%",
height: "100%",
}}
>
{component.personName && (
<div
style={{
display: "flex",
alignItems: "center",
fontSize: "12px",
fontWeight: "500",
}}
>
{component.personName}
</div>
)}
<div
style={{
position: "relative",
flex: 1,
}}
>
{component.imageUrl && (
<img
src={getFullImageUrl(component.imageUrl)}
alt="도장"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
)}
{component.showLabel !== false && (
<div
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: "500",
pointerEvents: "none",
}}
>
{component.labelText || "(인)"}
</div>
)}
</div>
</div>
)}
</div>
);
})}

View File

@ -83,7 +83,7 @@ export interface ExternalConnection {
// 컴포넌트 설정
export interface ComponentConfig {
id: string;
type: string;
type: string; // "text", "label", "table", "image", "divider", "signature", "stamp"
x: number;
y: number;
width: number;
@ -117,6 +117,12 @@ export interface ComponentConfig {
lineStyle?: "solid" | "dashed" | "dotted" | "double"; // 선 스타일
lineWidth?: number; // 구분선 두께 (borderWidth와 별도)
lineColor?: string; // 구분선 색상 (borderColor와 별도)
// 서명/도장 전용
showLabel?: boolean; // 레이블 표시 여부 ("서명:", "(인)")
labelText?: string; // 커스텀 레이블 텍스트
labelPosition?: "top" | "left" | "bottom" | "right"; // 레이블 위치
showUnderline?: boolean; // 서명란 밑줄 표시 여부
personName?: string; // 도장란 이름 (예: "홍길동")
}
// 리포트 상세