이미지 & 구분선 구현
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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";
|
||||
@@ -8,17 +8,78 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Trash2, Settings, Database, Link2 } from "lucide-react";
|
||||
import { Trash2, Settings, Database, Link2, Upload, Loader2 } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { QueryManager } from "./QueryManager";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export function ReportDesignerRightPanel() {
|
||||
const context = useReportDesigner();
|
||||
const { selectedComponentId, components, updateComponent, removeComponent, queries } = context;
|
||||
const [activeTab, setActiveTab] = useState<string>("properties");
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
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 (error) {
|
||||
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);
|
||||
@@ -300,6 +361,173 @@ export function ReportDesignerRightPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이미지 속성 */}
|
||||
{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 === "text" ||
|
||||
selectedComponent.type === "label" ||
|
||||
|
||||
Reference in New Issue
Block a user