ERP-node/frontend/components/report/designer/ReportDesignerRightPanel.tsx

1406 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { 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,
} = 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 === "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" && (
<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>
<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="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>
);
}