2323 lines
116 KiB
TypeScript
2323 lines
116 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef } from "react";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Trash2, Settings, Database, Link2, Upload, Loader2 } from "lucide-react";
|
||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||
import { QueryManager } from "./QueryManager";
|
||
import { SignaturePad } from "./SignaturePad";
|
||
import { SignatureGenerator } from "./SignatureGenerator";
|
||
import { reportApi } from "@/lib/api/reportApi";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
|
||
export function ReportDesignerRightPanel() {
|
||
const context = useReportDesigner();
|
||
const {
|
||
selectedComponentId,
|
||
components,
|
||
updateComponent,
|
||
removeComponent,
|
||
queries,
|
||
currentPage,
|
||
currentPageId,
|
||
updatePageSettings,
|
||
getQueryResult,
|
||
} = context;
|
||
const [activeTab, setActiveTab] = useState<string>("properties");
|
||
const [uploadingImage, setUploadingImage] = useState(false);
|
||
const [signatureMethod, setSignatureMethod] = useState<"draw" | "upload" | "generate">("draw");
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const { toast } = useToast();
|
||
|
||
const selectedComponent = components.find((c) => c.id === selectedComponentId);
|
||
|
||
// 이미지 업로드 핸들러
|
||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file || !selectedComponent) return;
|
||
|
||
// 파일 타입 체크
|
||
if (!file.type.startsWith("image/")) {
|
||
toast({
|
||
title: "오류",
|
||
description: "이미지 파일만 업로드 가능합니다.",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 파일 크기 체크 (10MB)
|
||
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) {
|
||
// 업로드된 이미지 URL을 컴포넌트에 설정
|
||
updateComponent(selectedComponent.id, {
|
||
imageUrl: result.data.fileUrl,
|
||
});
|
||
|
||
toast({
|
||
title: "성공",
|
||
description: "이미지가 업로드되었습니다.",
|
||
});
|
||
}
|
||
} catch {
|
||
toast({
|
||
title: "오류",
|
||
description: "이미지 업로드 중 오류가 발생했습니다.",
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setUploadingImage(false);
|
||
// input 초기화
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = "";
|
||
}
|
||
}
|
||
};
|
||
|
||
// 선택된 쿼리의 결과 필드 가져오기
|
||
const getQueryFields = (queryId: string): string[] => {
|
||
const result = context.getQueryResult(queryId);
|
||
return result ? result.fields : [];
|
||
};
|
||
|
||
return (
|
||
<div className="w-[450px] border-l bg-white">
|
||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
||
<div className="border-b p-2">
|
||
<TabsList className="grid w-full grid-cols-3">
|
||
<TabsTrigger value="page" className="gap-1 text-xs">
|
||
<Settings className="h-3 w-3" />
|
||
페이지
|
||
</TabsTrigger>
|
||
<TabsTrigger value="properties" className="gap-1 text-xs">
|
||
<Settings className="h-3 w-3" />
|
||
속성
|
||
</TabsTrigger>
|
||
<TabsTrigger value="queries" className="gap-1 text-xs">
|
||
<Database className="h-3 w-3" />
|
||
쿼리
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
{/* 속성 탭 */}
|
||
<TabsContent value="properties" className="mt-0 h-[calc(100vh-120px)]">
|
||
<ScrollArea className="h-full">
|
||
<div className="space-y-4 p-4">
|
||
{!selectedComponent ? (
|
||
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
|
||
컴포넌트를 선택하면 속성을 편집할 수 있습니다
|
||
</div>
|
||
) : (
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-sm">컴포넌트 속성</CardTitle>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => removeComponent(selectedComponent.id)}
|
||
className="text-destructive hover:bg-destructive/10 h-8 w-8 p-0"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* 타입 */}
|
||
<div>
|
||
<Label className="text-xs">타입</Label>
|
||
<div className="mt-1 text-sm font-medium capitalize">{selectedComponent.type}</div>
|
||
</div>
|
||
|
||
{/* 위치 */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<Label className="text-xs">X</Label>
|
||
<Input
|
||
type="number"
|
||
value={Math.round(selectedComponent.x)}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
x: parseInt(e.target.value) || 0,
|
||
})
|
||
}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">Y</Label>
|
||
<Input
|
||
type="number"
|
||
value={Math.round(selectedComponent.y)}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
y: parseInt(e.target.value) || 0,
|
||
})
|
||
}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 크기 */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<Label className="text-xs">너비</Label>
|
||
<Input
|
||
type="number"
|
||
value={Math.round(selectedComponent.width)}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
width: parseInt(e.target.value) || 50,
|
||
})
|
||
}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">높이</Label>
|
||
<Input
|
||
type="number"
|
||
value={Math.round(selectedComponent.height)}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
height: parseInt(e.target.value) || 30,
|
||
})
|
||
}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 스타일링 섹션 */}
|
||
<div className="space-y-3 rounded-md border border-gray-200 bg-gray-50 p-3">
|
||
<h4 className="text-xs font-semibold text-gray-700">스타일</h4>
|
||
|
||
{/* 글꼴 크기 */}
|
||
<div>
|
||
<Label className="text-xs">글꼴 크기</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.fontSize || 13}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
fontSize: parseInt(e.target.value) || 13,
|
||
})
|
||
}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
|
||
{/* 글꼴 색상 */}
|
||
<div>
|
||
<Label className="text-xs">글꼴 색상</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.fontColor || "#000000"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
fontColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-16"
|
||
/>
|
||
<Input
|
||
type="text"
|
||
value={selectedComponent.fontColor || "#000000"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
fontColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 flex-1 font-mono text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 텍스트 정렬 (텍스트/라벨만) */}
|
||
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
||
<div>
|
||
<Label className="text-xs">텍스트 정렬</Label>
|
||
<Select
|
||
value={selectedComponent.textAlign || "left"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
textAlign: value as "left" | "center" | "right",
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="left">왼쪽</SelectItem>
|
||
<SelectItem value="center">가운데</SelectItem>
|
||
<SelectItem value="right">오른쪽</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 글꼴 굵기 (텍스트/라벨만) */}
|
||
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
||
<div>
|
||
<Label className="text-xs">글꼴 굵기</Label>
|
||
<Select
|
||
value={selectedComponent.fontWeight || "normal"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
fontWeight: value as "normal" | "bold",
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="normal">보통</SelectItem>
|
||
<SelectItem value="bold">굵게</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 배경 색상 */}
|
||
<div>
|
||
<Label className="text-xs">배경 색상</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.backgroundColor || "#ffffff"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
backgroundColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-16"
|
||
/>
|
||
<Input
|
||
type="text"
|
||
value={selectedComponent.backgroundColor || "#ffffff"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
backgroundColor: e.target.value,
|
||
})
|
||
}
|
||
placeholder="transparent"
|
||
className="h-8 flex-1 font-mono text-xs"
|
||
/>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() =>
|
||
updateComponent(selectedComponent.id, {
|
||
backgroundColor: "transparent",
|
||
})
|
||
}
|
||
className="h-8 px-2 text-xs"
|
||
>
|
||
없음
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테두리 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">테두리</Label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<Label className="text-xs text-gray-600">두께</Label>
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
max="10"
|
||
value={selectedComponent.borderWidth || 0}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
borderWidth: parseInt(e.target.value) || 0,
|
||
})
|
||
}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs text-gray-600">색상</Label>
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.borderColor || "#cccccc"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
borderColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테이블 스타일 */}
|
||
{selectedComponent.type === "table" && (
|
||
<Card className="mt-4 border-indigo-200 bg-indigo-50">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm text-indigo-900">테이블 스타일</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* 헤더 배경색 */}
|
||
<div>
|
||
<Label className="text-xs">헤더 배경색</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.headerBackgroundColor || "#f3f4f6"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
headerBackgroundColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-16"
|
||
/>
|
||
<Input
|
||
type="text"
|
||
value={selectedComponent.headerBackgroundColor || "#f3f4f6"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
headerBackgroundColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 flex-1 font-mono text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 헤더 텍스트 색상 */}
|
||
<div>
|
||
<Label className="text-xs">헤더 텍스트 색상</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.headerTextColor || "#111827"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
headerTextColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-16"
|
||
/>
|
||
<Input
|
||
type="text"
|
||
value={selectedComponent.headerTextColor || "#111827"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
headerTextColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 flex-1 font-mono text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테두리 표시 */}
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
id="showBorder"
|
||
checked={selectedComponent.showBorder !== false}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
showBorder: e.target.checked,
|
||
})
|
||
}
|
||
className="h-4 w-4"
|
||
/>
|
||
<Label htmlFor="showBorder" className="text-xs">
|
||
테두리 표시
|
||
</Label>
|
||
</div>
|
||
|
||
{/* 행 높이 */}
|
||
<div>
|
||
<Label className="text-xs">행 높이 (px)</Label>
|
||
<Input
|
||
type="number"
|
||
min="20"
|
||
max="100"
|
||
value={selectedComponent.rowHeight || 32}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
rowHeight: parseInt(e.target.value),
|
||
})
|
||
}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 이미지 속성 */}
|
||
{selectedComponent.type === "image" && (
|
||
<Card className="mt-4 border-purple-200 bg-purple-50">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm text-purple-900">이미지 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* 파일 업로드 */}
|
||
<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 && (
|
||
<p className="mt-2 truncate text-xs text-purple-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>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 구분선 속성 */}
|
||
{selectedComponent.type === "divider" && (
|
||
<Card className="mt-4 border-gray-200 bg-gray-50">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm text-gray-900">구분선 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div>
|
||
<Label className="text-xs">방향</Label>
|
||
<Select
|
||
value={selectedComponent.orientation || "horizontal"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
orientation: value as "horizontal" | "vertical",
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="horizontal">가로</SelectItem>
|
||
<SelectItem value="vertical">세로</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs">선 스타일</Label>
|
||
<Select
|
||
value={selectedComponent.lineStyle || "solid"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
lineStyle: value as "solid" | "dashed" | "dotted" | "double",
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="solid">실선</SelectItem>
|
||
<SelectItem value="dashed">파선</SelectItem>
|
||
<SelectItem value="dotted">점선</SelectItem>
|
||
<SelectItem value="double">이중선</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs">선 두께</Label>
|
||
<Input
|
||
type="number"
|
||
min="1"
|
||
max="20"
|
||
value={selectedComponent.lineWidth || 1}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
lineWidth: Number(e.target.value),
|
||
})
|
||
}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs">선 색상</Label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.lineColor || "#000000"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
lineColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-16"
|
||
/>
|
||
<Input
|
||
type="text"
|
||
value={selectedComponent.lineColor || "#000000"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
lineColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 flex-1 font-mono text-xs"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</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">
|
||
{/* 서명란: 드롭다운으로 직접 서명 / 이미지 업로드 / 서명 만들기 선택 */}
|
||
{selectedComponent.type === "signature" ? (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">서명 방식</Label>
|
||
<Select
|
||
value={signatureMethod}
|
||
onValueChange={(value: "draw" | "upload" | "generate") => setSignatureMethod(value)}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="draw">직접 서명</SelectItem>
|
||
<SelectItem value="upload">이미지 업로드</SelectItem>
|
||
<SelectItem value="generate">서명 만들기</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 직접 서명 */}
|
||
{signatureMethod === "draw" && (
|
||
<div className="mt-3 space-y-2">
|
||
<SignaturePad
|
||
initialSignature={selectedComponent.imageUrl}
|
||
onSignatureChange={(dataUrl) => {
|
||
updateComponent(selectedComponent.id, {
|
||
imageUrl: dataUrl,
|
||
});
|
||
}}
|
||
/>
|
||
</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" />
|
||
{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>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 서명 만들기 */}
|
||
{signatureMethod === "generate" && (
|
||
<div className="mt-3">
|
||
<SignatureGenerator
|
||
onSignatureSelect={(dataUrl) => {
|
||
updateComponent(selectedComponent.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" />
|
||
{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>
|
||
)}
|
||
|
||
{/* 맞춤 방식 */}
|
||
<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 === "pageNumber" && (
|
||
<Card className="mt-4 border-purple-200 bg-purple-50">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm text-purple-900">페이지 번호 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div>
|
||
<Label className="text-xs">표시 형식</Label>
|
||
<Select
|
||
value={selectedComponent.pageNumberFormat || "number"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
pageNumberFormat: value as "number" | "numberTotal" | "koreanNumber",
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="number">숫자만 (1, 2, 3...)</SelectItem>
|
||
<SelectItem value="numberTotal">현재/전체 (1 / 3)</SelectItem>
|
||
<SelectItem value="koreanNumber">한글 (1 페이지)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 카드 컴포넌트 설정 */}
|
||
{selectedComponent.type === "card" && (
|
||
<Card className="mt-4 border-teal-200 bg-teal-50">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm text-teal-900">카드 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* 제목 표시 여부 */}
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
id="showCardTitle"
|
||
checked={selectedComponent.showCardTitle !== false}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
showCardTitle: e.target.checked,
|
||
})
|
||
}
|
||
className="h-4 w-4"
|
||
/>
|
||
<Label htmlFor="showCardTitle" className="text-xs">
|
||
제목 표시
|
||
</Label>
|
||
</div>
|
||
|
||
{/* 제목 텍스트 */}
|
||
{selectedComponent.showCardTitle !== false && (
|
||
<div>
|
||
<Label className="text-xs">카드 제목</Label>
|
||
<Input
|
||
type="text"
|
||
value={selectedComponent.cardTitle || ""}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
cardTitle: e.target.value,
|
||
})
|
||
}
|
||
placeholder="정보 카드"
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 라벨 너비 */}
|
||
<div>
|
||
<Label className="text-xs">라벨 너비 (px)</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.labelWidth || 80}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
labelWidth: Number(e.target.value),
|
||
})
|
||
}
|
||
min={40}
|
||
max={200}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
|
||
{/* 테두리 표시 */}
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
id="showCardBorder"
|
||
checked={selectedComponent.showCardBorder !== false}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
showCardBorder: e.target.checked,
|
||
borderWidth: e.target.checked ? 1 : 0,
|
||
})
|
||
}
|
||
className="h-4 w-4"
|
||
/>
|
||
<Label htmlFor="showCardBorder" className="text-xs">
|
||
테두리 표시
|
||
</Label>
|
||
</div>
|
||
|
||
{/* 폰트 크기 설정 */}
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<Label className="text-xs">제목 크기</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.titleFontSize || 14}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
titleFontSize: Number(e.target.value),
|
||
})
|
||
}
|
||
min={10}
|
||
max={24}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">라벨 크기</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.labelFontSize || 13}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
labelFontSize: Number(e.target.value),
|
||
})
|
||
}
|
||
min={10}
|
||
max={20}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">값 크기</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.valueFontSize || 13}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
valueFontSize: Number(e.target.value),
|
||
})
|
||
}
|
||
min={10}
|
||
max={20}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 색상 설정 */}
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<Label className="text-xs">제목 색상</Label>
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.titleColor || "#1e40af"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
titleColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-full cursor-pointer p-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">라벨 색상</Label>
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.labelColor || "#374151"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
labelColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-full cursor-pointer p-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">값 색상</Label>
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.valueColor || "#000000"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
valueColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-full cursor-pointer p-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 항목 목록 관리 */}
|
||
<div className="mt-4 border-t pt-3">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<Label className="text-xs font-semibold">항목 목록</Label>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 text-xs"
|
||
onClick={() => {
|
||
const currentItems = selectedComponent.cardItems || [];
|
||
updateComponent(selectedComponent.id, {
|
||
cardItems: [
|
||
...currentItems,
|
||
{ label: `항목${currentItems.length + 1}`, value: "", fieldName: "" },
|
||
],
|
||
});
|
||
}}
|
||
>
|
||
+ 항목 추가
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 쿼리 선택 (데이터 바인딩용) */}
|
||
<div className="mb-2">
|
||
<Label className="text-xs">데이터 소스 (쿼리)</Label>
|
||
<Select
|
||
value={selectedComponent.queryId || "none"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
queryId: value === "none" ? undefined : value,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue placeholder="쿼리 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">직접 입력</SelectItem>
|
||
{queries.map((q) => (
|
||
<SelectItem key={q.id} value={q.id}>
|
||
{q.name} ({q.type})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 항목 리스트 */}
|
||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||
{(selectedComponent.cardItems || []).map(
|
||
(item: { label: string; value: string; fieldName?: string }, index: number) => (
|
||
<div key={index} className="rounded border bg-white p-2">
|
||
<div className="mb-1 flex items-center justify-between">
|
||
<span className="text-xs font-medium">항목 {index + 1}</span>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||
onClick={() => {
|
||
const currentItems = [...(selectedComponent.cardItems || [])];
|
||
currentItems.splice(index, 1);
|
||
updateComponent(selectedComponent.id, {
|
||
cardItems: currentItems,
|
||
});
|
||
}}
|
||
>
|
||
x
|
||
</Button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-1">
|
||
<div>
|
||
<Label className="text-[10px]">라벨</Label>
|
||
<Input
|
||
type="text"
|
||
value={item.label}
|
||
onChange={(e) => {
|
||
const currentItems = [...(selectedComponent.cardItems || [])];
|
||
currentItems[index] = { ...item, label: e.target.value };
|
||
updateComponent(selectedComponent.id, {
|
||
cardItems: currentItems,
|
||
});
|
||
}}
|
||
className="h-6 text-xs"
|
||
placeholder="항목명"
|
||
/>
|
||
</div>
|
||
{selectedComponent.queryId ? (
|
||
<div>
|
||
<Label className="text-[10px]">필드</Label>
|
||
<Select
|
||
value={item.fieldName || "none"}
|
||
onValueChange={(value) => {
|
||
const currentItems = [...(selectedComponent.cardItems || [])];
|
||
currentItems[index] = {
|
||
...item,
|
||
fieldName: value === "none" ? "" : value,
|
||
};
|
||
updateComponent(selectedComponent.id, {
|
||
cardItems: currentItems,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-6 text-xs">
|
||
<SelectValue placeholder="필드 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">직접 입력</SelectItem>
|
||
{(() => {
|
||
const query = queries.find((q) => q.id === selectedComponent.queryId);
|
||
const result = query ? getQueryResult(query.id) : null;
|
||
if (result && result.fields) {
|
||
return result.fields.map((field: string) => (
|
||
<SelectItem key={field} value={field}>
|
||
{field}
|
||
</SelectItem>
|
||
));
|
||
}
|
||
return null;
|
||
})()}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<Label className="text-[10px]">값</Label>
|
||
<Input
|
||
type="text"
|
||
value={item.value}
|
||
onChange={(e) => {
|
||
const currentItems = [...(selectedComponent.cardItems || [])];
|
||
currentItems[index] = { ...item, value: e.target.value };
|
||
updateComponent(selectedComponent.id, {
|
||
cardItems: currentItems,
|
||
});
|
||
}}
|
||
className="h-6 text-xs"
|
||
placeholder="내용"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 계산 컴포넌트 설정 */}
|
||
{selectedComponent.type === "calculation" && (
|
||
<Card className="mt-4 border-orange-200 bg-orange-50">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm text-orange-900">계산 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* 결과 라벨 */}
|
||
<div>
|
||
<Label className="text-xs">결과 라벨</Label>
|
||
<Input
|
||
type="text"
|
||
value={selectedComponent.resultLabel || "합계"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
resultLabel: e.target.value,
|
||
})
|
||
}
|
||
placeholder="합계 금액"
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
|
||
{/* 라벨 너비 */}
|
||
<div>
|
||
<Label className="text-xs">라벨 너비 (px)</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.labelWidth || 120}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
labelWidth: Number(e.target.value),
|
||
})
|
||
}
|
||
min={60}
|
||
max={200}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
|
||
{/* 숫자 포맷 */}
|
||
<div>
|
||
<Label className="text-xs">숫자 포맷</Label>
|
||
<Select
|
||
value={selectedComponent.numberFormat || "currency"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
numberFormat: value as "none" | "comma" | "currency",
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">없음</SelectItem>
|
||
<SelectItem value="comma">천단위 구분</SelectItem>
|
||
<SelectItem value="currency">통화 (원)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 통화 접미사 */}
|
||
{selectedComponent.numberFormat === "currency" && (
|
||
<div>
|
||
<Label className="text-xs">통화 단위</Label>
|
||
<Input
|
||
type="text"
|
||
value={selectedComponent.currencySuffix || "원"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
currencySuffix: e.target.value,
|
||
})
|
||
}
|
||
placeholder="원"
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 폰트 크기 설정 */}
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<Label className="text-xs">라벨 크기</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.labelFontSize || 13}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
labelFontSize: Number(e.target.value),
|
||
})
|
||
}
|
||
min={10}
|
||
max={20}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">값 크기</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.valueFontSize || 13}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
valueFontSize: Number(e.target.value),
|
||
})
|
||
}
|
||
min={10}
|
||
max={20}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">결과 크기</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.resultFontSize || 16}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
resultFontSize: Number(e.target.value),
|
||
})
|
||
}
|
||
min={12}
|
||
max={24}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 색상 설정 */}
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<div>
|
||
<Label className="text-xs">라벨 색상</Label>
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.labelColor || "#374151"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
labelColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-full cursor-pointer p-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">값 색상</Label>
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.valueColor || "#000000"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
valueColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-full cursor-pointer p-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">결과 색상</Label>
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.resultColor || "#2563eb"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
resultColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-full cursor-pointer p-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 계산 항목 목록 관리 */}
|
||
<div className="mt-4 border-t pt-3">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<Label className="text-xs font-semibold">계산 항목</Label>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 text-xs"
|
||
onClick={() => {
|
||
const currentItems = selectedComponent.calcItems || [];
|
||
updateComponent(selectedComponent.id, {
|
||
calcItems: [
|
||
...currentItems,
|
||
{
|
||
label: `항목${currentItems.length + 1}`,
|
||
value: 0,
|
||
operator: "+" as const,
|
||
fieldName: "",
|
||
},
|
||
],
|
||
});
|
||
}}
|
||
>
|
||
+ 항목 추가
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 쿼리 선택 (데이터 바인딩용) */}
|
||
<div className="mb-2">
|
||
<Label className="text-xs">데이터 소스 (쿼리)</Label>
|
||
<Select
|
||
value={selectedComponent.queryId || "none"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
queryId: value === "none" ? undefined : value,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue placeholder="쿼리 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">직접 입력</SelectItem>
|
||
{queries.map((q) => (
|
||
<SelectItem key={q.id} value={q.id}>
|
||
{q.name} ({q.type})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 항목 리스트 */}
|
||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||
{(selectedComponent.calcItems || []).map((item, index: number) => (
|
||
<div key={index} className="rounded border bg-white p-2">
|
||
<div className="mb-1 flex items-center justify-between">
|
||
<span className="text-xs font-medium">항목 {index + 1}</span>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||
onClick={() => {
|
||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||
currentItems.splice(index, 1);
|
||
updateComponent(selectedComponent.id, {
|
||
calcItems: currentItems,
|
||
});
|
||
}}
|
||
>
|
||
x
|
||
</Button>
|
||
</div>
|
||
<div className={`grid gap-1 ${index === 0 ? "grid-cols-1" : "grid-cols-3"}`}>
|
||
<div className={index === 0 ? "" : "col-span-2"}>
|
||
<Label className="text-[10px]">라벨</Label>
|
||
<Input
|
||
type="text"
|
||
value={item.label}
|
||
onChange={(e) => {
|
||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||
currentItems[index] = { ...currentItems[index], label: e.target.value };
|
||
updateComponent(selectedComponent.id, {
|
||
calcItems: currentItems,
|
||
});
|
||
}}
|
||
className="h-6 text-xs"
|
||
placeholder="항목명"
|
||
/>
|
||
</div>
|
||
{/* 두 번째 항목부터 연산자 표시 */}
|
||
{index > 0 && (
|
||
<div>
|
||
<Label className="text-[10px]">연산자</Label>
|
||
<Select
|
||
value={item.operator}
|
||
onValueChange={(value) => {
|
||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||
currentItems[index] = {
|
||
...currentItems[index],
|
||
operator: value as "+" | "-" | "x" | "÷",
|
||
};
|
||
updateComponent(selectedComponent.id, {
|
||
calcItems: currentItems,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-6 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="+">+</SelectItem>
|
||
<SelectItem value="-">-</SelectItem>
|
||
<SelectItem value="x">x</SelectItem>
|
||
<SelectItem value="÷">÷</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="mt-1">
|
||
{selectedComponent.queryId ? (
|
||
<div>
|
||
<Label className="text-[10px]">필드</Label>
|
||
<Select
|
||
value={item.fieldName || "none"}
|
||
onValueChange={(value) => {
|
||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||
currentItems[index] = {
|
||
...currentItems[index],
|
||
fieldName: value === "none" ? "" : value,
|
||
};
|
||
updateComponent(selectedComponent.id, {
|
||
calcItems: currentItems,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-6 text-xs">
|
||
<SelectValue placeholder="필드 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">직접 입력</SelectItem>
|
||
{(() => {
|
||
const query = queries.find((q) => q.id === selectedComponent.queryId);
|
||
const result = query ? getQueryResult(query.id) : null;
|
||
if (result && result.fields) {
|
||
return result.fields.map((field: string) => (
|
||
<SelectItem key={field} value={field}>
|
||
{field}
|
||
</SelectItem>
|
||
));
|
||
}
|
||
return null;
|
||
})()}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<Label className="text-[10px]">값</Label>
|
||
<Input
|
||
type="number"
|
||
value={item.value}
|
||
onChange={(e) => {
|
||
const currentItems = [...(selectedComponent.calcItems || [])];
|
||
currentItems[index] = {
|
||
...currentItems[index],
|
||
value: Number(e.target.value),
|
||
};
|
||
updateComponent(selectedComponent.id, {
|
||
calcItems: currentItems,
|
||
});
|
||
}}
|
||
className="h-6 text-xs"
|
||
placeholder="0"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 바코드 컴포넌트 설정 */}
|
||
{selectedComponent.type === "barcode" && (
|
||
<Card className="mt-4 border-cyan-200 bg-cyan-50">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-sm text-cyan-900">바코드 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* 바코드 타입 */}
|
||
<div>
|
||
<Label className="text-xs">바코드 타입</Label>
|
||
<Select
|
||
value={selectedComponent.barcodeType || "CODE128"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
barcodeType: value as "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR",
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="CODE128">CODE128 (범용)</SelectItem>
|
||
<SelectItem value="CODE39">CODE39 (산업용)</SelectItem>
|
||
<SelectItem value="EAN13">EAN-13 (상품)</SelectItem>
|
||
<SelectItem value="EAN8">EAN-8 (소형상품)</SelectItem>
|
||
<SelectItem value="UPC">UPC (북미상품)</SelectItem>
|
||
<SelectItem value="QR">QR코드</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 바코드 값 입력 (쿼리 연결 없을 때) */}
|
||
{!selectedComponent.queryId && (
|
||
<div>
|
||
<Label className="text-xs">바코드 값</Label>
|
||
<Input
|
||
type="text"
|
||
value={selectedComponent.barcodeValue || ""}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
barcodeValue: e.target.value,
|
||
})
|
||
}
|
||
placeholder={
|
||
selectedComponent.barcodeType === "EAN13" ? "13자리 숫자" :
|
||
selectedComponent.barcodeType === "EAN8" ? "8자리 숫자" :
|
||
selectedComponent.barcodeType === "UPC" ? "12자리 숫자" :
|
||
"바코드에 표시할 값"
|
||
}
|
||
className="h-8"
|
||
/>
|
||
{(selectedComponent.barcodeType === "EAN13" ||
|
||
selectedComponent.barcodeType === "EAN8" ||
|
||
selectedComponent.barcodeType === "UPC") && (
|
||
<p className="mt-1 text-[10px] text-gray-500">
|
||
{selectedComponent.barcodeType === "EAN13" && "EAN-13: 12~13자리 숫자 필요"}
|
||
{selectedComponent.barcodeType === "EAN8" && "EAN-8: 7~8자리 숫자 필요"}
|
||
{selectedComponent.barcodeType === "UPC" && "UPC: 11~12자리 숫자 필요"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 쿼리 연결 시 필드 선택 */}
|
||
{selectedComponent.queryId && (
|
||
<div>
|
||
<Label className="text-xs">바인딩 필드</Label>
|
||
<Select
|
||
value={selectedComponent.barcodeFieldName || "none"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
barcodeFieldName: value === "none" ? "" : value,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue placeholder="필드 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">선택 안함</SelectItem>
|
||
{(() => {
|
||
const query = queries.find((q) => q.id === selectedComponent.queryId);
|
||
const result = query ? getQueryResult(query.id) : null;
|
||
if (result && result.fields) {
|
||
return result.fields.map((field: string) => (
|
||
<SelectItem key={field} value={field}>
|
||
{field}
|
||
</SelectItem>
|
||
));
|
||
}
|
||
return null;
|
||
})()}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 1D 바코드 전용 옵션 */}
|
||
{selectedComponent.barcodeType !== "QR" && (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
id="showBarcodeText"
|
||
checked={selectedComponent.showBarcodeText !== false}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
showBarcodeText: e.target.checked,
|
||
})
|
||
}
|
||
className="h-4 w-4 rounded border-gray-300"
|
||
/>
|
||
<Label htmlFor="showBarcodeText" className="text-xs">
|
||
바코드 아래 텍스트 표시
|
||
</Label>
|
||
</div>
|
||
)}
|
||
|
||
{/* QR 오류 보정 수준 */}
|
||
{selectedComponent.barcodeType === "QR" && (
|
||
<div>
|
||
<Label className="text-xs">오류 보정 수준</Label>
|
||
<Select
|
||
value={selectedComponent.qrErrorCorrectionLevel || "M"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
qrErrorCorrectionLevel: value as "L" | "M" | "Q" | "H",
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="L">L (7% 복구)</SelectItem>
|
||
<SelectItem value="M">M (15% 복구)</SelectItem>
|
||
<SelectItem value="Q">Q (25% 복구)</SelectItem>
|
||
<SelectItem value="H">H (30% 복구)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="mt-1 text-[10px] text-gray-500">
|
||
높을수록 손상에 강하지만 크기 증가
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 색상 설정 */}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<Label className="text-xs">바코드 색상</Label>
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.barcodeColor || "#000000"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
barcodeColor: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-full"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">배경 색상</Label>
|
||
<Input
|
||
type="color"
|
||
value={selectedComponent.barcodeBackground || "#ffffff"}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
barcodeBackground: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 여백 */}
|
||
<div>
|
||
<Label className="text-xs">여백 (px)</Label>
|
||
<Input
|
||
type="number"
|
||
value={selectedComponent.barcodeMargin ?? 10}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
barcodeMargin: Number(e.target.value),
|
||
})
|
||
}
|
||
min={0}
|
||
max={50}
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
|
||
{/* 쿼리 연결 안내 */}
|
||
{!selectedComponent.queryId && (
|
||
<div className="rounded border border-cyan-200 bg-cyan-100 p-2 text-xs text-cyan-800">
|
||
쿼리를 연결하면 데이터베이스 값으로 바코드를 생성할 수 있습니다.
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 데이터 바인딩 (텍스트/라벨/테이블/바코드 컴포넌트) */}
|
||
{(selectedComponent.type === "text" ||
|
||
selectedComponent.type === "label" ||
|
||
selectedComponent.type === "table" ||
|
||
selectedComponent.type === "barcode") && (
|
||
<Card className="mt-4 border-blue-200 bg-blue-50">
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-center gap-2">
|
||
<Link2 className="h-4 w-4 text-blue-600" />
|
||
<CardTitle className="text-sm text-blue-900">데이터 바인딩</CardTitle>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{/* 쿼리 선택 */}
|
||
<div>
|
||
<Label className="text-xs">쿼리</Label>
|
||
<Select
|
||
value={selectedComponent.queryId || "none"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
queryId: value === "none" ? undefined : value,
|
||
fieldName: undefined,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue placeholder="쿼리 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">선택 안함</SelectItem>
|
||
{queries.map((query) => (
|
||
<SelectItem key={query.id} value={query.id}>
|
||
{query.name} ({query.type})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 필드 선택 (텍스트/라벨만) */}
|
||
{selectedComponent.queryId &&
|
||
(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
||
<div>
|
||
<Label className="text-xs">필드</Label>
|
||
<Select
|
||
value={selectedComponent.fieldName || "none"}
|
||
onValueChange={(value) =>
|
||
updateComponent(selectedComponent.id, {
|
||
fieldName: value === "none" ? undefined : value,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue placeholder="필드 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">선택 안함</SelectItem>
|
||
{getQueryFields(selectedComponent.queryId).length > 0 ? (
|
||
getQueryFields(selectedComponent.queryId).map((field) => (
|
||
<SelectItem key={field} value={field}>
|
||
{field}
|
||
</SelectItem>
|
||
))
|
||
) : (
|
||
<SelectItem value="no-result" disabled>
|
||
쿼리 탭에서 실행 필요
|
||
</SelectItem>
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 테이블 컬럼 설정 */}
|
||
{selectedComponent.queryId && selectedComponent.type === "table" && (
|
||
<Card className="border-green-200 bg-green-50">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-xs text-green-900">컬럼 설정</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7 w-full text-xs"
|
||
onClick={() => {
|
||
const fields = getQueryFields(selectedComponent.queryId!);
|
||
if (fields.length > 0) {
|
||
const autoColumns = fields.map((field) => ({
|
||
field,
|
||
header: field,
|
||
align: "left" as const,
|
||
}));
|
||
updateComponent(selectedComponent.id, {
|
||
tableColumns: autoColumns,
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
자동 설정 (쿼리 필드 기반)
|
||
</Button>
|
||
|
||
{selectedComponent.tableColumns && selectedComponent.tableColumns.length > 0 && (
|
||
<div className="mt-3 space-y-2">
|
||
{selectedComponent.tableColumns.map((col, idx) => (
|
||
<div key={idx} className="space-y-1 rounded border border-green-200 bg-white p-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-medium text-gray-700">컬럼 {idx + 1}</span>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-5 w-5 p-0 text-red-500 hover:bg-red-50"
|
||
onClick={() => {
|
||
const newColumns = [...selectedComponent.tableColumns!];
|
||
newColumns.splice(idx, 1);
|
||
updateComponent(selectedComponent.id, {
|
||
tableColumns: newColumns,
|
||
});
|
||
}}
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<Label className="text-xs">필드</Label>
|
||
<Input
|
||
value={col.field}
|
||
onChange={(e) => {
|
||
const newColumns = [...selectedComponent.tableColumns!];
|
||
newColumns[idx].field = e.target.value;
|
||
updateComponent(selectedComponent.id, {
|
||
tableColumns: newColumns,
|
||
});
|
||
}}
|
||
className="h-7 text-xs"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">헤더명</Label>
|
||
<Input
|
||
value={col.header}
|
||
onChange={(e) => {
|
||
const newColumns = [...selectedComponent.tableColumns!];
|
||
newColumns[idx].header = e.target.value;
|
||
updateComponent(selectedComponent.id, {
|
||
tableColumns: newColumns,
|
||
});
|
||
}}
|
||
className="h-7 text-xs"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">너비(px)</Label>
|
||
<Input
|
||
type="number"
|
||
value={col.width || ""}
|
||
onChange={(e) => {
|
||
const newColumns = [...selectedComponent.tableColumns!];
|
||
newColumns[idx].width = e.target.value
|
||
? parseInt(e.target.value)
|
||
: undefined;
|
||
updateComponent(selectedComponent.id, {
|
||
tableColumns: newColumns,
|
||
});
|
||
}}
|
||
placeholder="자동"
|
||
className="h-7 text-xs"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">정렬</Label>
|
||
<Select
|
||
value={col.align || "left"}
|
||
onValueChange={(value) => {
|
||
const newColumns = [...selectedComponent.tableColumns!];
|
||
newColumns[idx].align = value as "left" | "center" | "right";
|
||
updateComponent(selectedComponent.id, {
|
||
tableColumns: newColumns,
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-7 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="left">왼쪽</SelectItem>
|
||
<SelectItem value="center">가운데</SelectItem>
|
||
<SelectItem value="right">오른쪽</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 기본값 (텍스트/라벨만) */}
|
||
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
||
<div>
|
||
<Label className="text-xs">텍스트 내용</Label>
|
||
<Textarea
|
||
value={selectedComponent.defaultValue || ""}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
defaultValue: e.target.value,
|
||
})
|
||
}
|
||
placeholder="텍스트 내용 (엔터로 줄바꿈 가능)"
|
||
className="min-h-[80px] resize-y"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 포맷 (텍스트/라벨만) */}
|
||
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
||
<div>
|
||
<Label className="text-xs">포맷</Label>
|
||
<Input
|
||
value={selectedComponent.format || ""}
|
||
onChange={(e) =>
|
||
updateComponent(selectedComponent.id, {
|
||
format: e.target.value,
|
||
})
|
||
}
|
||
placeholder="예: YYYY-MM-DD, #,###"
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
{/* 페이지 설정 탭 */}
|
||
<TabsContent value="page" className="mt-0 h-[calc(100vh-120px)]">
|
||
<ScrollArea className="h-full">
|
||
<div className="space-y-4 p-4">
|
||
{currentPage && currentPageId ? (
|
||
<>
|
||
{/* 페이지 정보 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-sm">페이지 정보</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div>
|
||
<Label className="text-xs">페이지 이름</Label>
|
||
<Input
|
||
value={currentPage.page_name}
|
||
onChange={(e) =>
|
||
updatePageSettings(currentPageId, {
|
||
page_name: e.target.value,
|
||
})
|
||
}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 페이지 크기 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-sm">페이지 크기</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<Label className="text-xs">너비 (mm)</Label>
|
||
<Input
|
||
type="number"
|
||
value={currentPage.width}
|
||
onChange={(e) =>
|
||
updatePageSettings(currentPageId, {
|
||
width: Number(e.target.value),
|
||
})
|
||
}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">높이 (mm)</Label>
|
||
<Input
|
||
type="number"
|
||
value={currentPage.height}
|
||
onChange={(e) =>
|
||
updatePageSettings(currentPageId, {
|
||
height: Number(e.target.value),
|
||
})
|
||
}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-xs">방향</Label>
|
||
<Select
|
||
value={currentPage.orientation}
|
||
onValueChange={(value: "portrait" | "landscape") =>
|
||
updatePageSettings(currentPageId, {
|
||
orientation: value,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="mt-1">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="portrait">세로 (Portrait)</SelectItem>
|
||
<SelectItem value="landscape">가로 (Landscape)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* 프리셋 버튼 */}
|
||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() =>
|
||
updatePageSettings(currentPageId, {
|
||
width: 210,
|
||
height: 297,
|
||
orientation: "portrait",
|
||
})
|
||
}
|
||
>
|
||
A4 세로
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() =>
|
||
updatePageSettings(currentPageId, {
|
||
width: 297,
|
||
height: 210,
|
||
orientation: "landscape",
|
||
})
|
||
}
|
||
>
|
||
A4 가로
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 여백 설정 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-sm">여백 (mm)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<Label className="text-xs">상단</Label>
|
||
<Input
|
||
type="number"
|
||
value={currentPage.margins.top}
|
||
onChange={(e) =>
|
||
updatePageSettings(currentPageId, {
|
||
margins: {
|
||
...currentPage.margins,
|
||
top: Number(e.target.value),
|
||
},
|
||
})
|
||
}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">하단</Label>
|
||
<Input
|
||
type="number"
|
||
value={currentPage.margins.bottom}
|
||
onChange={(e) =>
|
||
updatePageSettings(currentPageId, {
|
||
margins: {
|
||
...currentPage.margins,
|
||
bottom: Number(e.target.value),
|
||
},
|
||
})
|
||
}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">좌측</Label>
|
||
<Input
|
||
type="number"
|
||
value={currentPage.margins.left}
|
||
onChange={(e) =>
|
||
updatePageSettings(currentPageId, {
|
||
margins: {
|
||
...currentPage.margins,
|
||
left: Number(e.target.value),
|
||
},
|
||
})
|
||
}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">우측</Label>
|
||
<Input
|
||
type="number"
|
||
value={currentPage.margins.right}
|
||
onChange={(e) =>
|
||
updatePageSettings(currentPageId, {
|
||
margins: {
|
||
...currentPage.margins,
|
||
right: Number(e.target.value),
|
||
},
|
||
})
|
||
}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 여백 프리셋 */}
|
||
<div className="grid grid-cols-3 gap-2 pt-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() =>
|
||
updatePageSettings(currentPageId, {
|
||
margins: { top: 10, bottom: 10, left: 10, right: 10 },
|
||
})
|
||
}
|
||
>
|
||
좁게
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() =>
|
||
updatePageSettings(currentPageId, {
|
||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||
})
|
||
}
|
||
>
|
||
보통
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() =>
|
||
updatePageSettings(currentPageId, {
|
||
margins: { top: 30, bottom: 30, left: 30, right: 30 },
|
||
})
|
||
}
|
||
>
|
||
넓게
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
) : (
|
||
<div className="flex h-full items-center justify-center">
|
||
<p className="text-muted-foreground text-sm">페이지를 선택하세요</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</TabsContent>
|
||
|
||
{/* 쿼리 탭 */}
|
||
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
|
||
<QueryManager />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
);
|
||
}
|