2025-10-01 12:00:13 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-10-01 16:53:35 +09:00
|
|
|
import { useState, useRef } from "react";
|
2025-10-01 13:53:45 +09:00
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
2025-10-01 12:00:13 +09:00
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-10-01 13:53:45 +09:00
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
2025-10-01 16:53:35 +09:00
|
|
|
import { Trash2, Settings, Database, Link2, Upload, Loader2 } from "lucide-react";
|
2025-10-01 12:00:13 +09:00
|
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
2025-10-01 13:53:45 +09:00
|
|
|
import { QueryManager } from "./QueryManager";
|
2025-10-01 16:53:35 +09:00
|
|
|
import { reportApi } from "@/lib/api/reportApi";
|
|
|
|
|
import { useToast } from "@/hooks/use-toast";
|
2025-10-01 12:00:13 +09:00
|
|
|
|
|
|
|
|
export function ReportDesignerRightPanel() {
|
2025-10-01 13:53:45 +09:00
|
|
|
const context = useReportDesigner();
|
|
|
|
|
const { selectedComponentId, components, updateComponent, removeComponent, queries } = context;
|
|
|
|
|
const [activeTab, setActiveTab] = useState<string>("properties");
|
2025-10-01 16:53:35 +09:00
|
|
|
const [uploadingImage, setUploadingImage] = useState(false);
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const { toast } = useToast();
|
2025-10-01 12:00:13 +09:00
|
|
|
|
|
|
|
|
const selectedComponent = components.find((c) => c.id === selectedComponentId);
|
|
|
|
|
|
2025-10-01 16:53:35 +09:00
|
|
|
// 이미지 업로드 핸들러
|
|
|
|
|
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 (error) {
|
|
|
|
|
toast({
|
|
|
|
|
title: "오류",
|
|
|
|
|
description: "이미지 업로드 중 오류가 발생했습니다.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
setUploadingImage(false);
|
|
|
|
|
// input 초기화
|
|
|
|
|
if (fileInputRef.current) {
|
|
|
|
|
fileInputRef.current.value = "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-01 13:53:45 +09:00
|
|
|
// 선택된 쿼리의 결과 필드 가져오기
|
|
|
|
|
const getQueryFields = (queryId: string): string[] => {
|
|
|
|
|
const result = context.getQueryResult(queryId);
|
|
|
|
|
return result ? result.fields : [];
|
|
|
|
|
};
|
2025-10-01 12:00:13 +09:00
|
|
|
|
|
|
|
|
return (
|
2025-10-01 13:53:45 +09:00
|
|
|
<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-2">
|
|
|
|
|
<TabsTrigger value="properties" className="gap-2">
|
|
|
|
|
<Settings className="h-4 w-4" />
|
|
|
|
|
속성
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="queries" className="gap-2">
|
|
|
|
|
<Database className="h-4 w-4" />
|
|
|
|
|
쿼리
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
2025-10-01 12:00:13 +09:00
|
|
|
</div>
|
2025-10-01 13:53:45 +09:00
|
|
|
|
|
|
|
|
{/* 속성 탭 */}
|
|
|
|
|
<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>
|
|
|
|
|
|
2025-10-01 14:14:06 +09:00
|
|
|
{/* 스타일링 섹션 */}
|
|
|
|
|
<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>
|
2025-10-01 13:53:45 +09:00
|
|
|
|
2025-10-01 14:14:06 +09:00
|
|
|
{/* 글꼴 크기 */}
|
|
|
|
|
<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>
|
2025-10-01 13:53:45 +09:00
|
|
|
|
2025-10-01 14:14:06 +09:00
|
|
|
{/* 글꼴 색상 */}
|
|
|
|
|
<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>
|
2025-10-01 13:53:45 +09:00
|
|
|
</div>
|
|
|
|
|
|
2025-10-01 16:53:35 +09:00
|
|
|
{/* 이미지 속성 */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-10-01 13:53:45 +09:00
|
|
|
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
|
|
|
|
|
{(selectedComponent.type === "text" ||
|
|
|
|
|
selectedComponent.type === "label" ||
|
|
|
|
|
selectedComponent.type === "table") && (
|
|
|
|
|
<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" && (
|
|
|
|
|
<div className="rounded-md bg-blue-100 p-2 text-xs text-blue-800">
|
|
|
|
|
테이블은 선택한 쿼리의 모든 필드를 자동으로 표시합니다.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 기본값 (텍스트/라벨만) */}
|
|
|
|
|
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs">기본값</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={selectedComponent.defaultValue || ""}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
updateComponent(selectedComponent.id, {
|
|
|
|
|
defaultValue: e.target.value,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
placeholder="데이터가 없을 때 표시할 값"
|
|
|
|
|
className="h-8"
|
|
|
|
|
/>
|
|
|
|
|
</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="queries" className="mt-0 h-[calc(100vh-120px)]">
|
|
|
|
|
<QueryManager />
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
2025-10-01 12:00:13 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|