424 lines
20 KiB
TypeScript
424 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
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 { Switch } from "@/components/ui/switch";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { Loader2, Trash2, Upload, ChevronRight, FileText, Maximize2, Space, Droplet } from "lucide-react";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import { reportApi } from "@/lib/api/reportApi";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
export function PageSettingsTab() {
|
|
const { currentPage, currentPageId, updatePageSettings, layoutConfig, updateWatermark } = useReportDesigner();
|
|
const [uploadingWatermarkImage, setUploadingWatermarkImage] = useState(false);
|
|
const watermarkFileInputRef = useRef<HTMLInputElement>(null);
|
|
const [expandedSection, setExpandedSection] = useState<string>("page-info");
|
|
const { toast } = useToast();
|
|
|
|
const handleWatermarkImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.startsWith("image/")) {
|
|
toast({ title: "오류", description: "이미지 파일만 업로드 가능합니다.", variant: "destructive" });
|
|
return;
|
|
}
|
|
|
|
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) {
|
|
updateWatermark({ ...layoutConfig.watermark!, imageUrl: result.data.fileUrl });
|
|
toast({ title: "성공", description: "워터마크 이미지가 업로드되었습니다." });
|
|
}
|
|
} catch {
|
|
toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive" });
|
|
} finally {
|
|
setUploadingWatermarkImage(false);
|
|
if (watermarkFileInputRef.current) watermarkFileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
if (!currentPage || !currentPageId) {
|
|
return (
|
|
<ScrollArea className="h-full">
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">페이지를 선택하세요</p>
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|
|
|
|
const toggleSection = (id: string) => {
|
|
setExpandedSection(expandedSection === id ? "" : id);
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-y-auto bg-white">
|
|
{/* 페이지 정보 (아코디언) */}
|
|
<div className="border-b-2 border-gray-100">
|
|
<button
|
|
onClick={() => toggleSection("page-info")}
|
|
className={`flex h-12 w-full items-center justify-between px-4 transition-colors ${
|
|
expandedSection === "page-info"
|
|
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
|
: "bg-white text-gray-900 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<FileText className={`h-4 w-4 ${expandedSection === "page-info" ? "" : "text-blue-600"}`} />
|
|
<span className="text-sm font-bold">페이지 정보</span>
|
|
</div>
|
|
<ChevronRight className={`h-4 w-4 transition-transform ${expandedSection === "page-info" ? "rotate-90" : expandedSection === "page-info" ? "" : "text-gray-400"}`} />
|
|
</button>
|
|
{expandedSection === "page-info" && (
|
|
<div className="space-y-4 border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-4">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">페이지명</Label>
|
|
<Input
|
|
value={currentPage.page_name}
|
|
onChange={(e) => updatePageSettings(currentPageId, { page_name: e.target.value })}
|
|
className="h-10 border-2 text-sm focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 페이지 크기 (아코디언) */}
|
|
<div className="border-b-2 border-gray-100">
|
|
<button
|
|
onClick={() => toggleSection("page-size")}
|
|
className={`flex h-12 w-full items-center justify-between px-4 transition-colors ${
|
|
expandedSection === "page-size"
|
|
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
|
: "bg-white text-gray-900 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Maximize2 className={`h-4 w-4 ${expandedSection === "page-size" ? "" : "text-blue-600"}`} />
|
|
<span className="text-sm font-bold">페이지 크기</span>
|
|
</div>
|
|
<ChevronRight className={`h-4 w-4 transition-transform ${expandedSection === "page-size" ? "rotate-90" : "text-gray-400"}`} />
|
|
</button>
|
|
{expandedSection === "page-size" && (
|
|
<div className="space-y-4 border-t border-gray-100 bg-gray-50/50 p-4">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">방향</Label>
|
|
<Select
|
|
value={currentPage.orientation}
|
|
onValueChange={(value: "portrait" | "landscape") => updatePageSettings(currentPageId, { orientation: value })}
|
|
>
|
|
<SelectTrigger className="h-10 border-2 text-sm focus:border-blue-500">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="portrait">세로</SelectItem>
|
|
<SelectItem value="landscape">가로</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">너비 (mm)</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={currentPage.width}
|
|
onChange={(e) => updatePageSettings(currentPageId, { width: Math.max(1, Number(e.target.value)) })}
|
|
className="h-10 border-2 text-sm focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">높이 (mm)</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={currentPage.height}
|
|
onChange={(e) => updatePageSettings(currentPageId, { height: Math.max(1, Number(e.target.value)) })}
|
|
className="h-10 border-2 text-sm focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 pt-1">
|
|
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={() => updatePageSettings(currentPageId, { width: 210, height: 297, orientation: "portrait" })}>
|
|
A4 세로
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={() => updatePageSettings(currentPageId, { width: 297, height: 210, orientation: "landscape" })}>
|
|
A4 가로
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 여백 설정 (아코디언) */}
|
|
<div className="border-b-2 border-gray-100">
|
|
<button
|
|
onClick={() => toggleSection("margin")}
|
|
className={`flex h-12 w-full items-center justify-between px-4 transition-colors ${
|
|
expandedSection === "margin"
|
|
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
|
: "bg-white text-gray-900 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Space className={`h-4 w-4 ${expandedSection === "margin" ? "" : "text-blue-600"}`} />
|
|
<span className="text-sm font-bold">여백 설정</span>
|
|
</div>
|
|
<ChevronRight className={`h-4 w-4 transition-transform ${expandedSection === "margin" ? "rotate-90" : "text-gray-400"}`} />
|
|
</button>
|
|
{expandedSection === "margin" && (
|
|
<div className="space-y-3 border-t border-gray-100 bg-gray-50/50 p-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{(["top", "bottom", "left", "right"] as const).map((side) => (
|
|
<div key={side} className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">
|
|
{side === "top" ? "상단 (mm)" : side === "bottom" ? "하단 (mm)" : side === "left" ? "좌측 (mm)" : "우측 (mm)"}
|
|
</Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={currentPage.margins[side]}
|
|
onChange={(e) =>
|
|
updatePageSettings(currentPageId, {
|
|
margins: { ...currentPage.margins, [side]: Math.max(0, Number(e.target.value)) },
|
|
})
|
|
}
|
|
className="h-10 border-2 text-sm focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2 pt-1">
|
|
{[
|
|
{ label: "좁게", value: 10 },
|
|
{ label: "보통", value: 20 },
|
|
{ label: "넓게", value: 30 },
|
|
].map(({ label, value }) => (
|
|
<Button
|
|
key={label}
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 text-xs"
|
|
onClick={() => updatePageSettings(currentPageId, { margins: { top: value, bottom: value, left: value, right: value } })}
|
|
>
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 워터마크 설정 (아코디언) */}
|
|
<div className="border-b-2 border-gray-100">
|
|
<button
|
|
onClick={() => toggleSection("watermark")}
|
|
className={`flex h-12 w-full items-center justify-between px-4 transition-colors ${
|
|
expandedSection === "watermark"
|
|
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
|
: "bg-white text-gray-900 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Droplet className={`h-4 w-4 ${expandedSection === "watermark" ? "" : "text-blue-600"}`} />
|
|
<span className="text-sm font-bold">워터마크 설정</span>
|
|
</div>
|
|
<ChevronRight className={`h-4 w-4 transition-transform ${expandedSection === "watermark" ? "rotate-90" : "text-gray-400"}`} />
|
|
</button>
|
|
{expandedSection === "watermark" && (
|
|
<div className="space-y-4 border-t border-gray-100 bg-gray-50/50 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs font-semibold text-gray-700">워터마크 사용</Label>
|
|
<Switch
|
|
checked={layoutConfig.watermark?.enabled ?? false}
|
|
onCheckedChange={(checked) =>
|
|
updateWatermark({
|
|
...layoutConfig.watermark,
|
|
enabled: checked,
|
|
type: layoutConfig.watermark?.type ?? "text",
|
|
opacity: layoutConfig.watermark?.opacity ?? 0.3,
|
|
style: layoutConfig.watermark?.style ?? "diagonal",
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{layoutConfig.watermark?.enabled && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">타입</Label>
|
|
<Select
|
|
value={layoutConfig.watermark?.type ?? "text"}
|
|
onValueChange={(value: "text" | "image") => updateWatermark({ ...layoutConfig.watermark!, type: value })}
|
|
>
|
|
<SelectTrigger className="h-10 border-2 text-sm focus:border-blue-500">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">텍스트</SelectItem>
|
|
<SelectItem value="image">이미지</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{layoutConfig.watermark?.type === "text" && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">텍스트</Label>
|
|
<Input
|
|
value={layoutConfig.watermark?.text ?? ""}
|
|
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, text: e.target.value })}
|
|
placeholder="CONFIDENTIAL"
|
|
className="h-10 border-2 text-sm focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">폰트 크기</Label>
|
|
<Input
|
|
type="number"
|
|
value={layoutConfig.watermark?.fontSize ?? 48}
|
|
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, fontSize: Number(e.target.value) })}
|
|
className="h-10 border-2 text-sm focus:border-blue-500"
|
|
min={12}
|
|
max={200}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">색상</Label>
|
|
<div className="flex gap-1">
|
|
<Input
|
|
type="color"
|
|
value={layoutConfig.watermark?.fontColor ?? "#cccccc"}
|
|
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, fontColor: e.target.value })}
|
|
className="h-10 w-12 cursor-pointer p-1"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
value={layoutConfig.watermark?.fontColor ?? "#cccccc"}
|
|
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, fontColor: e.target.value })}
|
|
className="h-10 flex-1 border-2 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{layoutConfig.watermark?.type === "image" && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">워터마크 이미지</Label>
|
|
<div className="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="h-10 flex-1 border-2 text-sm"
|
|
>
|
|
{uploadingWatermarkImage ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
|
|
{layoutConfig.watermark?.imageUrl ? "이미지 변경" : "이미지 선택"}
|
|
</Button>
|
|
{layoutConfig.watermark?.imageUrl && (
|
|
<Button type="button" variant="ghost" size="sm" className="h-10" onClick={() => updateWatermark({ ...layoutConfig.watermark!, imageUrl: "" })}>
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">배치 스타일</Label>
|
|
<Select
|
|
value={layoutConfig.watermark?.style ?? "diagonal"}
|
|
onValueChange={(value: "diagonal" | "center" | "tile") => updateWatermark({ ...layoutConfig.watermark!, style: value })}
|
|
>
|
|
<SelectTrigger className="h-10 border-2 text-sm focus:border-blue-500">
|
|
<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") && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-semibold text-gray-700">회전 각도</Label>
|
|
<Input
|
|
type="number"
|
|
value={layoutConfig.watermark?.rotation ?? -45}
|
|
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, rotation: Number(e.target.value) })}
|
|
className="h-10 border-2 text-sm focus:border-blue-500"
|
|
min={-180}
|
|
max={180}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<Label className="text-xs font-semibold text-gray-700">투명도</Label>
|
|
<span className="text-xs text-gray-500">{Math.round((layoutConfig.watermark?.opacity ?? 0.3) * 100)}%</span>
|
|
</div>
|
|
<Slider
|
|
value={[(layoutConfig.watermark?.opacity ?? 0.3) * 100]}
|
|
onValueChange={(value) => updateWatermark({ ...layoutConfig.watermark!, opacity: value[0] / 100 })}
|
|
min={5}
|
|
max={100}
|
|
step={5}
|
|
className="my-2"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 pt-2">
|
|
{[
|
|
{ label: "초안", text: "DRAFT", fontSize: 64, fontColor: "#cccccc", style: "diagonal" as const, opacity: 0.2, rotation: -45 },
|
|
{ label: "대외비", text: "대외비", fontSize: 64, fontColor: "#ff0000", style: "diagonal" as const, opacity: 0.15, rotation: -45 },
|
|
{ label: "샘플", text: "SAMPLE", fontSize: 48, fontColor: "#888888", style: "tile" as const, opacity: 0.1, rotation: -30 },
|
|
{ label: "사본", text: "COPY", fontSize: 56, fontColor: "#aaaaaa", style: "center" as const, opacity: 0.25 },
|
|
].map(({ label, ...preset }) => (
|
|
<Button
|
|
key={label}
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 text-xs"
|
|
onClick={() => updateWatermark({ ...layoutConfig.watermark!, type: "text", ...preset })}
|
|
>
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|