이미지 & 구분선 구현

This commit is contained in:
dohyeons
2025-10-01 16:53:35 +09:00
parent f8be19c49f
commit d83264181c
12 changed files with 556 additions and 26 deletions

View File

@@ -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" ||