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

3014 lines
152 KiB
TypeScript
Raw Normal View History

2025-10-01 12:00:13 +09:00
"use client";
2025-10-01 16:53:35 +09:00
import { useState, useRef } from "react";
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 { Textarea } from "@/components/ui/textarea";
2025-10-01 12:00:13 +09:00
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
2025-12-22 15:40:31 +09:00
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import { Trash2, Settings, Database, Link2, Upload, Loader2, X } from "lucide-react";
2025-10-01 12:00:13 +09:00
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { QueryManager } from "./QueryManager";
2025-10-02 10:04:02 +09:00
import { SignaturePad } from "./SignaturePad";
2025-10-13 10:32:46 +09:00
import { SignatureGenerator } from "./SignatureGenerator";
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() {
const context = useReportDesigner();
const {
selectedComponentId,
components,
updateComponent,
removeComponent,
queries,
currentPage,
currentPageId,
updatePageSettings,
getQueryResult,
layoutConfig,
updateWatermark,
} = context;
const [activeTab, setActiveTab] = useState<string>("properties");
2025-10-01 16:53:35 +09:00
const [uploadingImage, setUploadingImage] = useState(false);
2025-12-22 15:40:31 +09:00
const [uploadingWatermarkImage, setUploadingWatermarkImage] = useState(false);
2025-10-13 10:32:46 +09:00
const [signatureMethod, setSignatureMethod] = useState<"draw" | "upload" | "generate">("draw");
2025-10-01 16:53:35 +09:00
const fileInputRef = useRef<HTMLInputElement>(null);
2025-12-22 15:40:31 +09:00
const watermarkFileInputRef = useRef<HTMLInputElement>(null);
2025-10-01 16:53:35 +09:00
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: "이미지가 업로드되었습니다.",
});
}
2025-10-13 10:32:46 +09:00
} catch {
2025-10-01 16:53:35 +09:00
toast({
title: "오류",
description: "이미지 업로드 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setUploadingImage(false);
// input 초기화
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
2025-12-22 15:40:31 +09:00
// 워터마크 이미지 업로드 핸들러
const handleWatermarkImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
2025-12-22 15:40:31 +09:00
// 파일 타입 체크
if (!file.type.startsWith("image/")) {
toast({
title: "오류",
description: "이미지 파일만 업로드 가능합니다.",
variant: "destructive",
});
return;
}
// 파일 크기 체크 (5MB)
if (file.size > 5 * 1024 * 1024) {
toast({
title: "오류",
description: "파일 크기는 5MB 이하여야 합니다.",
variant: "destructive",
});
return;
}
try {
setUploadingWatermarkImage(true);
const result = await reportApi.uploadImage(file);
if (result.success) {
// 업로드된 이미지 URL을 전체 워터마크에 설정
updateWatermark({
...layoutConfig.watermark!,
imageUrl: result.data.fileUrl,
2025-12-22 15:40:31 +09:00
});
toast({
title: "성공",
description: "워터마크 이미지가 업로드되었습니다.",
});
}
} catch {
toast({
title: "오류",
description: "이미지 업로드 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setUploadingWatermarkImage(false);
// input 초기화
if (watermarkFileInputRef.current) {
watermarkFileInputRef.current.value = "";
}
}
};
// 선택된 쿼리의 결과 필드 가져오기
const getQueryFields = (queryId: string): string[] => {
const result = context.getQueryResult(queryId);
return result ? result.fields : [];
};
2025-10-01 12:00:13 +09:00
return (
<div className="w-[450px] border-l bg-white">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
<div className="border-b p-2">
2025-10-13 19:15:52 +09:00
<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>
2025-10-01 12:00:13 +09:00
</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>
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 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 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>
</div>
2025-10-01 18:04:38 +09:00
{/* 테이블 스타일 */}
{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>
)}
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"}
2025-12-19 18:19:29 +09:00
onValueChange={(value) => {
// 방향 변경 시 너비/높이 스왑
const isToVertical = value === "vertical";
const currentWidth = selectedComponent.width;
const currentHeight = selectedComponent.height;
2025-10-01 16:53:35 +09:00
updateComponent(selectedComponent.id, {
orientation: value as "horizontal" | "vertical",
2025-12-19 18:19:29 +09:00
width: isToVertical ? 10 : currentWidth > 50 ? currentWidth : 300,
height: isToVertical ? currentWidth > 50 ? currentWidth : 300 : 10,
});
}}
2025-10-01 16:53:35 +09:00
>
<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 17:31:15 +09:00
{/* 서명/도장 속성 */}
{(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">
2025-10-13 10:32:46 +09:00
{/* 서명란: 드롭다운으로 직접 서명 / 이미지 업로드 / 서명 만들기 선택 */}
2025-10-02 10:04:02 +09:00
{selectedComponent.type === "signature" ? (
2025-10-13 10:32:46 +09:00
<>
<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,
});
}}
2025-10-02 10:04:02 +09:00
/>
</div>
2025-10-13 10:32:46 +09:00
)}
{/* 이미지 업로드 */}
{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>
)}
</>
2025-10-02 10:04:02 +09:00
) : (
// 도장란: 기존 방식 유지
<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>
)}
2025-10-01 17:31:15 +09:00
</div>
2025-10-02 10:04:02 +09:00
)}
2025-10-01 17:31:15 +09:00
{/* 맞춤 방식 */}
<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 === "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"}
2025-12-22 11:51:19 +09:00
onValueChange={(value) => {
const newType = value as "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
// QR코드는 정사각형으로 크기 조정
if (newType === "QR") {
const size = Math.max(selectedComponent.width, selectedComponent.height);
updateComponent(selectedComponent.id, {
barcodeType: newType,
width: size,
height: size,
});
} else {
updateComponent(selectedComponent.id, {
barcodeType: newType,
});
}
}}
>
<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 && (
<>
{/* QR코드: 다중 필드 모드 토글 */}
{selectedComponent.barcodeType === "QR" && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="qrUseMultiField"
checked={selectedComponent.qrUseMultiField === true}
onChange={(e) =>
updateComponent(selectedComponent.id, {
qrUseMultiField: e.target.checked,
// 다중 필드 모드 활성화 시 단일 필드 초기화
...(e.target.checked && { barcodeFieldName: "" }),
})
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="qrUseMultiField" className="text-xs">
(JSON )
</Label>
</div>
)}
{/* 단일 필드 모드 (1D 바코드 또는 QR 단일 모드) */}
{(selectedComponent.barcodeType !== "QR" || !selectedComponent.qrUseMultiField) && (
<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>
)}
{/* QR코드 다중 필드 모드 UI */}
{selectedComponent.barcodeType === "QR" && selectedComponent.qrUseMultiField && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">JSON </Label>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
const currentFields = selectedComponent.qrDataFields || [];
updateComponent(selectedComponent.id, {
qrDataFields: [...currentFields, { fieldName: "", label: "" }],
});
}}
>
+
</Button>
</div>
{/* 필드 목록 */}
<div className="max-h-[200px] space-y-2 overflow-y-auto">
{(selectedComponent.qrDataFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-1 rounded border p-2">
<div className="flex-1 space-y-1">
<Select
value={field.fieldName || "none"}
onValueChange={(value) => {
const newFields = [...(selectedComponent.qrDataFields || [])];
newFields[index] = {
...newFields[index],
fieldName: value === "none" ? "" : value,
// 라벨이 비어있으면 필드명으로 자동 설정
label: newFields[index].label || (value === "none" ? "" : value),
};
updateComponent(selectedComponent.id, { qrDataFields: newFields });
}}
>
<SelectTrigger className="h-7 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((f: string) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
));
}
return null;
})()}
</SelectContent>
</Select>
<Input
type="text"
value={field.label || ""}
onChange={(e) => {
const newFields = [...(selectedComponent.qrDataFields || [])];
newFields[index] = { ...newFields[index], label: e.target.value };
updateComponent(selectedComponent.id, { qrDataFields: newFields });
}}
placeholder="JSON 키 이름"
className="h-7 text-xs"
/>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
onClick={() => {
const newFields = (selectedComponent.qrDataFields || []).filter(
(_, i) => i !== index
);
updateComponent(selectedComponent.id, { qrDataFields: newFields });
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
{(selectedComponent.qrDataFields || []).length === 0 && (
<p className="text-center text-xs text-gray-400">
</p>
)}
<p className="text-[10px] text-gray-500">
: {selectedComponent.qrIncludeAllRows
? `[{"${(selectedComponent.qrDataFields || []).map(f => f.label || "key").join('":"값","')}"}, ...]`
: `{"${(selectedComponent.qrDataFields || []).map(f => f.label || "key").join('":"값","')}":"값"}`
}
</p>
</div>
)}
</>
)}
{/* QR코드 모든 행 포함 옵션 (다중 필드와 독립) */}
{selectedComponent.barcodeType === "QR" && selectedComponent.queryId && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="qrIncludeAllRows"
checked={selectedComponent.qrIncludeAllRows === true}
onChange={(e) =>
updateComponent(selectedComponent.id, {
qrIncludeAllRows: e.target.checked,
})
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="qrIncludeAllRows" className="text-xs">
()
</Label>
</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>
)}
2025-12-19 18:06:25 +09:00
{/* 체크박스 컴포넌트 전용 설정 */}
{selectedComponent.type === "checkbox" && (
<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">
{/* 체크 상태 (쿼리 연결 없을 때) */}
{!selectedComponent.queryId && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="checkboxChecked"
checked={selectedComponent.checkboxChecked === true}
onChange={(e) =>
updateComponent(selectedComponent.id, {
checkboxChecked: e.target.checked,
})
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="checkboxChecked" className="text-xs">
</Label>
</div>
)}
{/* 쿼리 연결 시 필드 선택 */}
{selectedComponent.queryId && (
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.checkboxFieldName || "none"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
checkboxFieldName: 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>
<p className="mt-1 text-[10px] text-gray-500">
true, "Y", 1 truthy
</p>
</div>
)}
{/* 레이블 텍스트 */}
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.checkboxLabel || ""}
onChange={(e) =>
updateComponent(selectedComponent.id, {
checkboxLabel: e.target.value,
})
}
placeholder="체크박스 옆 텍스트"
className="h-8"
/>
</div>
{/* 레이블 위치 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.checkboxLabelPosition || "right"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
checkboxLabelPosition: value as "left" | "right",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 체크박스 크기 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={selectedComponent.checkboxSize || 18}
onChange={(e) =>
updateComponent(selectedComponent.id, {
checkboxSize: Number(e.target.value),
})
}
min={12}
max={40}
className="h-8"
/>
</div>
{/* 색상 설정 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.checkboxColor || "#2563eb"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
checkboxColor: e.target.value,
})
}
className="h-8 w-full"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.checkboxBorderColor || "#6b7280"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
checkboxBorderColor: e.target.value,
})
}
className="h-8 w-full"
/>
</div>
</div>
{/* 쿼리 연결 안내 */}
{!selectedComponent.queryId && (
<div className="rounded border border-purple-200 bg-purple-100 p-2 text-xs text-purple-800">
.
</div>
)}
</CardContent>
</Card>
)}
{/* 데이터 바인딩 (텍스트/라벨/테이블/바코드/체크박스 컴포넌트) */}
{(selectedComponent.type === "text" ||
selectedComponent.type === "label" ||
selectedComponent.type === "table" ||
2025-12-19 18:06:25 +09:00
selectedComponent.type === "barcode" ||
selectedComponent.type === "checkbox") && (
<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>
)}
2025-10-01 18:04:38 +09:00
{/* 테이블 컬럼 설정 */}
{selectedComponent.queryId && selectedComponent.type === "table" && (
2025-10-01 18:04:38 +09:00
<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"
2025-12-22 18:20:16 +09:00
min={1}
value={currentPage.width}
onChange={(e) =>
updatePageSettings(currentPageId, {
2025-12-22 18:20:16 +09:00
width: Math.max(1, Number(e.target.value)),
})
}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs"> (mm)</Label>
<Input
type="number"
2025-12-22 18:20:16 +09:00
min={1}
value={currentPage.height}
onChange={(e) =>
updatePageSettings(currentPageId, {
2025-12-22 18:20:16 +09:00
height: Math.max(1, 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"
2025-12-22 18:17:58 +09:00
min={0}
value={currentPage.margins.top}
onChange={(e) =>
updatePageSettings(currentPageId, {
margins: {
...currentPage.margins,
2025-12-22 18:17:58 +09:00
top: Math.max(0, Number(e.target.value)),
},
})
}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
2025-12-22 18:17:58 +09:00
min={0}
value={currentPage.margins.bottom}
onChange={(e) =>
updatePageSettings(currentPageId, {
margins: {
...currentPage.margins,
2025-12-22 18:17:58 +09:00
bottom: Math.max(0, Number(e.target.value)),
},
})
}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
2025-12-22 18:17:58 +09:00
min={0}
value={currentPage.margins.left}
onChange={(e) =>
updatePageSettings(currentPageId, {
margins: {
...currentPage.margins,
2025-12-22 18:17:58 +09:00
left: Math.max(0, Number(e.target.value)),
},
})
}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
2025-12-22 18:17:58 +09:00
min={0}
value={currentPage.margins.right}
onChange={(e) =>
updatePageSettings(currentPageId, {
margins: {
...currentPage.margins,
2025-12-22 18:17:58 +09:00
right: Math.max(0, 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>
2025-12-22 15:40:31 +09:00
{/* 워터마크 설정 (전체 페이지 공유) */}
2025-12-22 15:40:31 +09:00
<Card>
<CardHeader>
<CardTitle className="text-sm"> ( )</CardTitle>
2025-12-22 15:40:31 +09:00
</CardHeader>
<CardContent className="space-y-3">
{/* 워터마크 활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={layoutConfig.watermark?.enabled ?? false}
2025-12-22 15:40:31 +09:00
onCheckedChange={(checked) =>
updateWatermark({
...layoutConfig.watermark,
enabled: checked,
type: layoutConfig.watermark?.type ?? "text",
opacity: layoutConfig.watermark?.opacity ?? 0.3,
style: layoutConfig.watermark?.style ?? "diagonal",
2025-12-22 15:40:31 +09:00
})
}
/>
</div>
{layoutConfig.watermark?.enabled && (
2025-12-22 15:40:31 +09:00
<>
{/* 워터마크 타입 */}
<div>
<Label className="text-xs"></Label>
<Select
value={layoutConfig.watermark?.type ?? "text"}
2025-12-22 15:40:31 +09:00
onValueChange={(value: "text" | "image") =>
updateWatermark({
...layoutConfig.watermark!,
type: value,
2025-12-22 15:40:31 +09:00
})
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="image"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 텍스트 워터마크 설정 */}
{layoutConfig.watermark?.type === "text" && (
2025-12-22 15:40:31 +09:00
<>
<div>
<Label className="text-xs"></Label>
<Input
value={layoutConfig.watermark?.text ?? ""}
2025-12-22 15:40:31 +09:00
onChange={(e) =>
updateWatermark({
...layoutConfig.watermark!,
text: e.target.value,
2025-12-22 15:40:31 +09:00
})
}
placeholder="DRAFT, 대외비 등"
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={layoutConfig.watermark?.fontSize ?? 48}
2025-12-22 15:40:31 +09:00
onChange={(e) =>
updateWatermark({
...layoutConfig.watermark!,
fontSize: Number(e.target.value),
2025-12-22 15:40:31 +09:00
})
}
className="mt-1"
min={12}
max={200}
/>
</div>
<div>
<Label className="text-xs"></Label>
<div className="mt-1 flex gap-1">
<Input
type="color"
value={layoutConfig.watermark?.fontColor ?? "#cccccc"}
2025-12-22 15:40:31 +09:00
onChange={(e) =>
updateWatermark({
...layoutConfig.watermark!,
fontColor: e.target.value,
2025-12-22 15:40:31 +09:00
})
}
className="h-9 w-12 cursor-pointer p-1"
/>
<Input
type="text"
value={layoutConfig.watermark?.fontColor ?? "#cccccc"}
2025-12-22 15:40:31 +09:00
onChange={(e) =>
updateWatermark({
...layoutConfig.watermark!,
fontColor: e.target.value,
2025-12-22 15:40:31 +09:00
})
}
className="flex-1"
/>
</div>
</div>
</div>
</>
)}
{/* 이미지 워터마크 설정 */}
{layoutConfig.watermark?.type === "image" && (
2025-12-22 15:40:31 +09:00
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-2">
<input
ref={watermarkFileInputRef}
type="file"
accept="image/*"
onChange={handleWatermarkImageUpload}
className="hidden"
disabled={uploadingWatermarkImage}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => watermarkFileInputRef.current?.click()}
disabled={uploadingWatermarkImage}
className="flex-1"
>
{uploadingWatermarkImage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
{layoutConfig.watermark?.imageUrl ? "이미지 변경" : "이미지 선택"}
2025-12-22 15:40:31 +09:00
</>
)}
</Button>
{layoutConfig.watermark?.imageUrl && (
2025-12-22 15:40:31 +09:00
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
updateWatermark({
...layoutConfig.watermark!,
imageUrl: "",
2025-12-22 15:40:31 +09:00
})
}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
<p className="text-muted-foreground mt-1 text-[10px]">
JPG, PNG, GIF, WEBP ( 5MB)
</p>
{layoutConfig.watermark?.imageUrl && (
2025-12-22 15:40:31 +09:00
<p className="mt-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-xs text-indigo-600">
현재: ...{layoutConfig.watermark.imageUrl.slice(-30)}
2025-12-22 15:40:31 +09:00
</p>
)}
</div>
)}
{/* 공통 설정 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={layoutConfig.watermark?.style ?? "diagonal"}
2025-12-22 15:40:31 +09:00
onValueChange={(value: "diagonal" | "center" | "tile") =>
updateWatermark({
...layoutConfig.watermark!,
style: value,
2025-12-22 15:40:31 +09:00
})
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="diagonal"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="tile"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* 대각선/타일 회전 각도 */}
{(layoutConfig.watermark?.style === "diagonal" ||
layoutConfig.watermark?.style === "tile") && (
2025-12-22 15:40:31 +09:00
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={layoutConfig.watermark?.rotation ?? -45}
2025-12-22 15:40:31 +09:00
onChange={(e) =>
updateWatermark({
...layoutConfig.watermark!,
rotation: Number(e.target.value),
2025-12-22 15:40:31 +09:00
})
}
className="mt-1"
min={-180}
max={180}
/>
</div>
)}
{/* 투명도 */}
<div>
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<span className="text-muted-foreground text-xs">
{Math.round((layoutConfig.watermark?.opacity ?? 0.3) * 100)}%
2025-12-22 15:40:31 +09:00
</span>
</div>
<Slider
value={[(layoutConfig.watermark?.opacity ?? 0.3) * 100]}
2025-12-22 15:40:31 +09:00
onValueChange={(value) =>
updateWatermark({
...layoutConfig.watermark!,
opacity: value[0] / 100,
2025-12-22 15:40:31 +09:00
})
}
min={5}
max={100}
step={5}
className="mt-2"
/>
</div>
{/* 프리셋 버튼 */}
<div className="grid grid-cols-2 gap-2 pt-2">
<Button
size="sm"
variant="outline"
onClick={() =>
updateWatermark({
...layoutConfig.watermark!,
type: "text",
text: "DRAFT",
fontSize: 64,
fontColor: "#cccccc",
style: "diagonal",
opacity: 0.2,
rotation: -45,
2025-12-22 15:40:31 +09:00
})
}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
updateWatermark({
...layoutConfig.watermark!,
type: "text",
text: "대외비",
fontSize: 64,
fontColor: "#ff0000",
style: "diagonal",
opacity: 0.15,
rotation: -45,
2025-12-22 15:40:31 +09:00
})
}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
updateWatermark({
...layoutConfig.watermark!,
type: "text",
text: "SAMPLE",
fontSize: 48,
fontColor: "#888888",
style: "tile",
opacity: 0.1,
rotation: -30,
2025-12-22 15:40:31 +09:00
})
}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
updateWatermark({
...layoutConfig.watermark!,
type: "text",
text: "COPY",
fontSize: 56,
fontColor: "#aaaaaa",
style: "center",
opacity: 0.25,
2025-12-22 15:40:31 +09:00
})
}
>
</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>
2025-10-01 12:00:13 +09:00
</div>
);
}