도장, 서명 컴포넌트 구현
This commit is contained in:
parent
d83264181c
commit
dfa642798e
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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; // 도장란 이름 (예: "홍길동")
|
||||
}
|
||||
|
||||
// 리포트 상세
|
||||
|
|
|
|||
Loading…
Reference in New Issue