Merge branch 'kwshin-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
@@ -1,29 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
||||
import { PageListPanel } from "@/components/report/designer/PageListPanel";
|
||||
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
||||
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
||||
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
|
||||
import { ReportDesignerProvider } from "@/contexts/ReportDesignerContext";
|
||||
import { ReportDesignerProvider, useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { ComponentSettingsModal } from "@/components/report/designer/modals/ComponentSettingsModal";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function ReportDesignerPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const reportId = params.reportId as string;
|
||||
const BREAKPOINT_COLLAPSE_LEFT = 1200;
|
||||
const BREAKPOINT_COLLAPSE_ALL = 900;
|
||||
|
||||
function DesignerLayout() {
|
||||
const {
|
||||
setIsPageListCollapsed,
|
||||
setIsLeftPanelCollapsed,
|
||||
setIsRightPanelCollapsed,
|
||||
} = useReportDesigner();
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
const w = window.innerWidth;
|
||||
if (w < BREAKPOINT_COLLAPSE_ALL) {
|
||||
setIsPageListCollapsed(true);
|
||||
setIsLeftPanelCollapsed(true);
|
||||
setIsRightPanelCollapsed(true);
|
||||
} else if (w < BREAKPOINT_COLLAPSE_LEFT) {
|
||||
setIsPageListCollapsed(true);
|
||||
setIsLeftPanelCollapsed(false);
|
||||
setIsRightPanelCollapsed(false);
|
||||
}
|
||||
}, [setIsPageListCollapsed, setIsLeftPanelCollapsed, setIsRightPanelCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [handleResize]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-w-[768px] flex-col overflow-hidden bg-gray-50">
|
||||
<ReportDesignerToolbar />
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<PageListPanel />
|
||||
<ReportDesignerLeftPanel />
|
||||
<ReportDesignerCanvas />
|
||||
<ReportDesignerRightPanel />
|
||||
</div>
|
||||
|
||||
<ComponentSettingsModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReportDesignerPageProps {
|
||||
adminParams?: { reportId?: string };
|
||||
}
|
||||
|
||||
export default function ReportDesignerPage({ adminParams }: ReportDesignerPageProps) {
|
||||
const routeParams = useParams();
|
||||
const reportId = adminParams?.reportId || (routeParams.reportId as string);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const currentTabId = useTabId();
|
||||
|
||||
const closeDesignerTab = useCallback(() => {
|
||||
if (currentTabId) {
|
||||
closeTab(currentTabId);
|
||||
}
|
||||
}, [currentTabId, closeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadReport = async () => {
|
||||
// 'new'는 새 리포트 생성 모드
|
||||
if (reportId === "new") {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -37,7 +95,7 @@ export default function ReportDesignerPage() {
|
||||
description: "리포트를 찾을 수 없습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
router.push("/admin/screenMng/reportList");
|
||||
closeDesignerTab();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
@@ -45,7 +103,7 @@ export default function ReportDesignerPage() {
|
||||
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
router.push("/admin/screenMng/reportList");
|
||||
closeDesignerTab();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -54,7 +112,7 @@ export default function ReportDesignerPage() {
|
||||
if (reportId) {
|
||||
loadReport();
|
||||
}
|
||||
}, [reportId, router, toast]);
|
||||
}, [reportId, closeDesignerTab, toast]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -65,28 +123,12 @@ export default function ReportDesignerPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ReportDesignerProvider reportId={reportId}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-muted">
|
||||
{/* 상단 툴바 */}
|
||||
<ReportDesignerToolbar />
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 페이지 목록 패널 */}
|
||||
<PageListPanel />
|
||||
|
||||
{/* 좌측 패널 (템플릿, 컴포넌트) */}
|
||||
<ReportDesignerLeftPanel />
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<ReportDesignerCanvas />
|
||||
|
||||
{/* 우측 패널 (속성) */}
|
||||
<ReportDesignerRightPanel />
|
||||
</div>
|
||||
</div>
|
||||
</ReportDesignerProvider>
|
||||
</DndProvider>
|
||||
<div id="report-designer-dnd-root">
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ReportDesignerProvider reportId={reportId}>
|
||||
<DesignerLayout />
|
||||
</ReportDesignerProvider>
|
||||
</DndProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,104 +1,528 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { ReportListTable } from "@/components/report/ReportListTable";
|
||||
import { Plus, Search, RotateCcw } from "lucide-react";
|
||||
import { ReportCreateModal } from "@/components/report/ReportCreateModal";
|
||||
import { ReportCopyModal } from "@/components/report/ReportCopyModal";
|
||||
import { ReportListPreviewModal } from "@/components/report/ReportListPreviewModal";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
LayoutGrid,
|
||||
List,
|
||||
FileText,
|
||||
Users,
|
||||
SlidersHorizontal,
|
||||
Check,
|
||||
Tag,
|
||||
CalendarDays,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useReportList } from "@/hooks/useReportList";
|
||||
import { ReportMaster } from "@/types/report";
|
||||
import { PieChart, Pie, Cell, Tooltip, BarChart, Bar, XAxis, LabelList } from "recharts";
|
||||
import { REPORT_TYPE_COLORS, getTypeColorIndex, getTypeLabel, getTypeIcon } from "@/lib/reportTypeColors";
|
||||
import { format, subDays, subMonths, startOfDay } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
|
||||
const SEARCH_FIELD_OPTIONS = [
|
||||
{ value: "report_type" as const, label: "카테고리", icon: Tag },
|
||||
{ value: "report_name" as const, label: "리포트명", icon: FileText },
|
||||
{ value: "updated_at" as const, label: "기간 검색", icon: CalendarDays },
|
||||
{ value: "created_by" as const, label: "작성자", icon: User },
|
||||
];
|
||||
|
||||
export default function ReportManagementPage() {
|
||||
const router = useRouter();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("list");
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [copyTarget, setCopyTarget] = useState<ReportMaster | null>(null);
|
||||
const [viewTarget, setViewTarget] = useState<ReportMaster | null>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [datePopoverOpen, setDatePopoverOpen] = useState(false);
|
||||
const [tempStartDate, setTempStartDate] = useState<Date | undefined>(undefined);
|
||||
const [tempEndDate, setTempEndDate] = useState<Date | undefined>(undefined);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList();
|
||||
const {
|
||||
reports,
|
||||
total,
|
||||
typeSummary,
|
||||
recentActivity,
|
||||
recentTotal,
|
||||
page,
|
||||
limit,
|
||||
isLoading,
|
||||
searchField,
|
||||
startDate,
|
||||
endDate,
|
||||
refetch,
|
||||
setPage,
|
||||
setLimit,
|
||||
handleSearch,
|
||||
handleSearchFieldChange,
|
||||
handleDateRangeChange,
|
||||
} = useReportList();
|
||||
|
||||
const handleSearchClick = () => {
|
||||
handleSearch(searchText);
|
||||
};
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (filterRef.current && !filterRef.current.contains(e.target as Node)) {
|
||||
setFilterOpen(false);
|
||||
setDatePopoverOpen(false);
|
||||
}
|
||||
};
|
||||
if (filterOpen) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [filterOpen]);
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchText("");
|
||||
handleSearch("");
|
||||
const isDateFilterActive = searchField === "updated_at" && startDate && endDate;
|
||||
|
||||
const handleDatePreset = useCallback((days: number) => {
|
||||
const end = new Date();
|
||||
const start = days === 0 ? startOfDay(end) : subDays(end, days);
|
||||
setTempStartDate(start);
|
||||
setTempEndDate(end);
|
||||
}, []);
|
||||
|
||||
const handleMonthPreset = useCallback((months: number) => {
|
||||
const end = new Date();
|
||||
const start = subMonths(end, months);
|
||||
setTempStartDate(start);
|
||||
setTempEndDate(end);
|
||||
}, []);
|
||||
|
||||
const handleApplyDateFilter = useCallback(() => {
|
||||
if (!tempStartDate || !tempEndDate) return;
|
||||
handleSearchFieldChange("updated_at");
|
||||
handleDateRangeChange(format(tempStartDate, "yyyy-MM-dd"), format(tempEndDate, "yyyy-MM-dd"));
|
||||
setDatePopoverOpen(false);
|
||||
setFilterOpen(false);
|
||||
}, [tempStartDate, tempEndDate, handleSearchFieldChange, handleDateRangeChange]);
|
||||
|
||||
const handleClearDateFilter = useCallback(() => {
|
||||
setTempStartDate(undefined);
|
||||
setTempEndDate(undefined);
|
||||
handleSearchFieldChange("report_name");
|
||||
handleDateRangeChange("", "");
|
||||
}, [handleSearchFieldChange, handleDateRangeChange]);
|
||||
|
||||
const typeData = useMemo(() => typeSummary.map(({ type, count }) => ({ type, value: count })), [typeSummary]);
|
||||
|
||||
const authorStats = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
reports.forEach((r) => {
|
||||
const author = r.created_by || "미지정";
|
||||
map.set(author, (map.get(author) || 0) + 1);
|
||||
});
|
||||
return Array.from(map.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 3)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "ko"));
|
||||
}, [reports]);
|
||||
|
||||
const authorCount = useMemo(() => new Set(reports.map((r) => r.created_by).filter(Boolean)).size, [reports]);
|
||||
|
||||
const handleSearchClick = () => handleSearch(searchText);
|
||||
|
||||
const handleViewModeChange = (mode: "grid" | "list") => {
|
||||
setViewMode(mode);
|
||||
setLimit(mode === "grid" ? 9 : 8);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
|
||||
router.push("/admin/screenMng/reportList/designer/new");
|
||||
setIsCreateOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted">
|
||||
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">리포트 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">리포트를 생성하고 관리합니다</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateNew} className="gap-2">
|
||||
<Plus className="h-4 w-4" />새 리포트
|
||||
</Button>
|
||||
</div>
|
||||
const currentFieldLabel = SEARCH_FIELD_OPTIONS.find((o) => o.value === searchField)?.label ?? "리포트명";
|
||||
|
||||
{/* 검색 영역 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-2">
|
||||
return (
|
||||
<div className="report-page-content flex h-[calc(100vh-56px)] flex-col bg-gray-50">
|
||||
<div className="shrink-0 border-b bg-white">
|
||||
<div className="mx-6 py-2.5">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-gray-900">리포트 관리</h1>
|
||||
<span className="text-sm text-gray-400">리포트를 생성하고 관리합니다</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative w-full sm:w-[480px]">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="리포트명으로 검색..."
|
||||
placeholder={`${currentFieldLabel}(으)로 검색...`}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearchClick();
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearchClick()}
|
||||
className="h-9 pl-9 text-sm"
|
||||
/>
|
||||
<Button onClick={handleSearchClick} className="gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
검색
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSearchClick}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-9 w-9 shrink-0"
|
||||
title="검색"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div ref={filterRef} className="relative">
|
||||
<Button
|
||||
onClick={() => setFilterOpen(!filterOpen)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className={`h-9 w-9 shrink-0 ${filterOpen || isDateFilterActive ? "border-blue-400 bg-blue-50 text-blue-600" : ""}`}
|
||||
title="검색 필터"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outline" className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
초기화
|
||||
|
||||
{filterOpen && !datePopoverOpen && (
|
||||
<div className="absolute top-full right-0 z-50 mt-1.5 w-52 rounded-lg border border-gray-200 bg-white py-1.5 shadow-lg">
|
||||
<div className="px-3.5 py-2 text-sm font-semibold text-gray-400">검색 기준</div>
|
||||
{SEARCH_FIELD_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const isDateOption = opt.value === "updated_at";
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
if (isDateOption) {
|
||||
setDatePopoverOpen(true);
|
||||
} else {
|
||||
handleSearchFieldChange(opt.value);
|
||||
handleDateRangeChange("", "");
|
||||
setFilterOpen(false);
|
||||
}
|
||||
}}
|
||||
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-base transition-colors hover:bg-gray-50 ${
|
||||
searchField === opt.value || (isDateOption && searchField === "updated_at")
|
||||
? "font-medium text-blue-600"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{opt.label}</span>
|
||||
{isDateOption && <span className="text-xs text-gray-400">▸</span>}
|
||||
{!isDateOption && searchField === opt.value && (
|
||||
<Check className="h-4.5 w-4.5 shrink-0 text-blue-600" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filterOpen && datePopoverOpen && (
|
||||
<div
|
||||
className="absolute top-full right-0 z-50 mt-1.5 rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<button
|
||||
onClick={() => setDatePopoverOpen(false)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
← 뒤로
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-gray-700">기간 검색</span>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex gap-1.5">
|
||||
{[
|
||||
{ label: "오늘", action: () => handleDatePreset(0) },
|
||||
{ label: "1주일", action: () => handleDatePreset(7) },
|
||||
{ label: "1개월", action: () => handleMonthPreset(1) },
|
||||
{ label: "3개월", action: () => handleMonthPreset(3) },
|
||||
].map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={preset.action}
|
||||
className="flex-1 rounded-md border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-500">시작일</label>
|
||||
<div className="flex h-9 w-full items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700">
|
||||
<CalendarDays className="mr-2 h-3.5 w-3.5 text-gray-400" />
|
||||
{tempStartDate ? (
|
||||
format(tempStartDate, "yyyy-MM-dd")
|
||||
) : (
|
||||
<span className="text-gray-400">선택</span>
|
||||
)}
|
||||
</div>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={tempStartDate}
|
||||
onSelect={setTempStartDate}
|
||||
locale={ko}
|
||||
className="mt-1.5 rounded-md border border-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center pt-6">
|
||||
<span className="text-sm text-gray-400">~</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-500">종료일</label>
|
||||
<div className="flex h-9 w-full items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700">
|
||||
<CalendarDays className="mr-2 h-3.5 w-3.5 text-gray-400" />
|
||||
{tempEndDate ? (
|
||||
format(tempEndDate, "yyyy-MM-dd")
|
||||
) : (
|
||||
<span className="text-gray-400">선택</span>
|
||||
)}
|
||||
</div>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={tempEndDate}
|
||||
onSelect={setTempEndDate}
|
||||
locale={ko}
|
||||
className="mt-1.5 rounded-md border border-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleApplyDateFilter}
|
||||
disabled={!tempStartDate || !tempEndDate}
|
||||
className="h-9 w-full bg-blue-600 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDateFilterActive && (
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5">
|
||||
<CalendarDays className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-700">
|
||||
{startDate} ~ {endDate}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearDateFilter}
|
||||
className="ml-1 rounded p-0.5 text-blue-400 transition-colors hover:bg-blue-100 hover:text-blue-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center overflow-hidden rounded-md border border-gray-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewModeChange("list")}
|
||||
className={`h-9 w-9 rounded-none ${viewMode === "list" ? "bg-gray-100" : ""}`}
|
||||
title="리스트 보기"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewModeChange("grid")}
|
||||
className={`h-9 w-9 rounded-none border-l ${viewMode === "grid" ? "bg-gray-100" : ""}`}
|
||||
title="그리드 보기"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 리포트 목록 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-muted/50">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
📋 리포트 목록
|
||||
<span className="text-muted-foreground text-sm font-normal">(총 {total}건)</span>
|
||||
<Button
|
||||
onClick={handleCreateNew}
|
||||
className="ml-auto h-9 gap-1.5 bg-blue-600 text-sm text-white hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />새 리포트
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 px-8 py-5">
|
||||
<div className="grid shrink-0 auto-rows-fr grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex items-center justify-center gap-5 overflow-hidden rounded-xl border border-gray-100 bg-white px-6 py-8 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-blue-50">
|
||||
<FileText className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-lg font-bold whitespace-nowrap text-gray-500">전체 리포트</span>
|
||||
<p className="mt-1 text-5xl font-bold tracking-tight whitespace-nowrap text-gray-900 tabular-nums">
|
||||
{total.toLocaleString()}
|
||||
<span className="ml-1.5 text-xl font-bold text-gray-400">건</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center justify-center gap-8 rounded-xl border border-gray-100 bg-white px-6 py-8 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-purple-50">
|
||||
<Users className="h-8 w-8 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-lg font-bold whitespace-nowrap text-gray-500">작성자</span>
|
||||
<p className="mt-1 text-5xl font-bold tracking-tight whitespace-nowrap text-gray-900 tabular-nums">
|
||||
{authorCount.toLocaleString()}
|
||||
<span className="ml-1.5 text-xl font-bold text-gray-400">명</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{authorStats.length > 0 && (
|
||||
<div className="h-[80px] w-[100px] shrink-0" style={{ overflow: "visible" }}>
|
||||
<BarChart width={100} height={80} data={authorStats} margin={{ top: 12, right: 0, bottom: 0, left: 0 }}>
|
||||
<Bar
|
||||
dataKey="count" fill="#a78bfa" radius={[3, 3, 0, 0]} maxBarSize={18}
|
||||
isAnimationActive={true} animationDuration={1200} animationEasing="ease-out"
|
||||
>
|
||||
<LabelList dataKey="count" position="top" style={{ fontSize: "10px", fontWeight: 700, fill: "#6d28d9" }} />
|
||||
</Bar>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value}건`, "리포트"]}
|
||||
labelFormatter={(_label: any, payload: any) => payload?.[0]?.payload?.name || _label}
|
||||
contentStyle={{ fontSize: "11px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }}
|
||||
wrapperStyle={{ zIndex: 50, pointerEvents: "none" }}
|
||||
cursor={false}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
/>
|
||||
</BarChart>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center rounded-xl border border-gray-100 bg-white px-6 py-6 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||
<span className="text-lg font-bold whitespace-nowrap text-gray-500">
|
||||
최근 30일 활동{" "}
|
||||
<span className="text-base font-semibold text-gray-400 tabular-nums">
|
||||
({recentTotal.toLocaleString()}건)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
</span>
|
||||
<div className="mt-2 flex w-full flex-1 items-center justify-center">
|
||||
<div className="shrink-0" style={{ overflow: "visible" }}>
|
||||
<BarChart
|
||||
width={220} height={100} data={recentActivity}
|
||||
barCategoryGap="12%" margin={{ top: 16, right: 2, bottom: 0, left: 2 }}
|
||||
>
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12, fill: "#374151", fontWeight: 700 }} axisLine={false} tickLine={false} />
|
||||
<Bar
|
||||
dataKey="count" fill="#60a5fa" radius={[4, 4, 0, 0]} maxBarSize={32}
|
||||
isAnimationActive={true} animationDuration={1200} animationEasing="ease-out"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value}건`, "수정"]}
|
||||
contentStyle={{ fontSize: "12px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }}
|
||||
cursor={false}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
/>
|
||||
</BarChart>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center rounded-xl border border-gray-100 bg-white px-6 py-6 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||
<span className="text-lg font-bold whitespace-nowrap text-gray-500">카테고리별 분포</span>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{typeData.length === 0 ? (
|
||||
<p className="text-lg font-bold text-gray-400">데이터 없음</p>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2.5">
|
||||
<div className="shrink-0" style={{ overflow: "visible", position: "relative" }}>
|
||||
<PieChart width={90} height={90} style={{ overflow: "visible" }}>
|
||||
<Pie
|
||||
data={typeData} cx="50%" cy="50%" innerRadius={20} outerRadius={40}
|
||||
dataKey="value" nameKey="type" startAngle={90} endAngle={-270}
|
||||
strokeWidth={2} stroke="#fff"
|
||||
isAnimationActive={true} animationDuration={1200} animationEasing="ease-out"
|
||||
>
|
||||
{typeData.map((entry) => (
|
||||
<Cell key={entry.type} fill={REPORT_TYPE_COLORS[getTypeColorIndex(entry.type) % REPORT_TYPE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [`${value}건`, getTypeLabel(name)]}
|
||||
contentStyle={{ fontSize: "12px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }}
|
||||
wrapperStyle={{ zIndex: 20, pointerEvents: "none" }}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
/>
|
||||
</PieChart>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
||||
{typeData.slice(0, 4).map((entry) => {
|
||||
const TypeIcon = getTypeIcon(entry.type);
|
||||
return (
|
||||
<div key={entry.type} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded"
|
||||
style={{ backgroundColor: REPORT_TYPE_COLORS[getTypeColorIndex(entry.type) % REPORT_TYPE_COLORS.length] }}
|
||||
>
|
||||
<TypeIcon className="h-2.5 w-2.5 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium whitespace-nowrap text-gray-600">{getTypeLabel(entry.type)}</span>
|
||||
<span className="text-sm font-bold whitespace-nowrap text-gray-900">{entry.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{typeData.length > 4 && <span className="text-xs text-gray-400">외 {typeData.length - 4}개</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50/50 px-5 py-3">
|
||||
<span className="flex items-center gap-2.5 text-base font-semibold text-gray-900">
|
||||
리포트 목록
|
||||
<span className="text-sm font-normal text-gray-400">(총 {total}건)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<ReportListTable
|
||||
reports={reports}
|
||||
total={total}
|
||||
page={page}
|
||||
limit={limit}
|
||||
isLoading={isLoading}
|
||||
viewMode={viewMode}
|
||||
onPageChange={setPage}
|
||||
onRefresh={refetch}
|
||||
onViewClick={setViewTarget}
|
||||
onCopyClick={setCopyTarget}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReportCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onSuccess={refetch} />
|
||||
|
||||
<ReportListPreviewModal report={viewTarget} onClose={() => setViewTarget(null)} />
|
||||
|
||||
{copyTarget && (
|
||||
<ReportCopyModal
|
||||
report={copyTarget}
|
||||
onClose={() => setCopyTarget(null)}
|
||||
onSuccess={() => {
|
||||
setCopyTarget(null);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Printer, Download } from "lucide-react";
|
||||
|
||||
interface DocumentLayoutProps {
|
||||
children: ReactNode;
|
||||
title: string;
|
||||
docNumber?: string;
|
||||
}
|
||||
|
||||
export default function DocumentLayout({ children, title, docNumber }: DocumentLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FC]">
|
||||
{/* Navigation Bar */}
|
||||
<div className="bg-[#1E3A5F] border-b-4 border-[#0F172A] px-6 py-3 print:hidden">
|
||||
<div className="max-w-[842px] mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/admin/screenMng/reportList/samples"
|
||||
className="flex items-center gap-2 text-white hover:text-[#EFF6FF] transition-colors border-2 border-white px-3 py-1"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-sm">돌아가기</span>
|
||||
</Link>
|
||||
<div className="h-6 w-px bg-[#64748B]" />
|
||||
<h1 className="text-lg text-white">{title}</h1>
|
||||
{docNumber && (
|
||||
<span className="text-xs text-[#94A3B8] border border-[#475569] px-2 py-0.5">{docNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-[#1E3A5F] border-2 border-white hover:bg-[#EFF6FF] transition-colors text-sm"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
인쇄
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2 border-2 border-white text-white hover:bg-[#2563EB] hover:border-[#2563EB] transition-colors text-sm">
|
||||
<Download className="w-4 h-4" />
|
||||
다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Container */}
|
||||
<div className="py-8 px-4">
|
||||
<div className="max-w-[842px] mx-auto bg-white border-4 border-[#1E3A5F] print:border-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
type StatusType = "합격" | "불합격" | "보류" | "발주완료" | "검토중" | "취소" | "완료" | "승인대기";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: StatusType;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const COLOR_MAP: Record<StatusType, string> = {
|
||||
합격: "bg-white text-[#16A34A] border-[#16A34A]",
|
||||
완료: "bg-white text-[#16A34A] border-[#16A34A]",
|
||||
발주완료: "bg-white text-[#16A34A] border-[#16A34A]",
|
||||
불합격: "bg-[#DC2626] text-white border-[#DC2626]",
|
||||
취소: "bg-[#DC2626] text-white border-[#DC2626]",
|
||||
보류: "bg-[#D97706] text-white border-[#D97706]",
|
||||
검토중: "bg-[#D97706] text-white border-[#D97706]",
|
||||
승인대기: "bg-[#2563EB] text-white border-[#2563EB]",
|
||||
};
|
||||
|
||||
const SIZE_MAP = {
|
||||
sm: "px-2 py-0.5 text-xs",
|
||||
md: "px-3 py-1 text-sm",
|
||||
lg: "px-8 py-3 text-2xl",
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={`inline-flex items-center justify-center border-2 ${COLOR_MAP[status]} ${SIZE_MAP[size]}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import DocumentLayout from "../components/DocumentLayout";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
|
||||
const INSPECTION_ITEMS = [
|
||||
{
|
||||
no: 1,
|
||||
item: "외관상태",
|
||||
subItem: "ee",
|
||||
method: "육안 및 뒤틀림이 없을 것",
|
||||
standard: "A",
|
||||
measured: ["A", "A", "A", "A", "A", "A", "A", "A"],
|
||||
result: "합격" as const,
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
item: "표면 및 표시",
|
||||
subItem: "ff",
|
||||
method: "100표에서 1시간 방치",
|
||||
standard: "O",
|
||||
measured: ["O", "O", "O", "O", "O", "O", "O", "O"],
|
||||
result: "합격" as const,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
item: "치수 yy",
|
||||
subItem: "yy",
|
||||
method: "길이",
|
||||
standard: "453.9±0.9",
|
||||
measured: ["453.6", "453.6", "454.4", "453.5", "453.1", "454.1", "454.3", "454.7"],
|
||||
result: "합격" as const,
|
||||
},
|
||||
{
|
||||
no: 4,
|
||||
item: "치수 hhh",
|
||||
subItem: "hhh",
|
||||
method: "폭",
|
||||
standard: "177.3±0.5",
|
||||
measured: ["177.4", "177.1", "177.5", "177.6", "177.3", "176.9", "177.7", "176.8"],
|
||||
result: "합격" as const,
|
||||
},
|
||||
{
|
||||
no: 5,
|
||||
item: "외관상태",
|
||||
subItem: "",
|
||||
method: "ff",
|
||||
standard: "A",
|
||||
measured: ["A", "A", "A", "A", "A", "A", "A", "A"],
|
||||
result: "합격" as const,
|
||||
},
|
||||
];
|
||||
|
||||
// ── 정보 카드 (CardRenderer 구조를 참고한 정적 구현) ────────────────────────
|
||||
|
||||
function InfoCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border-2 border-[#1E3A5F]">
|
||||
<div className="bg-[#EFF6FF] border-b-2 border-[#1E3A5F] px-4 py-2">
|
||||
<h3 className="text-sm text-[#0F172A]">▣ {title}</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-2 text-xs">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[100px,1fr] border-b border-[#E2E8F0] pb-1">
|
||||
<span className="text-[#64748B]">{label}</span>
|
||||
<span className={highlight ? "text-[#2563EB]" : "text-[#0F172A]"}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 결재란 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ApprovalSection({ columns }: { columns: string[] }) {
|
||||
return (
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="border-2 border-[#1E3A5F]">
|
||||
<div className="grid text-center text-xs" style={{ gridTemplateColumns: `repeat(${columns.length}, 96px)` }}>
|
||||
{columns.map((col, i) => (
|
||||
<div key={i} className={`p-3 ${i < columns.length - 1 ? "border-r-2 border-[#1E3A5F]" : ""}`}>
|
||||
<div className="text-[#64748B] mb-6 pb-2 border-b border-[#E2E8F0]">{col}</div>
|
||||
<div className="h-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InspectionReportPage() {
|
||||
return (
|
||||
<DocumentLayout title="검사 보고서" docNumber="IR-2026-00123">
|
||||
<div className="p-10">
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="border-4 border-[#1E3A5F] mb-6">
|
||||
<div className="bg-[#1E3A5F] text-white px-6 py-4 text-center">
|
||||
<h1 className="text-3xl tracking-widest">검 사 보 고 서</h1>
|
||||
<p className="text-xs mt-1 tracking-wider">INSPECTION REPORT</p>
|
||||
</div>
|
||||
<div className="bg-white px-6 py-3 flex justify-between items-center border-t-2 border-[#1E3A5F]">
|
||||
<div className="text-sm text-[#64748B]">문서번호: IR-2026-00123</div>
|
||||
<StatusBadge status="합격" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 기본 정보 (2열 카드) ── */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<InfoCard title="검사 대상">
|
||||
<InfoRow label="발행번호" value="HC2014 - 005" />
|
||||
<InfoRow label="협력업체" value="매직볼드" />
|
||||
<InfoRow label="규격명" value="SATA-234" highlight />
|
||||
<InfoRow label="수주계측기" value="버니어캘리퍼스 (Serial No.) #05233911" />
|
||||
<InfoRow label="검사전환일" value="저울 (Serial No.) #258-98-22" />
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="검사 정보">
|
||||
<InfoRow label="생산일자" value="2014-03-10" highlight />
|
||||
<InfoRow label="검사수량" value="565" />
|
||||
<InfoRow label="검사레벨" value="일반검사1" />
|
||||
<InfoRow label="AQL" value="1.5" />
|
||||
<InfoRow label="검사일자" value="2014-03-10" highlight />
|
||||
<InfoRow label="시료수량" value="8" />
|
||||
<InfoRow label="검사자" value="김수로" />
|
||||
</InfoCard>
|
||||
</div>
|
||||
|
||||
{/* ── 검사 항목 테이블 ── */}
|
||||
<div className="mb-6">
|
||||
<div className="bg-[#EFF6FF] border-2 border-[#1E3A5F] border-b-0 px-4 py-2">
|
||||
<h3 className="text-sm text-[#0F172A]">▣ 검사/시험 측정값</h3>
|
||||
</div>
|
||||
<div className="border-2 border-[#1E3A5F]">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-[#1E3A5F] text-white">
|
||||
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>검사항목</th>
|
||||
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>
|
||||
시험 및 검사대응<br />(검사기준)
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center border-r-2 border-white" colSpan={8}>
|
||||
검사/시험 측정값
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>X̄</th>
|
||||
<th className="px-3 py-2 text-center" rowSpan={2}>합격 판정</th>
|
||||
</tr>
|
||||
<tr className="bg-[#1E3A5F] text-white border-t-2 border-white">
|
||||
{["X1", "X2", "X3", "X4", "X5", "X6", "X7", "X8"].map((x) => (
|
||||
<th key={x} className="px-2 py-2 text-center border-r-2 border-white">{x}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{INSPECTION_ITEMS.map((item, idx) => (
|
||||
<tr key={item.no} className={`border-t border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||
<td className="px-3 py-2 border-r border-[#E2E8F0]">
|
||||
<div>{item.item}</div>
|
||||
{item.subItem && <div className="text-[#64748B]">{item.subItem}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 border-r border-[#E2E8F0]">
|
||||
<div>{item.method}</div>
|
||||
{(item.method === "길이" || item.method === "폭") && (
|
||||
<div className="text-[#64748B] mt-1">{item.standard}</div>
|
||||
)}
|
||||
</td>
|
||||
{item.measured.map((val, i) => (
|
||||
<td key={i} className="px-2 py-2 text-center border-r border-[#E2E8F0]">{val}</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-center border-r border-[#E2E8F0]">8</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<StatusBadge status={item.result} size="sm" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="mt-3 flex items-center gap-6 text-xs text-[#64748B]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-1 border-2 border-[#D97706] bg-yellow-100 text-[#0F172A]">비 고</span>
|
||||
<span>[범례] A : Accept, R : Reject, H : Hold</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>중량판정</span>
|
||||
<span className="px-2 py-1 bg-[#1E3A5F] text-white border-2 border-[#1E3A5F]">■ 합 격</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 결재란 ── */}
|
||||
<ApprovalSection columns={["작성", "검토", "승인"]} />
|
||||
|
||||
{/* ── 푸터 ── */}
|
||||
<div className="text-xs text-[#64748B] flex justify-between items-center pt-4 border-t-2 border-[#1E3A5F]">
|
||||
<div>양식번호 : QF-805-2 (Rev.0)</div>
|
||||
<div>A4(210mm×297mm)</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, ClipboardCheck, FileText, ShoppingCart } from "lucide-react";
|
||||
|
||||
const SAMPLES = [
|
||||
{
|
||||
title: "검사 보고서",
|
||||
titleEng: "Inspection Report",
|
||||
description: "품질 검사 결과를 기록하고 관리하는 문서입니다. 검사 항목, 측정값, 합격/불합격 판정을 포함합니다.",
|
||||
path: "/admin/screenMng/reportList/samples/inspection",
|
||||
icon: ClipboardCheck,
|
||||
docNo: "IR-2026-XXXX",
|
||||
},
|
||||
{
|
||||
title: "견적서",
|
||||
titleEng: "Quotation",
|
||||
description: "고객에게 제공하는 견적 문서입니다. 품목별 단가, 수량, 공급가액, 세액을 포함합니다.",
|
||||
path: "/admin/screenMng/reportList/samples/quotation",
|
||||
icon: FileText,
|
||||
docNo: "QT-2026-XXXX",
|
||||
},
|
||||
{
|
||||
title: "발주서",
|
||||
titleEng: "Purchase Order",
|
||||
description: "공급업체에 발주하는 공식 문서입니다. 발주처 정보, 발주 내역, 납기일 등을 포함합니다.",
|
||||
path: "/admin/screenMng/reportList/samples/purchase-order",
|
||||
icon: ShoppingCart,
|
||||
docNo: "PO-2026-XXXX",
|
||||
},
|
||||
];
|
||||
|
||||
export default function SamplesPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FC]">
|
||||
{/* Header */}
|
||||
<div className="bg-[#1E3A5F] border-b-4 border-[#0F172A] px-6 py-3">
|
||||
<div className="max-w-5xl mx-auto flex items-center gap-4">
|
||||
<Link
|
||||
href="/admin/screenMng/reportList"
|
||||
className="flex items-center gap-2 text-white hover:text-[#EFF6FF] transition-colors border-2 border-white px-3 py-1 text-sm"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
리포트 목록
|
||||
</Link>
|
||||
<div className="h-6 w-px bg-[#64748B]" />
|
||||
<h1 className="text-white text-lg">리포트 디자인 샘플</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-10 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Title Section */}
|
||||
<div className="bg-white border-4 border-[#1E3A5F] p-8 mb-8 text-center">
|
||||
<h2 className="text-3xl text-[#0F172A] border-b-4 border-[#2563EB] pb-4 mb-4">
|
||||
WACE PLM — 문서 양식 샘플
|
||||
</h2>
|
||||
<p className="text-[#64748B] text-sm">
|
||||
리포트 디자이너에서 활용 가능한 표준 문서 양식 샘플입니다.
|
||||
<br />
|
||||
카드(정보패널), 테이블, 결재란 등 기본 컴포넌트로 구성되었습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sample Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{SAMPLES.map((sample) => (
|
||||
<Link
|
||||
key={sample.path}
|
||||
href={sample.path}
|
||||
className="bg-white border-2 border-[#1E3A5F] hover:bg-[#EFF6FF] transition-colors group block"
|
||||
>
|
||||
<div className="border-b-2 border-[#1E3A5F] bg-[#EFF6FF] p-5 text-center group-hover:bg-[#DBEAFE] transition-colors">
|
||||
<sample.icon className="w-10 h-10 mx-auto text-[#2563EB] mb-2" />
|
||||
<p className="text-xs text-[#64748B]">{sample.docNo}</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl text-[#0F172A] text-center border-b border-[#E2E8F0] pb-2 mb-3">
|
||||
{sample.title}
|
||||
</h3>
|
||||
<p className="text-xs text-[#64748B] text-center mb-1">{sample.titleEng}</p>
|
||||
<p className="text-[#64748B] text-sm leading-relaxed text-center mt-3">
|
||||
{sample.description}
|
||||
</p>
|
||||
<div className="mt-6 text-center">
|
||||
<span className="inline-block border-2 border-[#2563EB] px-4 py-2 text-sm text-[#2563EB] hover:bg-[#2563EB] hover:text-white transition-colors">
|
||||
샘플 보기 →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center bg-white border-2 border-[#1E3A5F] p-4">
|
||||
<p className="text-[#64748B] text-xs">A4 인쇄 최적화 · WACE PLM 리포트 디자이너 v2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import DocumentLayout from "../components/DocumentLayout";
|
||||
import StatusBadge from "../components/StatusBadge";
|
||||
|
||||
const ITEMS = [
|
||||
{ no: 1, code: "P-001", name: "원자재 A", spec: "KS-100", unit: "KG", qty: 500, price: 5000 },
|
||||
{ no: 2, code: "P-002", name: "부품 B", spec: "ISO-200", unit: "EA", qty: 1000, price: 3000 },
|
||||
{ no: 3, code: "P-003", name: "자재 C", spec: "JIS-300", unit: "M", qty: 200, price: 8000 },
|
||||
];
|
||||
|
||||
const EMPTY_ROWS = 10;
|
||||
|
||||
// ── 발주처 정보 테이블 행 ─────────────────────────────────────────────────────
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
children,
|
||||
highlight,
|
||||
colSpan,
|
||||
}: {
|
||||
label: string;
|
||||
children?: React.ReactNode;
|
||||
highlight?: boolean;
|
||||
colSpan?: number;
|
||||
}) {
|
||||
const labelBg = highlight ? "bg-yellow-100" : "bg-[#EFF6FF]";
|
||||
return (
|
||||
<>
|
||||
<td className={`py-2 px-3 ${labelBg} border-r-2 border-[#1E3A5F] text-[#0F172A] w-28`}>{label}</td>
|
||||
<td className={`py-2 px-3 text-[#64748B]`} colSpan={colSpan ?? 1}>
|
||||
{children}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PurchaseOrderPage() {
|
||||
const totalAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0);
|
||||
const tax = Math.round(totalAmount * 0.1);
|
||||
const grandTotal = totalAmount + tax;
|
||||
|
||||
return (
|
||||
<DocumentLayout title="발주서" docNumber="PO-2026-00789">
|
||||
<div className="p-10">
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="border-4 border-[#1E3A5F] mb-6">
|
||||
<div className="flex items-center justify-between bg-[#1E3A5F] text-white px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-3xl tracking-[0.5em]">발 주 서</h1>
|
||||
<p className="text-xs mt-1 tracking-wider">PURCHASE ORDER</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusBadge status="발주완료" size="md" />
|
||||
{/* 결재란 인라인 */}
|
||||
<div className="border-2 border-white">
|
||||
<div className="grid grid-cols-4 text-xs">
|
||||
{["담당", "부서장", "임원", "사장"].map((col, i) => (
|
||||
<div key={i} className={`px-3 py-2 text-center ${i < 3 ? "border-r-2 border-white" : ""}`}>
|
||||
{col}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 문서 번호 ── */}
|
||||
<div className="mb-6 text-right">
|
||||
<div className="inline-block border-2 border-[#1E3A5F] px-6 py-2 bg-[#F8F9FC]">
|
||||
<div className="text-sm text-[#64748B]">발주번호: PO-2026-00789</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 발주처 정보 카드 ── */}
|
||||
<div className="mb-6 border-2 border-[#1E3A5F]">
|
||||
<div className="bg-[#EFF6FF] border-b-2 border-[#1E3A5F] px-4 py-2 text-sm text-[#0F172A]">
|
||||
▣ 발주처 정보
|
||||
</div>
|
||||
<div className="p-4 bg-white">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<tbody>
|
||||
<tr className="border-b border-[#E2E8F0]">
|
||||
<InfoRow label="수 신 처" />
|
||||
<td className="py-2 px-3 border-r border-[#E2E8F0] w-1/3" />
|
||||
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] w-20 text-[#0F172A]">TEL</td>
|
||||
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] w-20 text-[#0F172A]">담당</td>
|
||||
<td className="py-2 px-3" />
|
||||
</tr>
|
||||
<tr className="border-b border-[#E2E8F0]">
|
||||
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F]" />
|
||||
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
|
||||
<td className="py-2 px-3" colSpan={3} />
|
||||
</tr>
|
||||
<tr className="border-b border-[#E2E8F0]">
|
||||
<InfoRow label="발 신 처" />
|
||||
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">TEL</td>
|
||||
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">담당</td>
|
||||
<td className="py-2 px-3" />
|
||||
</tr>
|
||||
<tr className="border-b-2 border-[#1E3A5F]">
|
||||
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F]" />
|
||||
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
|
||||
<td className="py-2 px-3" colSpan={3} />
|
||||
</tr>
|
||||
<tr className="border-b border-[#E2E8F0]">
|
||||
<InfoRow label="납품일정" highlight />
|
||||
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">TEL</td>
|
||||
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">현장담당</td>
|
||||
<td className="py-2 px-3" />
|
||||
</tr>
|
||||
<tr className="border-b border-[#E2E8F0]">
|
||||
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F]" />
|
||||
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
|
||||
<td className="py-2 px-3" colSpan={3} />
|
||||
</tr>
|
||||
<tr className="border-b-2 border-[#1E3A5F]">
|
||||
<InfoRow label="납 기 일" highlight />
|
||||
<td className="py-2 px-3 text-[#64748B]" colSpan={3}>20___년 ___월 ___일</td>
|
||||
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A] w-20">인도조건</td>
|
||||
<td className="py-2 px-3" />
|
||||
</tr>
|
||||
<tr className="border-b border-[#E2E8F0]">
|
||||
<InfoRow label="대금결제조건" highlight colSpan={5} />
|
||||
</tr>
|
||||
<tr>
|
||||
<InfoRow label="검 수 방 법" highlight colSpan={5} />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 발주 내역 테이블 ── */}
|
||||
<div className="mb-6">
|
||||
<div className="bg-[#EFF6FF] border-2 border-[#1E3A5F] border-b-0 px-4 py-2 text-sm text-[#0F172A]">
|
||||
▣ 발주 내역
|
||||
</div>
|
||||
<div className="border-2 border-[#1E3A5F]">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-[#1E3A5F] text-white">
|
||||
{["NO", "품 명", "규격", "단위", "수량", "단가", "금액", "비고"].map((h, i) => (
|
||||
<th key={i} className={`px-3 py-3 text-center ${i < 7 ? "border-r-2 border-white" : ""} ${i === 0 ? "w-12" : ""} ${i === 3 || i === 4 ? "w-16" : ""} ${i === 7 ? "w-20" : ""}`}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ITEMS.map((item, idx) => (
|
||||
<tr key={item.no} className={`border-b border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.no}</td>
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.name}</td>
|
||||
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.spec}</td>
|
||||
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.unit}</td>
|
||||
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.qty.toLocaleString()}</td>
|
||||
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.price.toLocaleString()}</td>
|
||||
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{(item.qty * item.price).toLocaleString()}</td>
|
||||
<td className="px-3 py-3 text-center" />
|
||||
</tr>
|
||||
))}
|
||||
{Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
|
||||
<tr key={`e${idx}`} className={`border-b border-[#E2E8F0] ${(ITEMS.length + idx) % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||
<td className="px-3 py-3 text-center border-r border-[#E2E8F0] text-[#CBD5E1]">{ITEMS.length + idx + 1}</td>
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0] h-8" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3" />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 금액 요약 ── */}
|
||||
<div className="border-2 border-[#1E3A5F] mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="px-4 py-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-center w-32 text-[#0F172A]">공급가액</td>
|
||||
<td className="px-4 py-3 text-right border-r-2 border-[#1E3A5F] text-[#0F172A]">₩ {totalAmount.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-center w-32 text-[#0F172A]">부가세액</td>
|
||||
<td className="px-4 py-3 text-right border-r-2 border-[#1E3A5F] text-[#0F172A]">₩ {tax.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 bg-[#1E3A5F] text-white text-center w-32">합계금액</td>
|
||||
<td className="px-4 py-3 text-right bg-[#1E3A5F] text-white">₩ {grandTotal.toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ── 안내문 ── */}
|
||||
<div className="border-2 border-[#1E3A5F] p-4 text-center mb-6 bg-[#F8F9FC]">
|
||||
<p className="text-sm text-[#0F172A]">상기 자재를 발주하오니 납기를 준수하여 인도 바랍니다.</p>
|
||||
</div>
|
||||
|
||||
{/* ── 푸터 ── */}
|
||||
<div className="text-xs text-[#64748B] border-t-2 border-[#1E3A5F] pt-3 flex justify-between">
|
||||
<div>양식번호: PO-001 (Rev.2)</div>
|
||||
<div>문의: TEL 000-0000-0000 / FAX 000-0000-0000</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import DocumentLayout from "../components/DocumentLayout";
|
||||
|
||||
const ITEMS = [
|
||||
{ no: 1, name: "프리미엄 제품 A", spec: "Model-X1000", qty: 50, unit: "EA", price: 150000 },
|
||||
{ no: 2, name: "스탠다드 제품 B", spec: "Model-S500", qty: 100, unit: "EA", price: 80000 },
|
||||
{ no: 3, name: "베이직 제품 C", spec: "Model-B200", qty: 200, unit: "EA", price: 45000 },
|
||||
];
|
||||
|
||||
const EMPTY_ROWS = 5;
|
||||
|
||||
function ApprovalSection({ columns }: { columns: string[] }) {
|
||||
return (
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="border-2 border-[#1E3A5F]">
|
||||
<div className="grid text-center text-xs" style={{ gridTemplateColumns: `repeat(${columns.length}, 80px)` }}>
|
||||
{columns.map((col, i) => (
|
||||
<div key={i} className={`p-3 ${i < columns.length - 1 ? "border-r-2 border-[#1E3A5F]" : ""}`}>
|
||||
<div className="text-[#64748B] mb-6 pb-2 border-b border-[#E2E8F0]">{col}</div>
|
||||
<div className="h-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QuotationPage() {
|
||||
const supplyAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0);
|
||||
const tax = Math.round(supplyAmount * 0.1);
|
||||
const total = supplyAmount + tax;
|
||||
|
||||
return (
|
||||
<DocumentLayout title="견적서" docNumber="QT-2026-01234">
|
||||
<div className="p-10">
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="border-4 border-[#1E3A5F] mb-6">
|
||||
<div className="bg-[#1E3A5F] text-white px-6 py-4 text-center">
|
||||
<h1 className="text-4xl tracking-[0.5em]">견 적 서</h1>
|
||||
<p className="text-xs mt-2 tracking-wider">QUOTATION</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 문서 번호 ── */}
|
||||
<div className="mb-6 text-right">
|
||||
<div className="inline-block border-2 border-[#1E3A5F] px-6 py-2 bg-[#F8F9FC]">
|
||||
<div className="text-sm text-[#64748B]">문서번호: QT-2026-01234</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 날짜 / 수신 ── */}
|
||||
<div className="mb-6 text-right">
|
||||
<div className="inline-block text-sm">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="border-b-2 border-[#2563EB] px-8 pb-1">2026</span>
|
||||
<span className="text-[#64748B]">년</span>
|
||||
<span className="border-b-2 border-[#2563EB] px-6 pb-1">03</span>
|
||||
<span className="text-[#64748B]">월</span>
|
||||
<span className="border-b-2 border-[#2563EB] px-6 pb-1">09</span>
|
||||
<span className="text-[#64748B]">일</span>
|
||||
</div>
|
||||
<div className="border-b-2 border-[#1E3A5F] pb-2 text-lg">
|
||||
<span className="mr-8 text-[#0F172A]">(주) ○○○○</span>
|
||||
<span className="text-[#0F172A]">귀하</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 견적명 / 공급자 (2열 카드) ── */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="border-2 border-[#1E3A5F]">
|
||||
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center border-b-2 border-[#1E3A5F]">
|
||||
견 적 명
|
||||
</div>
|
||||
<div className="p-4 bg-white h-16" />
|
||||
</div>
|
||||
<div className="border-2 border-[#1E3A5F]">
|
||||
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center border-b-2 border-[#1E3A5F]">
|
||||
공 급 자
|
||||
</div>
|
||||
<div className="p-3 bg-white text-xs space-y-1">
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-[#E2E8F0] pb-1">
|
||||
<span className="text-[#64748B]">등록번호</span>
|
||||
<span className="text-[#64748B]">상호(법인명) / 성명</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-[#E2E8F0] pb-1">
|
||||
<span className="text-[#64748B]">업태 / 업종</span>
|
||||
<span className="text-[#64748B]">주소</span>
|
||||
</div>
|
||||
<div className="border-b border-[#E2E8F0] pb-1 text-[#64748B]">
|
||||
전화번호 팩스
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 합계금액 ── */}
|
||||
<div className="border-2 border-[#1E3A5F] mb-6">
|
||||
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center">합 계 금 액</div>
|
||||
<div className="p-4 bg-white text-center text-2xl border-t-2 border-[#1E3A5F] text-[#2563EB]">
|
||||
₩ {total.toLocaleString()} 원정
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 품목 테이블 ── */}
|
||||
<div className="mb-6 border-2 border-[#1E3A5F]">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-[#1E3A5F] text-white">
|
||||
{["품번", "품명", "규격", "수량", "단가", "공급가액", "세액", "비고"].map((h, i) => (
|
||||
<th key={i} className={`px-3 py-3 text-center ${i < 7 ? "border-r-2 border-white" : ""}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ITEMS.map((item, idx) => {
|
||||
const amount = item.qty * item.price;
|
||||
const itemTax = Math.round(amount * 0.1);
|
||||
return (
|
||||
<tr key={item.no} className={`border-t-2 border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||
<td className="px-2 py-3 text-center border-r border-[#E2E8F0]">{item.no}</td>
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.name}</td>
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.spec}</td>
|
||||
<td className="px-2 py-3 text-right border-r border-[#E2E8F0]">{item.qty.toLocaleString()}</td>
|
||||
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.price.toLocaleString()}</td>
|
||||
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{amount.toLocaleString()}</td>
|
||||
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{itemTax.toLocaleString()}</td>
|
||||
<td className="px-3 py-3 text-center" />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
|
||||
<tr key={`e${idx}`} className={`border-t border-[#E2E8F0] ${(ITEMS.length + idx) % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||
<td className="px-2 py-3 text-center border-r border-[#E2E8F0] text-[#CBD5E1]">{ITEMS.length + idx + 1}</td>
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0] h-10" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-2 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3" />
|
||||
</tr>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<tr className="border-t-2 border-[#1E3A5F] bg-[#EFF6FF]">
|
||||
<td colSpan={3} className="px-4 py-3 text-center border-r-2 border-[#1E3A5F] text-[#0F172A]">합 계</td>
|
||||
<td className="px-2 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||
<td className="px-3 py-3 text-right border-r border-[#E2E8F0] text-[#2563EB]">{supplyAmount.toLocaleString()}</td>
|
||||
<td className="px-3 py-3 text-right border-r border-[#E2E8F0] text-[#2563EB]">{tax.toLocaleString()}</td>
|
||||
<td className="px-3 py-3" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ── 금액 요약 (우측 정렬) ── */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="border-2 border-[#1E3A5F] min-w-[300px]">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr className="border-b-2 border-[#E2E8F0]">
|
||||
<td className="px-4 py-2 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">공급가액</td>
|
||||
<td className="px-4 py-2 text-right text-[#0F172A]">₩ {supplyAmount.toLocaleString()}</td>
|
||||
</tr>
|
||||
<tr className="border-b-2 border-[#1E3A5F]">
|
||||
<td className="px-4 py-2 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">부가세 (10%)</td>
|
||||
<td className="px-4 py-2 text-right text-[#0F172A]">₩ {tax.toLocaleString()}</td>
|
||||
</tr>
|
||||
<tr className="bg-[#1E3A5F] text-white">
|
||||
<td className="px-4 py-2 border-r-2 border-white">합계금액</td>
|
||||
<td className="px-4 py-2 text-right">₩ {total.toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 안내문 ── */}
|
||||
<div className="border-2 border-[#1E3A5F] p-4 mb-6 bg-[#F8F9FC]">
|
||||
<p className="text-sm text-[#0F172A] mb-1">위와 같이 견적합니다.</p>
|
||||
<p className="text-sm text-[#0F172A] mb-1">상기 견적서의 품목과 금액을 확인해 주시기 바랍니다.</p>
|
||||
<p className="text-sm text-[#0F172A]">감사합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* ── 결재란 ── */}
|
||||
<ApprovalSection columns={["담당", "검토", "승인", "대표"]} />
|
||||
|
||||
{/* ── 푸터 ── */}
|
||||
<div className="text-xs text-[#64748B] border-t-2 border-[#1E3A5F] pt-3">
|
||||
<div className="flex justify-between mb-1">
|
||||
<div>본 견적서의 유효기간은 견적일로부터 7일입니다.</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div>결제계좌: (예금주: )</div>
|
||||
<div>문의: TEL 000-0000-0000 / FAX 000-0000-0000</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface UseUnsavedChangesGuardOptions {
|
||||
hasChanges: () => boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface UnsavedChangesGuard {
|
||||
handleOpenChange: (open: boolean) => void;
|
||||
tryClose: () => void;
|
||||
doClose: () => void;
|
||||
showDialog: boolean;
|
||||
confirmClose: () => void;
|
||||
cancelClose: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function useUnsavedChangesGuard({
|
||||
hasChanges,
|
||||
onClose,
|
||||
title = "변경사항이 있습니다",
|
||||
description = "저장하지 않은 변경사항이 사라집니다. 정말 닫으시겠습니까?",
|
||||
}: UseUnsavedChangesGuardOptions): UnsavedChangesGuard {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const hasChangesRef = useRef(hasChanges);
|
||||
hasChangesRef.current = hasChanges;
|
||||
|
||||
const attemptClose = useCallback(() => {
|
||||
if (hasChangesRef.current()) {
|
||||
setShowDialog(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
attemptClose();
|
||||
}
|
||||
},
|
||||
[attemptClose],
|
||||
);
|
||||
|
||||
const confirmClose = useCallback(() => {
|
||||
setShowDialog(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const cancelClose = useCallback(() => {
|
||||
setShowDialog(false);
|
||||
}, []);
|
||||
|
||||
const doClose = useCallback(() => {
|
||||
setShowDialog(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return {
|
||||
handleOpenChange,
|
||||
tryClose: attemptClose,
|
||||
doClose,
|
||||
showDialog,
|
||||
confirmClose,
|
||||
cancelClose,
|
||||
title,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
interface UnsavedChangesDialogProps {
|
||||
guard: UnsavedChangesGuard;
|
||||
}
|
||||
|
||||
export function UnsavedChangesDialog({ guard }: UnsavedChangesDialogProps) {
|
||||
return (
|
||||
<AlertDialog open={guard.showDialog} onOpenChange={(open) => !open && guard.cancelClose()}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[420px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">
|
||||
{guard.title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
{guard.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={guard.confirmClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
닫기
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -360,7 +360,7 @@ function DynamicAdminLoader({ url, params }: { url: string; params?: Record<stri
|
||||
|
||||
if (failed) return <AdminPageFallback url={url} />;
|
||||
if (!Component) return <LoadingFallback />;
|
||||
if (params) return <Component params={Promise.resolve(params)} />;
|
||||
if (params) return <Component params={Promise.resolve(params)} adminParams={params} />;
|
||||
return <Component />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { ReportMaster } from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface ReportCopyModalProps {
|
||||
report: ReportMaster;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ReportCopyModal({ report, onClose, onSuccess }: ReportCopyModalProps) {
|
||||
const [newName, setNewName] = useState(`${report.report_name_kor} (복사)`);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const initialNameRef = useRef(`${report.report_name_kor} (복사)`);
|
||||
const { toast } = useToast();
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges: () => !isCopying && newName !== initialNameRef.current,
|
||||
onClose,
|
||||
title: "입력된 내용이 있습니다",
|
||||
description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?",
|
||||
});
|
||||
|
||||
const handleCopy = async () => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) {
|
||||
toast({ title: "오류", description: "리포트 이름을 입력해주세요.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const response = await reportApi.copyReport(report.report_id, trimmed);
|
||||
if (response.success) {
|
||||
toast({ title: "성공", description: "리포트가 복사되었습니다." });
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg">리포트 복사</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-3">
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="copy-name" className="text-base">
|
||||
새 리포트 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="copy-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !isCopying && handleCopy()}
|
||||
placeholder="리포트 이름 입력"
|
||||
className="h-11 text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={guard.tryClose} disabled={isCopying} className="text-base">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleCopy} disabled={isCopying} className="text-base">
|
||||
{isCopying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
복사 중...
|
||||
</>
|
||||
) : (
|
||||
"복사"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Loader2, LayoutTemplate, Check, ChevronsUpDown, Plus, Tag } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { CreateReportRequest, ReportTemplate } from "@/types/report";
|
||||
import { REPORT_TYPE_OPTIONS, getTypeIcon, getTypeLabel } from "@/lib/reportTypeColors";
|
||||
import { ReportTemplate } from "@/types/report";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ReportCreateModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -24,59 +48,137 @@ interface ReportCreateModalProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const TEMPLATE_NONE = "__none__";
|
||||
|
||||
export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateModalProps) {
|
||||
const [formData, setFormData] = useState<CreateReportRequest>({
|
||||
reportNameKor: "",
|
||||
reportNameEng: "",
|
||||
templateId: undefined,
|
||||
reportType: "BASIC",
|
||||
description: "",
|
||||
});
|
||||
const [templates, setTemplates] = useState<ReportTemplate[]>([]);
|
||||
const router = useRouter();
|
||||
const [reportName, setReportName] = useState("");
|
||||
const [reportType, setReportType] = useState("");
|
||||
const [customCategory, setCustomCategory] = useState("");
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState(TEMPLATE_NONE);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
|
||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||
const [systemTemplates, setSystemTemplates] = useState<ReportTemplate[]>([]);
|
||||
const [customTemplates, setCustomTemplates] = useState<ReportTemplate[]>([]);
|
||||
const [existingCategories, setExistingCategories] = useState<string[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 템플릿 목록 불러오기
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchTemplates();
|
||||
}
|
||||
if (!isOpen) return;
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const response = await reportApi.getTemplates();
|
||||
if (response.success && response.data) {
|
||||
setSystemTemplates(response.data.system || []);
|
||||
setCustomTemplates(response.data.custom || []);
|
||||
}
|
||||
} catch {
|
||||
// 템플릿 로딩 실패 시 빈 목록으로 진행
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setIsLoadingCategories(true);
|
||||
try {
|
||||
const response = await reportApi.getCategories();
|
||||
if (response.success && response.data) {
|
||||
setExistingCategories(response.data);
|
||||
}
|
||||
} catch {
|
||||
// 카테고리 로딩 실패 시 빈 목록으로 진행
|
||||
} finally {
|
||||
setIsLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTemplates();
|
||||
fetchCategories();
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const response = await reportApi.getTemplates();
|
||||
if (response.success && response.data) {
|
||||
setTemplates([...response.data.system, ...response.data.custom]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "템플릿 목록을 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
const hasTemplates = useMemo(
|
||||
() => systemTemplates.length > 0 || customTemplates.length > 0,
|
||||
[systemTemplates, customTemplates],
|
||||
);
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const defaultTypes = REPORT_TYPE_OPTIONS.map((opt) => opt.value);
|
||||
const merged = new Set([...defaultTypes, ...existingCategories]);
|
||||
return Array.from(merged).sort();
|
||||
}, [existingCategories]);
|
||||
|
||||
const effectiveCategory = useMemo(() => {
|
||||
return customCategory.trim() || reportType;
|
||||
}, [customCategory, reportType]);
|
||||
|
||||
const categoryDisplayLabel = useMemo(() => {
|
||||
if (customCategory.trim()) return customCategory.trim();
|
||||
if (reportType) return getTypeLabel(reportType);
|
||||
return "";
|
||||
}, [customCategory, reportType]);
|
||||
|
||||
const hasInputData = useCallback(() => {
|
||||
return reportName.trim() !== "" ||
|
||||
reportType !== "" ||
|
||||
customCategory.trim() !== "" ||
|
||||
description.trim() !== "" ||
|
||||
selectedTemplateId !== TEMPLATE_NONE;
|
||||
}, [reportName, reportType, customCategory, description, selectedTemplateId]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setReportName("");
|
||||
setReportType("");
|
||||
setCustomCategory("");
|
||||
setDescription("");
|
||||
setSelectedTemplateId(TEMPLATE_NONE);
|
||||
}, []);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges: () => !isLoading && hasInputData(),
|
||||
onClose: () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
},
|
||||
title: "입력된 내용이 있습니다",
|
||||
description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?",
|
||||
});
|
||||
|
||||
const handleCategorySelect = (value: string) => {
|
||||
setReportType(value);
|
||||
setCustomCategory("");
|
||||
setCategoryOpen(false);
|
||||
};
|
||||
|
||||
const handleCustomCategoryAdd = () => {
|
||||
const trimmed = customCategory.trim();
|
||||
if (trimmed) {
|
||||
setReportType("");
|
||||
setCategoryOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 유효성 검증
|
||||
if (!formData.reportNameKor.trim()) {
|
||||
const trimmed = reportName.trim();
|
||||
if (!trimmed) {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "리포트명(한글)을 입력해주세요.",
|
||||
description: "리포트명을 입력해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.reportType) {
|
||||
const finalCategory = effectiveCategory;
|
||||
if (!finalCategory) {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "리포트 타입을 선택해주세요.",
|
||||
description: "카테고리를 선택하거나 입력해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -84,144 +186,223 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await reportApi.createReport(formData);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "리포트가 생성되었습니다.",
|
||||
});
|
||||
handleClose();
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 생성에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
const response = await reportApi.createReport({
|
||||
reportNameKor: trimmed,
|
||||
reportType: finalCategory,
|
||||
description: description.trim() || undefined,
|
||||
templateId: selectedTemplateId !== TEMPLATE_NONE ? selectedTemplateId : undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
toast({ title: "성공", description: "리포트가 생성되었습니다." });
|
||||
guard.doClose();
|
||||
onSuccess();
|
||||
router.push(`/admin/screenMng/reportList/designer/${response.data.reportId}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : "리포트 생성에 실패했습니다.";
|
||||
toast({ title: "오류", description: msg, variant: "destructive" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFormData({
|
||||
reportNameKor: "",
|
||||
reportNameEng: "",
|
||||
templateId: undefined,
|
||||
reportType: "BASIC",
|
||||
description: "",
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 리포트 생성</DialogTitle>
|
||||
<DialogDescription>새로운 리포트를 생성합니다. 필수 항목을 입력해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg">새 리포트 생성</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
리포트명과 카테고리를 입력한 후 디자이너에서 상세 설계를 진행합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 리포트명 (한글) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportNameKor">
|
||||
리포트명 (한글) <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="reportNameKor"
|
||||
placeholder="예: 발주서"
|
||||
value={formData.reportNameKor}
|
||||
onChange={(e) => setFormData({ ...formData, reportNameKor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-5 py-3">
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="create-report-name" className="text-base">
|
||||
리포트명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="create-report-name"
|
||||
placeholder="예: 발주서"
|
||||
value={reportName}
|
||||
onChange={(e) => setReportName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !isLoading && handleSubmit()}
|
||||
className="h-11 text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 리포트명 (영문) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportNameEng">리포트명 (영문)</Label>
|
||||
<Input
|
||||
id="reportNameEng"
|
||||
placeholder="예: Purchase Order"
|
||||
value={formData.reportNameEng}
|
||||
onChange={(e) => setFormData({ ...formData, reportNameEng: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="create-report-type" className="text-base">
|
||||
카테고리 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Popover open={categoryOpen} onOpenChange={setCategoryOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={categoryOpen}
|
||||
className="h-11 w-full justify-between text-base font-normal"
|
||||
disabled={isLoadingCategories}
|
||||
>
|
||||
{isLoadingCategories ? (
|
||||
<span className="text-muted-foreground">카테고리 불러오는 중...</span>
|
||||
) : categoryDisplayLabel ? (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Tag className="h-4 w-4 shrink-0 text-gray-500" />
|
||||
<span>{categoryDisplayLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">카테고리 선택 또는 입력</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="카테고리 검색 또는 새로 입력..."
|
||||
value={customCategory}
|
||||
onValueChange={setCustomCategory}
|
||||
className="text-base"
|
||||
/>
|
||||
<CommandList>
|
||||
{customCategory.trim() && !allCategories.includes(customCategory.trim()) && (
|
||||
<CommandGroup heading="새 카테고리 추가">
|
||||
<CommandItem
|
||||
value={`__new__${customCategory.trim()}`}
|
||||
onSelect={handleCustomCategoryAdd}
|
||||
className="text-base"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
"<span className="font-medium">{customCategory.trim()}</span>" 새로 추가
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty className="py-3 text-center text-sm text-muted-foreground">
|
||||
일치하는 카테고리가 없습니다.
|
||||
<br />
|
||||
위에 입력한 값으로 새 카테고리를 추가할 수 있습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading="기존 카테고리">
|
||||
{allCategories.map((cat) => {
|
||||
const Icon = getTypeIcon(cat);
|
||||
const label = getTypeLabel(cat);
|
||||
return (
|
||||
<CommandItem
|
||||
key={cat}
|
||||
value={cat}
|
||||
onSelect={() => handleCategorySelect(cat)}
|
||||
className="text-base"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
reportType === cat && !customCategory.trim() ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Icon className="mr-2 h-4 w-4 shrink-0 text-gray-500" />
|
||||
<span>{label}</span>
|
||||
{cat !== label && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">({cat})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
기존 카테고리를 선택하거나 새로운 카테고리를 직접 입력할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="templateId">템플릿</Label>
|
||||
<Select
|
||||
value={formData.templateId || "none"}
|
||||
onValueChange={(value) => setFormData({ ...formData, templateId: value === "none" ? undefined : value })}
|
||||
disabled={isLoadingTemplates}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="템플릿 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">템플릿 없음</SelectItem>
|
||||
{templates.map((template) => (
|
||||
<SelectItem key={template.template_id} value={template.template_id}>
|
||||
{template.template_name_kor}
|
||||
{template.is_system === "Y" && " (시스템)"}
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="create-report-template" className="text-base">
|
||||
템플릿
|
||||
</Label>
|
||||
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId} disabled={isLoadingTemplates}>
|
||||
<SelectTrigger className="h-11 text-base">
|
||||
<SelectValue placeholder={isLoadingTemplates ? "템플릿 불러오는 중..." : "템플릿 선택 (선택사항)"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={TEMPLATE_NONE} className="text-base">
|
||||
<span className="text-gray-500">템플릿 없이 시작</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{systemTemplates.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-400">시스템 템플릿</div>
|
||||
{systemTemplates.map((t) => (
|
||||
<SelectItem key={t.template_id} value={t.template_id} className="text-base">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LayoutTemplate className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span>{t.template_name_kor}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{customTemplates.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-400">사용자 템플릿</div>
|
||||
{customTemplates.map((t) => (
|
||||
<SelectItem key={t.template_id} value={t.template_id} className="text-base">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LayoutTemplate className="h-4 w-4 shrink-0 text-green-500" />
|
||||
<span>{t.template_name_kor}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!isLoadingTemplates && !hasTemplates && (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">등록된 템플릿이 없습니다</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">템플릿을 선택하면 레이아웃이 자동으로 적용됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="create-report-desc" className="text-base">
|
||||
설명
|
||||
</Label>
|
||||
<Textarea
|
||||
id="create-report-desc"
|
||||
placeholder="리포트에 대한 간단한 설명을 입력하세요"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="min-h-[80px] resize-none text-base"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리포트 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportType">
|
||||
리포트 타입 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.reportType}
|
||||
onValueChange={(value) => setFormData({ ...formData, reportType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ORDER">발주서</SelectItem>
|
||||
<SelectItem value="INVOICE">청구서</SelectItem>
|
||||
<SelectItem value="STATEMENT">거래명세서</SelectItem>
|
||||
<SelectItem value="RECEIPT">영수증</SelectItem>
|
||||
<SelectItem value="BASIC">기본</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={guard.tryClose} disabled={isLoading} className="text-base">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading} className="text-base">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="리포트에 대한 설명을 입력하세요"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileDown, FileText, Loader2 } from "lucide-react";
|
||||
import { ComponentConfig, ReportDetail, ReportMaster, ReportPage, ReportQuery, WatermarkConfig } from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import {
|
||||
TextRenderer,
|
||||
TableRenderer,
|
||||
ImageRenderer,
|
||||
DividerRenderer,
|
||||
SignatureRenderer,
|
||||
StampRenderer,
|
||||
PageNumberRenderer,
|
||||
CardRenderer,
|
||||
CalculationRenderer,
|
||||
BarcodeCanvasRenderer,
|
||||
CheckboxRenderer,
|
||||
} from "./designer/renderers";
|
||||
import { MM_TO_PX } from "@/lib/report/constants";
|
||||
|
||||
interface QueryResult {
|
||||
queryId: string;
|
||||
fields: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
interface ReportListPreviewModalProps {
|
||||
report: ReportMaster | null;
|
||||
onClose: () => void;
|
||||
/** 컨텍스트에서 자동 주입할 쿼리 파라미터 (formData 기반) */
|
||||
contextParams?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ReportListPreviewModal({ report, onClose, contextParams }: ReportListPreviewModalProps) {
|
||||
const [detail, setDetail] = useState<ReportDetail | null>(null);
|
||||
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const getQueryResult = useCallback(
|
||||
(queryId: string): QueryResult | null => {
|
||||
return queryResults.find((r) => r.queryId === queryId) || null;
|
||||
},
|
||||
[queryResults],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!report) {
|
||||
setDetail(null);
|
||||
setQueryResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await reportApi.getReportById(report.report_id);
|
||||
if (cancelled || !res.success) return;
|
||||
|
||||
setDetail(res.data);
|
||||
|
||||
// 쿼리 자동 실행
|
||||
const queries: ReportQuery[] = res.data.queries ?? [];
|
||||
if (queries.length === 0) return;
|
||||
|
||||
// contextParams를 $1, $2 ... 형식으로 매핑 (휴리스틱)
|
||||
const contextEntries = Object.values(contextParams ?? {});
|
||||
const buildParams = (parameters: string[]): Record<string, unknown> => {
|
||||
const result: Record<string, unknown> = {};
|
||||
parameters.forEach((param, idx) => {
|
||||
result[param] = contextEntries[idx] ?? contextParams?.[param] ?? null;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const results: QueryResult[] = [];
|
||||
for (const q of queries) {
|
||||
try {
|
||||
const params = buildParams(q.parameters ?? []);
|
||||
const execRes = await reportApi.executeQuery(
|
||||
report.report_id,
|
||||
q.query_id,
|
||||
params,
|
||||
q.sql_query,
|
||||
q.external_connection_id,
|
||||
);
|
||||
if (execRes.success && execRes.data) {
|
||||
results.push({
|
||||
queryId: q.query_id,
|
||||
fields: execRes.data.fields,
|
||||
rows: execRes.data.rows,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 개별 쿼리 실패는 무시
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setQueryResults(results);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
toast({ title: "오류", description: "리포트를 불러올 수 없습니다.", variant: "destructive" });
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [report?.report_id, contextParams]);
|
||||
|
||||
const { pages, watermark } = useMemo(() => {
|
||||
const empty = { pages: [] as ReportPage[], watermark: undefined as WatermarkConfig | undefined };
|
||||
if (!detail?.layout) return empty;
|
||||
|
||||
const layout = detail.layout as unknown as Record<string, unknown>;
|
||||
|
||||
let config: Record<string, unknown> | null = null;
|
||||
|
||||
let raw: unknown = layout.components;
|
||||
|
||||
while (typeof raw === "string") {
|
||||
try {
|
||||
raw = JSON.parse(raw);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
config = raw as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (!config && Array.isArray(layout.pages)) {
|
||||
config = layout;
|
||||
}
|
||||
|
||||
if (!config) return empty;
|
||||
|
||||
const foundPages = Array.isArray(config.pages) ? (config.pages as ReportPage[]) : [];
|
||||
const foundWatermark = config.watermark as WatermarkConfig | undefined;
|
||||
|
||||
return { pages: foundPages, watermark: foundWatermark };
|
||||
}, [detail?.layout]);
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
if (!previewRef.current || pages.length === 0) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const [{ jsPDF }, html2canvas] = await Promise.all([
|
||||
import("jspdf"),
|
||||
import("html2canvas").then((m) => m.default),
|
||||
]);
|
||||
|
||||
const pageEls = previewRef.current.querySelectorAll<HTMLElement>("[data-list-preview-page]");
|
||||
if (pageEls.length === 0) return;
|
||||
|
||||
const firstPage = pages[0];
|
||||
const doc = new jsPDF({
|
||||
orientation: firstPage.orientation === "landscape" ? "l" : "p",
|
||||
unit: "mm",
|
||||
format: [firstPage.width, firstPage.height],
|
||||
});
|
||||
|
||||
for (let i = 0; i < pageEls.length; i++) {
|
||||
const canvas = await html2canvas(pageEls[i], {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
|
||||
if (i > 0) {
|
||||
const p = pages[i] ?? firstPage;
|
||||
doc.addPage([p.width, p.height], p.orientation === "landscape" ? "l" : "p");
|
||||
}
|
||||
|
||||
const p = pages[i] ?? firstPage;
|
||||
doc.addImage(canvas.toDataURL("image/jpeg", 0.92), "JPEG", 0, 0, p.width, p.height);
|
||||
}
|
||||
|
||||
doc.save(`${report?.report_name_kor ?? "report"}.pdf`);
|
||||
} catch {
|
||||
toast({ title: "오류", description: "PDF 다운로드에 실패했습니다.", variant: "destructive" });
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || pages.length === 0) return;
|
||||
|
||||
const calculateScale = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const firstPage = pages[0];
|
||||
const pageWidthPx = firstPage.width * MM_TO_PX;
|
||||
const pageHeightPx = firstPage.height * MM_TO_PX;
|
||||
|
||||
const availableWidth = container.clientWidth - 48;
|
||||
const availableHeight = container.clientHeight - 48;
|
||||
|
||||
const scaleX = availableWidth / pageWidthPx;
|
||||
const scaleY = availableHeight / pageHeightPx;
|
||||
setScale(Math.min(scaleX, scaleY, 1));
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(calculateScale);
|
||||
observer.observe(containerRef.current);
|
||||
calculateScale();
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [pages]);
|
||||
|
||||
return (
|
||||
<Dialog open={!!report} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 p-0"
|
||||
style={{ height: "85vh", width: "calc(85vh / 1.414)", maxWidth: "95vw" }}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-7 py-5">
|
||||
<DialogTitle className="flex items-center gap-2.5 text-lg">
|
||||
<FileText className="h-6 w-6 text-blue-600" />
|
||||
{report?.report_name_kor ?? "리포트 미리보기"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div ref={containerRef} className="min-h-0 flex-1 overflow-auto bg-gray-100">
|
||||
{isLoading ? (
|
||||
<div className="flex h-72 items-center justify-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : pages.length === 0 ? (
|
||||
<div className="flex h-72 flex-col items-center justify-center gap-3 text-gray-400">
|
||||
<FileText className="h-14 w-14 opacity-30" />
|
||||
<p className="text-base">{detail ? "저장된 레이아웃이 없습니다." : "데이터를 불러오는 중..."}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={previewRef} className="flex flex-col items-center p-6" style={{ gap: `${24 * scale}px` }}>
|
||||
{[...pages]
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page, pageIndex) => (
|
||||
<div
|
||||
key={page.page_id}
|
||||
style={{
|
||||
width: `${Math.ceil(page.width * MM_TO_PX * scale) + 1}px`,
|
||||
height: `${Math.ceil(page.height * MM_TO_PX * scale) + 1}px`,
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
width: `${page.width * MM_TO_PX}px`,
|
||||
height: `${page.height * MM_TO_PX}px`,
|
||||
}}
|
||||
>
|
||||
<PagePreview
|
||||
page={page}
|
||||
pageIndex={pageIndex}
|
||||
totalPages={pages.length}
|
||||
pages={pages}
|
||||
watermark={watermark}
|
||||
getQueryResult={getQueryResult}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t bg-white px-7 py-5">
|
||||
<Button variant="outline" onClick={onClose} className="text-base">
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isExporting || isLoading || pages.length === 0}
|
||||
className="gap-2 bg-blue-600 text-base text-white hover:bg-blue-700"
|
||||
>
|
||||
{isExporting ? <Loader2 className="h-5 w-5 animate-spin" /> : <FileDown className="h-5 w-5" />}
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WatermarkLayer({
|
||||
watermark,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: {
|
||||
watermark: WatermarkConfig;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: 0,
|
||||
};
|
||||
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
|
||||
const textOrImage =
|
||||
watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : watermark.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={watermark.imageUrl.startsWith("data:") ? watermark.imageUrl : getFullImageUrl(watermark.imageUrl)}
|
||||
alt=""
|
||||
style={{ maxWidth: "50%", maxHeight: "50%", objectFit: "contain" }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (watermark.style === "diagonal") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{textOrImage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (watermark.style === "center") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: watermark.opacity,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{textOrImage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (watermark.style === "tile") {
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
|
||||
const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignContent: "flex-start",
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows * cols }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{textOrImage}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function PagePreview({
|
||||
page,
|
||||
pageIndex,
|
||||
totalPages,
|
||||
pages,
|
||||
watermark,
|
||||
getQueryResult,
|
||||
}: {
|
||||
page: ReportPage;
|
||||
pageIndex: number;
|
||||
totalPages: number;
|
||||
pages: ReportPage[];
|
||||
watermark?: WatermarkConfig;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-list-preview-page={page.page_id}
|
||||
className="relative shadow-md"
|
||||
style={{
|
||||
width: `${page.width * MM_TO_PX}px`,
|
||||
height: `${page.height * MM_TO_PX}px`,
|
||||
backgroundColor: page.background_color || "#ffffff",
|
||||
flexShrink: 0,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{watermark?.enabled && <WatermarkLayer watermark={watermark} pageWidth={page.width} pageHeight={page.height} />}
|
||||
|
||||
{(page.components ?? [])
|
||||
.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0))
|
||||
.map((comp) => (
|
||||
<ComponentRenderer
|
||||
key={comp.id}
|
||||
comp={comp}
|
||||
pageIndex={pageIndex}
|
||||
totalPages={totalPages}
|
||||
pages={pages}
|
||||
getQueryResult={getQueryResult}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarcodePreviewRenderer({
|
||||
component,
|
||||
getQueryResult,
|
||||
}: {
|
||||
component: ComponentConfig;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
}) {
|
||||
return <BarcodeCanvasRenderer component={component} getQueryResult={getQueryResult} />;
|
||||
}
|
||||
|
||||
function ComponentRenderer({
|
||||
comp,
|
||||
pageIndex,
|
||||
totalPages,
|
||||
pages,
|
||||
getQueryResult,
|
||||
}: {
|
||||
comp: ComponentConfig;
|
||||
pageIndex: number;
|
||||
totalPages: number;
|
||||
pages: ReportPage[];
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
}) {
|
||||
const isDivider = comp.type === "divider";
|
||||
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${comp.x}px`,
|
||||
top: `${comp.y}px`,
|
||||
width: `${comp.width}px`,
|
||||
height: `${comp.height}px`,
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
zIndex: comp.zIndex ?? 0,
|
||||
backgroundColor: comp.backgroundColor || "transparent",
|
||||
...(comp.borderWidth
|
||||
? { borderWidth: `${comp.borderWidth}px`, borderColor: comp.borderColor || "#000", borderStyle: "solid" }
|
||||
: {}),
|
||||
...(comp.borderRadius ? { borderRadius: `${comp.borderRadius}px` } : {}),
|
||||
padding: isDivider
|
||||
? 0
|
||||
: comp.padding != null
|
||||
? typeof comp.padding === "number"
|
||||
? `${comp.padding}px`
|
||||
: comp.padding
|
||||
: "8px",
|
||||
};
|
||||
|
||||
const getComponentValue = (c: ComponentConfig): string => {
|
||||
if (c.queryId && c.fieldName) {
|
||||
const qr = getQueryResult(c.queryId);
|
||||
if (qr?.rows?.length) {
|
||||
const val = qr.rows[0][c.fieldName];
|
||||
if (val != null) return String(val);
|
||||
}
|
||||
return `{${c.fieldName}}`;
|
||||
}
|
||||
return c.defaultValue || "";
|
||||
};
|
||||
|
||||
const displayValue = getComponentValue(comp);
|
||||
|
||||
const sortedPages = [...pages].sort((a, b) => a.page_order - b.page_order);
|
||||
const currentPageId = sortedPages[pageIndex]?.page_id ?? null;
|
||||
const layoutConfig = { pages: sortedPages.map((p) => ({ page_id: p.page_id, page_order: p.page_order })) };
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{(comp.type === "text" || comp.type === "label") && (
|
||||
<TextRenderer component={comp} displayValue={displayValue} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "table" && (
|
||||
<TableRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "image" && <ImageRenderer component={comp} />}
|
||||
|
||||
{comp.type === "divider" && <DividerRenderer component={comp} />}
|
||||
|
||||
{comp.type === "signature" && <SignatureRenderer component={comp} />}
|
||||
|
||||
{comp.type === "stamp" && <StampRenderer component={comp} />}
|
||||
|
||||
{comp.type === "pageNumber" && (
|
||||
<PageNumberRenderer component={comp} currentPageId={currentPageId} layoutConfig={layoutConfig} />
|
||||
)}
|
||||
|
||||
{comp.type === "card" && (
|
||||
<CardRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "calculation" && (
|
||||
<CalculationRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "barcode" && (
|
||||
<BarcodeCanvasRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "checkbox" && (
|
||||
<CheckboxRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ReportMaster } from "@/types/report";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -14,11 +12,15 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Copy, Trash2, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Copy, Trash2, Edit, Eye, FileText, Calendar, User, Loader2, Pencil, AlignLeft } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { format } from "date-fns";
|
||||
import { getTypeBgClass, getTypeLabel, getTypeIcon } from "@/lib/reportTypeColors";
|
||||
|
||||
interface ReportListTableProps {
|
||||
reports: ReportMaster[];
|
||||
@@ -26,8 +28,11 @@ interface ReportListTableProps {
|
||||
page: number;
|
||||
limit: number;
|
||||
isLoading: boolean;
|
||||
viewMode: "grid" | "list";
|
||||
onPageChange: (page: number) => void;
|
||||
onRefresh: () => void;
|
||||
onViewClick: (report: ReportMaster) => void;
|
||||
onCopyClick: (report: ReportMaster) => void;
|
||||
}
|
||||
|
||||
export function ReportListTable({
|
||||
@@ -36,62 +41,34 @@ export function ReportListTable({
|
||||
page,
|
||||
limit,
|
||||
isLoading,
|
||||
viewMode,
|
||||
onPageChange,
|
||||
onRefresh,
|
||||
onViewClick,
|
||||
onCopyClick,
|
||||
}: ReportListTableProps) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// 수정
|
||||
const handleEdit = (reportId: string) => {
|
||||
router.push(`/admin/screenMng/reportList/designer/${reportId}`);
|
||||
openTab({
|
||||
type: "admin",
|
||||
title: "리포트 디자이너",
|
||||
adminUrl: `/admin/screenMng/reportList/designer/${reportId}`,
|
||||
});
|
||||
};
|
||||
|
||||
// 복사
|
||||
const handleCopy = async (reportId: string) => {
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const response = await reportApi.copyReport(reportId);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "리포트가 복사되었습니다.",
|
||||
});
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteClick = (reportId: string) => {
|
||||
setDeleteTarget(reportId);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await reportApi.deleteReport(deleteTarget);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "리포트가 삭제되었습니다.",
|
||||
});
|
||||
toast({ title: "성공", description: "리포트가 삭제되었습니다." });
|
||||
setDeleteTarget(null);
|
||||
onRefresh();
|
||||
}
|
||||
@@ -103,10 +80,9 @@ export function ReportListTable({
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
@@ -116,121 +92,144 @@ export function ReportListTable({
|
||||
}
|
||||
};
|
||||
|
||||
const formatUpdatedDate = (updatedAt: string | null, createdAt: string | null) => {
|
||||
if (!updatedAt) return "-";
|
||||
try {
|
||||
const updatedStr = format(new Date(updatedAt), "yyyy-MM-dd HH:mm:ss");
|
||||
const createdStr = createdAt ? format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss") : null;
|
||||
if (createdStr && updatedStr === createdStr) return "-";
|
||||
return format(new Date(updatedAt), "yyyy-MM-dd");
|
||||
} catch {
|
||||
return updatedAt || "-";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<div className="flex h-72 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 flex-col items-center justify-center">
|
||||
<p>등록된 리포트가 없습니다.</p>
|
||||
<div className="text-muted-foreground flex h-72 flex-col items-center justify-center gap-3">
|
||||
<FileText className="h-12 w-12 opacity-30" />
|
||||
<p className="text-base">등록된 리포트가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRename = async (reportId: string, newName: string) => {
|
||||
try {
|
||||
const response = await reportApi.updateReport(reportId, { reportNameKor: newName });
|
||||
if (response.success) {
|
||||
toast({ title: "성공", description: "리포트명이 변경되었습니다." });
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트명 변경에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDescriptionChange = async (reportId: string, newDesc: string) => {
|
||||
try {
|
||||
const response = await reportApi.updateReport(reportId, { description: newDesc });
|
||||
if (response.success) {
|
||||
toast({ title: "성공", description: "설명이 변경되었습니다." });
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "설명 변경에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">No</TableHead>
|
||||
<TableHead>리포트명</TableHead>
|
||||
<TableHead className="w-[120px]">작성자</TableHead>
|
||||
<TableHead className="w-[120px]">수정일</TableHead>
|
||||
<TableHead className="w-[200px]">액션</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reports.map((report, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={report.report_id}
|
||||
onClick={() => handleEdit(report.report_id)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="font-medium">{rowNumber}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{report.report_name_kor}</div>
|
||||
{report.report_name_eng && (
|
||||
<div className="text-muted-foreground text-sm">{report.report_name_eng}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{report.created_by || "-"}</TableCell>
|
||||
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleCopy(report.report_id)}
|
||||
disabled={isCopying}
|
||||
className="h-8 w-8"
|
||||
title="복사"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick(report.report_id)}
|
||||
className="h-8 w-8"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{viewMode === "grid" ? (
|
||||
<GridView
|
||||
reports={reports}
|
||||
page={page}
|
||||
limit={limit}
|
||||
onEdit={handleEdit}
|
||||
onView={onViewClick}
|
||||
onCopyClick={onCopyClick}
|
||||
onDeleteClick={setDeleteTarget}
|
||||
onRename={handleRename}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
formatDate={formatDate}
|
||||
formatUpdatedDate={formatUpdatedDate}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
reports={reports}
|
||||
page={page}
|
||||
limit={limit}
|
||||
onEdit={handleEdit}
|
||||
onView={onViewClick}
|
||||
onCopyClick={onCopyClick}
|
||||
onDeleteClick={setDeleteTarget}
|
||||
onRename={handleRename}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
formatDate={formatDate}
|
||||
formatUpdatedDate={formatUpdatedDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-4">
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<span className="text-sm text-gray-500">
|
||||
총 <span className="font-semibold text-gray-900">{total}건</span>의 리포트
|
||||
</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`h-8 w-8 p-0 text-sm ${
|
||||
p === page ? "bg-blue-600 text-white hover:bg-blue-700" : ""
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>리포트 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<AlertDialogTitle className="text-lg">리포트 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-base">
|
||||
이 리포트를 삭제하시겠습니까?
|
||||
<br />
|
||||
삭제된 리포트는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isDeleting} className="text-base">취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 text-base"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
@@ -243,3 +242,394 @@ export function ReportListTable({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ViewProps {
|
||||
reports: ReportMaster[];
|
||||
page: number;
|
||||
limit: number;
|
||||
onEdit: (id: string) => void;
|
||||
onView: (report: ReportMaster) => void;
|
||||
onCopyClick: (report: ReportMaster) => void;
|
||||
onDeleteClick: (id: string) => void;
|
||||
onRename: (reportId: string, newName: string) => Promise<void>;
|
||||
onDescriptionChange: (reportId: string, newDesc: string) => Promise<void>;
|
||||
formatDate: (d: string | null) => string;
|
||||
formatUpdatedDate: (updatedAt: string | null, createdAt: string | null) => string;
|
||||
}
|
||||
|
||||
function InlineReportName({
|
||||
reportId,
|
||||
name,
|
||||
onNavigate,
|
||||
onRename,
|
||||
}: {
|
||||
reportId: string;
|
||||
name: string;
|
||||
onNavigate: () => void;
|
||||
onRename: (reportId: string, newName: string) => Promise<void>;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(name);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (!trimmed || trimmed === name) {
|
||||
setIsEditing(false);
|
||||
setEditValue(name);
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onRename(reportId, trimmed);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [editValue, name, reportId, onRename]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditValue(name);
|
||||
}, [name]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleSave, handleCancel],
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
disabled={isSaving}
|
||||
className="h-7 text-sm font-medium"
|
||||
/>
|
||||
{isSaving && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-gray-400" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/name flex min-w-0 items-center gap-1.5">
|
||||
<button
|
||||
onClick={onNavigate}
|
||||
className="cursor-pointer truncate text-left text-sm font-semibold text-gray-900 hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-gray-100 group-hover/name:opacity-100"
|
||||
title="리포트명 수정"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineDescription({
|
||||
reportId,
|
||||
description,
|
||||
onSave,
|
||||
}: {
|
||||
reportId: string;
|
||||
description: string | null;
|
||||
onSave: (reportId: string, newDesc: string) => Promise<void>;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(description || "");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed === (description || "")) {
|
||||
setIsEditing(false);
|
||||
setEditValue(description || "");
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(reportId, trimmed);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [editValue, description, reportId, onSave]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditValue(description || "");
|
||||
}, [description]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleSave, handleCancel],
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
disabled={isSaving}
|
||||
placeholder="설명 입력"
|
||||
className="h-6 text-xs"
|
||||
/>
|
||||
{isSaving && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-gray-400" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/desc flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className="cursor-pointer truncate text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setIsEditing(true)}
|
||||
title={description || "클릭하여 설명 입력"}
|
||||
>
|
||||
{description || <span className="italic text-gray-300">Inline Description</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-gray-100 group-hover/desc:opacity-100"
|
||||
title="설명 수정"
|
||||
>
|
||||
<Pencil className="h-3 w-3 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({ reports, page, limit, onEdit, onView, onCopyClick, onDeleteClick, onRename, onDescriptionChange, formatDate, formatUpdatedDate }: ViewProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<colgroup>
|
||||
<col style={{ width: 50 }} />
|
||||
<col style={{ width: "20%" }} />
|
||||
<col style={{ width: "15%" }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col style={{ width: 70 }} />
|
||||
<col style={{ width: 110 }} />
|
||||
<col style={{ width: 110 }} />
|
||||
<col style={{ width: 150 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">NO</th>
|
||||
<th className="px-3 py-3 text-left text-sm font-semibold text-gray-500">리포트명</th>
|
||||
<th className="px-3 py-3 text-left text-sm font-semibold text-gray-500">설명</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">카테고리</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">작성자</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">생성일</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">수정일</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{reports.map((report, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
return (
|
||||
<tr key={report.report_id} className="transition-colors hover:bg-blue-50/70">
|
||||
<td className="px-3 py-3.5 text-center text-sm font-medium text-gray-400">
|
||||
{rowNumber}
|
||||
</td>
|
||||
<td className="px-3 py-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-blue-600">
|
||||
<FileText className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<InlineReportName
|
||||
reportId={report.report_id}
|
||||
name={report.report_name_kor}
|
||||
onNavigate={() => onEdit(report.report_id)}
|
||||
onRename={onRename}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="min-w-0 overflow-hidden px-3 py-3.5">
|
||||
<InlineDescription
|
||||
reportId={report.report_id}
|
||||
description={report.description}
|
||||
onSave={onDescriptionChange}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3.5 text-center">
|
||||
{report.report_type && (() => {
|
||||
const TypeIcon = getTypeIcon(report.report_type);
|
||||
return (
|
||||
<Badge className={`gap-1.5 whitespace-nowrap text-sm leading-tight hover:bg-transparent ${getTypeBgClass(report.report_type)}`}>
|
||||
<TypeIcon className="h-3.5 w-3.5" strokeWidth={2.2} />
|
||||
{getTypeLabel(report.report_type)}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-3 py-3.5 text-center text-sm text-gray-600">
|
||||
{report.created_by || "-"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3.5 text-center text-sm text-gray-500">
|
||||
{formatDate(report.created_at)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3.5 text-center text-sm text-gray-500">
|
||||
{formatUpdatedDate(report.updated_at, report.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-3.5">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => onView(report)} className="h-8 w-8" title="미리보기">
|
||||
<Eye className="h-4 w-4 text-gray-500" strokeWidth={2} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onEdit(report.report_id)} className="h-8 w-8" title="수정">
|
||||
<Edit className="h-4 w-4 text-gray-500" strokeWidth={2} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onCopyClick(report)} className="h-8 w-8" title="복사">
|
||||
<Copy className="h-4 w-4 text-gray-500" strokeWidth={2} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onDeleteClick(report.report_id)} className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600" title="삭제">
|
||||
<Trash2 className="h-4 w-4" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridView({ reports, page, limit, onEdit, onView, onCopyClick, onDeleteClick, onRename, onDescriptionChange, formatDate, formatUpdatedDate }: ViewProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 px-4 py-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{reports.map((report, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={report.report_id}
|
||||
className="group rounded-lg border border-gray-200 bg-white px-4 py-3 transition-all hover:border-blue-300 hover:bg-blue-50/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="ml-1 flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-blue-600">
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<InlineReportName
|
||||
reportId={report.report_id}
|
||||
name={report.report_name_kor}
|
||||
onNavigate={() => onEdit(report.report_id)}
|
||||
onRename={onRename}
|
||||
/>
|
||||
{report.report_type && (() => {
|
||||
const TypeIcon = getTypeIcon(report.report_type);
|
||||
return (
|
||||
<Badge className={`-ml-1.5 shrink-0 gap-1 text-[11px] leading-tight hover:bg-transparent ${getTypeBgClass(report.report_type)}`}>
|
||||
<TypeIcon className="h-2.5 w-2.5" strokeWidth={2.5} />
|
||||
{getTypeLabel(report.report_type)}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] font-medium text-gray-400">#{rowNumber}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-3 space-y-1 text-xs">
|
||||
<div className="flex items-center text-gray-600">
|
||||
<User className="mr-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="w-[38px] shrink-0 font-medium text-gray-700">작성자</span>
|
||||
<span>{report.created_by || "-"}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Calendar className="mr-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="w-[38px] shrink-0 font-medium text-gray-700">생성일</span>
|
||||
<span>{formatDate(report.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Calendar className="mr-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="w-[38px] shrink-0 font-medium text-gray-700">수정일</span>
|
||||
<span>{formatUpdatedDate(report.updated_at, report.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<AlignLeft className="mr-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="w-[38px] shrink-0 font-medium text-gray-700">설명</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<InlineDescription
|
||||
reportId={report.report_id}
|
||||
description={report.description}
|
||||
onSave={onDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onView(report)} className="h-7 flex-1 gap-1 px-0 text-[11px]">
|
||||
<Eye className="h-3 w-3" />
|
||||
미리보기
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(report.report_id)} className="h-7 flex-1 gap-1 px-0 text-[11px]">
|
||||
<Edit className="h-3 w-3" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onCopyClick(report)} className="h-7 flex-1 gap-1 px-0 text-[11px]">
|
||||
<Copy className="h-3 w-3" />
|
||||
복사
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onDeleteClick(report.report_id)} className="h-7 flex-1 gap-1 px-0 text-[11px] text-red-600 hover:border-red-200 hover:bg-red-50 hover:text-red-700">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator, Barcode, CheckSquare } from "lucide-react";
|
||||
import {
|
||||
Type,
|
||||
Table,
|
||||
Image,
|
||||
Minus,
|
||||
PenLine,
|
||||
Stamp as StampIcon,
|
||||
Hash,
|
||||
CreditCard,
|
||||
Calculator,
|
||||
Barcode,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
FileText,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ComponentItem {
|
||||
type: string;
|
||||
@@ -9,18 +26,45 @@ interface ComponentItem {
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const COMPONENTS: ComponentItem[] = [
|
||||
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
|
||||
{ type: "table", label: "테이블", icon: <Table className="h-4 w-4" /> },
|
||||
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
|
||||
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
|
||||
{ type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> },
|
||||
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
|
||||
{ type: "pageNumber", label: "페이지번호", icon: <Hash className="h-4 w-4" /> },
|
||||
{ type: "card", label: "정보카드", icon: <CreditCard className="h-4 w-4" /> },
|
||||
{ type: "calculation", label: "계산", icon: <Calculator className="h-4 w-4" /> },
|
||||
{ type: "barcode", label: "바코드/QR", icon: <Barcode className="h-4 w-4" /> },
|
||||
{ type: "checkbox", label: "체크박스", icon: <CheckSquare className="h-4 w-4" /> },
|
||||
interface ComponentCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
items: ComponentItem[];
|
||||
}
|
||||
|
||||
const CATEGORIES: ComponentCategory[] = [
|
||||
{
|
||||
id: "basic",
|
||||
label: "기본 요소",
|
||||
icon: <LayoutGrid className="h-5 w-5" />,
|
||||
items: [
|
||||
{ type: "text", label: "텍스트", icon: <Type className="h-5 w-5" /> },
|
||||
{ type: "image", label: "이미지", icon: <Image className="h-5 w-5" /> },
|
||||
{ type: "divider", label: "구분선", icon: <Minus className="h-5 w-5" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
label: "데이터 표시",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
items: [
|
||||
{ type: "table", label: "테이블", icon: <Table className="h-5 w-5" /> },
|
||||
{ type: "card", label: "정보카드", icon: <CreditCard className="h-5 w-5" /> },
|
||||
{ type: "calculation", label: "계산", icon: <Calculator className="h-5 w-5" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "form",
|
||||
label: "입력/인증",
|
||||
icon: <FileText className="h-5 w-5" />,
|
||||
items: [
|
||||
{ type: "checkbox", label: "체크박스", icon: <CheckSquare className="h-5 w-5" /> },
|
||||
{ type: "signature", label: "서명란", icon: <PenLine className="h-5 w-5" /> },
|
||||
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-5 w-5" /> },
|
||||
{ type: "barcode", label: "바코드/QR", icon: <Barcode className="h-5 w-5" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
|
||||
@@ -34,23 +78,60 @@ function DraggableComponentItem({ type, label, icon }: ComponentItem) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag}
|
||||
className={`flex cursor-move items-center gap-2 rounded border p-2 text-sm transition-all hover:border-primary hover:bg-primary/10 ${
|
||||
ref={drag as any}
|
||||
className={`flex h-20 cursor-move flex-col items-center justify-center gap-1.5 rounded-lg border border-gray-200 bg-gray-50 transition-colors hover:border-indigo-400 hover:bg-indigo-50 ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<div className="text-gray-600">{icon}</div>
|
||||
<span className="text-xs font-medium text-gray-700">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComponentPalette() {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(CATEGORIES.map((c) => c.id)),
|
||||
);
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{COMPONENTS.map((component) => (
|
||||
<DraggableComponentItem key={component.type} {...component} />
|
||||
))}
|
||||
<div className="space-y-1">
|
||||
{CATEGORIES.map((category) => {
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<button
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 text-gray-400 transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<span className="text-gray-500">{category.icon}</span>
|
||||
{category.label}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="grid grid-cols-2 gap-3 px-1 pt-2 pb-3">
|
||||
{category.items.map((item) => (
|
||||
<DraggableComponentItem key={item.type} {...item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function GridSettingsPanel() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">셀 너비</Label>
|
||||
<span className="text-xs text-muted-foreground">{gridConfig.cellWidth}px</span>
|
||||
<span className="text-xs text-gray-500">{gridConfig.cellWidth}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridConfig.cellWidth]}
|
||||
@@ -73,7 +73,7 @@ export function GridSettingsPanel() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">셀 높이</Label>
|
||||
<span className="text-xs text-muted-foreground">{gridConfig.cellHeight}px</span>
|
||||
<span className="text-xs text-gray-500">{gridConfig.cellHeight}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridConfig.cellHeight]}
|
||||
@@ -89,7 +89,7 @@ export function GridSettingsPanel() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">투명도</Label>
|
||||
<span className="text-xs text-muted-foreground">{Math.round(gridConfig.gridOpacity * 100)}%</span>
|
||||
<span className="text-xs text-gray-500">{Math.round(gridConfig.gridOpacity * 100)}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridConfig.gridOpacity * 100]}
|
||||
@@ -122,7 +122,7 @@ export function GridSettingsPanel() {
|
||||
</div>
|
||||
|
||||
{/* 그리드 정보 */}
|
||||
<div className="rounded border bg-muted p-2 text-xs text-muted-foreground">
|
||||
<div className="rounded border bg-gray-50 p-2 text-xs text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>행:</span>
|
||||
<span className="font-mono">{gridConfig.rows}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -25,7 +26,6 @@ interface MenuSelectModalProps {
|
||||
selectedMenuObjids?: number[];
|
||||
}
|
||||
|
||||
// 트리 구조의 메뉴 노드
|
||||
interface MenuTreeNode {
|
||||
objid: string;
|
||||
menuNameKor: string;
|
||||
@@ -35,26 +35,32 @@ interface MenuTreeNode {
|
||||
parentObjId: string;
|
||||
}
|
||||
|
||||
export function MenuSelectModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
selectedMenuObjids = [],
|
||||
}: MenuSelectModalProps) {
|
||||
export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids = [] }: MenuSelectModalProps) {
|
||||
const [menus, setMenus] = useState<MenuItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set(selectedMenuObjids));
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const initialSelectionRef = useRef<string>("");
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
const currentSelection = JSON.stringify(Array.from(selectedIds).sort());
|
||||
return currentSelection !== initialSelectionRef.current;
|
||||
}, [selectedIds]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges,
|
||||
onClose,
|
||||
description: "변경된 선택 내용이 저장되지 않습니다. 정말 닫으시겠습니까?",
|
||||
});
|
||||
|
||||
// 초기 선택 상태 동기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedIds(new Set(selectedMenuObjids));
|
||||
initialSelectionRef.current = JSON.stringify(Array.from(new Set(selectedMenuObjids)).sort());
|
||||
}
|
||||
}, [isOpen, selectedMenuObjids]);
|
||||
|
||||
// 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchMenus();
|
||||
@@ -66,10 +72,9 @@ export function MenuSelectModal({
|
||||
try {
|
||||
const response = await menuApi.getUserMenus();
|
||||
if (response.success && response.data) {
|
||||
setMenus(response.data);
|
||||
// 처음 2레벨까지 자동 확장
|
||||
setMenus(response.data as MenuItem[]);
|
||||
const initialExpanded = new Set<string>();
|
||||
response.data.forEach((menu) => {
|
||||
(response.data as MenuItem[]).forEach((menu: any) => {
|
||||
const level = menu.lev || menu.LEV || 1;
|
||||
if (level <= 2) {
|
||||
initialExpanded.add(menu.objid || menu.OBJID || "");
|
||||
@@ -84,12 +89,10 @@ export function MenuSelectModal({
|
||||
}
|
||||
};
|
||||
|
||||
// 메뉴 트리 구조 생성
|
||||
const menuTree = useMemo(() => {
|
||||
const menuMap = new Map<string, MenuTreeNode>();
|
||||
const rootMenus: MenuTreeNode[] = [];
|
||||
|
||||
// 모든 메뉴를 노드로 변환
|
||||
menus.forEach((menu) => {
|
||||
const objid = menu.objid || menu.OBJID || "";
|
||||
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
|
||||
@@ -97,25 +100,15 @@ export function MenuSelectModal({
|
||||
const menuUrl = menu.menuUrl || menu.MENU_URL || "";
|
||||
const level = menu.lev || menu.LEV || 1;
|
||||
|
||||
menuMap.set(objid, {
|
||||
objid,
|
||||
menuNameKor,
|
||||
menuUrl,
|
||||
level,
|
||||
children: [],
|
||||
parentObjId,
|
||||
});
|
||||
menuMap.set(objid, { objid, menuNameKor, menuUrl, level, children: [], parentObjId });
|
||||
});
|
||||
|
||||
// 부모-자식 관계 설정
|
||||
menus.forEach((menu) => {
|
||||
const objid = menu.objid || menu.OBJID || "";
|
||||
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
|
||||
const node = menuMap.get(objid);
|
||||
|
||||
if (!node) return;
|
||||
|
||||
// 최상위 메뉴인지 확인 (parent가 없거나, 특정 루트 ID)
|
||||
const parent = menuMap.get(parentObjId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
@@ -124,7 +117,6 @@ export function MenuSelectModal({
|
||||
}
|
||||
});
|
||||
|
||||
// 자식 메뉴 정렬
|
||||
const sortChildren = (nodes: MenuTreeNode[]) => {
|
||||
nodes.sort((a, b) => a.menuNameKor.localeCompare(b.menuNameKor, "ko"));
|
||||
nodes.forEach((node) => sortChildren(node.children));
|
||||
@@ -134,24 +126,18 @@ export function MenuSelectModal({
|
||||
return rootMenus;
|
||||
}, [menus]);
|
||||
|
||||
// 검색 필터링
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!searchText.trim()) return menuTree;
|
||||
|
||||
const searchLower = searchText.toLowerCase();
|
||||
|
||||
// 검색어에 맞는 노드와 그 조상 노드를 포함
|
||||
const filterNodes = (nodes: MenuTreeNode[]): MenuTreeNode[] => {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const filteredChildren = filterNodes(node.children);
|
||||
const matches = node.menuNameKor.toLowerCase().includes(searchLower);
|
||||
|
||||
if (matches || filteredChildren.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: filteredChildren,
|
||||
};
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
@@ -161,7 +147,6 @@ export function MenuSelectModal({
|
||||
return filterNodes(menuTree);
|
||||
}, [menuTree, searchText]);
|
||||
|
||||
// 체크박스 토글
|
||||
const toggleSelect = useCallback((objid: string) => {
|
||||
const numericId = Number(objid);
|
||||
setSelectedIds((prev) => {
|
||||
@@ -175,7 +160,6 @@ export function MenuSelectModal({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 확장/축소 토글
|
||||
const toggleExpand = useCallback((objid: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -188,13 +172,11 @@ export function MenuSelectModal({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 확인 버튼 클릭
|
||||
const handleConfirm = () => {
|
||||
onConfirm(Array.from(selectedIds));
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 메뉴 노드 렌더링
|
||||
const renderMenuNode = (node: MenuTreeNode, depth: number = 0) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedIds.has(node.objid);
|
||||
@@ -204,13 +186,12 @@ export function MenuSelectModal({
|
||||
<div key={node.objid}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/50 cursor-pointer",
|
||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5",
|
||||
isSelected && "bg-primary/10",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
||||
onClick={() => toggleSelect(node.objid)}
|
||||
>
|
||||
{/* 확장/축소 버튼 */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -218,103 +199,88 @@ export function MenuSelectModal({
|
||||
e.stopPropagation();
|
||||
toggleExpand(node.objid);
|
||||
}}
|
||||
className="p-0.5 hover:bg-muted rounded"
|
||||
className="hover:bg-muted rounded p-0.5"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-5" />
|
||||
)}
|
||||
|
||||
{/* 체크박스 - 모든 메뉴에서 선택 가능 */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(node.objid)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* 아이콘 */}
|
||||
{hasChildren ? (
|
||||
<FolderOpen className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
|
||||
{/* 메뉴명 */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1 truncate",
|
||||
isSelected && "font-medium text-primary",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex-1 truncate text-sm", isSelected && "text-primary font-medium")}>
|
||||
{node.menuNameKor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 자식 메뉴 */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>{node.children.map((child) => renderMenuNode(child, depth + 1))}</div>
|
||||
)}
|
||||
{hasChildren && isExpanded && <div>{node.children.map((child) => renderMenuNode(child, depth + 1))}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[600px] max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>사용 메뉴 선택</DialogTitle>
|
||||
<DialogDescription>
|
||||
이 리포트를 사용할 메뉴를 선택하세요. 선택한 메뉴에서 이 리포트를 사용할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[600px] flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>사용 메뉴 선택</DialogTitle>
|
||||
<DialogDescription>
|
||||
이 리포트를 사용할 메뉴를 선택하세요. 선택한 메뉴에서 이 리포트를 사용할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="메뉴 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="메뉴 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택된 메뉴 수 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedIds.size}개 메뉴 선택됨
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">{selectedIds.size}개 메뉴 선택됨</div>
|
||||
|
||||
{/* 메뉴 트리 */}
|
||||
<ScrollArea className="flex-1 border rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">메뉴 로드 중...</span>
|
||||
</div>
|
||||
) : filteredTree.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{searchText ? "검색 결과가 없습니다." : "표시할 메뉴가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">{filteredTree.map((node) => renderMenuNode(node))}</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<ScrollArea className="flex-1 rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">메뉴 로드 중...</span>
|
||||
</div>
|
||||
) : filteredTree.length === 0 ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center py-8 text-sm">
|
||||
{searchText ? "검색 결과가 없습니다." : "표시할 메뉴가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">{filteredTree.map((node) => renderMenuNode(node))}</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>
|
||||
확인 ({selectedIds.size}개 선택)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={guard.tryClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>확인 ({selectedIds.size}개 선택)</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { Plus, Copy, Trash2, GripVertical, Edit2, Check, X } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Plus, Copy, Trash2, Edit2, Check, X, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { MM_TO_PX } from "@/lib/report/constants";
|
||||
import type { ComponentConfig, ReportPage } from "@/types/report";
|
||||
|
||||
const THUMB_W = 80;
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
text: "#6366f1",
|
||||
label: "#6366f1",
|
||||
table: "#0891b2",
|
||||
image: "#16a34a",
|
||||
divider: "#9ca3af",
|
||||
signature: "#d97706",
|
||||
stamp: "#d97706",
|
||||
pageNumber: "#8b5cf6",
|
||||
card: "#0ea5e9",
|
||||
calculation: "#ec4899",
|
||||
barcode: "#1e293b",
|
||||
checkbox: "#f59e0b",
|
||||
};
|
||||
|
||||
function PageThumbnail({ page }: { page: ReportPage }) {
|
||||
const canvasW = page.width * MM_TO_PX;
|
||||
const canvasH = page.height * MM_TO_PX;
|
||||
const scale = THUMB_W / canvasW;
|
||||
const thumbH = canvasH * scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded border border-gray-200 bg-white shadow-sm"
|
||||
style={{ width: THUMB_W, height: thumbH }}
|
||||
>
|
||||
{page.components.map((comp: ComponentConfig) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="absolute rounded-[1px]"
|
||||
style={{
|
||||
left: comp.x * scale,
|
||||
top: comp.y * scale,
|
||||
width: Math.max(comp.width * scale, 2),
|
||||
height: Math.max(comp.height * scale, 2),
|
||||
backgroundColor: TYPE_COLORS[comp.type] ?? "#94a3b8",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageListPanel() {
|
||||
const {
|
||||
@@ -24,11 +65,14 @@ export function PageListPanel() {
|
||||
reorderPages,
|
||||
selectPage,
|
||||
updatePageSettings,
|
||||
isPageListCollapsed,
|
||||
setIsPageListCollapsed,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [editingPageId, setEditingPageId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
||||
|
||||
const handleStartEdit = (pageId: string, currentName: string) => {
|
||||
setEditingPageId(pageId);
|
||||
@@ -48,80 +92,144 @@ export function PageListPanel() {
|
||||
setEditingName("");
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
// 실시간으로 순서 변경하지 않고, drop 시에만 변경
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (draggedIndex === null || draggedIndex === index) {
|
||||
setDropTargetIndex(null);
|
||||
return;
|
||||
}
|
||||
setDropTargetIndex(index);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (draggedIndex === null) return;
|
||||
|
||||
const sourceIndex = draggedIndex;
|
||||
if (sourceIndex !== targetIndex) {
|
||||
reorderPages(sourceIndex, targetIndex);
|
||||
if (draggedIndex !== targetIndex) {
|
||||
reorderPages(draggedIndex, targetIndex);
|
||||
}
|
||||
|
||||
setDraggedIndex(null);
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
|
||||
if (isPageListCollapsed) {
|
||||
return (
|
||||
<div className="flex h-full w-10 shrink-0 flex-col items-center border-r border-gray-200 bg-white pt-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setIsPageListCollapsed(false)}
|
||||
title="페이지 목록 열기"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full w-32 flex-col border-r">
|
||||
<div className="flex h-full w-40 shrink-0 flex-col border-r border-gray-200 bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b px-2 py-1.5">
|
||||
<h3 className="text-[10px] font-semibold">페이지</h3>
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => addPage()}>
|
||||
<Plus className="h-3 w-3" />
|
||||
<div className="flex h-11 items-center justify-between border-b border-gray-200 px-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setIsPageListCollapsed(true)}
|
||||
title="페이지 목록 접기"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-bold text-gray-800">페이지</span>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => addPage()} title="페이지 추가">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 페이지 목록 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full p-1">
|
||||
<div className="space-y-1">
|
||||
<ScrollArea className="h-full p-2">
|
||||
<div className="space-y-2" onDragOver={(e) => e.preventDefault()}>
|
||||
{layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page, index) => (
|
||||
<div
|
||||
key={page.page_id}
|
||||
className={`group relative cursor-pointer rounded border p-1.5 transition-all ${
|
||||
page.page_id === currentPageId
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent/50"
|
||||
} ${draggedIndex === index ? "opacity-50" : ""}`}
|
||||
onClick={() => selectPage(page.page_id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
className={`group relative cursor-grab rounded-md border px-2 pt-1 pb-2 transition-all hover:shadow active:cursor-grabbing ${
|
||||
page.page_id === currentPageId
|
||||
? "border-indigo-400 bg-indigo-50 shadow-md ring-1 ring-indigo-200"
|
||||
: "border-gray-100 bg-white hover:border-gray-200"
|
||||
} ${draggedIndex === index ? "opacity-30" : ""} ${dropTargetIndex === index ? "border-dashed border-blue-400" : ""}`}
|
||||
onClick={() => selectPage(page.page_id)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 드래그 핸들 */}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
{/* 상단 우측: 액션 버튼 모음 */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 text-gray-400 hover:text-blue-600"
|
||||
title="이름 변경"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDragStart(index);
|
||||
handleStartEdit(page.page_id, page.page_name);
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="text-muted-foreground cursor-grab opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
<Edit2 className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 text-gray-400 hover:text-blue-600"
|
||||
title="복제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicatePage(page.page_id);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 text-gray-400 hover:text-red-500"
|
||||
title="삭제"
|
||||
disabled={layoutConfig.pages.length <= 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deletePage(page.page_id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 페이지 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* 썸네일 + 제목 (하단부 배치) */}
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<PageThumbnail page={page} />
|
||||
<div className="mt-2 w-full text-center">
|
||||
{editingPageId === page.page_id ? (
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
@@ -129,81 +237,36 @@ export function PageListPanel() {
|
||||
if (e.key === "Enter") handleSaveEdit();
|
||||
if (e.key === "Escape") handleCancelEdit();
|
||||
}}
|
||||
className="h-5 text-[10px]"
|
||||
className="h-6 w-full px-1 text-center text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleSaveEdit}>
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 shrink-0 p-0 text-blue-600 hover:text-blue-700" onClick={handleSaveEdit}>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleCancelEdit}>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 shrink-0 p-0 text-red-500 hover:text-red-600" onClick={handleCancelEdit}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate text-[10px] font-medium">{page.page_name}</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-[8px]">
|
||||
{page.width}x{page.height}mm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 메뉴 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<span className="sr-only">메뉴</span>
|
||||
<span className="text-[10px] leading-none">⋮</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
<div
|
||||
className="cursor-text"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEdit(page.page_id, page.page_name);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-3 w-3" />
|
||||
이름 변경
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicatePage(page.page_id);
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-3 w-3" />
|
||||
복제
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deletePage(page.page_id);
|
||||
}}
|
||||
disabled={layoutConfig.pages.length <= 1}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="block text-[11px] font-semibold text-gray-700">
|
||||
{index + 1}. {page.page_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t p-1">
|
||||
<Button size="sm" variant="outline" className="h-6 w-full text-[10px]" onClick={() => addPage()}>
|
||||
<Plus className="mr-1 h-3 w-3" />추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -264,7 +264,7 @@ export function QueryManager() {
|
||||
const queryParamTypes = parameterTypes[query.id] || {};
|
||||
|
||||
return (
|
||||
<AccordionItem key={query.id} value={query.id} className="border-b border-border">
|
||||
<AccordionItem key={query.id} value={query.id} className="border-b border-gray-200">
|
||||
<div className="flex items-center gap-1">
|
||||
<AccordionTrigger className="flex-1 px-0 py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -274,15 +274,15 @@ export function QueryManager() {
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => handleDeleteQuery(query.id, e)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => handleDeleteQuery(query.id, e)}
|
||||
className="h-7 w-7 shrink-0 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
|
||||
{/* 쿼리 이름 */}
|
||||
<div className="space-y-2">
|
||||
@@ -339,7 +339,7 @@ export function QueryManager() {
|
||||
</SelectItem>
|
||||
{externalConnections.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">외부 DB</div>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">외부 DB</div>
|
||||
{externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -369,9 +369,9 @@ export function QueryManager() {
|
||||
|
||||
{/* 파라미터 입력 */}
|
||||
{query.parameters.length > 0 && (
|
||||
<div className="space-y-3 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<div className="space-y-3 rounded-md border border-yellow-200 bg-yellow-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600" />
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
||||
<Label className="text-xs font-semibold text-yellow-800">파라미터</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -435,7 +435,7 @@ export function QueryManager() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="w-full bg-destructive hover:bg-destructive"
|
||||
className="w-full bg-red-500 hover:bg-red-600"
|
||||
onClick={() => handleTestQuery(query)}
|
||||
disabled={!queryValidation.isValid || isTestRunning[query.id] || !isAllParametersFilled(query)}
|
||||
>
|
||||
@@ -445,8 +445,8 @@ export function QueryManager() {
|
||||
|
||||
{/* 결과 필드 */}
|
||||
{testResult && (
|
||||
<div className="space-y-2 rounded-md border border-emerald-200 bg-emerald-50 p-3">
|
||||
<Label className="text-xs font-semibold text-emerald-800">결과 필드</Label>
|
||||
<div className="space-y-2 rounded-md border border-green-200 bg-green-50 p-3">
|
||||
<Label className="text-xs font-semibold text-green-800">결과 필드</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{testResult.fields.map((field) => (
|
||||
<Badge key={field} variant="default" className="bg-teal-500">
|
||||
@@ -454,7 +454,7 @@ export function QueryManager() {
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-emerald-700">{testResult.rows.length}건의 데이터가 조회되었습니다.</p>
|
||||
<p className="text-xs text-green-700">{testResult.rows.length}건의 데이터가 조회되었습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
@@ -464,8 +464,8 @@ export function QueryManager() {
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Database className="mb-2 h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Database className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">
|
||||
쿼리를 추가하여 리포트에
|
||||
<br />
|
||||
데이터를 연결하세요
|
||||
|
||||
@@ -1,196 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { ComponentConfig, WatermarkConfig } from "@/types/report";
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
import { CanvasComponent } from "./CanvasComponent";
|
||||
import { Ruler } from "./Ruler";
|
||||
import { WatermarkLayer } from "./WatermarkLayer";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
|
||||
// mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정)
|
||||
// A4 기준: 210mm x 297mm → 840px x 1188px
|
||||
export const MM_TO_PX = 4;
|
||||
|
||||
// 워터마크 레이어 컴포넌트
|
||||
interface WatermarkLayerProps {
|
||||
watermark: WatermarkConfig;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
function WatermarkLayer({ watermark, canvasWidth, canvasHeight }: WatermarkLayerProps) {
|
||||
// 공통 스타일
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: 1, // 컴포넌트보다 낮은 z-index
|
||||
};
|
||||
|
||||
// 대각선 스타일
|
||||
if (watermark.style === "diagonal") {
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 중앙 스타일
|
||||
if (watermark.style === "center") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 타일 스타일
|
||||
if (watermark.style === "tile") {
|
||||
const rotation = watermark.rotation ?? -30;
|
||||
// 타일 간격 계산
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil(canvasWidth / tileSize) + 2;
|
||||
const rows = Math.ceil(canvasHeight / tileSize) + 2;
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignContent: "flex-start",
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows * cols }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 24}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
width: `${tileSize * 0.6}px`,
|
||||
height: `${tileSize * 0.6}px`,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import { MousePointer } from "lucide-react";
|
||||
import { MM_TO_PX } from "@/lib/report/constants";
|
||||
|
||||
export function ReportDesignerCanvas() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
currentPageId,
|
||||
currentPage,
|
||||
@@ -219,8 +42,29 @@ export function ReportDesignerCanvas() {
|
||||
redo,
|
||||
showRuler,
|
||||
layoutConfig,
|
||||
zoom,
|
||||
setZoom,
|
||||
fitTrigger,
|
||||
} = useReportDesigner();
|
||||
|
||||
// 캔버스 Auto-Fit: fitTrigger 변경 시 컨테이너에 맞춰 줌 재계산
|
||||
const calculateFitZoom = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rulerSpace = showRuler ? 20 : 0;
|
||||
const padding = 24; // p-3 × 2
|
||||
const availableWidth = containerRef.current.clientWidth - rulerSpace - padding;
|
||||
const availableHeight = containerRef.current.clientHeight - rulerSpace - padding;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
if (availableWidth <= 0 || availableHeight <= 0 || canvasWidthPx <= 0 || canvasHeightPx <= 0) return;
|
||||
const newZoom = Math.min(availableWidth / canvasWidthPx, availableHeight / canvasHeightPx, 1);
|
||||
setZoom(Math.round(Math.max(0.1, newZoom) * 100) / 100);
|
||||
}, [showRuler, canvasWidth, canvasHeight, setZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateFitZoom();
|
||||
}, [fitTrigger, calculateFitZoom]);
|
||||
|
||||
// 드래그 영역 선택 (Marquee Selection) 상태
|
||||
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
||||
const [marqueeStart, setMarqueeStart] = useState({ x: 0, y: 0 });
|
||||
@@ -247,8 +91,8 @@ export function ReportDesignerCanvas() {
|
||||
|
||||
if (!offset) return;
|
||||
|
||||
const x = offset.x - canvasRect.left;
|
||||
const y = offset.y - canvasRect.top;
|
||||
const x = (offset.x - canvasRect.left) / zoom;
|
||||
const y = (offset.y - canvasRect.top) / zoom;
|
||||
|
||||
// 컴포넌트 타입별 기본 설정
|
||||
let width = 200;
|
||||
@@ -325,6 +169,17 @@ export function ReportDesignerCanvas() {
|
||||
...(item.componentType === "image" && {
|
||||
imageUrl: "",
|
||||
objectFit: "contain" as const,
|
||||
imageOpacity: 1,
|
||||
imageBorderRadius: 0,
|
||||
imageCaption: "",
|
||||
imageCaptionPosition: "bottom" as const,
|
||||
imageCaptionFontSize: 12,
|
||||
imageCaptionColor: "#666666",
|
||||
imageCaptionAlign: "center" as const,
|
||||
imageAlt: "",
|
||||
imageRotation: 0,
|
||||
imageFlipH: false,
|
||||
imageFlipV: false,
|
||||
}),
|
||||
// 구분선 전용
|
||||
...(item.componentType === "divider" && {
|
||||
@@ -460,8 +315,8 @@ export function ReportDesignerCanvas() {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const x = (e.clientX - rect.left) / zoom;
|
||||
const y = (e.clientY - rect.top) / zoom;
|
||||
|
||||
// state와 ref 모두 설정
|
||||
setIsMarqueeSelecting(true);
|
||||
@@ -484,8 +339,8 @@ export function ReportDesignerCanvas() {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, canvasWidth * MM_TO_PX));
|
||||
const y = Math.max(0, Math.min(e.clientY - rect.top, canvasHeight * MM_TO_PX));
|
||||
const x = Math.max(0, Math.min((e.clientX - rect.left) / zoom, canvasWidth * MM_TO_PX));
|
||||
const y = Math.max(0, Math.min((e.clientY - rect.top) / zoom, canvasHeight * MM_TO_PX));
|
||||
|
||||
// state와 ref 둘 다 업데이트
|
||||
setMarqueeEnd({ x, y });
|
||||
@@ -509,7 +364,7 @@ export function ReportDesignerCanvas() {
|
||||
|
||||
// 최소 드래그 거리 체크 (5px 이상이어야 선택으로 인식)
|
||||
const dragDistance = Math.sqrt(
|
||||
Math.pow(currentEnd.x - currentStart.x, 2) + Math.pow(currentEnd.y - currentStart.y, 2)
|
||||
Math.pow(currentEnd.x - currentStart.x, 2) + Math.pow(currentEnd.y - currentStart.y, 2),
|
||||
);
|
||||
|
||||
if (dragDistance > 5) {
|
||||
@@ -715,26 +570,26 @@ export function ReportDesignerCanvas() {
|
||||
// 페이지가 없는 경우
|
||||
if (!currentPageId || !currentPage) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-muted">
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground">페이지가 없습니다</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">좌측에서 페이지를 추가하세요.</p>
|
||||
<h3 className="text-lg font-semibold text-gray-700">페이지가 없습니다</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">좌측에서 페이지를 추가하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden bg-muted">
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-gray-100">
|
||||
{/* 캔버스 스크롤 영역 */}
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto px-8 pt-[280px] pb-8">
|
||||
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
|
||||
<div className="inline-flex flex-col">
|
||||
<div ref={containerRef} className="flex flex-1 justify-center overflow-auto">
|
||||
{/* 눈금자와 캔버스를 감싸는 컨테이너 (zoom 적용) */}
|
||||
<div className="inline-flex flex-col p-3" style={{ zoom }}>
|
||||
{/* 좌상단 코너 + 가로 눈금자 */}
|
||||
{showRuler && (
|
||||
<div className="flex">
|
||||
{/* 좌상단 코너 (20x20) */}
|
||||
<div className="h-5 w-5 bg-muted/80" />
|
||||
<div className="h-5 w-5 bg-gray-200" />
|
||||
{/* 가로 눈금자 */}
|
||||
<Ruler orientation="horizontal" length={canvasWidth} />
|
||||
</div>
|
||||
@@ -751,7 +606,7 @@ export function ReportDesignerCanvas() {
|
||||
canvasRef.current = node;
|
||||
drop(node);
|
||||
}}
|
||||
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-ring" : ""}`}
|
||||
className={`relative bg-white shadow-[0_4px_24px_rgba(0,0,0,0.12)] ${isOver ? "ring-2 ring-blue-500" : ""}`}
|
||||
style={{
|
||||
width: `${canvasWidth * MM_TO_PX}px`,
|
||||
minHeight: `${canvasHeight * MM_TO_PX}px`,
|
||||
@@ -770,7 +625,7 @@ export function ReportDesignerCanvas() {
|
||||
{/* 페이지 여백 가이드 */}
|
||||
{currentPage && (
|
||||
<div
|
||||
className="pointer-events-none absolute border-2 border-dashed border-primary/40/50"
|
||||
className="pointer-events-none absolute border-2 border-dashed border-blue-300/50"
|
||||
style={{
|
||||
top: `${currentPage.margins.top * MM_TO_PX}px`,
|
||||
left: `${currentPage.margins.left * MM_TO_PX}px`,
|
||||
@@ -784,8 +639,8 @@ export function ReportDesignerCanvas() {
|
||||
{layoutConfig.watermark?.enabled && (
|
||||
<WatermarkLayer
|
||||
watermark={layoutConfig.watermark}
|
||||
canvasWidth={canvasWidth * MM_TO_PX}
|
||||
canvasHeight={canvasHeight * MM_TO_PX}
|
||||
width={canvasWidth * MM_TO_PX}
|
||||
height={canvasHeight * MM_TO_PX}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -823,7 +678,7 @@ export function ReportDesignerCanvas() {
|
||||
{/* 드래그 영역 선택 사각형 */}
|
||||
{isMarqueeSelecting && (
|
||||
<div
|
||||
className="pointer-events-none absolute border-2 border-primary bg-primary/10"
|
||||
className="pointer-events-none absolute border-2 border-blue-500 bg-blue-500/10"
|
||||
style={{
|
||||
left: `${getMarqueeRect().left}px`,
|
||||
top: `${getMarqueeRect().top}px`,
|
||||
@@ -836,8 +691,12 @@ export function ReportDesignerCanvas() {
|
||||
|
||||
{/* 빈 캔버스 안내 */}
|
||||
{components.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground/70">
|
||||
<p className="text-sm">왼쪽에서 컴포넌트를 드래그하여 추가하세요</p>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MousePointer className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<p className="mb-1 text-base text-gray-400">좌측 도구상자에서</p>
|
||||
<p className="text-base text-gray-400">컴포넌트를 끌어다 놓으세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight, Puzzle, FileText, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { ComponentPalette } from "./ComponentPalette";
|
||||
import { TemplatePalette } from "./TemplatePalette";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
|
||||
interface AccordionSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const SECTIONS: AccordionSection[] = [
|
||||
{ id: "templates", label: "기본 템플릿", icon: <FileText className="h-5 w-5" /> },
|
||||
{ id: "components", label: "컴포넌트", icon: <Puzzle className="h-5 w-5" /> },
|
||||
];
|
||||
|
||||
export function ReportDesignerLeftPanel() {
|
||||
return (
|
||||
<div className="w-80 border-r bg-white">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 템플릿 */}
|
||||
<Card className="border-2">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">기본 템플릿</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<TemplatePalette />
|
||||
</CardContent>
|
||||
</Card>
|
||||
const [expandedSection, setExpandedSection] = useState<string>("components");
|
||||
const { isLeftPanelCollapsed, setIsLeftPanelCollapsed } = useReportDesigner();
|
||||
|
||||
{/* 컴포넌트 */}
|
||||
<Card className="border-2">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">컴포넌트</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ComponentPalette />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
const toggleSection = (id: string) => {
|
||||
setExpandedSection(expandedSection === id ? "" : id);
|
||||
};
|
||||
|
||||
if (isLeftPanelCollapsed) {
|
||||
return (
|
||||
<div className="flex h-full w-10 shrink-0 flex-col items-center border-r border-gray-200 bg-white pt-2 gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setIsLeftPanelCollapsed(false)}
|
||||
title="도구상자 열기"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
{SECTIONS.map((section) => (
|
||||
<Button
|
||||
key={section.id}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
title={section.label}
|
||||
onClick={() => {
|
||||
setIsLeftPanelCollapsed(false);
|
||||
setExpandedSection(section.id);
|
||||
}}
|
||||
>
|
||||
{section.icon}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[260px] shrink-0 flex-col border-r border-gray-200 bg-white">
|
||||
<div className="flex h-11 items-center justify-between border-b border-gray-200 px-3">
|
||||
<span className="text-sm font-bold text-gray-800">도구상자</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setIsLeftPanelCollapsed(true)}
|
||||
title="도구상자 접기"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{SECTIONS.map((section) => {
|
||||
const isExpanded = expandedSection === section.id;
|
||||
return (
|
||||
<div key={section.id} className="border-b-2 border-gray-100">
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className={`flex h-14 w-full items-center justify-between px-5 transition-colors ${
|
||||
isExpanded
|
||||
? "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-3">
|
||||
<span className={isExpanded ? "" : "text-blue-600"}>{section.icon}</span>
|
||||
<span className="text-base font-bold">{section.label}</span>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={`h-5 w-5 transition-transform ${isExpanded ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-5">
|
||||
{section.id === "templates" && <TemplatePalette />}
|
||||
{section.id === "components" && <ComponentPalette />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,14 @@ import {
|
||||
Ruler as RulerIcon,
|
||||
Group,
|
||||
Ungroup,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -60,8 +64,10 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { ReportPreviewModal } from "./ReportPreviewModal";
|
||||
|
||||
export function ReportDesignerToolbar() {
|
||||
const router = useRouter();
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const currentTabId = useTabId();
|
||||
const {
|
||||
reportId,
|
||||
reportDetail,
|
||||
saveLayoutWithMenus,
|
||||
isSaving,
|
||||
@@ -102,6 +108,12 @@ export function ReportDesignerToolbar() {
|
||||
groupComponents,
|
||||
ungroupComponents,
|
||||
menuObjids,
|
||||
zoom,
|
||||
setZoom,
|
||||
fitToScreen,
|
||||
isPageListCollapsed,
|
||||
isLeftPanelCollapsed,
|
||||
isRightPanelCollapsed,
|
||||
} = useReportDesigner();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
@@ -127,12 +139,27 @@ export function ReportDesignerToolbar() {
|
||||
setShowGrid(newValue);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (reportId !== "new") {
|
||||
await saveLayoutWithMenus(menuObjids);
|
||||
return;
|
||||
}
|
||||
setPendingSaveAndClose(false);
|
||||
setShowMenuSelect(true);
|
||||
};
|
||||
|
||||
const handleSaveAndClose = () => {
|
||||
const closeDesignerTab = useCallback(() => {
|
||||
if (currentTabId) {
|
||||
closeTab(currentTabId);
|
||||
}
|
||||
}, [currentTabId, closeTab]);
|
||||
|
||||
const handleSaveAndClose = async () => {
|
||||
if (reportId !== "new") {
|
||||
await saveLayoutWithMenus(menuObjids);
|
||||
closeDesignerTab();
|
||||
return;
|
||||
}
|
||||
setPendingSaveAndClose(true);
|
||||
setShowMenuSelect(true);
|
||||
};
|
||||
@@ -140,7 +167,7 @@ export function ReportDesignerToolbar() {
|
||||
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
|
||||
await saveLayoutWithMenus(selectedMenuObjids);
|
||||
if (pendingSaveAndClose) {
|
||||
router.push("/admin/screenMng/reportList");
|
||||
closeDesignerTab();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,7 +178,7 @@ export function ReportDesignerToolbar() {
|
||||
|
||||
const handleBackConfirm = () => {
|
||||
setShowBackConfirm(false);
|
||||
router.push("/admin/screenMng/reportList");
|
||||
closeDesignerTab();
|
||||
};
|
||||
|
||||
const handleSaveAsTemplate = async (data: {
|
||||
@@ -211,298 +238,245 @@ export function ReportDesignerToolbar() {
|
||||
}
|
||||
};
|
||||
|
||||
const leftToolbarWidth = (isPageListCollapsed ? 40 : 160) + (isLeftPanelCollapsed ? 40 : 260);
|
||||
const rightToolbarWidth = isRightPanelCollapsed ? 40 : 340;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowBackConfirm(true)} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
<div className="flex h-14 items-center border-b border-gray-200 bg-white">
|
||||
{/* 좌측: 뒤로가기 + 제목 (패널 너비에 연동) */}
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 pl-3 transition-all duration-200"
|
||||
style={{ width: `${leftToolbarWidth}px` }}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => setShowBackConfirm(true)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-muted/60" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-sm leading-tight font-bold text-gray-900 lg:text-lg">
|
||||
{reportDetail?.report.report_name_kor || "리포트 디자이너"}
|
||||
</h2>
|
||||
{reportDetail?.report.report_name_eng && (
|
||||
<p className="text-sm text-muted-foreground">{reportDetail.report.report_name_eng}</p>
|
||||
</h1>
|
||||
{reportDetail?.report.report_name_eng && !isLeftPanelCollapsed && (
|
||||
<p className="truncate text-xs leading-tight text-gray-500">{reportDetail.report.report_name_eng}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={snapToGrid && showGrid ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleGrid}
|
||||
className="gap-2"
|
||||
title="Grid Snap 및 표시 켜기/끄기"
|
||||
>
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
{snapToGrid && showGrid ? "Grid ON" : "Grid OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showRuler ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setShowRuler(!showRuler)}
|
||||
className="gap-2"
|
||||
title="눈금자 표시 켜기/끄기"
|
||||
>
|
||||
<RulerIcon className="h-4 w-4" />
|
||||
{showRuler ? "눈금자 ON" : "눈금자 OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
className="gap-2"
|
||||
title="실행 취소 (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
className="gap-2"
|
||||
title="다시 실행 (Ctrl+Shift+Z)"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 중앙: 도구 그룹 (캔버스 영역에 맞춰 중앙 정렬) */}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-1 overflow-x-auto px-2 scrollbar-none lg:gap-2">
|
||||
{/* 뷰 도구 */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg bg-gray-50 px-1 py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 ${showRuler ? "bg-white shadow-sm" : ""}`}
|
||||
onClick={() => setShowRuler(!showRuler)}
|
||||
title="눈금자 표시 켜기/끄기"
|
||||
>
|
||||
<RulerIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 ${snapToGrid && showGrid ? "bg-white shadow-sm" : ""}`}
|
||||
onClick={handleToggleGrid}
|
||||
title="Grid Snap 및 표시 켜기/끄기"
|
||||
>
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 정렬 드롭다운 */}
|
||||
{/* 줌 도구 */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg bg-gray-50 px-1 py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom(Math.max(0.1, Math.round((zoom - 0.1) * 10) / 10))}
|
||||
title="축소"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
className="min-w-[46px] rounded px-1 py-1 text-center text-xs font-medium text-gray-700 hover:bg-gray-200"
|
||||
onClick={fitToScreen}
|
||||
title="화면에 맞추기"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom(Math.min(3, Math.round((zoom + 0.1) * 10) / 10))}
|
||||
title="확대"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={fitToScreen}
|
||||
title="화면에 맞추기"
|
||||
>
|
||||
<Maximize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 편집 도구 */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg bg-gray-50 px-1 py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="실행 취소 (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="다시 실행 (Ctrl+Shift+Z)"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 정렬 도구 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canAlign}
|
||||
className="gap-2"
|
||||
title="정렬 (2개 이상 선택 필요)"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="h-9 gap-2" title="정렬 및 배치 도구">
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
정렬
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={alignLeft}>
|
||||
<AlignLeft className="mr-2 h-4 w-4" />
|
||||
왼쪽 정렬
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignRight}>
|
||||
<AlignRight className="mr-2 h-4 w-4" />
|
||||
오른쪽 정렬
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignTop}>
|
||||
<AlignVerticalJustifyStart className="mr-2 h-4 w-4" />
|
||||
위쪽 정렬
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignBottom}>
|
||||
<AlignVerticalJustifyEnd className="mr-2 h-4 w-4" />
|
||||
아래쪽 정렬
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">정렬 (2개 이상 선택)</div>
|
||||
<div className="grid grid-cols-2 gap-1 p-1">
|
||||
<DropdownMenuItem onClick={alignLeft} disabled={!canAlign} className="justify-center">
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignRight} disabled={!canAlign} className="justify-center">
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignTop} disabled={!canAlign} className="justify-center">
|
||||
<AlignVerticalJustifyStart className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignBottom} disabled={!canAlign} className="justify-center">
|
||||
<AlignVerticalJustifyEnd className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignCenterHorizontal} disabled={!canAlign} className="justify-center">
|
||||
<AlignCenterHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignCenterVertical} disabled={!canAlign} className="justify-center">
|
||||
<AlignCenterVertical className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={alignCenterHorizontal}>
|
||||
<AlignCenterHorizontal className="mr-2 h-4 w-4" />
|
||||
가로 중앙 정렬
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignCenterVertical}>
|
||||
<AlignCenterVertical className="mr-2 h-4 w-4" />
|
||||
세로 중앙 정렬
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">배치 (3개 이상 선택)</div>
|
||||
<div className="grid grid-cols-2 gap-1 p-1">
|
||||
<DropdownMenuItem onClick={distributeHorizontal} disabled={!canDistribute} className="justify-center">
|
||||
<AlignHorizontalDistributeCenter className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={distributeVertical} disabled={!canDistribute} className="justify-center">
|
||||
<AlignVerticalDistributeCenter className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
|
||||
{/* 배치 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canDistribute}
|
||||
className="gap-2"
|
||||
title="균등 배치 (3개 이상 선택 필요)"
|
||||
>
|
||||
<AlignHorizontalDistributeCenter className="h-4 w-4" />
|
||||
배치
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={distributeHorizontal}>
|
||||
<AlignHorizontalDistributeCenter className="mr-2 h-4 w-4" />
|
||||
가로 균등 배치
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={distributeVertical}>
|
||||
<AlignVerticalDistributeCenter className="mr-2 h-4 w-4" />
|
||||
세로 균등 배치
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 크기 조정 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canAlign}
|
||||
className="gap-2"
|
||||
title="크기 조정 (2개 이상 선택 필요)"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
크기
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={makeSameWidth}>
|
||||
<RectangleHorizontal className="mr-2 h-4 w-4" />
|
||||
같은 너비로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={makeSameHeight}>
|
||||
<RectangleVertical className="mr-2 h-4 w-4" />
|
||||
같은 높이로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={makeSameSize}>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
같은 크기로
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 레이어 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasSelection}
|
||||
className="gap-2"
|
||||
title="레이어 순서 (1개 이상 선택 필요)"
|
||||
>
|
||||
<ChevronsUp className="h-4 w-4" />
|
||||
레이어
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={bringToFront}>
|
||||
<ChevronsUp className="mr-2 h-4 w-4" />맨 앞으로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={bringForward}>
|
||||
<ChevronUp className="mr-2 h-4 w-4" />한 단계 앞으로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={sendBackward}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />한 단계 뒤로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={sendToBack}>
|
||||
<ChevronsDown className="mr-2 h-4 w-4" />맨 뒤로
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">크기 맞춤 (2개 이상 선택)</div>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={makeSameWidth}
|
||||
disabled={!canAlign}
|
||||
className="justify-center"
|
||||
title="같은 너비로"
|
||||
>
|
||||
<RectangleHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={makeSameHeight}
|
||||
disabled={!canAlign}
|
||||
className="justify-center"
|
||||
title="같은 높이로"
|
||||
>
|
||||
<RectangleVertical className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={makeSameSize}
|
||||
disabled={!canAlign}
|
||||
className="justify-center"
|
||||
title="같은 크기로"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
|
||||
{/* 잠금 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasSelection}
|
||||
className="gap-2"
|
||||
title="컴포넌트 잠금/해제 (1개 이상 선택 필요)"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
잠금
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={toggleLock}>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
토글 (잠금/해제)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={lockComponents}>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
잠금 설정
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">레이어 및 그룹 (1개 이상 선택)</div>
|
||||
<DropdownMenuItem onClick={bringToFront} disabled={!hasSelection}>
|
||||
<ChevronsUp className="mr-2 h-4 w-4" /> 맨 앞으로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={unlockComponents}>
|
||||
<Unlock className="mr-2 h-4 w-4" />
|
||||
잠금 해제
|
||||
<DropdownMenuItem onClick={sendToBack} disabled={!hasSelection}>
|
||||
<ChevronsDown className="mr-2 h-4 w-4" /> 맨 뒤로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={toggleLock} disabled={!hasSelection}>
|
||||
<Lock className="mr-2 h-4 w-4" /> 잠금/해제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 그룹화 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasSelection}
|
||||
className="gap-2"
|
||||
title="컴포넌트 그룹화/해제"
|
||||
>
|
||||
<Group className="h-4 w-4" />
|
||||
그룹
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={groupComponents} disabled={!canGroup}>
|
||||
<Group className="mr-2 h-4 w-4" />
|
||||
그룹화 (2개 이상)
|
||||
<Group className="mr-2 h-4 w-4" /> 그룹화
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={ungroupComponents} disabled={!hasSelection}>
|
||||
<Ungroup className="mr-2 h-4 w-4" />
|
||||
그룹 해제
|
||||
<Ungroup className="mr-2 h-4 w-4" /> 그룹 해제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setShowResetConfirm(true)} className="gap-2">
|
||||
{/* 우측: 액션 버튼들 (패널 너비에 연동) */}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-end gap-1 pr-2 transition-all duration-200 lg:gap-2 lg:pr-3"
|
||||
style={{ width: `${Math.max(rightToolbarWidth, 220)}px` }}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowResetConfirm(true)} className="h-9 gap-1 px-2 lg:gap-2 lg:px-3">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
초기화
|
||||
<span className="hidden xl:inline">초기화</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)} className="gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)} className="h-9 gap-1 px-2 lg:gap-2 lg:px-3">
|
||||
<Eye className="h-4 w-4" />
|
||||
미리보기
|
||||
<span className="hidden xl:inline">미리보기</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveAsTemplate(true)}
|
||||
disabled={!canSaveAsTemplate}
|
||||
className="gap-2"
|
||||
className="hidden h-9 gap-2 xl:flex"
|
||||
title={!canSaveAsTemplate ? "컴포넌트를 추가한 후 템플릿으로 저장할 수 있습니다" : ""}
|
||||
>
|
||||
<BookTemplate className="h-4 w-4" />
|
||||
템플릿으로 저장
|
||||
템플릿 저장
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="h-9 gap-1 px-2 lg:gap-2 lg:px-3">
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="hidden lg:inline">저장</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveAndClose} disabled={isSaving} className="gap-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
저장 후 닫기
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveAndClose}
|
||||
disabled={isSaving}
|
||||
className="h-9 gap-1 bg-blue-600 px-2 text-white hover:bg-blue-700 lg:gap-2 lg:px-3"
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="hidden lg:inline">저장 후 닫기</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { JSX } from "react";
|
||||
import { MM_TO_PX } from "@/lib/report/constants";
|
||||
|
||||
interface RulerProps {
|
||||
orientation: "horizontal" | "vertical";
|
||||
length: number; // mm 단위
|
||||
offset?: number; // 스크롤 오프셋 (px)
|
||||
length: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// 고정 스케일 팩터 (화면 해상도와 무관)
|
||||
const MM_TO_PX = 4;
|
||||
|
||||
export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
// mm를 px로 변환
|
||||
const mmToPx = (mm: number) => mm * MM_TO_PX;
|
||||
@@ -31,7 +29,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={`major-${mm}`}
|
||||
className="absolute bg-foreground/90"
|
||||
className="absolute bg-gray-700"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
@@ -55,7 +53,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={`label-${mm}`}
|
||||
className="absolute text-[9px] text-muted-foreground"
|
||||
className="absolute text-[9px] text-gray-600"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
@@ -79,7 +77,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={`medium-${mm}`}
|
||||
className="absolute bg-muted0"
|
||||
className="absolute bg-gray-500"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
@@ -103,7 +101,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={`minor-${mm}`}
|
||||
className="absolute bg-muted-foreground"
|
||||
className="absolute bg-gray-400"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
@@ -129,7 +127,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-muted select-none"
|
||||
className="relative bg-gray-100 select-none"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -29,6 +30,22 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const hasInputData = useCallback(() => {
|
||||
return formData.templateNameKor.trim() !== "" ||
|
||||
formData.templateNameEng.trim() !== "" ||
|
||||
formData.description.trim() !== "";
|
||||
}, [formData]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges: () => !isSaving && hasInputData(),
|
||||
onClose: () => {
|
||||
setFormData({ templateNameKor: "", templateNameEng: "", description: "" });
|
||||
onClose();
|
||||
},
|
||||
title: "입력된 내용이 있습니다",
|
||||
description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?",
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.templateNameKor.trim()) {
|
||||
alert("템플릿명을 입력해주세요.");
|
||||
@@ -43,12 +60,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||
description: formData.description || undefined,
|
||||
});
|
||||
|
||||
// 초기화
|
||||
setFormData({
|
||||
templateNameKor: "",
|
||||
templateNameEng: "",
|
||||
description: "",
|
||||
});
|
||||
setFormData({ templateNameKor: "", templateNameEng: "", description: "" });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("템플릿 저장 실패:", error);
|
||||
@@ -57,96 +69,89 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSaving) {
|
||||
setFormData({
|
||||
templateNameKor: "",
|
||||
templateNameEng: "",
|
||||
description: "",
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>템플릿으로 저장</DialogTitle>
|
||||
<DialogDescription>
|
||||
현재 리포트 레이아웃을 템플릿으로 저장하면 다른 리포트에서 재사용할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>템플릿으로 저장</DialogTitle>
|
||||
<DialogDescription>
|
||||
현재 리포트 레이아웃을 템플릿으로 저장하면 다른 리포트에서 재사용할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="templateNameKor">
|
||||
템플릿명 (한국어) <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="templateNameKor"
|
||||
value={formData.templateNameKor}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
templateNameKor: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="예: 발주서 양식"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="templateNameKor">
|
||||
템플릿명 (한국어) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="templateNameKor"
|
||||
value={formData.templateNameKor}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
templateNameKor: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="예: 발주서 양식"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="templateNameEng">템플릿명 (영어)</Label>
|
||||
<Input
|
||||
id="templateNameEng"
|
||||
value={formData.templateNameEng}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
templateNameEng: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="예: Purchase Order Template"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="템플릿에 대한 간단한 설명을 입력하세요"
|
||||
rows={3}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="templateNameEng">템플릿명 (영어)</Label>
|
||||
<Input
|
||||
id="templateNameEng"
|
||||
value={formData.templateNameEng}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
templateNameEng: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="예: Purchase Order Template"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={guard.tryClose} disabled={isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
"저장"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="템플릿에 대한 간단한 설명을 입력하세요"
|
||||
rows={3}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
"저장"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProp
|
||||
{generatedSignatures.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">레이블 위치</Label>
|
||||
<p className="text-xs text-muted-foreground">더블클릭하여 서명을 선택하세요</p>
|
||||
<p className="text-xs text-gray-500">더블클릭하여 서명을 선택하세요</p>
|
||||
<ScrollArea className="h-[300px] rounded-md border bg-white">
|
||||
<div className="space-y-2 p-2">
|
||||
{generatedSignatures.map((signature, index) => (
|
||||
@@ -255,7 +255,7 @@ export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProp
|
||||
alt={`서명 ${index + 1}`}
|
||||
className="h-auto max-h-[45px] w-auto max-w-[280px] object-contain"
|
||||
/>
|
||||
<p className="ml-2 text-xs text-muted-foreground/70 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<p className="ml-2 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{fonts[index].name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@ export function SignaturePad({ onSignatureChange, initialSignature }: SignatureP
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Card className="overflow-hidden border-2 border-dashed border-input bg-white p-2">
|
||||
<Card className="overflow-hidden border-2 border-dashed border-gray-300 bg-white p-2">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={300}
|
||||
@@ -121,7 +121,7 @@ export function SignaturePad({ onSignatureChange, initialSignature }: SignatureP
|
||||
/>
|
||||
</Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-gray-500">
|
||||
<Pen className="mr-1 inline h-3 w-3" />
|
||||
마우스로 서명해주세요
|
||||
</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Trash2, Loader2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -92,20 +92,13 @@ export function TemplatePalette() {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 사용자 정의 템플릿 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={fetchTemplates} disabled={isLoading} className="h-6 w-6 p-0">
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground/70" />
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : customTemplates.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground/70">저장된 템플릿이 없습니다</p>
|
||||
<p className="py-4 text-center text-xs text-gray-400">저장된 템플릿이 없습니다</p>
|
||||
) : (
|
||||
customTemplates.map((template) => (
|
||||
<div key={template.template_id} className="group relative">
|
||||
@@ -131,7 +124,7 @@ export function TemplatePalette() {
|
||||
{deletingId === template.template_id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
<Trash2 className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { WatermarkConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
watermark: WatermarkConfig;
|
||||
/** 캔버스/페이지 너비 (px) */
|
||||
width: number;
|
||||
/** 캔버스/페이지 높이 (px) */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 워터마크 레이어 공용 컴포넌트
|
||||
*
|
||||
* ReportDesignerCanvas 와 ReportPreviewModal 양쪽에서 사용.
|
||||
* imageUrl 이 "data:" 로 시작하면 그대로 사용하고,
|
||||
* 서버 경로인 경우 getFullImageUrl 로 변환한다.
|
||||
*/
|
||||
export function WatermarkLayer({ watermark, width, height }: Props) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
|
||||
const resolveImageSrc = (url: string): string => (url.startsWith("data:") ? url : getFullImageUrl(url));
|
||||
|
||||
const renderContent = (tileFontSize?: number) => {
|
||||
if (watermark.type === "text") {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${tileFontSize ?? watermark.fontSize ?? 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (watermark.imageUrl) {
|
||||
return null; // 이미지는 각 스타일 블록에서 직접 렌더링
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 대각선 스타일
|
||||
if (watermark.style === "diagonal") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text"
|
||||
? renderContent()
|
||||
: watermark.imageUrl && (
|
||||
<img
|
||||
src={resolveImageSrc(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 중앙 스타일
|
||||
if (watermark.style === "center") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text"
|
||||
? renderContent()
|
||||
: watermark.imageUrl && (
|
||||
<img
|
||||
src={resolveImageSrc(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 타일 스타일
|
||||
if (watermark.style === "tile") {
|
||||
const tileRotation = watermark.rotation ?? -30;
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil(width / tileSize) + 2;
|
||||
const rows = Math.ceil(height / tileSize) + 2;
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignContent: "flex-start",
|
||||
transform: `rotate(${tileRotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows * cols }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text"
|
||||
? renderContent(watermark.fontSize || 24)
|
||||
: watermark.imageUrl && (
|
||||
<img
|
||||
src={resolveImageSrc(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
width: `${tileSize * 0.6}px`,
|
||||
height: `${tileSize * 0.6}px`,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useDrag } from "react-dnd";
|
||||
import {
|
||||
Type, CreditCard, Minus, Tag,
|
||||
ImageIcon, Hash, Calendar, Link2, Circle, Space, FileText,
|
||||
} from "lucide-react";
|
||||
import type { CardElementType } from "@/types/report";
|
||||
|
||||
interface CardElementItem {
|
||||
type: CardElementType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const CARD_ELEMENTS: CardElementItem[] = [
|
||||
{ type: "header", label: "헤더", icon: <Type className="h-4 w-4" /> },
|
||||
{ type: "dataCell", label: "데이터 셀", icon: <CreditCard className="h-4 w-4" /> },
|
||||
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
|
||||
{ type: "badge", label: "뱃지", icon: <Tag className="h-4 w-4" /> },
|
||||
{ type: "image", label: "이미지", icon: <ImageIcon className="h-4 w-4" /> },
|
||||
{ type: "number", label: "숫자/금액", icon: <Hash className="h-4 w-4" /> },
|
||||
{ type: "date", label: "날짜", icon: <Calendar className="h-4 w-4" /> },
|
||||
{ type: "link", label: "링크", icon: <Link2 className="h-4 w-4" /> },
|
||||
{ type: "status", label: "상태", icon: <Circle className="h-4 w-4" /> },
|
||||
{ type: "spacer", label: "빈 공간", icon: <Space className="h-4 w-4" /> },
|
||||
{ type: "staticText", label: "고정 텍스트", icon: <FileText className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
export const CARD_ELEMENT_DND_TYPE = "card-element";
|
||||
|
||||
interface DraggableElementProps {
|
||||
type: CardElementType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function DraggableElement({ type, label, icon }: DraggableElementProps) {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: CARD_ELEMENT_DND_TYPE,
|
||||
item: { elementType: type },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag as any}
|
||||
className={`flex cursor-move flex-col items-center justify-center gap-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 transition-colors hover:border-blue-400 hover:bg-blue-50 ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="text-gray-600">{icon}</div>
|
||||
<span className="text-xs font-medium text-gray-700">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardElementPalette() {
|
||||
return (
|
||||
<div className="bg-white border border-border rounded-xl p-4">
|
||||
<div className="text-sm font-bold text-gray-800 mb-3">
|
||||
요소 팔레트
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">드래그하여 추가</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{CARD_ELEMENTS.map((item) => (
|
||||
<DraggableElement key={item.type} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Database,
|
||||
Palette,
|
||||
X,
|
||||
Type,
|
||||
CreditCard,
|
||||
Minus,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import type {
|
||||
CardLayoutConfig,
|
||||
CardLayoutRow,
|
||||
CardElement,
|
||||
CardDataCellElement,
|
||||
CardBadgeElement,
|
||||
} from "@/types/report";
|
||||
import { CardElementPalette } from "./CardElementPalette";
|
||||
import { CardCanvasEditor } from "./CardCanvasEditor";
|
||||
|
||||
interface CardLayoutModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialConfig?: CardLayoutConfig;
|
||||
onSave: (config: CardLayoutConfig) => void;
|
||||
}
|
||||
|
||||
type TabType = "layout" | "binding" | "style";
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
const generateId = () =>
|
||||
`row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
const DEFAULT_CONFIG: CardLayoutConfig = {
|
||||
tableName: "",
|
||||
primaryKey: "",
|
||||
rows: [
|
||||
{
|
||||
id: generateId(),
|
||||
gridColumns: 4,
|
||||
elements: [],
|
||||
},
|
||||
],
|
||||
padding: "12px",
|
||||
gap: "8px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "#e5e7eb",
|
||||
backgroundColor: "#ffffff",
|
||||
headerTitleFontSize: 14,
|
||||
headerTitleColor: "#1e40af",
|
||||
labelFontSize: 13,
|
||||
labelColor: "#374151",
|
||||
valueFontSize: 13,
|
||||
valueColor: "#000000",
|
||||
dividerThickness: 1,
|
||||
dividerColor: "#e5e7eb",
|
||||
};
|
||||
|
||||
const getElementIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "header":
|
||||
return <Type className="w-3 h-3" />;
|
||||
case "dataCell":
|
||||
return <CreditCard className="w-3 h-3" />;
|
||||
case "divider":
|
||||
return <Minus className="w-3 h-3" />;
|
||||
case "badge":
|
||||
return <Tag className="w-3 h-3" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function CardLayoutModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialConfig,
|
||||
onSave,
|
||||
}: CardLayoutModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("layout");
|
||||
const [config, setConfig] = useState<CardLayoutConfig>(
|
||||
initialConfig || DEFAULT_CONFIG,
|
||||
);
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const initialSnapshotRef = useRef<string>("");
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
return JSON.stringify(config) !== initialSnapshotRef.current;
|
||||
}, [config]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges,
|
||||
onClose: () => onOpenChange(false),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const initConfig = initialConfig || DEFAULT_CONFIG;
|
||||
setConfig(initConfig);
|
||||
initialSnapshotRef.current = JSON.stringify(initConfig);
|
||||
setActiveTab("layout");
|
||||
fetchTables();
|
||||
}
|
||||
}, [open, initialConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.tableName) {
|
||||
fetchColumns(config.tableName);
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
}, [config.tableName]);
|
||||
|
||||
const fetchTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await reportApi.getSchemaTableList();
|
||||
if (response.success) {
|
||||
setTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchColumns = async (tableName: string) => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const response = await reportApi.getSchemaTableColumns(tableName);
|
||||
if (response.success) {
|
||||
setColumns(response.data);
|
||||
const pkCandidate = response.data.find(
|
||||
(col) =>
|
||||
col.column_name.endsWith("_id") ||
|
||||
col.column_name === "id" ||
|
||||
col.column_name.endsWith("_pk"),
|
||||
);
|
||||
if (pkCandidate && !config.primaryKey) {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
primaryKey: pkCandidate.column_name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const usedColumns = useMemo(() => {
|
||||
const used = new Set<string>();
|
||||
config.rows.forEach((row) => {
|
||||
row.elements.forEach((el) => {
|
||||
if (el.type === "dataCell" && (el as CardDataCellElement).columnName) {
|
||||
used.add((el as CardDataCellElement).columnName!);
|
||||
}
|
||||
if (el.type === "badge" && (el as CardBadgeElement).columnName) {
|
||||
used.add((el as CardBadgeElement).columnName!);
|
||||
}
|
||||
});
|
||||
});
|
||||
return used;
|
||||
}, [config.rows]);
|
||||
|
||||
const handleTableChange = (tableName: string) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
tableName,
|
||||
primaryKey: "",
|
||||
rows: prev.rows.map((row) => ({
|
||||
...row,
|
||||
elements: row.elements.map((el) => {
|
||||
if (el.type === "dataCell") {
|
||||
return { ...el, columnName: undefined } as CardDataCellElement;
|
||||
}
|
||||
if (el.type === "badge") {
|
||||
return { ...el, columnName: undefined } as CardBadgeElement;
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRowsChange = (rows: CardLayoutRow[]) => {
|
||||
setConfig((prev) => ({ ...prev, rows }));
|
||||
};
|
||||
|
||||
const handleColumnMapping = (
|
||||
rowIndex: number,
|
||||
elementIndex: number,
|
||||
columnName: string,
|
||||
) => {
|
||||
setConfig((prev) => {
|
||||
const newRows = [...prev.rows];
|
||||
const newElements = [...newRows[rowIndex].elements];
|
||||
const element = newElements[elementIndex];
|
||||
if (element.type === "dataCell") {
|
||||
newElements[elementIndex] = {
|
||||
...element,
|
||||
columnName,
|
||||
} as CardDataCellElement;
|
||||
} else if (element.type === "badge") {
|
||||
newElements[elementIndex] = {
|
||||
...element,
|
||||
columnName,
|
||||
} as CardBadgeElement;
|
||||
}
|
||||
newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements };
|
||||
return { ...prev, rows: newRows };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(config);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const renderLayoutTab = () => (
|
||||
<div className="space-y-4">
|
||||
<CardElementPalette />
|
||||
<CardCanvasEditor rows={config.rows} onRowsChange={handleRowsChange} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBindingTab = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
테이블 선택
|
||||
</Label>
|
||||
<Select
|
||||
value={config.tableName || ""}
|
||||
onValueChange={handleTableChange}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue
|
||||
placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
{table.table_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
기본 키
|
||||
</Label>
|
||||
<Select
|
||||
value={config.primaryKey || ""}
|
||||
onValueChange={(pk) => setConfig((prev) => ({ ...prev, primaryKey: pk }))}
|
||||
disabled={!config.tableName || loadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm bg-muted">
|
||||
<SelectValue placeholder="기본 키 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
backgroundColor: config.backgroundColor,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: config.gap,
|
||||
}}
|
||||
>
|
||||
{config.rows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${row.gridColumns}, 1fr)`,
|
||||
gap: config.gap,
|
||||
}}
|
||||
>
|
||||
{row.elements.map((element, elementIndex) => {
|
||||
const needsBinding =
|
||||
element.type === "dataCell" || element.type === "badge";
|
||||
const currentColumn =
|
||||
element.type === "dataCell"
|
||||
? (element as CardDataCellElement).columnName
|
||||
: element.type === "badge"
|
||||
? (element as CardBadgeElement).columnName
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="border border-gray-100 rounded px-2 py-1.5 hover:bg-gray-50"
|
||||
style={{ gridColumn: `span ${element.colspan || 1}` }}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="text-gray-400">
|
||||
{getElementIcon(element.type)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{element.type === "header" && (element as any).title}
|
||||
{element.type === "dataCell" &&
|
||||
(element as CardDataCellElement).label}
|
||||
{element.type === "divider" && "구분선"}
|
||||
{element.type === "badge" &&
|
||||
((element as CardBadgeElement).label || "뱃지")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{needsBinding && (
|
||||
<Select
|
||||
value={currentColumn || ""}
|
||||
onValueChange={(col) =>
|
||||
handleColumnMapping(rowIndex, elementIndex, col)
|
||||
}
|
||||
disabled={!config.tableName}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`h-7 text-xs ${
|
||||
currentColumn
|
||||
? "bg-blue-50 text-blue-700 border-blue-200"
|
||||
: "border-dashed"
|
||||
}`}
|
||||
>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => {
|
||||
const isUsed =
|
||||
usedColumns.has(col.column_name) &&
|
||||
currentColumn !== col.column_name;
|
||||
return (
|
||||
<SelectItem
|
||||
key={col.column_name}
|
||||
value={col.column_name}
|
||||
disabled={isUsed}
|
||||
className={isUsed ? "opacity-50" : ""}
|
||||
>
|
||||
{col.column_name}
|
||||
{isUsed && " (사용 중)"}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!config.tableName && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
테이블을 먼저 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.tableName && config.rows.every((r) => r.elements.length === 0) && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
레이아웃 탭에서 요소를 먼저 추가해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStyleTab = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white border border-border rounded-xl p-4 space-y-3">
|
||||
<div className="text-xs font-medium text-foreground mb-2">카드 스타일</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">패딩</Label>
|
||||
<Select value={config.padding || "12px"} onValueChange={(v) => setConfig((prev) => ({ ...prev, padding: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="8px">8px</SelectItem>
|
||||
<SelectItem value="12px">12px</SelectItem>
|
||||
<SelectItem value="16px">16px</SelectItem>
|
||||
<SelectItem value="20px">20px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">행 간격</Label>
|
||||
<Select value={config.gap || "8px"} onValueChange={(v) => setConfig((prev) => ({ ...prev, gap: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="4px">4px</SelectItem>
|
||||
<SelectItem value="8px">8px</SelectItem>
|
||||
<SelectItem value="12px">12px</SelectItem>
|
||||
<SelectItem value="16px">16px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">테두리</Label>
|
||||
<Select value={config.borderStyle || "solid"} onValueChange={(v) => setConfig((prev) => ({ ...prev, borderStyle: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">점선</SelectItem>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">테두리 색상</Label>
|
||||
<Input type="color" value={config.borderColor || "#e5e7eb"} onChange={(e) => setConfig((prev) => ({ ...prev, borderColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label className="text-xs font-medium text-foreground">배경색</Label>
|
||||
<Input type="color" value={config.backgroundColor || "#ffffff"} onChange={(e) => setConfig((prev) => ({ ...prev, backgroundColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-border rounded-xl p-4 space-y-3">
|
||||
<div className="text-xs font-medium text-foreground mb-2">요소별 스타일</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">헤더 폰트 크기</Label>
|
||||
<Input type="number" min={10} max={24} value={config.headerTitleFontSize || 14} onChange={(e) => setConfig((prev) => ({ ...prev, headerTitleFontSize: parseInt(e.target.value) || 14 }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">헤더 색상</Label>
|
||||
<Input type="color" value={config.headerTitleColor || "#1e40af"} onChange={(e) => setConfig((prev) => ({ ...prev, headerTitleColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">라벨 폰트 크기</Label>
|
||||
<Input type="number" min={10} max={20} value={config.labelFontSize || 13} onChange={(e) => setConfig((prev) => ({ ...prev, labelFontSize: parseInt(e.target.value) || 13 }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">라벨 색상</Label>
|
||||
<Input type="color" value={config.labelColor || "#374151"} onChange={(e) => setConfig((prev) => ({ ...prev, labelColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">값 폰트 크기</Label>
|
||||
<Input type="number" min={10} max={20} value={config.valueFontSize || 13} onChange={(e) => setConfig((prev) => ({ ...prev, valueFontSize: parseInt(e.target.value) || 13 }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">값 색상</Label>
|
||||
<Input type="color" value={config.valueColor || "#000000"} onChange={(e) => setConfig((prev) => ({ ...prev, valueColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">구분선 두께</Label>
|
||||
<Input type="number" min={1} max={5} value={config.dividerThickness || 1} onChange={(e) => setConfig((prev) => ({ ...prev, dividerThickness: parseInt(e.target.value) || 1 }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">구분선 색상</Label>
|
||||
<Input type="color" value={config.dividerColor || "#e5e7eb"} onChange={(e) => setConfig((prev) => ({ ...prev, dividerColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="max-w-4xl h-[92vh] overflow-hidden flex flex-col p-0 [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">카드 레이아웃 설정</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
카드 컴포넌트의 레이아웃, 데이터 바인딩, 스타일을 설정합니다
|
||||
</DialogDescription>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutGrid className="w-4 h-4 text-blue-600" />
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
카드 레이아웃 설정
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={guard.tryClose}
|
||||
className="w-8 h-8"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab */}
|
||||
<div className="mx-6 mt-3">
|
||||
<div className="h-9 bg-muted/30 rounded-lg p-0.5 inline-flex">
|
||||
<button
|
||||
onClick={() => setActiveTab("layout")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
activeTab === "layout"
|
||||
? "bg-blue-50 text-blue-700 shadow-sm"
|
||||
: "bg-transparent text-foreground hover:text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
레이아웃
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("binding")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
activeTab === "binding"
|
||||
? "bg-blue-50 text-blue-700 shadow-sm"
|
||||
: "bg-transparent text-foreground hover:text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
데이터 바인딩
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("style")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
activeTab === "style"
|
||||
? "bg-blue-50 text-blue-700 shadow-sm"
|
||||
: "bg-transparent text-foreground hover:text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
<Palette className="w-3.5 h-3.5" />
|
||||
스타일
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{activeTab === "layout" && renderLayoutTab()}
|
||||
{activeTab === "binding" && renderBindingTab()}
|
||||
{activeTab === "style" && renderStyleTab()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={guard.tryClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CardLayoutTabs.tsx — 카드 컴포넌트 설정 탭
|
||||
*
|
||||
* [역할]
|
||||
* - 카드 컴포넌트의 레이아웃 구성 / 데이터 연결 / 표시 조건을 3탭 구조로 제공
|
||||
* - ComponentSettingsModal 내에 직접 임베드되어 사용
|
||||
*
|
||||
* [사용처]
|
||||
* - CardProperties.tsx (section="data"일 때)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Database,
|
||||
CreditCard as CreditCardIcon,
|
||||
Eye,
|
||||
Type,
|
||||
CreditCard,
|
||||
Minus,
|
||||
Tag,
|
||||
ImageIcon,
|
||||
Hash,
|
||||
Calendar,
|
||||
Link2,
|
||||
Circle,
|
||||
Space,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import type {
|
||||
CardLayoutConfig,
|
||||
CardLayoutRow,
|
||||
CardDataCellElement,
|
||||
CardBadgeElement,
|
||||
CardNumberElement,
|
||||
CardDateElement,
|
||||
CardLinkElement,
|
||||
CardStatusElement,
|
||||
CardImageElement,
|
||||
ComponentConfig,
|
||||
} from "@/types/report";
|
||||
import { CardElementPalette } from "./CardElementPalette";
|
||||
import { CardCanvasEditor } from "./CardCanvasEditor";
|
||||
import { ConditionalProperties } from "../properties/ConditionalProperties";
|
||||
import type { CardColumnLabel } from "../properties/ConditionalProperties";
|
||||
|
||||
interface CardLayoutTabsProps {
|
||||
config: CardLayoutConfig;
|
||||
onConfigChange: (config: CardLayoutConfig) => void;
|
||||
component?: ComponentConfig;
|
||||
onComponentChange?: (updates: Partial<ComponentConfig>) => void;
|
||||
}
|
||||
|
||||
type TabType = "layout" | "binding" | "condition";
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
const getElementIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "header":
|
||||
return <Type className="w-3 h-3" />;
|
||||
case "dataCell":
|
||||
return <CreditCard className="w-3 h-3" />;
|
||||
case "divider":
|
||||
return <Minus className="w-3 h-3" />;
|
||||
case "badge":
|
||||
return <Tag className="w-3 h-3" />;
|
||||
case "image":
|
||||
return <ImageIcon className="w-3 h-3" />;
|
||||
case "number":
|
||||
return <Hash className="w-3 h-3" />;
|
||||
case "date":
|
||||
return <Calendar className="w-3 h-3" />;
|
||||
case "link":
|
||||
return <Link2 className="w-3 h-3" />;
|
||||
case "status":
|
||||
return <Circle className="w-3 h-3" />;
|
||||
case "spacer":
|
||||
return <Space className="w-3 h-3" />;
|
||||
case "staticText":
|
||||
return <FileText className="w-3 h-3" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function CardLayoutTabs({
|
||||
config,
|
||||
onConfigChange,
|
||||
component,
|
||||
onComponentChange,
|
||||
}: CardLayoutTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("layout");
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTables();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.tableName) {
|
||||
fetchColumns(config.tableName);
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
}, [config.tableName]);
|
||||
|
||||
const fetchTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await reportApi.getSchemaTableList();
|
||||
if (response.success) {
|
||||
setTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchColumns = async (tableName: string) => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const response = await reportApi.getSchemaTableColumns(tableName);
|
||||
if (response.success) {
|
||||
setColumns(response.data);
|
||||
const pkCandidate = response.data.find(
|
||||
(col) =>
|
||||
col.column_name.endsWith("_id") ||
|
||||
col.column_name === "id" ||
|
||||
col.column_name.endsWith("_pk"),
|
||||
);
|
||||
if (pkCandidate && !config.primaryKey) {
|
||||
onConfigChange({
|
||||
...config,
|
||||
primaryKey: pkCandidate.column_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const usedColumns = useMemo(() => {
|
||||
const used = new Set<string>();
|
||||
config.rows.forEach((row) => {
|
||||
row.elements.forEach((el) => {
|
||||
const colName =
|
||||
(el as CardDataCellElement | CardBadgeElement | CardNumberElement | CardDateElement | CardLinkElement | CardStatusElement | CardImageElement)
|
||||
.columnName;
|
||||
if (colName) used.add(colName);
|
||||
});
|
||||
});
|
||||
return used;
|
||||
}, [config.rows]);
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(tableName: string) => {
|
||||
onConfigChange({
|
||||
...config,
|
||||
tableName,
|
||||
primaryKey: "",
|
||||
rows: config.rows.map((row) => ({
|
||||
...row,
|
||||
elements: row.elements.map((el) => {
|
||||
if (el.type === "dataCell") {
|
||||
return { ...el, columnName: undefined } as CardDataCellElement;
|
||||
}
|
||||
if (el.type === "badge") {
|
||||
return { ...el, columnName: undefined } as CardBadgeElement;
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
})),
|
||||
});
|
||||
},
|
||||
[config, onConfigChange],
|
||||
);
|
||||
|
||||
const handleRowsChange = useCallback(
|
||||
(rows: CardLayoutRow[]) => {
|
||||
onConfigChange({ ...config, rows });
|
||||
},
|
||||
[config, onConfigChange],
|
||||
);
|
||||
|
||||
const handleColumnMapping = useCallback(
|
||||
(rowIndex: number, elementIndex: number, columnName: string) => {
|
||||
const newRows = [...config.rows];
|
||||
const newElements = [...newRows[rowIndex].elements];
|
||||
newElements[elementIndex] = { ...newElements[elementIndex], columnName } as typeof newElements[number];
|
||||
newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements };
|
||||
onConfigChange({ ...config, rows: newRows });
|
||||
},
|
||||
[config, onConfigChange],
|
||||
);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<CardLayoutConfig>) => {
|
||||
onConfigChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onConfigChange],
|
||||
);
|
||||
|
||||
const renderLayoutTab = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="sticky top-0 z-10 bg-white pb-2">
|
||||
<CardElementPalette />
|
||||
</div>
|
||||
<CardCanvasEditor rows={config.rows} onRowsChange={handleRowsChange} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBindingTab = () => (
|
||||
<div className="space-y-4">
|
||||
{/* 데이터 소스 섹션 */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
테이블 선택
|
||||
</Label>
|
||||
<Select
|
||||
value={config.tableName || ""}
|
||||
onValueChange={handleTableChange}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue
|
||||
placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
{table.table_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
기본 키
|
||||
</Label>
|
||||
<Select
|
||||
value={config.primaryKey || ""}
|
||||
onValueChange={(pk) => updateConfig({ primaryKey: pk })}
|
||||
disabled={!config.tableName || loadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm bg-muted">
|
||||
<SelectValue placeholder="기본 키 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 WYSIWYG 미리보기 + 인라인 드롭다운 */}
|
||||
<div className="bg-white border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
backgroundColor: config.backgroundColor,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: config.gap,
|
||||
}}
|
||||
>
|
||||
{config.rows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${row.gridColumns}, 1fr)`,
|
||||
gap: config.gap,
|
||||
marginBottom: row.marginBottom || undefined,
|
||||
}}
|
||||
>
|
||||
{row.elements.map((element, elementIndex) => {
|
||||
const needsBinding = ["dataCell", "badge", "number", "date", "link", "status", "image"]
|
||||
.includes(element.type);
|
||||
const currentColumn =
|
||||
(element as CardDataCellElement | CardBadgeElement | CardNumberElement | CardDateElement | CardLinkElement | CardStatusElement | CardImageElement)
|
||||
.columnName;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="border border-gray-100 rounded px-2 py-1.5 hover:bg-gray-50"
|
||||
style={{ gridColumn: `span ${element.colspan || 1}` }}
|
||||
>
|
||||
{/* 요소 타입 표시 */}
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="text-gray-400">
|
||||
{getElementIcon(element.type)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{element.type === "header" && (element as any).title}
|
||||
{element.type === "dataCell" && (element as CardDataCellElement).label}
|
||||
{element.type === "divider" && "구분선"}
|
||||
{element.type === "badge" && ((element as CardBadgeElement).label || "뱃지")}
|
||||
{element.type === "image" && "이미지"}
|
||||
{element.type === "number" && ((element as CardNumberElement).label || "숫자/금액")}
|
||||
{element.type === "date" && ((element as CardDateElement).label || "날짜")}
|
||||
{element.type === "link" && ((element as CardLinkElement).label || "링크")}
|
||||
{element.type === "status" && "상태"}
|
||||
{element.type === "spacer" && "빈 공간"}
|
||||
{element.type === "staticText" && "고정 텍스트"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 매핑 드롭다운 */}
|
||||
{needsBinding && (
|
||||
<Select
|
||||
value={currentColumn || ""}
|
||||
onValueChange={(col) =>
|
||||
handleColumnMapping(rowIndex, elementIndex, col)
|
||||
}
|
||||
disabled={!config.tableName}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`h-7 text-xs ${
|
||||
currentColumn
|
||||
? "bg-blue-50 text-blue-700 border-blue-200"
|
||||
: "border-dashed"
|
||||
}`}
|
||||
>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => {
|
||||
const isUsed =
|
||||
usedColumns.has(col.column_name) &&
|
||||
currentColumn !== col.column_name;
|
||||
return (
|
||||
<SelectItem
|
||||
key={col.column_name}
|
||||
value={col.column_name}
|
||||
disabled={isUsed}
|
||||
className={isUsed ? "opacity-50" : ""}
|
||||
>
|
||||
{col.column_name}
|
||||
{isUsed && " (사용 중)"}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!config.tableName && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
테이블을 먼저 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.tableName && config.rows.every((r) => r.elements.length === 0) && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
레이아웃 탭에서 요소를 먼저 추가해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const cardColumnLabels = useMemo<CardColumnLabel[]>(() => {
|
||||
const labels: CardColumnLabel[] = [];
|
||||
config.rows.forEach((row) => {
|
||||
row.elements.forEach((el) => {
|
||||
const colName = (el as any).columnName as string | undefined;
|
||||
if (!colName) return;
|
||||
|
||||
let label = "";
|
||||
if (el.type === "dataCell") label = (el as any).label || "";
|
||||
else if (el.type === "badge") label = (el as any).label || "뱃지";
|
||||
else if (el.type === "number") label = (el as any).label || "숫자/금액";
|
||||
else if (el.type === "date") label = (el as any).label || "날짜";
|
||||
else if (el.type === "link") label = (el as any).label || "링크";
|
||||
else if (el.type === "status") label = "상태";
|
||||
else if (el.type === "image") label = "이미지";
|
||||
|
||||
if (label) labels.push({ columnName: colName, label });
|
||||
});
|
||||
});
|
||||
return labels;
|
||||
}, [config.rows]);
|
||||
|
||||
const renderConditionTab = () => {
|
||||
if (!component) {
|
||||
return (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
표시 조건을 설정하려면 컴포넌트 정보가 필요합니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ConditionalProperties
|
||||
component={component}
|
||||
onConfigChange={onComponentChange}
|
||||
cardColumns={columns}
|
||||
cardTableName={config.tableName}
|
||||
cardColumnLabels={cardColumnLabels}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs: { key: TabType; icon: React.ReactNode; label: string }[] = [
|
||||
{ key: "layout", icon: <LayoutGrid className="w-3.5 h-3.5" />, label: "레이아웃 구성" },
|
||||
{ key: "binding", icon: <Database className="w-3.5 h-3.5" />, label: "데이터 연결" },
|
||||
{ key: "condition", icon: <Eye className="w-3.5 h-3.5" />, label: "표시 조건" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* 헤더 + 탭 */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<CreditCardIcon className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span className="text-xs font-medium text-foreground">카드 기능 설정</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
activeTab === tab.key
|
||||
? "bg-white text-blue-700 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div>
|
||||
{activeTab === "layout" && renderLayoutTab()}
|
||||
{activeTab === "binding" && renderBindingTab()}
|
||||
{activeTab === "condition" && renderConditionTab()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
import { CardRenderer } from "../renderers/CardRenderer";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import type { QueryResult } from "../renderers/types";
|
||||
|
||||
interface CardPreviewPanelProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function CardPreviewPanel({ component }: CardPreviewPanelProps) {
|
||||
const dummyGetQueryResult = useCallback(
|
||||
(): QueryResult | null => null,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 p-4 shrink-0">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 mb-1 text-sm font-semibold text-gray-700">
|
||||
<Eye className="w-4 h-4 text-blue-600" />
|
||||
미리보기 (저장 전 상태)
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
실제 데이터는 저장 후 확인 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-[600px] w-full items-center justify-center rounded-xl border border-gray-200 bg-gray-100 p-8">
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg border border-gray-300 bg-white shadow-sm"
|
||||
style={{
|
||||
maxWidth: Math.min(component.width, 700),
|
||||
minHeight: Math.min(component.height, 550),
|
||||
}}
|
||||
>
|
||||
<CardRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ComponentPreviewPanel.tsx — 범용 컴포넌트 미리보기 패널
|
||||
*
|
||||
* 모든 컴포넌트 타입의 미리보기를 통일된 레이아웃(회색 그리드 + 가운데 배치)으로 제공.
|
||||
* 카드/테이블 전용 패널을 대체하는 단일 진입점.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import type { QueryResult } from "../renderers/types";
|
||||
|
||||
import { TextRenderer } from "../renderers/TextRenderer";
|
||||
import { ImageRenderer } from "../renderers/ImageRenderer";
|
||||
import { DividerRenderer } from "../renderers/DividerRenderer";
|
||||
import { SignatureRenderer, StampRenderer } from "../renderers/SignatureRenderer";
|
||||
import { PageNumberRenderer } from "../renderers/PageNumberRenderer";
|
||||
import { CardRenderer } from "../renderers/CardRenderer";
|
||||
import { CalculationRenderer } from "../renderers/CalculationRenderer";
|
||||
import { BarcodeCanvasRenderer } from "../renderers/BarcodeCanvasRenderer";
|
||||
import { CheckboxRenderer } from "../renderers/CheckboxRenderer";
|
||||
import { TableRenderer } from "../renderers/TableRenderer";
|
||||
|
||||
interface ComponentPreviewPanelProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
const DUMMY_LAYOUT_CONFIG = {
|
||||
pages: [{ page_id: "preview_page", page_order: 1 }],
|
||||
};
|
||||
|
||||
export function ComponentPreviewPanel({ component }: ComponentPreviewPanelProps) {
|
||||
const dummyGetQueryResult = useCallback(
|
||||
(): QueryResult | null => null,
|
||||
[],
|
||||
);
|
||||
|
||||
const previewWidth = useMemo(
|
||||
() => Math.min(component.width || 700, 700),
|
||||
[component.width],
|
||||
);
|
||||
|
||||
const previewHeight = useMemo(
|
||||
() => Math.min(component.height || 400, 550),
|
||||
[component.height],
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (component.type) {
|
||||
case "text":
|
||||
case "label":
|
||||
return (
|
||||
<TextRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
displayValue={component.content || component.text || "텍스트 미리보기"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "image":
|
||||
return <ImageRenderer component={component} />;
|
||||
|
||||
case "divider":
|
||||
return <DividerRenderer component={component} />;
|
||||
|
||||
case "signature":
|
||||
return <SignatureRenderer component={component} />;
|
||||
|
||||
case "stamp":
|
||||
return <StampRenderer component={component} />;
|
||||
|
||||
case "pageNumber":
|
||||
return (
|
||||
<PageNumberRenderer
|
||||
component={component}
|
||||
currentPageId="preview_page"
|
||||
layoutConfig={DUMMY_LAYOUT_CONFIG}
|
||||
/>
|
||||
);
|
||||
|
||||
case "card":
|
||||
return (
|
||||
<CardRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
case "table":
|
||||
return (
|
||||
<TableRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
case "calculation":
|
||||
return (
|
||||
<CalculationRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
case "barcode":
|
||||
return (
|
||||
<BarcodeCanvasRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<CheckboxRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-gray-400">
|
||||
미리보기를 지원하지 않는 컴포넌트입니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col items-center gap-4 p-5">
|
||||
<div className="w-full">
|
||||
<div className="mb-1 flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<Eye className="h-4 w-4 text-blue-600" />
|
||||
미리보기 (저장 전 상태)
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
실제 데이터는 저장 후 확인 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-[600px] w-full items-center justify-center overflow-auto rounded-xl border border-gray-200 bg-gray-100 p-8">
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-sm"
|
||||
style={{
|
||||
width: previewWidth,
|
||||
minHeight: previewHeight,
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ComponentSettingsModal.tsx
|
||||
*
|
||||
* 인캔버스 설정 모달 — 기능 설정 / 표시 조건 / 미리보기 3탭 구조.
|
||||
* SettingsModalShell 모듈을 사용하여 모든 컴포넌트가 동일한 모달 형식을 유지.
|
||||
* card 타입은 Draft 기반 저장/취소 지원.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Eye as EyeIcon, Sliders, Layers } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { SettingsModalShell, useModalAlert } from "./SettingsModalShell";
|
||||
import type { ModalTabDef } from "./SettingsModalShell";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
|
||||
import { ConditionalSettingsTab } from "./ConditionalSettingsTab";
|
||||
import { ComponentPreviewPanel } from "./ComponentPreviewPanel";
|
||||
|
||||
import { TextProperties } from "../properties/TextProperties";
|
||||
import { ImageProperties } from "../properties/ImageProperties";
|
||||
import { TableProperties } from "../properties/TableProperties";
|
||||
import { CardProperties } from "../properties/CardProperties";
|
||||
import { CalculationProperties } from "../properties/CalculationProperties";
|
||||
import { BarcodeProperties } from "../properties/BarcodeProperties";
|
||||
import { CheckboxProperties } from "../properties/CheckboxProperties";
|
||||
import { SignatureProperties } from "../properties/SignatureProperties";
|
||||
import { PageNumberProperties } from "../properties/PageNumberProperties";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
text: "텍스트",
|
||||
label: "레이블",
|
||||
table: "테이블",
|
||||
image: "이미지",
|
||||
divider: "구분선",
|
||||
signature: "서명",
|
||||
stamp: "도장",
|
||||
pageNumber: "페이지 번호",
|
||||
card: "카드",
|
||||
calculation: "계산",
|
||||
barcode: "바코드",
|
||||
checkbox: "체크박스",
|
||||
};
|
||||
|
||||
interface DataTabProps {
|
||||
component: ComponentConfig;
|
||||
onConfigChange?: (updates: Partial<ComponentConfig>) => void;
|
||||
}
|
||||
|
||||
function DataTab({ component, onConfigChange }: DataTabProps) {
|
||||
switch (component.type) {
|
||||
case "text":
|
||||
case "label":
|
||||
return <TextProperties component={component} section="data" />;
|
||||
case "table":
|
||||
return <TableProperties component={component} section="data" />;
|
||||
case "image":
|
||||
return <ImageProperties component={component} section="data" />;
|
||||
case "signature":
|
||||
case "stamp":
|
||||
return <SignatureProperties component={component} section="data" />;
|
||||
case "pageNumber":
|
||||
return <PageNumberProperties component={component} />;
|
||||
case "card":
|
||||
return (
|
||||
<CardProperties
|
||||
component={component}
|
||||
section="data"
|
||||
onConfigChange={onConfigChange}
|
||||
/>
|
||||
);
|
||||
case "calculation":
|
||||
return <CalculationProperties component={component} section="data" />;
|
||||
case "barcode":
|
||||
return <BarcodeProperties component={component} section="data" />;
|
||||
case "checkbox":
|
||||
return <CheckboxProperties component={component} section="data" />;
|
||||
default:
|
||||
return <p className="p-4 text-sm text-gray-500">이 타입은 추가 기능 설정이 없습니다.</p>;
|
||||
}
|
||||
}
|
||||
|
||||
const TYPES_WITH_DATA_TAB = new Set([
|
||||
"text",
|
||||
"label",
|
||||
"table",
|
||||
"image",
|
||||
"signature",
|
||||
"stamp",
|
||||
"pageNumber",
|
||||
"card",
|
||||
"calculation",
|
||||
"barcode",
|
||||
"checkbox",
|
||||
]);
|
||||
|
||||
export function ComponentSettingsModal() {
|
||||
const { componentModalTargetId, closeComponentModal, components, updateComponent } = useReportDesigner();
|
||||
|
||||
const component = components.find((c) => c.id === componentModalTargetId) ?? null;
|
||||
const isDivider = component?.type === "divider";
|
||||
const isOpen = componentModalTargetId !== null && component !== null && !isDivider;
|
||||
|
||||
const [activeTab, setActiveTab] = useState("content");
|
||||
const [localDraft, setLocalDraft] = useState<ComponentConfig | null>(null);
|
||||
const { alert, clearAlert } = useModalAlert();
|
||||
const initialSnapshotRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (component) {
|
||||
setLocalDraft(component);
|
||||
initialSnapshotRef.current = JSON.stringify(component);
|
||||
clearAlert();
|
||||
const hasData = TYPES_WITH_DATA_TAB.has(component.type);
|
||||
setActiveTab(hasData ? "content" : "preview");
|
||||
}
|
||||
}, [componentModalTargetId, clearAlert]);
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
if (component?.type === "card") {
|
||||
if (!localDraft) return false;
|
||||
return JSON.stringify(localDraft) !== initialSnapshotRef.current;
|
||||
}
|
||||
if (!component) return false;
|
||||
return JSON.stringify(component) !== initialSnapshotRef.current;
|
||||
}, [localDraft, component]);
|
||||
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges,
|
||||
onClose: () => {
|
||||
if (!isSavingRef.current && initialSnapshotRef.current && component) {
|
||||
const original = JSON.parse(initialSnapshotRef.current) as ComponentConfig;
|
||||
updateComponent(component.id, original);
|
||||
}
|
||||
isSavingRef.current = false;
|
||||
setLocalDraft(null);
|
||||
clearAlert();
|
||||
closeComponentModal();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (component?.type === "card" && localDraft) {
|
||||
updateComponent(localDraft.id, localDraft);
|
||||
}
|
||||
isSavingRef.current = true;
|
||||
initialSnapshotRef.current = component?.type === "card" && localDraft
|
||||
? JSON.stringify(localDraft)
|
||||
: JSON.stringify(component);
|
||||
guard.doClose();
|
||||
}, [component, localDraft, updateComponent, guard]);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => {
|
||||
setLocalDraft((prev) => {
|
||||
if (!prev && component) return { ...component, ...updates };
|
||||
if (!prev) return null;
|
||||
return { ...prev, ...updates };
|
||||
});
|
||||
},
|
||||
[component],
|
||||
);
|
||||
|
||||
if (!component) return null;
|
||||
|
||||
const hasDataTab = TYPES_WITH_DATA_TAB.has(component.type);
|
||||
const typeLabel = TYPE_LABELS[component.type] ?? component.type;
|
||||
const isCard = component.type === "card";
|
||||
const isTable = component.type === "table";
|
||||
const isText = component.type === "text" || component.type === "label";
|
||||
const isImage = component.type === "image";
|
||||
const hideConditionTab = isText || isImage || isDivider;
|
||||
const hasInternalConditionTab = isCard || isTable || hideConditionTab;
|
||||
const displayComponent = isCard && localDraft ? localDraft : component;
|
||||
|
||||
const tabs: ModalTabDef[] = [
|
||||
hasDataTab && {
|
||||
key: "content",
|
||||
icon: <Sliders className="h-4 w-4" />,
|
||||
label: "기능 설정",
|
||||
},
|
||||
!hasInternalConditionTab && {
|
||||
key: "conditional",
|
||||
icon: <EyeIcon className="h-4 w-4" />,
|
||||
label: "표시 조건",
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
icon: <EyeIcon className="h-4 w-4" />,
|
||||
label: "미리보기",
|
||||
},
|
||||
].filter(Boolean) as ModalTabDef[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsModalShell
|
||||
open={isOpen}
|
||||
onOpenChange={guard.handleOpenChange}
|
||||
title={`${typeLabel} 설정`}
|
||||
icon={<Layers className="h-5 w-5" />}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onSave={handleSave}
|
||||
onClose={guard.tryClose}
|
||||
alert={alert}
|
||||
>
|
||||
{activeTab === "content" && hasDataTab && (
|
||||
<DataTab
|
||||
component={displayComponent}
|
||||
onConfigChange={isCard ? handleDraftChange : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "conditional" && !hasInternalConditionTab && (
|
||||
<ConditionalSettingsTab component={component} />
|
||||
)}
|
||||
|
||||
{activeTab === "preview" && (
|
||||
<ComponentPreviewPanel component={displayComponent} />
|
||||
)}
|
||||
</SettingsModalShell>
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ConditionalSettingsTab.tsx
|
||||
*
|
||||
* 조건부 표시 탭 — ConditionalProperties를 모달 탭으로 래핑한다.
|
||||
*
|
||||
* [사용처]
|
||||
* - ComponentSettingsModal의 "조건부 표시" 탭
|
||||
* - ConditionalProperties는 `component` prop을 받고 내부에서
|
||||
* updateComponent(context)를 호출하므로 추가 연결 없이 동작한다.
|
||||
*/
|
||||
|
||||
import { ConditionalProperties } from "../properties/ConditionalProperties";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function ConditionalSettingsTab({ component }: Props) {
|
||||
return <ConditionalProperties component={component} />;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FooterAggregateModal — 테이블 푸터 집계 설정 모달
|
||||
*
|
||||
* design-system.md Shell 패턴 적용.
|
||||
* 특정 열을 클릭하면 열리며, 해당 열의 집계 유형(합계/평균/개수/수식)을 설정.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calculator, X } from "lucide-react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
||||
|
||||
interface FooterAggregateModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
column: TableColumn | null;
|
||||
columnIndex: number;
|
||||
onSave: (idx: number, updates: Partial<TableColumn>) => void;
|
||||
}
|
||||
|
||||
export function FooterAggregateModal({ open, onOpenChange, column, columnIndex, onSave }: FooterAggregateModalProps) {
|
||||
const [summaryType, setSummaryType] = useState<"SUM" | "AVG" | "COUNT" | "NONE">("NONE");
|
||||
const [formula, setFormula] = useState("");
|
||||
const initialSnapshotRef = useRef<string>("");
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
const current = JSON.stringify({ summaryType, formula });
|
||||
return current !== initialSnapshotRef.current;
|
||||
}, [summaryType, formula]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges,
|
||||
onClose: () => onOpenChange(false),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (column) {
|
||||
const initType = column.summaryType || "NONE";
|
||||
const initFormula = column.formula || "";
|
||||
setSummaryType(initType);
|
||||
setFormula(initFormula);
|
||||
initialSnapshotRef.current = JSON.stringify({ summaryType: initType, formula: initFormula });
|
||||
}
|
||||
}, [column]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(columnIndex, {
|
||||
summaryType,
|
||||
formula: summaryType === "NONE" ? undefined : formula || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!column) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="flex h-auto max-w-lg flex-col overflow-hidden p-0 [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">계산 방식 설정</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{column.field || column.header} 열의 계산 방식을 설정합니다
|
||||
</DialogDescription>
|
||||
|
||||
{/* Header */}
|
||||
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4 text-blue-600" />
|
||||
<h2 className="text-foreground text-base font-semibold">계산 방식 설정</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={guard.tryClose} className="h-8 w-8">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-3 px-6 py-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-mono font-medium text-blue-700">{column.field || column.header}</span> 열의 계산
|
||||
방식을 설정합니다.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-xs font-medium">계산 방식 *</Label>
|
||||
<Select value={summaryType} onValueChange={(v) => setSummaryType(v as typeof summaryType)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NONE">없음</SelectItem>
|
||||
<SelectItem value="SUM">합계</SelectItem>
|
||||
<SelectItem value="AVG">평균</SelectItem>
|
||||
<SelectItem value="COUNT">개수</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{summaryType !== "NONE" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-xs font-medium">계산식 (선택)</Label>
|
||||
<Input
|
||||
value={formula}
|
||||
onChange={(e) => setFormula(e.target.value)}
|
||||
placeholder="예: {price} * {qty}"
|
||||
className="h-9 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">비워두면 기본 계산이 적용됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-border flex items-center justify-end gap-2 border-t px-6 py-4">
|
||||
<Button variant="outline" onClick={guard.tryClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleSave}>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* GridCellDropZone — 그리드 양식의 데이터 연결 드롭 존
|
||||
*
|
||||
* 모든 셀에 드롭 가능. 헤더 영역(좌측/상단)은 배경색으로 시각 구분.
|
||||
* 푸터 영역(하단)은 요약 집계 셀로 시각 구분.
|
||||
* - 헤더 영역 드롭 → 고정 라벨(컬럼명)로 설정
|
||||
* - 데이터 영역 드롭 → 데이터 바인딩(field)으로 설정
|
||||
* - 푸터 영역 → 요약(집계) 타입 설정 가능
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { X, Database, Type, Calculator } from "lucide-react";
|
||||
import { TABLE_COLUMN_DND_TYPE } from "./TableColumnPalette";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { GridCell } from "@/types/report";
|
||||
|
||||
interface PaletteDragItem {
|
||||
columnName: string;
|
||||
dataType: string;
|
||||
}
|
||||
|
||||
interface GridCellDropZoneProps {
|
||||
cells: GridCell[];
|
||||
rowCount: number;
|
||||
colCount: number;
|
||||
colWidths: number[];
|
||||
rowHeights: number[];
|
||||
headerRows?: number;
|
||||
headerCols?: number;
|
||||
footerRows?: number;
|
||||
onCellDrop: (row: number, col: number, columnName: string) => void;
|
||||
onHeaderDrop: (row: number, col: number, columnName: string) => void;
|
||||
onCellClear: (row: number, col: number) => void;
|
||||
onFooterCellClick?: (row: number, col: number) => void;
|
||||
}
|
||||
|
||||
const SUMMARY_LABELS: Record<string, string> = {
|
||||
SUM: "합계",
|
||||
AVG: "평균",
|
||||
COUNT: "개수",
|
||||
};
|
||||
|
||||
// ─── 통합 드롭 셀 ───────────────────────────────────────────────────────────
|
||||
|
||||
interface DropCellProps {
|
||||
cell: GridCell;
|
||||
width: number;
|
||||
height: number;
|
||||
isHeader: boolean;
|
||||
isFooter: boolean;
|
||||
onDrop: (columnName: string) => void;
|
||||
onClear: () => void;
|
||||
onFooterClick?: () => void;
|
||||
}
|
||||
|
||||
function DropCell({ cell, width, height, isHeader, isFooter, onDrop, onClear, onFooterClick }: DropCellProps) {
|
||||
const isField = cell.cellType === "field" && !!cell.field;
|
||||
const isLabel = cell.cellType === "static" && !!cell.value;
|
||||
const hasSummary = !!cell.summaryType && cell.summaryType !== "NONE";
|
||||
|
||||
const [{ isOver, canDrop }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: TABLE_COLUMN_DND_TYPE,
|
||||
drop: (item: PaletteDragItem) => onDrop(item.columnName),
|
||||
canDrop: () => !cell.merged,
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}),
|
||||
[cell, onDrop],
|
||||
);
|
||||
|
||||
if (cell.merged) return null;
|
||||
|
||||
const rSpan = cell.rowSpan ?? 1;
|
||||
const cSpan = cell.colSpan ?? 1;
|
||||
|
||||
let bg = isHeader ? "#f3f4f6" : isFooter ? "#f8fafc" : "white";
|
||||
if (isOver && canDrop) bg = "#dbeafe";
|
||||
else if (hasSummary && isFooter) bg = "#eff6ff";
|
||||
else if (isField) bg = "#eff6ff";
|
||||
else if (isLabel && isHeader) bg = "#f0fdf4";
|
||||
|
||||
const renderContent = () => {
|
||||
if (isFooter && hasSummary) {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center justify-center gap-1" onClick={onFooterClick}>
|
||||
<Calculator className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="text-[10px] font-semibold text-blue-700">
|
||||
{SUMMARY_LABELS[cell.summaryType!] || cell.summaryType}
|
||||
</span>
|
||||
{cell.field && <span className="truncate text-[10px] text-blue-400">({cell.field})</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFooter && !isField && !isLabel) {
|
||||
return (
|
||||
<span
|
||||
className="block cursor-pointer text-center text-[10px] text-gray-400 hover:text-blue-600"
|
||||
onClick={onFooterClick}
|
||||
>
|
||||
클릭하여 설정
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isField) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Database className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs font-medium text-blue-700">{cell.field}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-500"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLabel) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Type className="h-3 w-3 shrink-0 text-green-600" />
|
||||
<span className="truncate text-xs font-semibold text-gray-700">{cell.value}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-500"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="block text-center text-xs text-gray-300">—</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<td
|
||||
ref={(node) => {
|
||||
drop(node);
|
||||
}}
|
||||
rowSpan={rSpan > 1 ? rSpan : undefined}
|
||||
colSpan={cSpan > 1 ? cSpan : undefined}
|
||||
className={`border ${isHeader ? "border-gray-300" : isFooter ? "border-blue-200" : "border-gray-200"}`}
|
||||
style={{
|
||||
width,
|
||||
height: Math.max(height, 32),
|
||||
minWidth: width,
|
||||
backgroundColor: bg,
|
||||
padding: "3px 6px",
|
||||
verticalAlign: "middle",
|
||||
transition: "background-color 150ms",
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function GridCellDropZone({
|
||||
cells,
|
||||
rowCount,
|
||||
colCount,
|
||||
colWidths,
|
||||
rowHeights,
|
||||
headerRows = 1,
|
||||
headerCols = 1,
|
||||
footerRows = 0,
|
||||
onCellDrop,
|
||||
onHeaderDrop,
|
||||
onCellClear,
|
||||
onFooterCellClick,
|
||||
}: GridCellDropZoneProps) {
|
||||
const totalWidth = colWidths.reduce((a, b) => a + b, 0);
|
||||
|
||||
const findCell = (row: number, col: number) => cells.find((c) => c.row === row && c.col === col);
|
||||
|
||||
if (cells.length === 0 || rowCount === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-gray-300 py-8 text-xs text-gray-400">
|
||||
레이아웃 탭에서 격자를 먼저 구성하세요.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldCount = cells.filter(
|
||||
(c) => (c.cellType === "field" || (c.cellType === "static" && c.value)) && !c.merged,
|
||||
).length;
|
||||
const totalNonMerged = cells.filter((c) => !c.merged).length;
|
||||
const footerStartRow = rowCount - footerRows;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<span className="text-sm font-bold text-gray-800">열 격자 배치</span>
|
||||
<span className="text-[10px] text-gray-400">{fieldCount}/{totalNonMerged} 배치됨</span>
|
||||
</div>
|
||||
<div className="overflow-auto p-3">
|
||||
<table className="border-collapse" style={{ width: totalWidth, tableLayout: "fixed" }}>
|
||||
<colgroup>
|
||||
{colWidths.map((w, i) => (
|
||||
<col key={i} style={{ width: w }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{Array.from({ length: rowCount }).map((_, r) => {
|
||||
const tds: React.ReactNode[] = [];
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
const cell = findCell(r, c);
|
||||
if (!cell || cell.merged) continue;
|
||||
|
||||
const cSpan = cell.colSpan ?? 1;
|
||||
const rSpan = cell.rowSpan ?? 1;
|
||||
const w = colWidths.slice(c, c + cSpan).reduce((a, b) => a + b, 0);
|
||||
const h = rowHeights.slice(r, r + rSpan).reduce((a, b) => a + b, 0);
|
||||
const isHeader = r < headerRows || c < headerCols;
|
||||
const isFooter = footerRows > 0 && r >= footerStartRow;
|
||||
|
||||
const cellNode = (
|
||||
<DropCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
width={w}
|
||||
height={h}
|
||||
isHeader={isHeader}
|
||||
isFooter={isFooter}
|
||||
onDrop={(columnName) =>
|
||||
isHeader ? onHeaderDrop(r, c, columnName) : onCellDrop(r, c, columnName)
|
||||
}
|
||||
onClear={() => onCellClear(r, c)}
|
||||
onFooterClick={isFooter && onFooterCellClick ? () => onFooterCellClick(r, c) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isFooter) {
|
||||
tds.push(
|
||||
<Tooltip key={cell.id}>
|
||||
<TooltipTrigger asChild>{cellNode}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[200px]">
|
||||
<p className="text-xs">
|
||||
{cell.summaryType && cell.summaryType !== "NONE"
|
||||
? `${SUMMARY_LABELS[cell.summaryType] || cell.summaryType}${cell.field ? ` (${cell.field})` : ""}`
|
||||
: "클릭하여 계산 방식을 선택하세요"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>,
|
||||
);
|
||||
} else {
|
||||
tds.push(cellNode);
|
||||
}
|
||||
}
|
||||
return <tr key={`drop-row-${r}`}>{tds}</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,897 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* GridEditor — 복잡한 테이블 양식 편집기
|
||||
*
|
||||
* 셀 병합(rowspan/colspan), 고정 텍스트/데이터 바인딩 혼합,
|
||||
* 셀별 스타일 설정이 가능한 그리드 에디터.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Plus,
|
||||
Minus,
|
||||
Merge,
|
||||
SplitSquareHorizontal,
|
||||
Type,
|
||||
Database,
|
||||
Paintbrush,
|
||||
Bold,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Rows3,
|
||||
Columns3,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { GridCell, ComponentConfig } from "@/types/report";
|
||||
|
||||
// ─── 상수 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_COL_WIDTH = 100;
|
||||
const DEFAULT_ROW_HEIGHT = 32;
|
||||
const MIN_ROWS = 1;
|
||||
const MIN_COLS = 1;
|
||||
const MAX_ROWS = 100;
|
||||
const MAX_COLS = 30;
|
||||
const INITIAL_ROWS = 4;
|
||||
const INITIAL_COLS = 6;
|
||||
|
||||
// ─── 유틸 함수 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function cellId(row: number, col: number): string {
|
||||
return `r${row}c${col}`;
|
||||
}
|
||||
|
||||
function createEmptyCell(row: number, col: number): GridCell {
|
||||
return {
|
||||
id: cellId(row, col),
|
||||
row,
|
||||
col,
|
||||
rowSpan: 1,
|
||||
colSpan: 1,
|
||||
cellType: "static",
|
||||
value: "",
|
||||
align: "center",
|
||||
verticalAlign: "middle",
|
||||
fontWeight: "normal",
|
||||
fontSize: 12,
|
||||
borderStyle: "thin",
|
||||
};
|
||||
}
|
||||
|
||||
function initGrid(rows: number, cols: number): GridCell[] {
|
||||
const cells: GridCell[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
cells.push(createEmptyCell(r, c));
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function getCell(cells: GridCell[], row: number, col: number): GridCell | undefined {
|
||||
return cells.find((c) => c.row === row && c.col === col);
|
||||
}
|
||||
|
||||
interface SelectionRange {
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
endRow: number;
|
||||
endCol: number;
|
||||
}
|
||||
|
||||
function normalizeRange(range: SelectionRange): SelectionRange {
|
||||
return {
|
||||
startRow: Math.min(range.startRow, range.endRow),
|
||||
startCol: Math.min(range.startCol, range.endCol),
|
||||
endRow: Math.max(range.startRow, range.endRow),
|
||||
endCol: Math.max(range.startCol, range.endCol),
|
||||
};
|
||||
}
|
||||
|
||||
function isCellInRange(row: number, col: number, range: SelectionRange | null): boolean {
|
||||
if (!range) return false;
|
||||
const n = normalizeRange(range);
|
||||
return row >= n.startRow && row <= n.endRow && col >= n.startCol && col <= n.endCol;
|
||||
}
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GridEditorProps {
|
||||
component: ComponentConfig;
|
||||
onUpdate: (updates: Partial<ComponentConfig>) => void;
|
||||
schemaColumns?: Array<{ column_name: string; data_type: string }>;
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function GridEditor({ component, onUpdate, schemaColumns = [] }: GridEditorProps) {
|
||||
const rowCount = component.gridRowCount ?? INITIAL_ROWS;
|
||||
const colCount = component.gridColCount ?? INITIAL_COLS;
|
||||
const cells = component.gridCells ?? initGrid(rowCount, colCount);
|
||||
const colWidths = component.gridColWidths ?? Array(colCount).fill(DEFAULT_COL_WIDTH);
|
||||
const rowHeights = component.gridRowHeights ?? Array(rowCount).fill(DEFAULT_ROW_HEIGHT);
|
||||
|
||||
const [selection, setSelection] = useState<SelectionRange | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ row: number; col: number } | null>(null);
|
||||
|
||||
// 열/행 리사이즈
|
||||
const [resizingColIdx, setResizingColIdx] = useState<number | null>(null);
|
||||
const [resizingRowIdx, setResizingRowIdx] = useState<number | null>(null);
|
||||
const resizeStartRef = useRef(0);
|
||||
const resizeStartSizeRef = useRef(0);
|
||||
|
||||
// 선택된 단일 셀 (설정 패널용)
|
||||
const selectedCell = useMemo(() => {
|
||||
if (!selection) return null;
|
||||
const n = normalizeRange(selection);
|
||||
return getCell(cells, n.startRow, n.startCol);
|
||||
}, [selection, cells]);
|
||||
|
||||
// 선택 범위 내 셀 개수
|
||||
const selectedCellCount = useMemo(() => {
|
||||
if (!selection) return 0;
|
||||
const n = normalizeRange(selection);
|
||||
let count = 0;
|
||||
for (let r = n.startRow; r <= n.endRow; r++) {
|
||||
for (let c = n.startCol; c <= n.endCol; c++) {
|
||||
const cell = getCell(cells, r, c);
|
||||
if (cell && !cell.merged) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, [selection, cells]);
|
||||
|
||||
// ─── 그리드 업데이트 헬퍼 ─────────────────────────────────────────────────
|
||||
|
||||
const updateGrid = useCallback(
|
||||
(newCells: GridCell[], newRowCount?: number, newColCount?: number, newColWidths?: number[], newRowHeights?: number[]) => {
|
||||
onUpdate({
|
||||
gridCells: newCells,
|
||||
gridRowCount: newRowCount ?? rowCount,
|
||||
gridColCount: newColCount ?? colCount,
|
||||
gridColWidths: newColWidths ?? colWidths,
|
||||
gridRowHeights: newRowHeights ?? rowHeights,
|
||||
});
|
||||
},
|
||||
[onUpdate, rowCount, colCount, colWidths, rowHeights],
|
||||
);
|
||||
|
||||
const updateCellProps = useCallback(
|
||||
(row: number, col: number, updates: Partial<GridCell>) => {
|
||||
const newCells = cells.map((c) =>
|
||||
c.row === row && c.col === col ? { ...c, ...updates } : c,
|
||||
);
|
||||
updateGrid(newCells);
|
||||
},
|
||||
[cells, updateGrid],
|
||||
);
|
||||
|
||||
// ─── 셀 선택 핸들러 ──────────────────────────────────────────────────────
|
||||
|
||||
const handleCellMouseDown = useCallback(
|
||||
(row: number, col: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// 병합된 셀 클릭 시 주체 셀로 리다이렉트
|
||||
const cell = getCell(cells, row, col);
|
||||
if (cell?.merged && cell.mergedBy) {
|
||||
const master = cells.find((c) => c.id === cell.mergedBy);
|
||||
if (master) {
|
||||
row = master.row;
|
||||
col = master.col;
|
||||
}
|
||||
}
|
||||
|
||||
dragStartRef.current = { row, col };
|
||||
setIsDragging(true);
|
||||
setSelection({ startRow: row, startCol: col, endRow: row, endCol: col });
|
||||
},
|
||||
[cells],
|
||||
);
|
||||
|
||||
const handleCellMouseEnter = useCallback(
|
||||
(row: number, col: number) => {
|
||||
if (!isDragging || !dragStartRef.current) return;
|
||||
setSelection({
|
||||
startRow: dragStartRef.current.row,
|
||||
startCol: dragStartRef.current.col,
|
||||
endRow: row,
|
||||
endCol: col,
|
||||
});
|
||||
},
|
||||
[isDragging],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
// ─── 행/열 추가/삭제 ─────────────────────────────────────────────────────
|
||||
|
||||
const handleAddRow = useCallback(() => {
|
||||
if (rowCount >= MAX_ROWS) return;
|
||||
const newRow = rowCount;
|
||||
const newCells = [
|
||||
...cells,
|
||||
...Array.from({ length: colCount }, (_, c) => createEmptyCell(newRow, c)),
|
||||
];
|
||||
updateGrid(newCells, newRow + 1, colCount, colWidths, [...rowHeights, DEFAULT_ROW_HEIGHT]);
|
||||
}, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]);
|
||||
|
||||
const handleAddCol = useCallback(() => {
|
||||
if (colCount >= MAX_COLS) return;
|
||||
const newCol = colCount;
|
||||
const newCells = [
|
||||
...cells,
|
||||
...Array.from({ length: rowCount }, (_, r) => createEmptyCell(r, newCol)),
|
||||
];
|
||||
updateGrid(newCells, rowCount, newCol + 1, [...colWidths, DEFAULT_COL_WIDTH], rowHeights);
|
||||
}, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]);
|
||||
|
||||
const handleRemoveRow = useCallback(() => {
|
||||
if (rowCount <= MIN_ROWS) return;
|
||||
const lastRow = rowCount - 1;
|
||||
// 병합이 마지막 행을 포함하면 제거 불가
|
||||
const hasMergeConflict = cells.some(
|
||||
(c) => !c.merged && c.row < lastRow && c.row + (c.rowSpan ?? 1) - 1 >= lastRow,
|
||||
);
|
||||
if (hasMergeConflict) return;
|
||||
|
||||
const newCells = cells.filter((c) => c.row < lastRow);
|
||||
updateGrid(newCells, lastRow, colCount, colWidths, rowHeights.slice(0, lastRow));
|
||||
}, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]);
|
||||
|
||||
const handleRemoveCol = useCallback(() => {
|
||||
if (colCount <= MIN_COLS) return;
|
||||
const lastCol = colCount - 1;
|
||||
const hasMergeConflict = cells.some(
|
||||
(c) => !c.merged && c.col < lastCol && c.col + (c.colSpan ?? 1) - 1 >= lastCol,
|
||||
);
|
||||
if (hasMergeConflict) return;
|
||||
|
||||
const newCells = cells.filter((c) => c.col < lastCol);
|
||||
updateGrid(newCells, rowCount, lastCol, colWidths.slice(0, lastCol), rowHeights);
|
||||
}, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]);
|
||||
|
||||
const handleRemoveColAt = useCallback(
|
||||
(targetCol: number) => {
|
||||
if (colCount <= MIN_COLS) return;
|
||||
|
||||
const hasMergeConflict = cells.some((c) => {
|
||||
if (c.merged) return false;
|
||||
const cStart = c.col;
|
||||
const cEnd = c.col + (c.colSpan ?? 1) - 1;
|
||||
return cStart !== targetCol && cEnd >= targetCol && cStart < targetCol;
|
||||
});
|
||||
if (hasMergeConflict) return;
|
||||
|
||||
const newCells = cells
|
||||
.filter((c) => c.col !== targetCol)
|
||||
.map((c) => {
|
||||
const newCol = c.col > targetCol ? c.col - 1 : c.col;
|
||||
return {
|
||||
...c,
|
||||
col: newCol,
|
||||
id: cellId(c.row, newCol),
|
||||
...(c.mergedBy
|
||||
? {
|
||||
mergedBy: cells.find((m) => m.id === c.mergedBy)
|
||||
? cellId(
|
||||
cells.find((m) => m.id === c.mergedBy)!.row,
|
||||
cells.find((m) => m.id === c.mergedBy)!.col > targetCol
|
||||
? cells.find((m) => m.id === c.mergedBy)!.col - 1
|
||||
: cells.find((m) => m.id === c.mergedBy)!.col,
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
const newColWidths = colWidths.filter((_, i) => i !== targetCol);
|
||||
updateGrid(newCells, rowCount, colCount - 1, newColWidths, rowHeights);
|
||||
setSelection(null);
|
||||
},
|
||||
[cells, rowCount, colCount, colWidths, rowHeights, updateGrid],
|
||||
);
|
||||
|
||||
const handleRemoveRowAt = useCallback(
|
||||
(targetRow: number) => {
|
||||
if (rowCount <= MIN_ROWS) return;
|
||||
|
||||
const hasMergeConflict = cells.some((c) => {
|
||||
if (c.merged) return false;
|
||||
const rStart = c.row;
|
||||
const rEnd = c.row + (c.rowSpan ?? 1) - 1;
|
||||
return rStart !== targetRow && rEnd >= targetRow && rStart < targetRow;
|
||||
});
|
||||
if (hasMergeConflict) return;
|
||||
|
||||
const newCells = cells
|
||||
.filter((c) => c.row !== targetRow)
|
||||
.map((c) => {
|
||||
const newRow = c.row > targetRow ? c.row - 1 : c.row;
|
||||
return {
|
||||
...c,
|
||||
row: newRow,
|
||||
id: cellId(newRow, c.col),
|
||||
...(c.mergedBy
|
||||
? {
|
||||
mergedBy: cells.find((m) => m.id === c.mergedBy)
|
||||
? cellId(
|
||||
cells.find((m) => m.id === c.mergedBy)!.row > targetRow
|
||||
? cells.find((m) => m.id === c.mergedBy)!.row - 1
|
||||
: cells.find((m) => m.id === c.mergedBy)!.row,
|
||||
cells.find((m) => m.id === c.mergedBy)!.col,
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
const newRowHeights = rowHeights.filter((_, i) => i !== targetRow);
|
||||
updateGrid(newCells, rowCount - 1, colCount, colWidths, newRowHeights);
|
||||
setSelection(null);
|
||||
},
|
||||
[cells, rowCount, colCount, colWidths, rowHeights, updateGrid],
|
||||
);
|
||||
|
||||
// ─── 셀 병합 / 해제 ──────────────────────────────────────────────────────
|
||||
|
||||
const canMerge = useMemo(() => {
|
||||
if (!selection) return false;
|
||||
const n = normalizeRange(selection);
|
||||
if (n.startRow === n.endRow && n.startCol === n.endCol) return false;
|
||||
// 선택 범위 안에 이미 병합된 셀이 있으면 불가
|
||||
for (let r = n.startRow; r <= n.endRow; r++) {
|
||||
for (let c = n.startCol; c <= n.endCol; c++) {
|
||||
const cell = getCell(cells, r, c);
|
||||
if (!cell) return false;
|
||||
if (cell.merged) return false;
|
||||
if ((cell.rowSpan ?? 1) > 1 || (cell.colSpan ?? 1) > 1) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, [selection, cells]);
|
||||
|
||||
const canUnmerge = useMemo(() => {
|
||||
if (!selectedCell) return false;
|
||||
return (selectedCell.rowSpan ?? 1) > 1 || (selectedCell.colSpan ?? 1) > 1;
|
||||
}, [selectedCell]);
|
||||
|
||||
const handleMerge = useCallback(() => {
|
||||
if (!selection || !canMerge) return;
|
||||
const n = normalizeRange(selection);
|
||||
const rSpan = n.endRow - n.startRow + 1;
|
||||
const cSpan = n.endCol - n.startCol + 1;
|
||||
const masterId = cellId(n.startRow, n.startCol);
|
||||
|
||||
const newCells = cells.map((cell) => {
|
||||
if (cell.row === n.startRow && cell.col === n.startCol) {
|
||||
return { ...cell, rowSpan: rSpan, colSpan: cSpan };
|
||||
}
|
||||
if (isCellInRange(cell.row, cell.col, n) && !(cell.row === n.startRow && cell.col === n.startCol)) {
|
||||
return { ...cell, merged: true, mergedBy: masterId, value: "", field: "", formula: "" };
|
||||
}
|
||||
return cell;
|
||||
});
|
||||
|
||||
updateGrid(newCells);
|
||||
setSelection({ startRow: n.startRow, startCol: n.startCol, endRow: n.startRow, endCol: n.startCol });
|
||||
}, [selection, canMerge, cells, updateGrid]);
|
||||
|
||||
const handleUnmerge = useCallback(() => {
|
||||
if (!selectedCell || !canUnmerge) return;
|
||||
const masterId = selectedCell.id;
|
||||
|
||||
const newCells = cells.map((cell) => {
|
||||
if (cell.id === masterId) {
|
||||
return { ...cell, rowSpan: 1, colSpan: 1 };
|
||||
}
|
||||
if (cell.mergedBy === masterId) {
|
||||
return { ...cell, merged: false, mergedBy: undefined };
|
||||
}
|
||||
return cell;
|
||||
});
|
||||
|
||||
updateGrid(newCells);
|
||||
}, [selectedCell, canUnmerge, cells, updateGrid]);
|
||||
|
||||
// ─── 열/행 리사이즈 ────────────────────────────────────────────────────────
|
||||
|
||||
const MIN_COL_WIDTH = 40;
|
||||
const MIN_ROW_HEIGHT = 20;
|
||||
|
||||
const handleColResizeStart = useCallback(
|
||||
(colIdx: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setResizingColIdx(colIdx);
|
||||
resizeStartRef.current = e.clientX;
|
||||
resizeStartSizeRef.current = colWidths[colIdx] ?? DEFAULT_COL_WIDTH;
|
||||
|
||||
const onMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - resizeStartRef.current;
|
||||
const newWidth = Math.max(MIN_COL_WIDTH, resizeStartSizeRef.current + delta);
|
||||
const newColWidths = colWidths.map((w, i) => (i === colIdx ? newWidth : w));
|
||||
updateGrid(cells, rowCount, colCount, newColWidths, rowHeights);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
setResizingColIdx(null);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[colWidths, cells, rowCount, colCount, rowHeights, updateGrid],
|
||||
);
|
||||
|
||||
const handleRowResizeStart = useCallback(
|
||||
(rowIdx: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setResizingRowIdx(rowIdx);
|
||||
resizeStartRef.current = e.clientY;
|
||||
resizeStartSizeRef.current = rowHeights[rowIdx] ?? DEFAULT_ROW_HEIGHT;
|
||||
|
||||
const onMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientY - resizeStartRef.current;
|
||||
const newHeight = Math.max(MIN_ROW_HEIGHT, resizeStartSizeRef.current + delta);
|
||||
const newRowHeights = rowHeights.map((h, i) => (i === rowIdx ? newHeight : h));
|
||||
updateGrid(cells, rowCount, colCount, colWidths, newRowHeights);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
setResizingRowIdx(null);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[rowHeights, cells, rowCount, colCount, colWidths, updateGrid],
|
||||
);
|
||||
|
||||
// ─── 셀 직접 편집 (더블클릭) ──────────────────────────────────────────────
|
||||
|
||||
const [editingCell, setEditingCell] = useState<string | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCellDoubleClick = useCallback((cell: GridCell) => {
|
||||
if (cell.merged) return;
|
||||
setEditingCell(cell.id);
|
||||
setTimeout(() => editInputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
const handleEditBlur = useCallback(
|
||||
(cell: GridCell, newValue: string) => {
|
||||
setEditingCell(null);
|
||||
if (cell.cellType === "static") {
|
||||
updateCellProps(cell.row, cell.col, { value: newValue });
|
||||
} else if (cell.cellType === "field") {
|
||||
updateCellProps(cell.row, cell.col, { field: newValue });
|
||||
}
|
||||
},
|
||||
[updateCellProps],
|
||||
);
|
||||
|
||||
const handleEditKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, cell: GridCell, value: string) => {
|
||||
if (e.key === "Enter") {
|
||||
handleEditBlur(cell, value);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingCell(null);
|
||||
}
|
||||
},
|
||||
[handleEditBlur],
|
||||
);
|
||||
|
||||
// ─── 그리드 렌더링 ────────────────────────────────────────────────────────
|
||||
|
||||
const totalWidth = colWidths.reduce((a, b) => a + b, 0);
|
||||
|
||||
const renderGridRow = (r: number): React.ReactNode[] => {
|
||||
const tds: React.ReactNode[] = [];
|
||||
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
const cell = getCell(cells, r, c);
|
||||
if (!cell || cell.merged) continue;
|
||||
|
||||
const rSpan = cell.rowSpan ?? 1;
|
||||
const cSpan = cell.colSpan ?? 1;
|
||||
const isSelected = isCellInRange(r, c, selection);
|
||||
const isEditing = editingCell === cell.id;
|
||||
|
||||
const w = colWidths.slice(c, c + cSpan).reduce((a, b) => a + b, 0);
|
||||
const h = rowHeights.slice(r, r + rSpan).reduce((a, b) => a + b, 0);
|
||||
|
||||
const cellBg = cell.backgroundColor || (isSelected ? "#dbeafe" : "white");
|
||||
const borderW = cell.borderStyle === "medium" ? 2 : cell.borderStyle === "thick" ? 3 : cell.borderStyle === "none" ? 0 : 1;
|
||||
|
||||
const displayValue =
|
||||
cell.cellType === "field" && cell.field
|
||||
? `{${cell.field}}`
|
||||
: cell.cellType === "formula" && cell.formula
|
||||
? `=${cell.formula}`
|
||||
: cell.value ?? "";
|
||||
|
||||
tds.push(
|
||||
<td
|
||||
key={cell.id}
|
||||
rowSpan={rSpan > 1 ? rSpan : undefined}
|
||||
colSpan={cSpan > 1 ? cSpan : undefined}
|
||||
className="relative cursor-pointer select-none"
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
minWidth: w,
|
||||
minHeight: h,
|
||||
backgroundColor: cellBg,
|
||||
border: `${borderW}px solid ${isSelected ? "#3b82f6" : "#e5e7eb"}`,
|
||||
padding: "2px 4px",
|
||||
fontSize: cell.fontSize ?? 12,
|
||||
fontWeight: cell.fontWeight === "bold" ? 700 : 400,
|
||||
color: cell.textColor || "#111827",
|
||||
textAlign: cell.align || "center",
|
||||
verticalAlign: cell.verticalAlign || "middle",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
outline: isSelected ? "2px solid #3b82f6" : "none",
|
||||
outlineOffset: "-2px",
|
||||
}}
|
||||
onMouseDown={(e) => handleCellMouseDown(r, c, e)}
|
||||
onMouseEnter={() => handleCellMouseEnter(r, c)}
|
||||
onDoubleClick={() => handleCellDoubleClick(cell)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="h-full w-full border-none bg-transparent text-center text-xs outline-none"
|
||||
defaultValue={cell.cellType === "field" ? cell.field ?? "" : cell.value ?? ""}
|
||||
onBlur={(e) => handleEditBlur(cell, e.target.value)}
|
||||
onKeyDown={(e) => handleEditKeyDown(e, cell, (e.target as HTMLInputElement).value)}
|
||||
style={{ fontSize: cell.fontSize ?? 12 }}
|
||||
/>
|
||||
) : (
|
||||
<span className="pointer-events-none block truncate text-xs">
|
||||
{displayValue || <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
)}
|
||||
{cell.cellType === "field" && !isEditing && (
|
||||
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</td>,
|
||||
);
|
||||
}
|
||||
|
||||
return tds;
|
||||
};
|
||||
|
||||
// ─── 메인 렌더링 ──────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-border bg-white shadow-sm"
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* 컨트롤 바 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<span className="text-sm font-bold text-gray-800">격자 양식</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 행 조절 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleRemoveRow}
|
||||
disabled={rowCount <= MIN_ROWS}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
||||
<Rows3 className="h-3 w-3 text-gray-400" />{rowCount}행
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleAddRow}
|
||||
disabled={rowCount >= MAX_ROWS}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<div className="mx-1.5 h-4 w-px bg-gray-300" />
|
||||
|
||||
{/* 열 조절 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleRemoveCol}
|
||||
disabled={colCount <= MIN_COLS}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
||||
<Columns3 className="h-3 w-3 text-gray-400" />{colCount}열
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleAddCol}
|
||||
disabled={colCount >= MAX_COLS}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<div className="mx-1.5 h-4 w-px bg-gray-300" />
|
||||
|
||||
{/* 병합 / 해제 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 border-gray-300 px-2 text-xs text-blue-600 hover:bg-blue-50 disabled:opacity-30 disabled:text-gray-400"
|
||||
onClick={handleMerge}
|
||||
disabled={!canMerge}
|
||||
>
|
||||
<Merge className="h-3 w-3" />
|
||||
병합
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 border-gray-300 px-2 text-xs text-orange-600 hover:bg-orange-50 disabled:opacity-30 disabled:text-gray-400"
|
||||
onClick={handleUnmerge}
|
||||
disabled={!canUnmerge}
|
||||
>
|
||||
<SplitSquareHorizontal className="h-3 w-3" />
|
||||
해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그리드 캔버스 */}
|
||||
<div className="overflow-auto p-3" style={{ maxHeight: 360 }}>
|
||||
<table
|
||||
className="select-none border-collapse"
|
||||
style={{ width: totalWidth + 28, tableLayout: "fixed" }}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: 28, minWidth: 28 }} />
|
||||
{colWidths.map((w, i) => (
|
||||
<col key={i} style={{ width: w, minWidth: 40 }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-gray-200 bg-gray-100" />
|
||||
{colWidths.map((_, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="group/colhdr relative border border-gray-200 bg-gray-100 px-1 py-1 text-[10px] font-medium text-gray-400"
|
||||
>
|
||||
<span>{i + 1}</span>
|
||||
{colCount > MIN_COLS && (
|
||||
<button
|
||||
onClick={() => handleRemoveColAt(i)}
|
||||
className="absolute -top-0.5 right-0.5 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-white shadow-sm transition-colors hover:bg-red-500 group-hover/colhdr:flex"
|
||||
title={`${i + 1}열 삭제`}
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
{i < colCount - 1 && (
|
||||
<div
|
||||
className={`absolute -right-[3px] top-0 z-10 h-full w-[5px] cursor-col-resize transition-colors ${
|
||||
resizingColIdx === i ? "bg-blue-400" : "hover:bg-blue-300"
|
||||
}`}
|
||||
onMouseDown={(e) => handleColResizeStart(i, e)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rowCount }).map((_, r) => {
|
||||
const gridRow = renderGridRow(r);
|
||||
return (
|
||||
<tr key={`row-${r}`}>
|
||||
<td
|
||||
className="group/rowhdr relative border border-gray-200 bg-gray-100 text-center text-[10px] font-medium text-gray-400"
|
||||
style={{ width: 28, minWidth: 28, height: rowHeights[r] ?? DEFAULT_ROW_HEIGHT, minHeight: rowHeights[r] ?? DEFAULT_ROW_HEIGHT }}
|
||||
>
|
||||
<span>{r + 1}</span>
|
||||
{rowCount > MIN_ROWS && (
|
||||
<button
|
||||
onClick={() => handleRemoveRowAt(r)}
|
||||
className="absolute -left-0.5 top-0.5 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-white shadow-sm transition-colors hover:bg-red-500 group-hover/rowhdr:flex"
|
||||
title={`${r + 1}행 삭제`}
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
{r < rowCount - 1 && (
|
||||
<div
|
||||
className={`absolute -bottom-[3px] left-0 z-10 h-[5px] w-full cursor-row-resize transition-colors ${
|
||||
resizingRowIdx === r ? "bg-blue-400" : "hover:bg-blue-300"
|
||||
}`}
|
||||
onMouseDown={(e) => handleRowResizeStart(r, e)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{gridRow}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 셀 설정 패널 */}
|
||||
<div className="border-t border-gray-200 bg-gray-50/50 px-4 py-3">
|
||||
{!selectedCell ? (
|
||||
<div className="flex items-center justify-center py-3 text-xs text-gray-400">
|
||||
셀을 클릭하여 선택하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 셀 위치 */}
|
||||
<span className="inline-block rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-500">
|
||||
{selectedCell.row + 1}행 {selectedCell.col + 1}열
|
||||
{((selectedCell.rowSpan ?? 1) > 1 || (selectedCell.colSpan ?? 1) > 1)
|
||||
? ` (${selectedCell.rowSpan ?? 1}x${selectedCell.colSpan ?? 1})`
|
||||
: ""}
|
||||
</span>
|
||||
|
||||
{/* 셀 타입 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">셀 타입</label>
|
||||
<Select
|
||||
value={selectedCell.cellType}
|
||||
onValueChange={(v) => updateCellProps(selectedCell.row, selectedCell.col, { cellType: v as GridCell["cellType"] })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">
|
||||
<span className="flex items-center gap-1"><Type className="h-3 w-3" /> 고정 텍스트</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="field">
|
||||
<span className="flex items-center gap-1"><Database className="h-3 w-3" /> 데이터 연결</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 셀 값 */}
|
||||
{selectedCell.cellType === "static" && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">텍스트</label>
|
||||
<Input
|
||||
className="h-8 w-full text-xs"
|
||||
value={selectedCell.value ?? ""}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { value: e.target.value })}
|
||||
placeholder="텍스트 입력"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCell.cellType === "field" && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">연결 필드</label>
|
||||
{selectedCell.field ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-blue-50 px-2 py-1.5 text-xs font-medium text-blue-700">
|
||||
<Database className="h-3 w-3" />{selectedCell.field}
|
||||
</span>
|
||||
) : (
|
||||
<p className="text-[10px] text-gray-400">데이터 연결 탭에서 배치</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="h-px bg-gray-200" />
|
||||
|
||||
{/* 스타일 설정 — 1행 5열 */}
|
||||
<div className="flex items-end gap-2">
|
||||
{/* 정렬 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">정렬</label>
|
||||
<div className="flex items-center gap-0.5 rounded border border-gray-200 bg-white p-0.5">
|
||||
{(["left", "center", "right"] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded ${
|
||||
selectedCell.align === a ? "bg-blue-100 text-blue-700" : "text-gray-400 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => updateCellProps(selectedCell.row, selectedCell.col, { align: a })}
|
||||
>
|
||||
{a === "left" ? <AlignLeft className="h-3 w-3" /> : a === "center" ? <AlignCenter className="h-3 w-3" /> : <AlignRight className="h-3 w-3" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 굵기 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">굵기</label>
|
||||
<button
|
||||
className={`flex h-7 w-7 items-center justify-center rounded border ${
|
||||
selectedCell.fontWeight === "bold" ? "border-blue-300 bg-blue-100 text-blue-700" : "border-gray-200 text-gray-400 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => updateCellProps(selectedCell.row, selectedCell.col, { fontWeight: selectedCell.fontWeight === "bold" ? "normal" : "bold" })}
|
||||
>
|
||||
<Bold className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 글자 크기 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">크기</label>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-7 w-12 text-center text-xs"
|
||||
value={selectedCell.fontSize ?? 12}
|
||||
min={8}
|
||||
max={36}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { fontSize: parseInt(e.target.value) || 12 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">배경색</label>
|
||||
<div className="flex h-7 items-center gap-1 rounded border border-gray-200 bg-white px-1.5">
|
||||
<Paintbrush className="h-3 w-3 shrink-0 text-gray-400" />
|
||||
<input
|
||||
type="color"
|
||||
className="h-5 w-6 cursor-pointer rounded border-none"
|
||||
value={selectedCell.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 글자색 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">글자색</label>
|
||||
<div className="flex h-7 items-center gap-1 rounded border border-gray-200 bg-white px-1.5">
|
||||
<Type className="h-3 w-3 shrink-0 text-gray-400" />
|
||||
<input
|
||||
type="color"
|
||||
className="h-5 w-6 cursor-pointer rounded border-none"
|
||||
value={selectedCell.textColor || "#111827"}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { textColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ImageLayoutTabs.tsx — 이미지 컴포넌트 설정 탭
|
||||
*
|
||||
* [역할]
|
||||
* - 모달 내 이미지 핵심 설정: 업로드 / 자르기(Crop) / 맞춤 방식 / 캡션
|
||||
* - 시각 스타일(투명도·모서리·회전 등)은 우측 패널에서 관리
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useMemo, useCallback } from "react";
|
||||
import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop";
|
||||
import "react-image-crop/dist/ReactCrop.css";
|
||||
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 {
|
||||
Upload,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Link,
|
||||
Crop as CropIcon,
|
||||
RotateCcw,
|
||||
AlignCenter,
|
||||
} from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Section as SharedSection, TabContent, Field, FieldGroup, Grid, InfoBox } from "./shared";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface ImageLayoutTabsProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
const OBJECT_FIT_OPTIONS = [
|
||||
{ value: "contain", label: "비율 유지 (포함)", desc: "전체가 보이도록 축소" },
|
||||
{ value: "cover", label: "영역 채우기", desc: "꽉 채우고 넘침 잘림" },
|
||||
{ value: "fill", label: "늘리기", desc: "비율 무시 영역 맞춤" },
|
||||
{ value: "none", label: "원본 크기", desc: "원본 그대로 표시" },
|
||||
] as const;
|
||||
|
||||
export function ImageLayoutTabs({ component }: ImageLayoutTabsProps) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => {
|
||||
updateComponent(component.id, updates);
|
||||
},
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
const handleImageUpload = 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 > 10 * 1024 * 1024) {
|
||||
toast({ title: "오류", description: "파일 크기는 10MB 이하여야 합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
const result = await reportApi.uploadImage(file);
|
||||
if (result.success) {
|
||||
update({
|
||||
imageUrl: result.data.fileUrl,
|
||||
imageCropX: undefined,
|
||||
imageCropY: undefined,
|
||||
imageCropWidth: undefined,
|
||||
imageCropHeight: undefined,
|
||||
});
|
||||
toast({ title: "성공", description: "이미지가 업로드되었습니다." });
|
||||
}
|
||||
} catch {
|
||||
toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive" });
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
update({ imageUrl: "", imageCropX: undefined, imageCropY: undefined, imageCropWidth: undefined, imageCropHeight: undefined });
|
||||
toast({ title: "알림", description: "이미지가 제거되었습니다." });
|
||||
};
|
||||
|
||||
const previewUrl = useMemo(() => {
|
||||
if (!component.imageUrl) return null;
|
||||
return getFullImageUrl(component.imageUrl);
|
||||
}, [component.imageUrl]);
|
||||
|
||||
return (
|
||||
<TabContent>
|
||||
{/* 이미지 업로드 */}
|
||||
<SharedSection emphasis icon={<Upload className="h-3.5 w-3.5" />} title="이미지 소스">
|
||||
<FieldGroup>
|
||||
<Field label="이미지 파일" help="JPG, PNG, GIF, WEBP 파일을 업로드하세요 (최대 10MB)">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-[120px] w-[140px] shrink-0 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-blue-200 bg-white">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="미리보기" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<span className="text-xs">미리보기</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-center 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="h-9 w-full text-xs"
|
||||
>
|
||||
{uploadingImage ? (
|
||||
<><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />업로드 중...</>
|
||||
) : (
|
||||
<><Upload className="mr-1.5 h-3.5 w-3.5" />{component.imageUrl ? "이미지 변경" : "이미지 업로드"}</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemoveImage}
|
||||
disabled={!component.imageUrl}
|
||||
className="h-9 w-full text-xs text-red-500 hover:bg-red-50 hover:text-red-600 disabled:text-gray-300"
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />이미지 제거
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
{component.imageUrl && (
|
||||
<InfoBox variant="blue">
|
||||
<p className="truncate">
|
||||
<Link className="mr-1 inline h-3 w-3" />
|
||||
{component.imageUrl}
|
||||
</p>
|
||||
</InfoBox>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</SharedSection>
|
||||
|
||||
{/* 이미지 자르기 */}
|
||||
{previewUrl && (
|
||||
<SharedSection icon={<CropIcon className="h-3.5 w-3.5" />} title="이미지 자르기 (크롭)">
|
||||
<ImageCropEditor
|
||||
imageUrl={previewUrl}
|
||||
component={component}
|
||||
onUpdate={update}
|
||||
/>
|
||||
</SharedSection>
|
||||
)}
|
||||
|
||||
{/* 표시 방식 */}
|
||||
<SharedSection icon={<AlignCenter className="h-3.5 w-3.5" />} title="표시 방식">
|
||||
<FieldGroup>
|
||||
<Field label="이미지 맞춤 방식">
|
||||
<Select
|
||||
value={component.objectFit || "contain"}
|
||||
onValueChange={(v) => update({ objectFit: v as ComponentConfig["objectFit"] })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OBJECT_FIT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<span>{opt.label}</span>
|
||||
<span className="ml-1.5 text-[11px] text-gray-400">{opt.desc}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="대체 텍스트 (alt)">
|
||||
<Input
|
||||
value={component.imageAlt || ""}
|
||||
onChange={(e) => update({ imageAlt: e.target.value })}
|
||||
placeholder="이미지 설명 (접근성)"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</SharedSection>
|
||||
|
||||
{/* 캡션 */}
|
||||
<SharedSection icon={<ImageIcon className="h-3.5 w-3.5" />} title="캡션">
|
||||
<FieldGroup>
|
||||
<Field label="캡션 텍스트" help="이미지 아래에 설명 텍스트를 표시합니다">
|
||||
<Input
|
||||
value={component.imageCaption || ""}
|
||||
onChange={(e) => update({ imageCaption: e.target.value })}
|
||||
placeholder="이미지에 표시할 설명 텍스트"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
{component.imageCaption && (
|
||||
<Grid cols={2}>
|
||||
<Field label="위치">
|
||||
<Select
|
||||
value={component.imageCaptionPosition || "bottom"}
|
||||
onValueChange={(v) => update({ imageCaptionPosition: v as "top" | "bottom" })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">상단</SelectItem>
|
||||
<SelectItem value="bottom">하단</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="정렬">
|
||||
<Select
|
||||
value={component.imageCaptionAlign || "center"}
|
||||
onValueChange={(v) => update({ imageCaptionAlign: v as "left" | "center" | "right" })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</Grid>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</SharedSection>
|
||||
|
||||
<InfoBox variant="gray">
|
||||
투명도, 테두리, 모서리, 회전 등 스타일 설정은 우측 패널에서 변경할 수 있습니다.
|
||||
</InfoBox>
|
||||
</TabContent>
|
||||
);
|
||||
}
|
||||
|
||||
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
* 이미지 자르기(Crop) 에디터
|
||||
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
|
||||
|
||||
interface ImageCropEditorProps {
|
||||
imageUrl: string;
|
||||
component: ComponentConfig;
|
||||
onUpdate: (updates: Partial<ComponentConfig>) => void;
|
||||
}
|
||||
|
||||
function ImageCropEditor({ imageUrl, component, onUpdate }: ImageCropEditorProps) {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const hasCrop = component.imageCropWidth != null && component.imageCropHeight != null;
|
||||
|
||||
const [crop, setCrop] = useState<Crop | undefined>(() => {
|
||||
if (hasCrop) {
|
||||
return {
|
||||
unit: "%" as const,
|
||||
x: component.imageCropX ?? 0,
|
||||
y: component.imageCropY ?? 0,
|
||||
width: component.imageCropWidth ?? 100,
|
||||
height: component.imageCropHeight ?? 100,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const handleCropComplete = useCallback(
|
||||
(_pixelCrop: PixelCrop, percentCrop: Crop) => {
|
||||
if (percentCrop.width < 1 || percentCrop.height < 1) return;
|
||||
onUpdate({
|
||||
imageCropX: Math.round(percentCrop.x * 100) / 100,
|
||||
imageCropY: Math.round(percentCrop.y * 100) / 100,
|
||||
imageCropWidth: Math.round(percentCrop.width * 100) / 100,
|
||||
imageCropHeight: Math.round(percentCrop.height * 100) / 100,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleResetCrop = () => {
|
||||
setCrop(undefined);
|
||||
onUpdate({
|
||||
imageCropX: undefined,
|
||||
imageCropY: undefined,
|
||||
imageCropWidth: undefined,
|
||||
imageCropHeight: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
||||
onComplete={handleCropComplete}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageUrl}
|
||||
alt="자르기 편집"
|
||||
style={{ maxWidth: "100%", maxHeight: 300, display: "block" }}
|
||||
/>
|
||||
</ReactCrop>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] text-gray-400">
|
||||
{hasCrop
|
||||
? `자르기 영역: ${Math.round(component.imageCropWidth ?? 0)}% x ${Math.round(component.imageCropHeight ?? 0)}%`
|
||||
: "드래그하여 자르기 영역을 지정하세요"}
|
||||
</p>
|
||||
{hasCrop && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetCrop}
|
||||
className="h-7 text-xs text-gray-500"
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getShadowStyle(shadow: string): string {
|
||||
switch (shadow) {
|
||||
case "sm": return "0 1px 2px rgba(0,0,0,0.05)";
|
||||
case "md": return "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)";
|
||||
case "lg": return "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* QuerySettingsTab.tsx
|
||||
*
|
||||
* 데이터 연결 탭 — 기존 QueryManager를 모달 탭 안에서 렌더링한다.
|
||||
*
|
||||
* [사용처]
|
||||
* - ComponentSettingsModal의 "데이터 연결" 탭
|
||||
* - QueryManager는 Context에서 직접 queries/setQueries를 구독하므로
|
||||
* 별도 props 없이 그대로 mount하면 동작한다.
|
||||
*
|
||||
* [설계 결정]
|
||||
* - 외부 DB 연결 목록은 QueryManager 내부 useEffect로 로드되므로
|
||||
* 탭을 열 때마다 재조회된다. 빈번한 재마운트를 최소화하려면
|
||||
* Context로 올리는 방향을 검토할 수 있으나, 현재는 단순성 우선.
|
||||
*/
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { QueryManager } from "../QueryManager";
|
||||
|
||||
export function QuerySettingsTab() {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
<QueryManager />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SettingsModalShell.tsx — 컴포넌트 설정 모달 공통 Shell
|
||||
*
|
||||
* [역할]
|
||||
* 모든 컴포넌트 설정 모달이 동일한 형식을 유지하도록 하는 재사용 모듈.
|
||||
* 파란색 그라데이션 헤더 + 탭(헤더 하단 인라인) + 스크롤 콘텐츠 + 하단 Footer 구조.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Save, AlertTriangle, CheckCircle } from "lucide-react";
|
||||
|
||||
const DND_ROOT_ID = "report-designer-dnd-root";
|
||||
|
||||
export interface ModalTabDef {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ModalAlert {
|
||||
type: "success" | "warning";
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SettingsModalShellProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
tabs: ModalTabDef[];
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
alert?: ModalAlert | null;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
export function SettingsModalShell({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
icon,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onSave,
|
||||
onClose,
|
||||
alert,
|
||||
children,
|
||||
maxWidth = "max-w-4xl",
|
||||
saveLabel = "저장",
|
||||
}: SettingsModalShellProps) {
|
||||
const [dndContainer, setDndContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const el = document.getElementById(DND_ROOT_ID);
|
||||
setDndContainer(el ?? undefined);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
container={dndContainer}
|
||||
className={`flex h-[92vh] ${maxWidth} flex-col gap-0 overflow-hidden rounded-xl p-0 [&>button]:hidden`}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{title} 설정 모달</DialogDescription>
|
||||
|
||||
{/* Gradient Header */}
|
||||
<div className="shrink-0 rounded-t-xl bg-linear-to-r from-blue-600 to-indigo-600">
|
||||
{/* Title row */}
|
||||
<div className="flex items-center gap-3 px-6 pt-4 pb-3">
|
||||
{icon && <span className="shrink-0 text-white/90">{icon}</span>}
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
className="ml-auto shrink-0 rounded-md p-1.5 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">닫기</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs row */}
|
||||
{tabs.length > 0 && (
|
||||
<div className="flex items-end gap-1 px-6 pb-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={`flex items-center gap-1.5 rounded-t-lg px-4 py-2 text-xs font-medium transition-all ${
|
||||
activeTab === tab.key
|
||||
? "bg-white text-blue-700 shadow-sm"
|
||||
: "text-white/80 hover:bg-white/15 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab.icon && <span className="shrink-0">{tab.icon}</span>}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 토스트 알림 */}
|
||||
{alert && (
|
||||
<div className="pointer-events-none absolute right-4 top-2 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div
|
||||
className={`pointer-events-auto flex items-start gap-3 rounded-lg border bg-white px-4 py-3 shadow-lg ${
|
||||
alert.type === "success" ? "border-gray-200" : "border-amber-200"
|
||||
}`}
|
||||
>
|
||||
{alert.type === "success" ? (
|
||||
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-500">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{alert.type === "success" ? "성공" : "알림"}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">{alert.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0 rounded-b-xl border-t bg-white px-6 py-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
className="h-8 px-4 text-xs bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saveLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 px-4 text-xs"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 토스트 알림 훅 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function useModalAlert() {
|
||||
const [alert, setAlert] = useState<ModalAlert | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showAlert = useCallback((type: ModalAlert["type"], message: string) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setAlert({ type, message });
|
||||
timerRef.current = setTimeout(() => setAlert(null), 3000);
|
||||
}, []);
|
||||
|
||||
const clearAlert = useCallback(() => setAlert(null), []);
|
||||
|
||||
return { alert, showAlert, clearAlert };
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TableCanvasEditor — 테이블 레이아웃 탭의 시각적 편집기
|
||||
*
|
||||
* - 컨트롤 바(열 추가, 행 조절)는 고정
|
||||
* - 테이블 미리보기만 가로/세로 스크롤
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2, Minus, Rows3, Columns3 } from "lucide-react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
||||
|
||||
const MIN_COL_WIDTH = 60;
|
||||
const DEFAULT_COL_WIDTH = 120;
|
||||
const MIN_ROWS = 1;
|
||||
const MAX_ROWS = 50;
|
||||
const MAX_COLUMNS = 14;
|
||||
const DEFAULT_ROWS = 3;
|
||||
|
||||
interface TableCanvasEditorProps {
|
||||
columns: TableColumn[];
|
||||
onColumnsChange: (columns: TableColumn[]) => void;
|
||||
rowCount?: number;
|
||||
onRowCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function TableCanvasEditor({ columns, onColumnsChange, rowCount, onRowCountChange }: TableCanvasEditorProps) {
|
||||
const displayRows = rowCount ?? DEFAULT_ROWS;
|
||||
const [resizingIdx, setResizingIdx] = useState<number | null>(null);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(0);
|
||||
|
||||
const canAddColumn = columns.length < MAX_COLUMNS;
|
||||
|
||||
const handleAddColumn = useCallback(() => {
|
||||
if (!canAddColumn) return;
|
||||
onColumnsChange([
|
||||
...columns,
|
||||
{
|
||||
field: "",
|
||||
header: `열 ${columns.length + 1}`,
|
||||
width: DEFAULT_COL_WIDTH,
|
||||
align: "left",
|
||||
mappingType: "field",
|
||||
summaryType: "NONE",
|
||||
visible: true,
|
||||
numberFormat: "none",
|
||||
},
|
||||
]);
|
||||
}, [columns, onColumnsChange, canAddColumn]);
|
||||
|
||||
const handleRemoveColumn = useCallback(
|
||||
(idx: number) => {
|
||||
if (columns.length <= 1) return;
|
||||
const remaining = columns.filter((_, i) => i !== idx);
|
||||
const renumbered = remaining.map((col, i) => {
|
||||
const isDefaultHeader = /^열 \d+$/.test(col.header);
|
||||
return isDefaultHeader ? { ...col, header: `열 ${i + 1}` } : col;
|
||||
});
|
||||
onColumnsChange(renumbered);
|
||||
},
|
||||
[columns, onColumnsChange],
|
||||
);
|
||||
|
||||
const handleRemoveLastColumn = useCallback(() => {
|
||||
if (columns.length <= 1) return;
|
||||
handleRemoveColumn(columns.length - 1);
|
||||
}, [columns, handleRemoveColumn]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(idx: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setResizingIdx(idx);
|
||||
startXRef.current = e.clientX;
|
||||
startWidthRef.current = columns[idx]?.width || DEFAULT_COL_WIDTH;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - startXRef.current;
|
||||
const newWidth = Math.max(MIN_COL_WIDTH, startWidthRef.current + delta);
|
||||
onColumnsChange(
|
||||
columns.map((col, i) => (i === idx ? { ...col, width: newWidth } : col)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizingIdx(null);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[columns, onColumnsChange],
|
||||
);
|
||||
|
||||
const totalWidth = columns.reduce((sum, col) => sum + (col.width || DEFAULT_COL_WIDTH), 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-white shadow-sm">
|
||||
{/* 컨트롤 바 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<span className="text-sm font-bold text-gray-800">테이블 레이아웃</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={() => onRowCountChange?.(Math.max(MIN_ROWS, displayRows - 1))}
|
||||
disabled={displayRows <= MIN_ROWS}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
||||
<Rows3 className="h-3 w-3 text-gray-400" />{displayRows}행
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={() => onRowCountChange?.(Math.min(MAX_ROWS, displayRows + 1))}
|
||||
disabled={displayRows >= MAX_ROWS}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<div className="mx-1.5 h-4 w-px bg-gray-300" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleRemoveLastColumn}
|
||||
disabled={columns.length <= 1}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
||||
<Columns3 className="h-3 w-3 text-gray-400" />{columns.length}열
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleAddColumn}
|
||||
disabled={!canAddColumn}
|
||||
title={canAddColumn ? "열 추가" : `최대 ${MAX_COLUMNS}개까지 추가 가능합니다`}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 미리보기 */}
|
||||
{columns.length > 0 ? (
|
||||
<div className="overflow-auto p-3" style={{ maxHeight: 360 }}>
|
||||
<table
|
||||
className="select-none border-collapse"
|
||||
style={{ width: totalWidth + 28, tableLayout: "fixed" }}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: 28, minWidth: 28 }} />
|
||||
{columns.map((col, idx) => (
|
||||
<col key={idx} style={{ width: col.width || DEFAULT_COL_WIDTH, minWidth: MIN_COL_WIDTH }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
{/* 열 번호 헤더 */}
|
||||
<tr>
|
||||
<th className="border border-gray-200 bg-gray-100" />
|
||||
{columns.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className="group/colhdr relative border border-gray-200 bg-gray-100 px-1 py-1 text-[10px] font-medium text-gray-400"
|
||||
>
|
||||
<span>{idx + 1}</span>
|
||||
{columns.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleRemoveColumn(idx)}
|
||||
className="absolute -top-0.5 right-0.5 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-white shadow-sm transition-colors hover:bg-red-500 group-hover/colhdr:flex"
|
||||
title={`${idx + 1}열 삭제`}
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
{idx < columns.length - 1 && (
|
||||
<div
|
||||
className={`absolute -right-[3px] top-0 z-10 h-full w-[5px] cursor-col-resize transition-colors ${
|
||||
resizingIdx === idx ? "bg-blue-400" : "hover:bg-blue-300"
|
||||
}`}
|
||||
onMouseDown={(e) => handleResizeStart(idx, e)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* 열 이름 헤더 */}
|
||||
<tr>
|
||||
<td className="border border-gray-200 bg-gray-100" />
|
||||
{columns.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className="border border-gray-200 bg-gray-50 px-1 text-center text-xs font-semibold text-gray-700"
|
||||
style={{ height: 32, minHeight: 32 }}
|
||||
>
|
||||
<span className="block truncate">{col.header || `열 ${idx + 1}`}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: displayRows }).map((_, rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
<td
|
||||
className="border border-gray-200 bg-gray-100 text-center text-[10px] font-medium text-gray-400"
|
||||
style={{ width: 28, minWidth: 28, height: 32, minHeight: 32 }}
|
||||
>
|
||||
{rowIdx + 1}
|
||||
</td>
|
||||
{columns.map((col, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
className="select-none border border-gray-200"
|
||||
style={{
|
||||
height: 32,
|
||||
minHeight: 32,
|
||||
padding: "2px 4px",
|
||||
textAlign: col.align || "center",
|
||||
verticalAlign: "middle",
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap" as const,
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<span className="pointer-events-none block truncate text-xs text-gray-300">—</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-gray-400">
|
||||
셀을 클릭하여 선택하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TableColumnDropZone — 테이블 데이터 연결 탭의 드롭 영역
|
||||
*
|
||||
* 탭1에서 설정한 열 개수만큼 슬롯을 표시하고,
|
||||
* TableColumnPalette에서 드래그한 컬럼을 드롭하여 배치.
|
||||
* 이미 배치된 컬럼은 다른 슬롯으로 드래그하여 위치 교환 가능.
|
||||
* 배치된 컬럼에는 인라인으로 헤더명/숫자형식 설정 가능.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { X, Columns, GripVertical } from "lucide-react";
|
||||
import { TABLE_COLUMN_DND_TYPE } from "./TableColumnPalette";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
||||
|
||||
export const TABLE_SLOT_DND_TYPE = "table-slot";
|
||||
|
||||
interface TableColumnDropZoneProps {
|
||||
columns: TableColumn[];
|
||||
onUpdate: (idx: number, updates: Partial<TableColumn>) => void;
|
||||
onDrop: (slotIndex: number, columnName: string, dataType: string) => void;
|
||||
onClear: (slotIndex: number) => void;
|
||||
onMove: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
interface SlotDragItem {
|
||||
type: typeof TABLE_SLOT_DND_TYPE;
|
||||
sourceIndex: number;
|
||||
}
|
||||
|
||||
interface PaletteDragItem {
|
||||
columnName: string;
|
||||
dataType: string;
|
||||
}
|
||||
|
||||
interface SlotProps {
|
||||
col: TableColumn;
|
||||
idx: number;
|
||||
onUpdate: (idx: number, updates: Partial<TableColumn>) => void;
|
||||
onDrop: (slotIndex: number, columnName: string, dataType: string) => void;
|
||||
onClear: (slotIndex: number) => void;
|
||||
onMove: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
function DropSlot({ col, idx, onUpdate, onDrop, onClear, onMove }: SlotProps) {
|
||||
const isEmpty = !col.field;
|
||||
|
||||
const [{ isDragging }, drag, preview] = useDrag(() => ({
|
||||
type: TABLE_SLOT_DND_TYPE,
|
||||
item: { type: TABLE_SLOT_DND_TYPE, sourceIndex: idx } as SlotDragItem,
|
||||
canDrag: !isEmpty,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}), [idx, isEmpty]);
|
||||
|
||||
const [{ isOver, canDrop: canDropHere }, drop] = useDrop(() => ({
|
||||
accept: [TABLE_COLUMN_DND_TYPE, TABLE_SLOT_DND_TYPE],
|
||||
drop: (item: PaletteDragItem | SlotDragItem, monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
if (itemType === TABLE_SLOT_DND_TYPE) {
|
||||
const slotItem = item as SlotDragItem;
|
||||
if (slotItem.sourceIndex !== idx) {
|
||||
onMove(slotItem.sourceIndex, idx);
|
||||
}
|
||||
} else {
|
||||
const paletteItem = item as PaletteDragItem;
|
||||
onDrop(idx, paletteItem.columnName, paletteItem.dataType);
|
||||
}
|
||||
},
|
||||
canDrop: (item, monitor) => {
|
||||
if (monitor.getItemType() === TABLE_SLOT_DND_TYPE) {
|
||||
return (item as SlotDragItem).sourceIndex !== idx;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}), [idx, onDrop, onMove]);
|
||||
|
||||
const isActive = isOver && canDropHere;
|
||||
|
||||
const ref = (node: HTMLDivElement | null) => {
|
||||
preview(drop(node));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative rounded-lg border p-3 transition-all ${
|
||||
isDragging
|
||||
? "border-blue-300 bg-blue-50/60 opacity-50 shadow-inner"
|
||||
: isActive
|
||||
? "border-blue-400 bg-blue-50 shadow-sm ring-2 ring-blue-200"
|
||||
: isEmpty
|
||||
? "border-dashed border-gray-300 bg-gray-50/50"
|
||||
: "border-gray-200 bg-white hover:border-blue-200 hover:bg-blue-50/20"
|
||||
}`}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-center">
|
||||
<Columns className="mb-1 h-5 w-5 text-gray-300" />
|
||||
<span className="text-xs text-gray-400">
|
||||
{isActive ? "여기에 놓으세요" : "컬럼을 드래그하여 배치"}
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] text-gray-300">열 {idx + 1}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* 드래그 핸들 + 컬럼명 배지 + 제거 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
ref={drag as any}
|
||||
className="flex h-5 w-5 cursor-grab items-center justify-center rounded text-gray-400 hover:bg-gray-100 hover:text-gray-600 active:cursor-grabbing"
|
||||
title="드래그하여 위치 이동"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 font-mono text-xs font-medium text-blue-700">
|
||||
<Columns className="h-3 w-3" />
|
||||
{col.field}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onClear(idx)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-red-50 hover:text-red-500"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 헤더명 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] text-gray-500">헤더명</label>
|
||||
<Input
|
||||
value={col.header}
|
||||
onChange={(e) => onUpdate(idx, { header: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 숫자형식 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] text-gray-500">숫자형식</label>
|
||||
<Select
|
||||
value={col.numberFormat || "none"}
|
||||
onValueChange={(v) => onUpdate(idx, { numberFormat: v as "none" | "comma" | "currency" })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="comma">천단위(,)</SelectItem>
|
||||
<SelectItem value="currency">통화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableColumnDropZone({ columns, onUpdate, onDrop, onClear, onMove }: TableColumnDropZoneProps) {
|
||||
const filledCount = columns.filter((c) => c.field).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-white shadow-sm">
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<span className="text-sm font-bold text-gray-800">
|
||||
테이블 컬럼 배치
|
||||
</span>
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{filledCount}/{columns.length} 배치됨
|
||||
</span>
|
||||
{filledCount >= 2 && (
|
||||
<span className="ml-2 text-[10px] text-gray-400">
|
||||
(드래그로 위치 변경 가능)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{columns.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-gray-400">
|
||||
레이아웃 탭에서 열을 먼저 추가하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: `repeat(${Math.min(columns.length, 4)}, 1fr)` }}
|
||||
>
|
||||
{columns.map((col, idx) => (
|
||||
<DropSlot
|
||||
key={idx}
|
||||
col={col}
|
||||
idx={idx}
|
||||
onUpdate={onUpdate}
|
||||
onDrop={onDrop}
|
||||
onClear={onClear}
|
||||
onMove={onMove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TableColumnPalette — 테이블 데이터 연결 탭의 컬럼 팔레트
|
||||
*
|
||||
* 2단계 플로우:
|
||||
* 1. 체크박스로 사용할 컬럼을 중복 선택
|
||||
* 2. 선택된 컬럼만 드래그 가능한 칩으로 표시 → 드롭 존에 배치
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Columns, Loader2, ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
|
||||
export const TABLE_COLUMN_DND_TYPE = "table-column";
|
||||
|
||||
export interface SchemaColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
// ─── 드래그 가능한 선택된 컬럼 칩 ────────────────────────────────────────────
|
||||
|
||||
interface DraggableColumnProps {
|
||||
column: SchemaColumn;
|
||||
placed?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
function DraggableColumn({ column, placed = false, onRemove }: DraggableColumnProps) {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: TABLE_COLUMN_DND_TYPE,
|
||||
item: { columnName: column.column_name, dataType: column.data_type },
|
||||
canDrag: () => !placed,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}), [column, placed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={placed ? undefined : (drag as any)}
|
||||
className={`relative flex items-center gap-1.5 rounded-lg border px-3 py-2 transition-colors ${
|
||||
placed
|
||||
? "cursor-default border-gray-200 bg-gray-100 opacity-50"
|
||||
: `cursor-move border-blue-200 bg-blue-50 hover:border-blue-400 hover:bg-blue-100 ${isDragging ? "opacity-50 shadow-lg" : ""}`
|
||||
}`}
|
||||
>
|
||||
<Columns className={`h-3.5 w-3.5 shrink-0 ${placed ? "text-gray-400" : "text-blue-500"}`} />
|
||||
<span className={`truncate font-mono text-xs font-medium ${placed ? "text-gray-400 line-through" : "text-blue-700"}`}>
|
||||
{column.column_name}
|
||||
</span>
|
||||
{placed ? (
|
||||
<span className="shrink-0 rounded bg-green-100 px-1 py-0.5 text-[9px] text-green-600">배치됨</span>
|
||||
) : (
|
||||
<span className="shrink-0 rounded bg-blue-100 px-1 py-0.5 text-[9px] text-blue-500">
|
||||
{column.data_type}
|
||||
</span>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 text-white shadow-sm hover:bg-red-500"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 팔레트 ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface TableColumnPaletteProps {
|
||||
columns: SchemaColumn[];
|
||||
loading?: boolean;
|
||||
maxSelectable?: number;
|
||||
placedColumns?: Set<string>;
|
||||
onColumnRemove?: (columnName: string) => void;
|
||||
}
|
||||
|
||||
export function TableColumnPalette({ columns, loading, maxSelectable = 0, placedColumns, onColumnRemove }: TableColumnPaletteProps) {
|
||||
const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
|
||||
const [listExpanded, setListExpanded] = useState(false);
|
||||
|
||||
// 이미 배치된 컬럼을 선택 목록에 자동 포함
|
||||
useEffect(() => {
|
||||
if (!placedColumns || placedColumns.size === 0) return;
|
||||
setSelectedNames((prev) => {
|
||||
const next = new Set(prev);
|
||||
let changed = false;
|
||||
placedColumns.forEach((name) => {
|
||||
if (!next.has(name)) {
|
||||
next.add(name);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [placedColumns]);
|
||||
|
||||
const isLimitReached = maxSelectable > 0 && selectedNames.size >= maxSelectable;
|
||||
|
||||
const selectedColumns = useMemo(
|
||||
() => columns.filter((c) => selectedNames.has(c.column_name)),
|
||||
[columns, selectedNames],
|
||||
);
|
||||
|
||||
const toggleColumn = (name: string) => {
|
||||
setSelectedNames((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
if (maxSelectable > 0 && next.size >= maxSelectable) return prev;
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Step 1: 컬럼 선택 */}
|
||||
<div className="rounded-xl border border-border bg-white">
|
||||
<button
|
||||
onClick={() => setListExpanded(!listExpanded)}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-gray-800">
|
||||
열 선택
|
||||
</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{selectedNames.size}{maxSelectable > 0 ? `/${maxSelectable}` : `/${columns.length}`} 선택됨
|
||||
</span>
|
||||
{maxSelectable > 0 && isLimitReached && (
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-600">
|
||||
최대
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{listExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{listExpanded && (
|
||||
<div className="border-t border-gray-100">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-xs text-gray-500">컬럼 로딩 중...</span>
|
||||
</div>
|
||||
) : columns.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-gray-400">
|
||||
레이아웃 탭에서 데이터 소스를 먼저 선택하세요.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{maxSelectable > 0 && (
|
||||
<div className="border-b border-gray-100 px-4 py-2">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
배치 가능 열 수: {maxSelectable}개
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[200px] overflow-y-auto divide-y divide-gray-50">
|
||||
{columns.map((col) => {
|
||||
const checked = selectedNames.has(col.column_name);
|
||||
const disabled = !checked && isLimitReached;
|
||||
return (
|
||||
<label
|
||||
key={col.column_name}
|
||||
className={`flex items-center gap-3 px-4 py-2 transition-colors ${
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: "cursor-pointer hover:bg-gray-50"
|
||||
} ${checked ? "bg-blue-50/40" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleColumn(col.column_name)}
|
||||
className="h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-blue-600 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span
|
||||
className={`font-mono text-xs font-medium ${
|
||||
checked ? "text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{col.column_name}
|
||||
</span>
|
||||
<span className="rounded bg-gray-100 px-1 py-0.5 text-[9px] text-gray-500">
|
||||
{col.data_type}
|
||||
</span>
|
||||
{col.is_nullable === "NO" && (
|
||||
<span className="rounded bg-orange-50 px-1 py-0.5 text-[9px] text-orange-500">
|
||||
NOT NULL
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: 선택된 컬럼 드래그 영역 */}
|
||||
{selectedColumns.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-white p-4">
|
||||
<div className="mb-3 text-sm font-bold text-gray-800">
|
||||
선택된 열
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
드래그하여 아래 표에 배치 ({selectedColumns.length}개)
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{selectedColumns.map((col) => (
|
||||
<DraggableColumn
|
||||
key={col.column_name}
|
||||
column={col}
|
||||
placed={placedColumns?.has(col.column_name)}
|
||||
onRemove={() => {
|
||||
setSelectedNames((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(col.column_name);
|
||||
return next;
|
||||
});
|
||||
onColumnRemove?.(col.column_name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { Eye } from "lucide-react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface TablePreviewPanelProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function TablePreviewPanel({ component }: TablePreviewPanelProps) {
|
||||
const visibleCols = (component.tableColumns ?? []).filter((c) => c.visible !== false);
|
||||
const hasColumns = visibleCols.length > 0;
|
||||
const previewRowCount = component.tableRowCount ?? 3;
|
||||
|
||||
const headerBg = component.headerBackgroundColor || "#f3f4f6";
|
||||
const headerColor = component.headerTextColor || "#111827";
|
||||
const rowHeight = component.rowHeight ?? 28;
|
||||
const hasBorder = component.showBorder !== false;
|
||||
const borderClass = hasBorder ? "border border-gray-300" : "";
|
||||
|
||||
const totalConfigWidth = visibleCols.reduce((s, c) => s + (c.width || 120), 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 mb-1 text-sm font-semibold text-gray-700">
|
||||
<Eye className="w-4 h-4 text-blue-600" />
|
||||
미리보기 (저장 전 상태)
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
실제 데이터는 저장 후 확인 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center w-full min-h-[600px] rounded-xl bg-gray-100 border border-gray-200 p-8 overflow-auto">
|
||||
{hasColumns ? (
|
||||
<div
|
||||
className="rounded-lg bg-white shadow-sm overflow-hidden"
|
||||
style={{
|
||||
width: Math.min(component.width || 700, 700),
|
||||
minHeight: Math.min(component.height || 400, 400),
|
||||
}}
|
||||
>
|
||||
<table className="w-full border-collapse text-xs" style={{ tableLayout: "fixed" }}>
|
||||
<colgroup>
|
||||
{visibleCols.map((col, idx) => {
|
||||
const ratio = (col.width || 120) / totalConfigWidth;
|
||||
return <col key={idx} style={{ width: `${(ratio * 100).toFixed(2)}%` }} />;
|
||||
})}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: headerBg, color: headerColor }}>
|
||||
{visibleCols.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className={borderClass}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
textAlign: col.align || "left",
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: previewRowCount }).map((_, rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
{visibleCols.map((col, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
className={borderClass}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
textAlign: col.align || "left",
|
||||
height: `${rowHeight}px`,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: "#d1d5db",
|
||||
}}
|
||||
>
|
||||
{col.field ? `{${col.field}}` : "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
|
||||
컬럼을 설정하면 미리보기가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TextLayoutTabs.tsx — 텍스트 컴포넌트 설정
|
||||
*
|
||||
* [역할]
|
||||
* - 텍스트 내용 입력 + 데이터 바인딩(queryId/fieldName) 설정
|
||||
* - ComponentSettingsModal 내에 직접 임베드되어 사용
|
||||
*
|
||||
* [사용처]
|
||||
* - TextProperties.tsx (section="data"일 때)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Type, Database, Link2 } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { Section, TabContent, Field, FieldGroup, InfoBox } from "./shared";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface TextLayoutTabsProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function TextLayoutTabs({ component }: TextLayoutTabsProps) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
const selectedQueryFields = useMemo(() => {
|
||||
if (!component.queryId) return [];
|
||||
const result = getQueryResult(component.queryId);
|
||||
if (result?.fields) return result.fields;
|
||||
return [];
|
||||
}, [component.queryId, getQueryResult]);
|
||||
|
||||
const hasBinding = !!(component.queryId && component.fieldName);
|
||||
|
||||
return (
|
||||
<TabContent>
|
||||
{/* 데이터 바인딩 섹션 */}
|
||||
<Section
|
||||
emphasis
|
||||
icon={<Database className="h-3.5 w-3.5" />}
|
||||
title="데이터 바인딩"
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field label="데이터 소스 (쿼리)">
|
||||
<Select
|
||||
value={component.queryId || "none"}
|
||||
onValueChange={(value) =>
|
||||
update({
|
||||
queryId: value === "none" ? undefined : value,
|
||||
fieldName: value === "none" ? undefined : component.fieldName,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<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>
|
||||
</Field>
|
||||
|
||||
{component.queryId && (
|
||||
<Field label="바인딩 필드">
|
||||
{selectedQueryFields.length > 0 ? (
|
||||
<Select
|
||||
value={component.fieldName || "none"}
|
||||
onValueChange={(value) =>
|
||||
update({ fieldName: value === "none" ? undefined : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{selectedQueryFields.map((field) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={component.fieldName || ""}
|
||||
onChange={(e) => update({ fieldName: e.target.value || undefined })}
|
||||
placeholder="필드명 직접 입력 (예: doc_number)"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!component.queryId && component.fieldName && (
|
||||
<Field label="바인딩 필드 (쿼리 미연결)">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<Input
|
||||
value={component.fieldName || ""}
|
||||
onChange={(e) => update({ fieldName: e.target.value || undefined })}
|
||||
placeholder="필드명"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-amber-600">
|
||||
쿼리를 연결하면 실제 데이터가 표시됩니다.
|
||||
</p>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
{hasBinding && (
|
||||
<InfoBox variant="blue">
|
||||
쿼리 실행 시 <code className="rounded bg-blue-100 px-1 text-xs font-mono">{`{${component.fieldName}}`}</code> 값이 표시됩니다.
|
||||
</InfoBox>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* 기본값 / 정적 텍스트 */}
|
||||
<Section icon={<Type className="h-3.5 w-3.5" />} title={hasBinding ? "기본값 (데이터 없을 때)" : "텍스트 내용"}>
|
||||
<FieldGroup>
|
||||
<Field label={hasBinding ? "기본 표시 텍스트" : "표시 텍스트"}>
|
||||
<Textarea
|
||||
value={component.defaultValue || ""}
|
||||
onChange={(e) => update({ defaultValue: e.target.value })}
|
||||
placeholder={
|
||||
hasBinding
|
||||
? "데이터가 없을 때 표시할 텍스트"
|
||||
: "텍스트 내용을 입력하세요 (엔터로 줄바꿈 가능)"
|
||||
}
|
||||
className="min-h-[72px] resize-y text-sm"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Section>
|
||||
|
||||
<InfoBox variant="gray">
|
||||
폰트, 색상, 정렬 등 스타일 설정은 우측 패널에서 변경할 수 있습니다.
|
||||
</InfoBox>
|
||||
</TabContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
Trash2,
|
||||
Link2,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Table2,
|
||||
} from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ReportQuery } from "@/contexts/report-designer/types";
|
||||
import type {
|
||||
ComponentConfig,
|
||||
VisualDataSource,
|
||||
VisualDetailSource,
|
||||
VisualColumn,
|
||||
JoinKeyPair,
|
||||
} from "@/types/report";
|
||||
|
||||
interface SchemaTable {
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}
|
||||
|
||||
interface SchemaColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
interface ForeignKey {
|
||||
constraint_name: string;
|
||||
columns: string[];
|
||||
foreign_table: string;
|
||||
foreign_columns: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_DATA_SOURCE: VisualDataSource = {
|
||||
master: { tableName: "", columns: [] },
|
||||
details: [],
|
||||
};
|
||||
|
||||
function buildSqlForMaster(ds: VisualDataSource): string {
|
||||
if (!ds.master.tableName || ds.master.columns.length === 0) return "";
|
||||
const cols = ds.master.columns.map((c) => `"${c.name}"`).join(", ");
|
||||
return `SELECT ${cols} FROM "${ds.master.tableName}" WHERE 1=1`;
|
||||
}
|
||||
|
||||
function buildSqlForDetail(ds: VisualDataSource, detail: VisualDetailSource): string {
|
||||
if (!detail.tableName || detail.columns.length === 0) return "";
|
||||
const cols = detail.columns.map((c) => `"${c.name}"`).join(", ");
|
||||
const conditions = detail.joinKeys
|
||||
.filter((jk) => jk.detailColumn)
|
||||
.map((jk, idx) => `"${jk.detailColumn}" = $${idx + 1}`)
|
||||
.join(" AND ");
|
||||
const where = conditions ? ` WHERE ${conditions}` : "";
|
||||
return `SELECT ${cols} FROM "${detail.tableName}"${where}`;
|
||||
}
|
||||
|
||||
function buildQueriesFromDataSource(ds: VisualDataSource): ReportQuery[] {
|
||||
const queries: ReportQuery[] = [];
|
||||
|
||||
const masterSql = buildSqlForMaster(ds);
|
||||
if (masterSql) {
|
||||
queries.push({
|
||||
id: `vds_master_${ds.master.tableName}`,
|
||||
name: `${ds.master.tableName} (마스터)`,
|
||||
type: "MASTER",
|
||||
sqlQuery: masterSql,
|
||||
parameters: [],
|
||||
visualDataSource: ds,
|
||||
});
|
||||
}
|
||||
|
||||
for (const detail of ds.details) {
|
||||
const detailSql = buildSqlForDetail(ds, detail);
|
||||
if (detailSql) {
|
||||
const params = detail.joinKeys
|
||||
.filter((jk) => jk.detailColumn)
|
||||
.map((_, idx) => `$${idx + 1}`);
|
||||
queries.push({
|
||||
id: `vds_detail_${detail.id}`,
|
||||
name: `${detail.tableName} (디테일)`,
|
||||
type: "DETAIL",
|
||||
sqlQuery: detailSql,
|
||||
parameters: params,
|
||||
visualDataSource: ds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
export function VisualDataSourceBuilder({ component, embedded = false }: Props) {
|
||||
const { updateComponent, queries, setQueries } = useReportDesigner();
|
||||
|
||||
const ds: VisualDataSource = component.visualDataSource ?? EMPTY_DATA_SOURCE;
|
||||
const prevDsRef = useRef<string>("");
|
||||
|
||||
// 데이터 소스 변경 시 queries 배열 자동 동기화
|
||||
useEffect(() => {
|
||||
const dsJson = JSON.stringify(ds);
|
||||
if (dsJson === prevDsRef.current) return;
|
||||
prevDsRef.current = dsJson;
|
||||
|
||||
if (!ds.master.tableName) return;
|
||||
|
||||
const vdsQueries = buildQueriesFromDataSource(ds);
|
||||
if (vdsQueries.length === 0) return;
|
||||
|
||||
const nonVdsQueries = queries.filter((q) => !q.id.startsWith("vds_"));
|
||||
setQueries([...nonVdsQueries, ...vdsQueries]);
|
||||
}, [ds, queries, setQueries]);
|
||||
|
||||
const [tables, setTables] = useState<SchemaTable[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [masterColumns, setMasterColumns] = useState<SchemaColumn[]>([]);
|
||||
const [masterColumnsLoading, setMasterColumnsLoading] = useState(false);
|
||||
const [detailColumnsMap, setDetailColumnsMap] = useState<Record<string, SchemaColumn[]>>({});
|
||||
const [detailColumnsLoading, setDetailColumnsLoading] = useState<Record<string, boolean>>({});
|
||||
const [detailFkMap, setDetailFkMap] = useState<Record<string, ForeignKey[]>>({});
|
||||
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 복원 시 디테일 테이블의 컬럼 목록 자동 로드
|
||||
useEffect(() => {
|
||||
for (const detail of ds.details) {
|
||||
if (detail.tableName && !detailColumnsMap[detail.id]) {
|
||||
setDetailColumnsLoading((prev) => ({ ...prev, [detail.id]: true }));
|
||||
reportApi
|
||||
.getSchemaTableColumns(detail.tableName)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
setDetailColumnsMap((prev) => ({ ...prev, [detail.id]: res.data }));
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setDetailColumnsLoading((prev) => ({ ...prev, [detail.id]: false }));
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [ds.details, detailColumnsMap]);
|
||||
|
||||
const updateDS = useCallback(
|
||||
(patch: Partial<VisualDataSource>) => {
|
||||
updateComponent(component.id, {
|
||||
visualDataSource: { ...ds, ...patch },
|
||||
});
|
||||
},
|
||||
[component.id, ds, updateComponent],
|
||||
);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTablesLoading(true);
|
||||
reportApi
|
||||
.getSchemaTableList()
|
||||
.then((res) => {
|
||||
if (!cancelled && res.success) setTables(res.data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTablesLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// 마스터 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!ds.master.tableName) {
|
||||
setMasterColumns([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setMasterColumnsLoading(true);
|
||||
reportApi
|
||||
.getSchemaTableColumns(ds.master.tableName)
|
||||
.then((res) => {
|
||||
if (!cancelled && res.success) setMasterColumns(res.data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setMasterColumnsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [ds.master.tableName]);
|
||||
|
||||
const handleMasterTableChange = useCallback(
|
||||
(tableName: string) => {
|
||||
const newTableName = tableName === "none" ? "" : tableName;
|
||||
updateDS({
|
||||
master: { tableName: newTableName, columns: [] },
|
||||
details: [],
|
||||
});
|
||||
setDetailColumnsMap({});
|
||||
setDetailFkMap({});
|
||||
},
|
||||
[updateDS],
|
||||
);
|
||||
|
||||
const toggleMasterColumn = useCallback(
|
||||
(colName: string, dataType: string) => {
|
||||
const existing = ds.master.columns.find((c) => c.name === colName);
|
||||
const newColumns = existing
|
||||
? ds.master.columns.filter((c) => c.name !== colName)
|
||||
: [...ds.master.columns, { name: colName, label: colName, dataType, selected: true }];
|
||||
updateDS({ master: { ...ds.master, columns: newColumns } });
|
||||
},
|
||||
[ds, updateDS],
|
||||
);
|
||||
|
||||
const updateMasterColumnLabel = useCallback(
|
||||
(colName: string, label: string) => {
|
||||
const newColumns = ds.master.columns.map((c) =>
|
||||
c.name === colName ? { ...c, label } : c,
|
||||
);
|
||||
updateDS({ master: { ...ds.master, columns: newColumns } });
|
||||
},
|
||||
[ds, updateDS],
|
||||
);
|
||||
|
||||
// 디테일 추가
|
||||
const addDetail = useCallback(() => {
|
||||
const newDetail: VisualDetailSource = {
|
||||
id: `detail_${Date.now()}`,
|
||||
tableName: "",
|
||||
columns: [],
|
||||
joinKeys: [],
|
||||
};
|
||||
updateDS({ details: [...ds.details, newDetail] });
|
||||
setExpandedDetails((prev) => ({ ...prev, [newDetail.id]: true }));
|
||||
}, [ds, updateDS]);
|
||||
|
||||
const removeDetail = useCallback(
|
||||
(detailId: string) => {
|
||||
updateDS({ details: ds.details.filter((d) => d.id !== detailId) });
|
||||
},
|
||||
[ds, updateDS],
|
||||
);
|
||||
|
||||
const updateDetail = useCallback(
|
||||
(detailId: string, patch: Partial<VisualDetailSource>) => {
|
||||
updateDS({
|
||||
details: ds.details.map((d) => (d.id === detailId ? { ...d, ...patch } : d)),
|
||||
});
|
||||
},
|
||||
[ds, updateDS],
|
||||
);
|
||||
|
||||
// 디테일 테이블 변경 시 컬럼 + FK 로드
|
||||
const handleDetailTableChange = useCallback(
|
||||
async (detailId: string, tableName: string) => {
|
||||
const newTableName = tableName === "none" ? "" : tableName;
|
||||
updateDetail(detailId, { tableName: newTableName, columns: [], joinKeys: [] });
|
||||
|
||||
if (!newTableName) return;
|
||||
|
||||
setDetailColumnsLoading((prev) => ({ ...prev, [detailId]: true }));
|
||||
try {
|
||||
const [colRes, fkRes] = await Promise.all([
|
||||
reportApi.getSchemaTableColumns(newTableName),
|
||||
reportApi.getSchemaTableForeignKeys(newTableName),
|
||||
]);
|
||||
|
||||
if (colRes.success) {
|
||||
setDetailColumnsMap((prev) => ({ ...prev, [detailId]: colRes.data }));
|
||||
}
|
||||
|
||||
if (fkRes.success) {
|
||||
setDetailFkMap((prev) => ({ ...prev, [detailId]: fkRes.data }));
|
||||
|
||||
// FK 자동 감지: 마스터 테이블을 참조하는 FK 찾기
|
||||
const matchingFk = fkRes.data.find(
|
||||
(fk: ForeignKey) => fk.foreign_table === ds.master.tableName,
|
||||
);
|
||||
if (matchingFk) {
|
||||
const autoJoinKeys: JoinKeyPair[] = matchingFk.columns.map(
|
||||
(col: string, idx: number) => ({
|
||||
masterColumn: matchingFk.foreign_columns[idx] || "",
|
||||
detailColumn: col,
|
||||
autoDetected: true,
|
||||
}),
|
||||
);
|
||||
updateDetail(detailId, {
|
||||
tableName: newTableName,
|
||||
columns: [],
|
||||
joinKeys: autoJoinKeys,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 무시
|
||||
} finally {
|
||||
setDetailColumnsLoading((prev) => ({ ...prev, [detailId]: false }));
|
||||
}
|
||||
},
|
||||
[ds.master.tableName, updateDetail],
|
||||
);
|
||||
|
||||
const toggleDetailColumn = useCallback(
|
||||
(detailId: string, colName: string, dataType: string) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
const existing = detail.columns.find((c) => c.name === colName);
|
||||
const newColumns = existing
|
||||
? detail.columns.filter((c) => c.name !== colName)
|
||||
: [...detail.columns, { name: colName, label: colName, dataType, selected: true }];
|
||||
updateDetail(detailId, { columns: newColumns });
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
const updateDetailColumnLabel = useCallback(
|
||||
(detailId: string, colName: string, label: string) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
const newColumns = detail.columns.map((c) =>
|
||||
c.name === colName ? { ...c, label } : c,
|
||||
);
|
||||
updateDetail(detailId, { columns: newColumns });
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
// 연결 키 관리
|
||||
const addJoinKey = useCallback(
|
||||
(detailId: string) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
updateDetail(detailId, {
|
||||
joinKeys: [
|
||||
...detail.joinKeys,
|
||||
{ masterColumn: "", detailColumn: "", autoDetected: false },
|
||||
],
|
||||
});
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
const updateJoinKey = useCallback(
|
||||
(detailId: string, keyIdx: number, patch: Partial<JoinKeyPair>) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
const newKeys = detail.joinKeys.map((k, i) =>
|
||||
i === keyIdx ? { ...k, ...patch, autoDetected: false } : k,
|
||||
);
|
||||
updateDetail(detailId, { joinKeys: newKeys });
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
const removeJoinKey = useCallback(
|
||||
(detailId: string, keyIdx: number) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
updateDetail(detailId, {
|
||||
joinKeys: detail.joinKeys.filter((_, i) => i !== keyIdx),
|
||||
});
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
const toggleDetailExpanded = useCallback((detailId: string) => {
|
||||
setExpandedDetails((prev) => ({ ...prev, [detailId]: !prev[detailId] }));
|
||||
}, []);
|
||||
|
||||
// 컬럼 체크박스 렌더링 헬퍼
|
||||
const renderColumnCheckboxes = (
|
||||
schemaColumns: SchemaColumn[],
|
||||
selectedColumns: VisualColumn[],
|
||||
onToggle: (colName: string, dataType: string) => void,
|
||||
onLabelChange: (colName: string, label: string) => void,
|
||||
loading: boolean,
|
||||
) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-xs text-muted-foreground">컬럼 로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (schemaColumns.length === 0) {
|
||||
return <p className="py-2 text-xs text-muted-foreground">테이블을 선택해주세요.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{schemaColumns.map((col) => {
|
||||
const selected = selectedColumns.find((c) => c.name === col.column_name);
|
||||
return (
|
||||
<div key={col.column_name} className="flex items-center gap-2 rounded px-1 py-0.5 hover:bg-muted/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selected}
|
||||
onChange={() => onToggle(col.column_name, col.data_type)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{col.column_name}</span>
|
||||
<span className="shrink-0 text-[9px] text-gray-400">{col.data_type}</span>
|
||||
{selected && (
|
||||
<Input
|
||||
value={selected.label}
|
||||
onChange={(e) => onLabelChange(col.column_name, e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-6 w-24 text-[10px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="space-y-3">
|
||||
{/* 마스터 데이터 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">마스터 테이블</Label>
|
||||
<Select
|
||||
value={ds.master.tableName || "none"}
|
||||
onValueChange={handleMasterTableChange}
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={tablesLoading ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.table_name} value={t.table_name}>
|
||||
{t.table_name}
|
||||
{t.table_type === "VIEW" && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">(뷰)</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{ds.master.tableName && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">
|
||||
마스터 컬럼
|
||||
{ds.master.columns.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px]">
|
||||
{ds.master.columns.length}개
|
||||
</Badge>
|
||||
)}
|
||||
</Label>
|
||||
{renderColumnCheckboxes(
|
||||
masterColumns,
|
||||
ds.master.columns,
|
||||
toggleMasterColumn,
|
||||
updateMasterColumnLabel,
|
||||
masterColumnsLoading,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 디테일 데이터 */}
|
||||
{ds.details.map((detail, idx) => {
|
||||
const detailCols = detailColumnsMap[detail.id] || [];
|
||||
const isLoading = detailColumnsLoading[detail.id] || false;
|
||||
const isExpanded = expandedDetails[detail.id] !== false;
|
||||
|
||||
return (
|
||||
<div key={detail.id} className="rounded-lg border border-gray-200 bg-white p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDetailExpanded(detail.id)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Table2 className="h-3.5 w-3.5 text-gray-500" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
디테일 #{idx + 1}
|
||||
{detail.tableName && (
|
||||
<span className="ml-1 font-normal text-muted-foreground">
|
||||
({detail.tableName})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-red-400 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => removeDetail(detail.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">테이블</Label>
|
||||
<Select
|
||||
value={detail.tableName || "none"}
|
||||
onValueChange={(v) => handleDetailTableChange(detail.id, v)}
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.table_name} value={t.table_name}>
|
||||
{t.table_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{detail.tableName && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-2.5">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-[11px] font-medium text-blue-700">연결 키</span>
|
||||
{detail.joinKeys.some((k) => k.autoDetected) && (
|
||||
<Badge variant="outline" className="h-4 border-blue-300 px-1 text-[9px] text-blue-600">
|
||||
자동 감지
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px]"
|
||||
onClick={() => addJoinKey(detail.id)}
|
||||
>
|
||||
<Plus className="mr-0.5 h-2.5 w-2.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{detail.joinKeys.length === 0 && (
|
||||
<p className="text-[10px] text-blue-600">
|
||||
FK가 감지되지 않았습니다. 수동으로 추가해주세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{detail.joinKeys.map((jk, ki) => (
|
||||
<div key={ki} className="flex items-center gap-1">
|
||||
<Select
|
||||
value={jk.masterColumn || "none"}
|
||||
onValueChange={(v) =>
|
||||
updateJoinKey(detail.id, ki, {
|
||||
masterColumn: v === "none" ? "" : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
||||
<SelectValue placeholder="마스터 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ds.master.columns.length > 0
|
||||
? ds.master.columns.map((c) => (
|
||||
<SelectItem key={c.name} value={c.name}>
|
||||
{c.label !== c.name ? `${c.label} (${c.name})` : c.name}
|
||||
</SelectItem>
|
||||
))
|
||||
: masterColumns.map((c) => (
|
||||
<SelectItem key={c.column_name} value={c.column_name}>
|
||||
{c.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[10px] text-gray-400">↔</span>
|
||||
<Select
|
||||
value={jk.detailColumn || "none"}
|
||||
onValueChange={(v) =>
|
||||
updateJoinKey(detail.id, ki, {
|
||||
detailColumn: v === "none" ? "" : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
||||
<SelectValue placeholder="디테일 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{detailCols.map((c) => (
|
||||
<SelectItem key={c.column_name} value={c.column_name}>
|
||||
{c.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0 text-red-400 hover:text-red-600"
|
||||
onClick={() => removeJoinKey(detail.id, ki)}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.tableName && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">
|
||||
디테일 컬럼
|
||||
{detail.columns.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px]">
|
||||
{detail.columns.length}개
|
||||
</Badge>
|
||||
)}
|
||||
</Label>
|
||||
{renderColumnCheckboxes(
|
||||
detailCols,
|
||||
detail.columns,
|
||||
(colName, dataType) => toggleDetailColumn(detail.id, colName, dataType),
|
||||
(colName, label) => updateDetailColumnLabel(detail.id, colName, label),
|
||||
isLoading,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{ds.master.tableName && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-dashed text-xs"
|
||||
onClick={addDetail}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
디테일 데이터 추가
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!ds.master.tableName && (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 p-4 text-center">
|
||||
<Database className="mx-auto mb-1.5 h-6 w-6 text-gray-300" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
마스터 테이블을 선택하면 데이터 소스를 구성할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">{content}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* shared.tsx — 모달 내부 공통 UI 헬퍼 컴포넌트
|
||||
*
|
||||
* Section, TabContent, Field, FieldGroup, Grid, InfoBox, PreviewPanel 등
|
||||
* 모든 컴포넌트 설정 모달에서 일관된 UI를 유지하기 위한 빌딩 블록.
|
||||
*/
|
||||
|
||||
import React, { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// ─── 섹션 컨테이너 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface SectionProps {
|
||||
emphasis?: boolean;
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
emphasis = false,
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-4",
|
||||
emphasis
|
||||
? "border-blue-200 bg-blue-50/50"
|
||||
: "border-gray-200 bg-white",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{(icon || title) && (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{icon && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
emphasis ? "text-blue-600" : "text-gray-500",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{title && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
emphasis ? "text-blue-700" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 탭 콘텐츠 래퍼 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface TabContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabContent({ children, className }: TabContentProps) {
|
||||
return (
|
||||
<div className={cn("space-y-4 px-6 py-5", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 폼 필드 래퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
help?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
htmlFor?: string;
|
||||
}
|
||||
|
||||
export function Field({ label, help, children, className, htmlFor }: FieldProps) {
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
<Label
|
||||
htmlFor={htmlFor}
|
||||
className="block text-xs font-medium text-foreground"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{help && (
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">{help}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 2/3열 그리드 ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface GridProps {
|
||||
children: ReactNode;
|
||||
cols?: 2 | 3;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Grid({ children, cols = 2, className }: GridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
cols === 2 ? "grid-cols-2" : "grid-cols-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 필드 그룹 (space-y-3) ───────────────────────────────────────────────────
|
||||
|
||||
interface FieldGroupProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FieldGroup({ children, className }: FieldGroupProps) {
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 인라인 정보 박스 ─────────────────────────────────────────────────────────
|
||||
|
||||
interface InfoBoxProps {
|
||||
children: ReactNode;
|
||||
variant?: "blue" | "gray";
|
||||
}
|
||||
|
||||
export function InfoBox({ children, variant = "blue" }: InfoBoxProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-xs",
|
||||
variant === "blue"
|
||||
? "border-blue-200 bg-blue-50 text-blue-800"
|
||||
: "border-gray-200 bg-gray-50 text-gray-600",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 미리보기 패널 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PreviewPanelProps {
|
||||
children?: ReactNode;
|
||||
label?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
children,
|
||||
label = "미리보기",
|
||||
height = "h-48",
|
||||
}: PreviewPanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
{children ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center overflow-hidden rounded-xl border border-gray-200 bg-white",
|
||||
height,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-white",
|
||||
height,
|
||||
)}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
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 { QrCode, X } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
export function BarcodeProperties({ component, section }: Props) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 바코드 스타일 — 우측 패널(section="style")에서 표시 */}
|
||||
{showStyle && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-cyan-200 bg-cyan-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-cyan-700">
|
||||
<QrCode className="h-4 w-4" />
|
||||
바코드 스타일
|
||||
</div>
|
||||
|
||||
{/* 1D 바코드 전용: 텍스트 표시 */}
|
||||
{component.barcodeType !== "QR" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showBarcodeText"
|
||||
checked={component.showBarcodeText !== false}
|
||||
onChange={(e) => updateComponent(component.id, { showBarcodeText: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="showBarcodeText" className="text-xs">
|
||||
바코드 아래 텍스트 표시
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">바코드 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.barcodeColor || "#000000"}
|
||||
onChange={(e) => updateComponent(component.id, { barcodeColor: e.target.value })}
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">배경 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.barcodeBackground || "#ffffff"}
|
||||
onChange={(e) => updateComponent(component.id, { barcodeBackground: e.target.value })}
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 여백 */}
|
||||
<div>
|
||||
<Label className="text-xs">여백 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.barcodeMargin ?? 10}
|
||||
onChange={(e) => updateComponent(component.id, { barcodeMargin: Number(e.target.value) })}
|
||||
min={0}
|
||||
max={50}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 바코드 데이터 — 모달(section="data")에서 표시 */}
|
||||
{showData && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-cyan-200 bg-cyan-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-cyan-700">
|
||||
<QrCode className="h-4 w-4" />
|
||||
바코드 데이터
|
||||
</div>
|
||||
|
||||
{/* 바코드 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">바코드 타입</Label>
|
||||
<Select
|
||||
value={component.barcodeType || "CODE128"}
|
||||
onValueChange={(value) => {
|
||||
const newType = value as "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||
if (newType === "QR") {
|
||||
const size = Math.max(component.width, component.height);
|
||||
updateComponent(component.id, { barcodeType: newType, width: size, height: size });
|
||||
} else {
|
||||
updateComponent(component.id, { barcodeType: newType });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<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>
|
||||
|
||||
{/* QR 오류 보정 수준 */}
|
||||
{component.barcodeType === "QR" && (
|
||||
<div>
|
||||
<Label className="text-xs">오류 보정 수준</Label>
|
||||
<Select
|
||||
value={component.qrErrorCorrectionLevel || "M"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { qrErrorCorrectionLevel: value as "L" | "M" | "Q" | "H" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 바코드 값 입력 (쿼리 연결 없을 때) */}
|
||||
{!component.queryId && (
|
||||
<div>
|
||||
<Label className="text-xs">바코드 값</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.barcodeValue || ""}
|
||||
onChange={(e) => updateComponent(component.id, { barcodeValue: e.target.value })}
|
||||
placeholder={
|
||||
component.barcodeType === "EAN13"
|
||||
? "13자리 숫자"
|
||||
: component.barcodeType === "EAN8"
|
||||
? "8자리 숫자"
|
||||
: component.barcodeType === "UPC"
|
||||
? "12자리 숫자"
|
||||
: "바코드에 표시할 값"
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
{(component.barcodeType === "EAN13" ||
|
||||
component.barcodeType === "EAN8" ||
|
||||
component.barcodeType === "UPC") && (
|
||||
<p className="mt-1 text-[10px] text-gray-500">
|
||||
{component.barcodeType === "EAN13" && "EAN-13: 12~13자리 숫자 필요"}
|
||||
{component.barcodeType === "EAN8" && "EAN-8: 7~8자리 숫자 필요"}
|
||||
{component.barcodeType === "UPC" && "UPC: 11~12자리 숫자 필요"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 쿼리 연결 안내 — 인라인 안내 텍스트 (수정하지 않음) */}
|
||||
{!component.queryId && (
|
||||
<div className="rounded border border-cyan-200 bg-cyan-100 p-2 text-xs text-cyan-800">
|
||||
쿼리를 연결하면 데이터베이스 값으로 바코드를 생성할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 쿼리 연결 시 필드 선택 */}
|
||||
{component.queryId && (
|
||||
<>
|
||||
{/* QR코드: 다중 필드 모드 토글 */}
|
||||
{component.barcodeType === "QR" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="qrUseMultiField"
|
||||
checked={component.qrUseMultiField === true}
|
||||
onChange={(e) =>
|
||||
updateComponent(component.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 단일 모드) */}
|
||||
{(component.barcodeType !== "QR" || !component.qrUseMultiField) && (
|
||||
<div>
|
||||
<Label className="text-xs">바인딩 필드</Label>
|
||||
<Select
|
||||
value={component.barcodeFieldName || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { barcodeFieldName: value === "none" ? "" : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === component.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 */}
|
||||
{component.barcodeType === "QR" && component.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 = component.qrDataFields || [];
|
||||
updateComponent(component.id, {
|
||||
qrDataFields: [...currentFields, { fieldName: "", label: "" }],
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ 필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="max-h-[200px] space-y-2 overflow-y-auto">
|
||||
{(component.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 = [...(component.qrDataFields || [])];
|
||||
newFields[index] = {
|
||||
...newFields[index],
|
||||
fieldName: value === "none" ? "" : value,
|
||||
label: newFields[index].label || (value === "none" ? "" : value),
|
||||
};
|
||||
updateComponent(component.id, { qrDataFields: newFields });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === component.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 = [...(component.qrDataFields || [])];
|
||||
newFields[index] = { ...newFields[index], label: e.target.value };
|
||||
updateComponent(component.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 = (component.qrDataFields || []).filter((_, i) => i !== index);
|
||||
updateComponent(component.id, { qrDataFields: newFields });
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(component.qrDataFields || []).length === 0 && (
|
||||
<p className="text-center text-xs text-gray-400">필드를 추가하세요</p>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-gray-500">
|
||||
결과:{" "}
|
||||
{component.qrIncludeAllRows
|
||||
? `[{"${(component.qrDataFields || []).map((f) => f.label || "key").join('":"값","')}"}, ...]`
|
||||
: `{"${(component.qrDataFields || []).map((f) => f.label || "key").join('":"값","')}":"값"}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR코드 모든 행 포함 옵션 */}
|
||||
{component.barcodeType === "QR" && component.queryId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="qrIncludeAllRows"
|
||||
checked={component.qrIncludeAllRows === true}
|
||||
onChange={(e) => updateComponent(component.id, { qrIncludeAllRows: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="qrIncludeAllRows" className="text-xs">
|
||||
모든 행 포함 (배열)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
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 { Calculator } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
export function CalculationProperties({ component, section }: Props) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 표시 설정 — 우측 패널(section="style")에서 표시 */}
|
||||
{showStyle && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-orange-200 bg-orange-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-700">
|
||||
<Calculator className="h-4 w-4" />
|
||||
표시 설정
|
||||
</div>
|
||||
|
||||
{/* 라벨 너비 */}
|
||||
<div>
|
||||
<Label className="text-xs">라벨 너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.labelWidth || 120}
|
||||
onChange={(e) => updateComponent(component.id, { labelWidth: Number(e.target.value) })}
|
||||
min={60}
|
||||
max={200}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 숫자 포맷 */}
|
||||
<div>
|
||||
<Label className="text-xs">숫자 포맷</Label>
|
||||
<Select
|
||||
value={component.numberFormat || "currency"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { numberFormat: value as "none" | "comma" | "currency" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="comma">천단위 구분</SelectItem>
|
||||
<SelectItem value="currency">통화 (원)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 통화 접미사 */}
|
||||
{component.numberFormat === "currency" && (
|
||||
<div>
|
||||
<Label className="text-xs">통화 단위</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.currencySuffix || "원"}
|
||||
onChange={(e) => updateComponent(component.id, { currencySuffix: e.target.value })}
|
||||
placeholder="원"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 폰트 크기 설정 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">라벨 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.labelFontSize || 13}
|
||||
onChange={(e) => updateComponent(component.id, { labelFontSize: Number(e.target.value) })}
|
||||
min={10}
|
||||
max={20}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">값 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.valueFontSize || 13}
|
||||
onChange={(e) => updateComponent(component.id, { valueFontSize: Number(e.target.value) })}
|
||||
min={10}
|
||||
max={20}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">결과 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.resultFontSize || 16}
|
||||
onChange={(e) => updateComponent(component.id, { resultFontSize: Number(e.target.value) })}
|
||||
min={12}
|
||||
max={24}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">라벨 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.labelColor || "#374151"}
|
||||
onChange={(e) => updateComponent(component.id, { labelColor: e.target.value })}
|
||||
className="h-9 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">값 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.valueColor || "#000000"}
|
||||
onChange={(e) => updateComponent(component.id, { valueColor: e.target.value })}
|
||||
className="h-9 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">결과 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.resultColor || "#2563eb"}
|
||||
onChange={(e) => updateComponent(component.id, { resultColor: e.target.value })}
|
||||
className="h-9 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 계산 항목 — 모달(section="data")에서 표시 */}
|
||||
{showData && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-orange-200 bg-orange-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-700">
|
||||
<Calculator className="h-4 w-4" />
|
||||
계산 항목
|
||||
</div>
|
||||
|
||||
{/* 결과 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs">결과 라벨</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.resultLabel || "합계"}
|
||||
onChange={(e) => updateComponent(component.id, { resultLabel: e.target.value })}
|
||||
placeholder="합계 금액"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 선택 (데이터 바인딩용) */}
|
||||
<div>
|
||||
<Label className="text-xs">데이터 소스 (쿼리)</Label>
|
||||
<Select
|
||||
value={component.queryId || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { queryId: value === "none" ? undefined : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<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="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 = component.calcItems || [];
|
||||
updateComponent(component.id, {
|
||||
calcItems: [
|
||||
...currentItems,
|
||||
{
|
||||
label: `항목${currentItems.length + 1}`,
|
||||
value: 0,
|
||||
operator: "+" as const,
|
||||
fieldName: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ 항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 항목 리스트 — 개별 항목 카드(rounded border bg-white p-2)는 유지 */}
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{(component.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 = [...(component.calcItems || [])];
|
||||
currentItems.splice(index, 1);
|
||||
updateComponent(component.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 = [...(component.calcItems || [])];
|
||||
currentItems[index] = { ...currentItems[index], label: e.target.value };
|
||||
updateComponent(component.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 = [...(component.calcItems || [])];
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
operator: value as "+" | "-" | "x" | "÷",
|
||||
};
|
||||
updateComponent(component.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">
|
||||
{component.queryId ? (
|
||||
<div>
|
||||
<Label className="text-[10px]">필드</Label>
|
||||
<Select
|
||||
value={item.fieldName || "none"}
|
||||
onValueChange={(value) => {
|
||||
const currentItems = [...(component.calcItems || [])];
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
fieldName: value === "none" ? "" : value,
|
||||
};
|
||||
updateComponent(component.id, { calcItems: currentItems });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">직접 입력</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === component.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 = [...(component.calcItems || [])];
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
value: Number(e.target.value),
|
||||
};
|
||||
updateComponent(component.id, { calcItems: currentItems });
|
||||
}}
|
||||
className="h-6 text-xs"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CardProperties.tsx — 카드 컴포넌트 설정
|
||||
*
|
||||
* - section="data": 모달 내 기능 설정 탭에서 CardLayoutTabs 직접 렌더링
|
||||
* - section="style": 우측 패널에서 프리셋 + 8개 스타일 섹션 제공
|
||||
* - onConfigChange: Draft 모드 — 모달에서 저장 전 로컬 변경용
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig, CardLayoutConfig } from "@/types/report";
|
||||
import { CardLayoutTabs } from "../modals/CardLayoutTabs";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
section?: "style" | "data";
|
||||
onConfigChange?: (updates: Partial<ComponentConfig>) => void;
|
||||
}
|
||||
|
||||
const generateId = () =>
|
||||
`row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
const DEFAULT_CONFIG: CardLayoutConfig = {
|
||||
tableName: "",
|
||||
primaryKey: "",
|
||||
rows: [{ id: generateId(), gridColumns: 2, elements: [] }],
|
||||
padding: "12px",
|
||||
gap: "8px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "#e5e7eb",
|
||||
backgroundColor: "#ffffff",
|
||||
headerTitleFontSize: 14,
|
||||
headerTitleColor: "#1e40af",
|
||||
labelFontSize: 13,
|
||||
labelColor: "#374151",
|
||||
valueFontSize: 13,
|
||||
valueColor: "#000000",
|
||||
dividerThickness: 1,
|
||||
dividerColor: "#e5e7eb",
|
||||
};
|
||||
|
||||
const CARD_STYLE_PRESETS = {
|
||||
info: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderStyle: "solid",
|
||||
borderColor: "#e5e7eb",
|
||||
borderWidth: 1,
|
||||
accentBorderWidth: 0,
|
||||
borderRadius: "12px",
|
||||
headerFontWeight: "bold" as const,
|
||||
headerTitleColor: "#111827",
|
||||
labelColor: "#6b7280",
|
||||
valueFontWeight: "normal" as const,
|
||||
valueColor: "#111827",
|
||||
dividerColor: "#3b82f6",
|
||||
dividerThickness: 1,
|
||||
},
|
||||
compact: {
|
||||
backgroundColor: "#eff6ff",
|
||||
borderStyle: "none",
|
||||
borderWidth: 0,
|
||||
accentBorderColor: "#3b82f6",
|
||||
accentBorderWidth: 4,
|
||||
borderRadius: "8px",
|
||||
headerFontWeight: "normal" as const,
|
||||
headerTitleColor: "#6b7280",
|
||||
labelColor: "#6b7280",
|
||||
valueFontWeight: "bold" as const,
|
||||
valueColor: "#111827",
|
||||
dividerColor: "#e5e7eb",
|
||||
dividerThickness: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardProperties({ component, section, onConfigChange }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const [openStyleSections, setOpenStyleSections] = useState<Set<string>>(new Set(["preset"]));
|
||||
const toggleStyleSection = (id: string) => {
|
||||
setOpenStyleSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const config = useMemo(() => {
|
||||
return component.cardLayoutConfig || DEFAULT_CONFIG;
|
||||
}, [component.cardLayoutConfig]);
|
||||
|
||||
const handleConfigChange = useCallback(
|
||||
(newConfig: CardLayoutConfig) => {
|
||||
const updates = { cardLayoutConfig: newConfig };
|
||||
if (onConfigChange) onConfigChange(updates);
|
||||
else updateComponent(component.id, updates);
|
||||
},
|
||||
[component.id, onConfigChange, updateComponent],
|
||||
);
|
||||
|
||||
const updateDesignConfig = useCallback(
|
||||
(updates: Partial<CardLayoutConfig>) => {
|
||||
handleConfigChange({ ...config, ...updates });
|
||||
},
|
||||
[config, handleConfigChange],
|
||||
);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(presetKey: keyof typeof CARD_STYLE_PRESETS) => {
|
||||
updateDesignConfig(CARD_STYLE_PRESETS[presetKey]);
|
||||
},
|
||||
[updateDesignConfig],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showData && (
|
||||
<CardLayoutTabs config={config} onConfigChange={handleConfigChange} component={component} onComponentChange={onConfigChange} />
|
||||
)}
|
||||
|
||||
{showStyle && (
|
||||
<div className="mt-2 rounded-lg border border-gray-200 overflow-hidden">
|
||||
<StyleAccordion label="프리셋" isOpen={openStyleSections.has("preset")} onToggle={() => toggleStyleSection("preset")}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => applyPreset("info")} className="text-xs h-8">인포 카드</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => applyPreset("compact")} className="text-xs h-8">컴팩트 카드</Button>
|
||||
<Button variant="ghost" size="sm" className="text-xs h-8 text-muted-foreground" disabled>커스텀</Button>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
<StyleAccordion label="카드 외형" isOpen={openStyleSections.has("appearance")} onToggle={() => toggleStyleSection("appearance")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">배경색</Label>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input type="color" value={config.backgroundColor || "#ffffff"} onChange={(e) => updateDesignConfig({ backgroundColor: e.target.value })} className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0" />
|
||||
<Input value={config.backgroundColor || "#ffffff"} onChange={(e) => updateDesignConfig({ backgroundColor: e.target.value })} className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">모서리</Label>
|
||||
<Select value={config.borderRadius || "0"} onValueChange={(v) => updateDesignConfig({ borderRadius: v })}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">없음</SelectItem>
|
||||
<SelectItem value="8px">보통</SelectItem>
|
||||
<SelectItem value="20px">둥글게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">안쪽 여백</Label>
|
||||
<Select value={config.padding || "12px"} onValueChange={(v) => updateDesignConfig({ padding: v })}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2px">보통</SelectItem>
|
||||
<SelectItem value="14px">넓게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">행 간격</Label>
|
||||
<Select value={config.gap || "8px"} onValueChange={(v) => updateDesignConfig({ gap: v })}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1px">보통</SelectItem>
|
||||
<SelectItem value="10px">넓게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
<StyleAccordion label="좌측 액센트 보더" isOpen={openStyleSections.has("accent")} onToggle={() => toggleStyleSection("accent")}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
<Label className="text-xs text-gray-500">활성화</Label>
|
||||
<Switch checked={(config.accentBorderWidth ?? 0) > 0} onCheckedChange={(checked) => updateDesignConfig({ accentBorderWidth: checked ? 4 : 0, accentBorderColor: config.accentBorderColor || "#3b82f6" })} />
|
||||
</div>
|
||||
{(config.accentBorderWidth ?? 0) > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">두께</Label>
|
||||
<Input type="number" min={1} max={10} value={config.accentBorderWidth || 4} onChange={(e) => updateDesignConfig({ accentBorderWidth: parseInt(e.target.value) || 4 })} className="h-9 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input type="color" value={config.accentBorderColor || "#3b82f6"} onChange={(e) => updateDesignConfig({ accentBorderColor: e.target.value })} className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0" />
|
||||
<Input value={config.accentBorderColor || "#3b82f6"} onChange={(e) => updateDesignConfig({ accentBorderColor: e.target.value })} className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { CheckSquare } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
export function CheckboxProperties({ component, section }: Props) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 체크박스 스타일 — 우측 패널(section="style")에서 표시 */}
|
||||
{showStyle && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-purple-200 bg-purple-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-purple-700">
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
체크박스 스타일
|
||||
</div>
|
||||
|
||||
{/* 레이블 위치 */}
|
||||
<div>
|
||||
<Label className="text-xs">레이블 위치</Label>
|
||||
<Select
|
||||
value={component.checkboxLabelPosition || "right"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { checkboxLabelPosition: value as "left" | "right" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<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={component.checkboxSize || 18}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxSize: Number(e.target.value) })}
|
||||
min={12}
|
||||
max={40}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">체크 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.checkboxColor || "#2563eb"}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxColor: e.target.value })}
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">테두리 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.checkboxBorderColor || "#6b7280"}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxBorderColor: e.target.value })}
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 체크박스 데이터 — 모달(section="data")에서 표시 */}
|
||||
{showData && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-purple-200 bg-purple-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-purple-700">
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
체크박스 데이터
|
||||
</div>
|
||||
|
||||
{/* 체크 상태 (쿼리 연결 없을 때) */}
|
||||
{!component.queryId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkboxChecked"
|
||||
checked={component.checkboxChecked === true}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxChecked: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="checkboxChecked" className="text-xs">
|
||||
체크됨
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 쿼리 연결 시 필드 선택 */}
|
||||
{component.queryId && (
|
||||
<div>
|
||||
<Label className="text-xs">체크 상태 바인딩 필드</Label>
|
||||
<Select
|
||||
value={component.checkboxFieldName || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { checkboxFieldName: value === "none" ? "" : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === component.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={component.checkboxLabel || ""}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxLabel: e.target.value })}
|
||||
placeholder="체크박스 옆 텍스트"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 연결 안내 */}
|
||||
{!component.queryId && (
|
||||
<div className="rounded border border-purple-200 bg-purple-100 p-2 text-xs text-purple-800">
|
||||
쿼리를 연결하면 데이터베이스 값으로 체크 상태를 결정할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ComponentStylePanel.tsx — 우측 패널 전용 디자인 설정 컨테이너
|
||||
*
|
||||
* 컴포넌트 타입별 전용 디자인 아코디언이 최상단에 동적 생성되고,
|
||||
* 공통 속성(배치/글꼴/배경/테두리)이 그 아래에 위치한다.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import {
|
||||
ChevronRight,
|
||||
Move,
|
||||
Type as TypeIcon,
|
||||
Square,
|
||||
CreditCard,
|
||||
Table2,
|
||||
ImageIcon,
|
||||
Minus,
|
||||
PenTool,
|
||||
Stamp,
|
||||
Hash,
|
||||
QrCode,
|
||||
CheckSquare,
|
||||
Tag,
|
||||
BarChart3,
|
||||
Lock,
|
||||
Unlock,
|
||||
Layers,
|
||||
ArrowUpToLine,
|
||||
ArrowDownToLine,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Group,
|
||||
Ungroup,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
import { CardProperties } from "./CardProperties";
|
||||
import { DividerProperties } from "./DividerProperties";
|
||||
import { ImageProperties } from "./ImageProperties";
|
||||
import { TableProperties } from "./TableProperties";
|
||||
import { TextProperties } from "./TextProperties";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
text: "텍스트",
|
||||
label: "레이블",
|
||||
table: "테이블",
|
||||
image: "이미지",
|
||||
divider: "구분선",
|
||||
signature: "서명",
|
||||
stamp: "도장",
|
||||
pageNumber: "페이지 번호",
|
||||
card: "카드",
|
||||
calculation: "계산",
|
||||
barcode: "바코드",
|
||||
checkbox: "체크박스",
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
text: <TypeIcon className="h-4 w-4" />,
|
||||
label: <Tag className="h-4 w-4" />,
|
||||
table: <Table2 className="h-4 w-4" />,
|
||||
image: <ImageIcon className="h-4 w-4" />,
|
||||
divider: <Minus className="h-4 w-4" />,
|
||||
signature: <PenTool className="h-4 w-4" />,
|
||||
stamp: <Stamp className="h-4 w-4" />,
|
||||
pageNumber: <Hash className="h-4 w-4" />,
|
||||
card: <CreditCard className="h-4 w-4" />,
|
||||
calculation: <BarChart3 className="h-4 w-4" />,
|
||||
barcode: <QrCode className="h-4 w-4" />,
|
||||
checkbox: <CheckSquare className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
interface AccordionSectionProps {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function AccordionSection({ label, icon, isExpanded, onToggle, children }: AccordionSectionProps) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-11 w-full items-center justify-between px-4 transition-colors ${
|
||||
isExpanded
|
||||
? "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.5">
|
||||
<span className={isExpanded ? "" : "text-blue-600"}>{icon}</span>
|
||||
<span className="text-sm font-bold">{label}</span>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeSpecificDesign({
|
||||
component,
|
||||
update,
|
||||
}: {
|
||||
component: ComponentConfig;
|
||||
update: (changes: Partial<ComponentConfig>) => void;
|
||||
}) {
|
||||
const type = component.type;
|
||||
|
||||
if (type === "text" || type === "label") {
|
||||
return (
|
||||
<TextProperties component={component} section="style" />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "table") {
|
||||
return (
|
||||
<TableProperties component={component} section="style" />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "image") {
|
||||
return (
|
||||
<ImageProperties component={component} section="style" />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "divider") {
|
||||
return (
|
||||
<DividerProperties component={component} section="style" />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "signature" || type === "stamp") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">표시 방식</Label>
|
||||
<Select value={component.objectFit || "contain"} onValueChange={(v) => update({ objectFit: v as ComponentConfig["objectFit"] })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="contain">비율 유지</SelectItem>
|
||||
<SelectItem value="cover">채우기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">라벨 위치</Label>
|
||||
<Select value={component.labelPosition || "left"} onValueChange={(v) => update({ labelPosition: v as ComponentConfig["labelPosition"] })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
<SelectItem value="top">위</SelectItem>
|
||||
<SelectItem value="bottom">아래</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={component.showLabel !== false} onChange={(e) => update({ showLabel: e.target.checked })} className="h-4 w-4 rounded" />
|
||||
<Label className="text-xs">라벨 표시</Label>
|
||||
</div>
|
||||
{component.showLabel !== false && (
|
||||
<div>
|
||||
<Label className="text-xs">라벨 텍스트</Label>
|
||||
<Input value={component.labelText || (type === "stamp" ? "(인)" : "서명:")} onChange={(e) => update({ labelText: e.target.value })} className="h-9 text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "pageNumber") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">페이지 번호 형식</Label>
|
||||
<Select value={component.pageNumberFormat || "number"} onValueChange={(v) => update({ pageNumberFormat: v as ComponentConfig["pageNumberFormat"] })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="number">1</SelectItem>
|
||||
<SelectItem value="numberTotal">1 / 3</SelectItem>
|
||||
<SelectItem value="koreanNumber">1 페이지</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">텍스트 정렬</Label>
|
||||
<Select value={component.textAlign || "center"} onValueChange={(v) => update({ textAlign: v })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 굵기</Label>
|
||||
<Select value={component.fontWeight || "normal"} onValueChange={(v) => update({ fontWeight: v })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "calculation") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">숫자 형식</Label>
|
||||
<Select value={component.numberFormat || "currency"} onValueChange={(v) => update({ numberFormat: v as ComponentConfig["numberFormat"] })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="comma">콤마 (1,000)</SelectItem>
|
||||
<SelectItem value="currency">통화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{component.numberFormat === "currency" && (
|
||||
<div>
|
||||
<Label className="text-xs">통화 단위</Label>
|
||||
<Input value={component.currencySuffix || "원"} onChange={(e) => update({ currencySuffix: e.target.value })} className="h-9 text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">결과 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.resultColor || "#2563eb"} onChange={(e) => update({ resultColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
<Input value={component.resultColor || "#2563eb"} onChange={(e) => update({ resultColor: e.target.value })} className="h-9 text-sm flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">결과 크기</Label>
|
||||
<Input type="number" min={10} max={30} value={component.resultFontSize || 16} onChange={(e) => update({ resultFontSize: parseInt(e.target.value) || 16 })} className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "barcode") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">바코드 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.barcodeColor || "#000000"} onChange={(e) => update({ barcodeColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">배경 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.barcodeBackground || "#ffffff"} onChange={(e) => update({ barcodeBackground: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={component.showBarcodeText !== false} onChange={(e) => update({ showBarcodeText: e.target.checked })} className="h-4 w-4 rounded" />
|
||||
<Label className="text-xs">하단 텍스트 표시</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">여백 (px)</Label>
|
||||
<Input type="number" min={0} max={30} value={component.barcodeMargin ?? 10} onChange={(e) => update({ barcodeMargin: parseInt(e.target.value) || 0 })} className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "checkbox") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">체크 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.checkboxColor || "#2563eb"} onChange={(e) => update({ checkboxColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
<Input value={component.checkboxColor || "#2563eb"} onChange={(e) => update({ checkboxColor: e.target.value })} className="h-9 text-sm flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">테두리 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.checkboxBorderColor || "#6b7280"} onChange={(e) => update({ checkboxBorderColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">크기 (px)</Label>
|
||||
<Input type="number" min={12} max={40} value={component.checkboxSize || 18} onChange={(e) => update({ checkboxSize: parseInt(e.target.value) || 18 })} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">라벨 위치</Label>
|
||||
<Select value={component.checkboxLabelPosition || "right"} onValueChange={(v) => update({ checkboxLabelPosition: v as "left" | "right" })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "statusBadge") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">상태 매핑은 모달(더블클릭)에서 설정합니다.</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">텍스트 정렬</Label>
|
||||
<Select value={component.textAlign || "center"} onValueChange={(v) => update({ textAlign: v })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 굵기</Label>
|
||||
<Select value={component.fontWeight || "bold"} onValueChange={(v) => update({ fontWeight: v })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ComponentStylePanel() {
|
||||
const {
|
||||
selectedComponentId,
|
||||
selectedComponentIds,
|
||||
components,
|
||||
updateComponent,
|
||||
bringToFront,
|
||||
sendToBack,
|
||||
bringForward,
|
||||
sendBackward,
|
||||
toggleLock,
|
||||
groupComponents,
|
||||
ungroupComponents,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(["typeDesign"]));
|
||||
|
||||
const component =
|
||||
components.find((c) => c.id === selectedComponentId) ?? null;
|
||||
|
||||
if (!component) return null;
|
||||
|
||||
const typeLabel = TYPE_LABELS[component.type] ?? component.type;
|
||||
const update = (changes: Partial<ComponentConfig>) =>
|
||||
updateComponent(component.id, changes);
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasTypeDesign = component.type !== "card";
|
||||
const typeIcon = TYPE_ICONS[component.type] || <Square className="h-4 w-4" />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 카드 전용: CardProperties 하위 아코디언 포함 */}
|
||||
{component.type === "card" && (
|
||||
<AccordionSection
|
||||
id="cardDesign"
|
||||
label="카드 디자인"
|
||||
icon={<CreditCard className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("cardDesign")}
|
||||
onToggle={() => toggleSection("cardDesign")}
|
||||
>
|
||||
<CardProperties component={component} section="style" />
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{/* 카드 외 모든 타입: 타입별 전용 디자인 */}
|
||||
{hasTypeDesign && (
|
||||
<AccordionSection
|
||||
id="typeDesign"
|
||||
label={`${typeLabel} 디자인`}
|
||||
icon={typeIcon}
|
||||
isExpanded={expandedSections.has("typeDesign")}
|
||||
onToggle={() => toggleSection("typeDesign")}
|
||||
>
|
||||
<TypeSpecificDesign component={component} update={update} />
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{/* 배치 (X/Y/W/H) */}
|
||||
<AccordionSection
|
||||
id="layout"
|
||||
label="배치"
|
||||
icon={<Move className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("layout")}
|
||||
onToggle={() => toggleSection("layout")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ label: "X", key: "x" as const, min: undefined },
|
||||
{ label: "Y", key: "y" as const, min: undefined },
|
||||
{ label: "너비", key: "width" as const, min: 50 },
|
||||
{ label: "높이", key: "height" as const, min: 30 },
|
||||
] as const).map(({ label, key, min }) => (
|
||||
<div key={key}>
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(component[key] as number)}
|
||||
onChange={(e) =>
|
||||
update({ [key]: parseInt(e.target.value) || min || 0 })
|
||||
}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{/* 잠금 */}
|
||||
<AccordionSection
|
||||
id="lock"
|
||||
label="잠금"
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("lock")}
|
||||
onToggle={() => toggleSection("lock")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant={component.locked ? "destructive" : "outline"}
|
||||
size="sm"
|
||||
className="w-full h-9 gap-2 text-sm"
|
||||
onClick={() => toggleLock()}
|
||||
>
|
||||
{component.locked ? (
|
||||
<>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
잠금 해제
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
위치 잠금
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
잠금 시 드래그/리사이즈 불가
|
||||
</p>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{/* 레이어 */}
|
||||
<AccordionSection
|
||||
id="layer"
|
||||
label="레이어"
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("layer")}
|
||||
onToggle={() => toggleSection("layer")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => bringToFront()} title="맨 앞으로">
|
||||
<ArrowUpToLine className="h-3.5 w-3.5" />
|
||||
맨 앞
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => sendToBack()} title="맨 뒤로">
|
||||
<ArrowDownToLine className="h-3.5 w-3.5" />
|
||||
맨 뒤
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => bringForward()} title="한 단계 앞으로">
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
앞으로
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => sendBackward()} title="한 단계 뒤로">
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
뒤로
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{/* 그룹 */}
|
||||
<AccordionSection
|
||||
id="group"
|
||||
label={`그룹${component.groupId ? " (그룹됨)" : ""}`}
|
||||
icon={<Group className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("group")}
|
||||
onToggle={() => toggleSection("group")}
|
||||
>
|
||||
{component.groupId ? (
|
||||
<div className="space-y-2.5">
|
||||
<div className="rounded-md border border-purple-200 bg-purple-50 px-3 py-2">
|
||||
<p className="text-xs text-purple-700">
|
||||
그룹 내 컴포넌트를 드래그하면 함께 이동합니다.
|
||||
</p>
|
||||
<p className="text-[10px] text-purple-500 mt-1">
|
||||
같은 그룹: {components.filter((c) => c.groupId === component.groupId).length}개
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8 gap-1.5 text-xs border-purple-300 text-purple-700 hover:bg-purple-50"
|
||||
onClick={() => ungroupComponents()}
|
||||
>
|
||||
<Ungroup className="h-3.5 w-3.5" />
|
||||
그룹 해제
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8 gap-1.5 text-xs"
|
||||
onClick={() => groupComponents()}
|
||||
disabled={selectedComponentIds.length < 2}
|
||||
>
|
||||
<Group className="h-3.5 w-3.5" />
|
||||
선택 컴포넌트 그룹화
|
||||
</Button>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{selectedComponentIds.length < 2
|
||||
? "드래그 또는 Ctrl+클릭으로 2개 이상 선택 후 그룹화"
|
||||
: `${selectedComponentIds.length}개 선택됨 - 그룹화 가능`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AccordionSection>
|
||||
|
||||
{/* 테두리 */}
|
||||
<AccordionSection
|
||||
id="border"
|
||||
label="테두리"
|
||||
icon={<Square className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("border")}
|
||||
onToggle={() => toggleSection("border")}
|
||||
>
|
||||
<div>
|
||||
<Label className="text-xs">모양</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button onClick={() => update({ borderRadius: 0 })} className={`flex-1 h-9 rounded-md border text-xs font-medium transition-colors ${!component.borderRadius || component.borderRadius === 0 ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"}`}>사각형</button>
|
||||
<button onClick={() => update({ borderRadius: 16 })} className={`flex-1 h-9 rounded-md border text-xs font-medium transition-colors ${component.borderRadius && component.borderRadius > 0 ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"}`}>둥글게</button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<div className="h-6 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Eye, EyeOff, HelpCircle, Trash2, Plus } from "lucide-react";
|
||||
import type { ComponentConfig, ConditionalRule } from "@/types/report";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
text: "텍스트",
|
||||
label: "레이블",
|
||||
table: "테이블",
|
||||
image: "이미지",
|
||||
divider: "구분선",
|
||||
signature: "서명",
|
||||
stamp: "도장",
|
||||
pageNumber: "페이지 번호",
|
||||
card: "카드",
|
||||
calculation: "계산",
|
||||
barcode: "바코드",
|
||||
checkbox: "체크박스",
|
||||
};
|
||||
|
||||
interface OperatorDef {
|
||||
value: ConditionalRule["operator"];
|
||||
symbol: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
group: "compare" | "text" | "exist";
|
||||
}
|
||||
|
||||
const OPERATORS: OperatorDef[] = [
|
||||
{ value: "eq", symbol: "=", label: "같을 때", summary: "가 '$V'일 때", group: "compare" },
|
||||
{ value: "ne", symbol: "≠", label: "다를 때", summary: "가 '$V'이(가) 아닐 때", group: "compare" },
|
||||
{ value: "gt", symbol: ">", label: "보다 클 때", summary: "이(가) $V보다 클 때", group: "compare" },
|
||||
{ value: "lt", symbol: "<", label: "보다 작을 때", summary: "이(가) $V보다 작을 때", group: "compare" },
|
||||
{ value: "gte", symbol: "≥", label: "이상일 때", summary: "이(가) $V 이상일 때", group: "compare" },
|
||||
{ value: "lte", symbol: "≤", label: "이하일 때", summary: "이(가) $V 이하일 때", group: "compare" },
|
||||
{ value: "contains", symbol: "⊃", label: "포함할 때", summary: "에 '$V'이(가) 포함될 때", group: "text" },
|
||||
{ value: "notEmpty", symbol: "✓", label: "값이 있을 때", summary: "에 값이 있을 때", group: "exist" },
|
||||
{ value: "empty", symbol: "∅", label: "값이 없을 때", summary: "에 값이 없을 때", group: "exist" },
|
||||
];
|
||||
|
||||
const OPERATOR_GROUPS: { key: OperatorDef["group"]; label: string }[] = [
|
||||
{ key: "compare", label: "비교" },
|
||||
{ key: "text", label: "텍스트" },
|
||||
{ key: "exist", label: "값 유무" },
|
||||
];
|
||||
|
||||
const NO_VALUE_OPERATORS = ["notEmpty", "empty"];
|
||||
|
||||
interface CardColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
}
|
||||
|
||||
export interface CardColumnLabel {
|
||||
columnName: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
onConfigChange?: (updates: Partial<ComponentConfig>) => void;
|
||||
cardColumns?: CardColumn[];
|
||||
cardTableName?: string;
|
||||
cardColumnLabels?: CardColumnLabel[];
|
||||
/** conditionalRules 대신 사용할 키 (격자 모드 분리용) */
|
||||
rulesKey?: "conditionalRules" | "gridConditionalRules";
|
||||
/** conditionalRule 대신 사용할 키 (격자 모드 분리용) */
|
||||
ruleKey?: "conditionalRule" | "gridConditionalRule";
|
||||
}
|
||||
|
||||
const DEFAULT_RULE: ConditionalRule = {
|
||||
queryId: "",
|
||||
field: "",
|
||||
operator: "eq",
|
||||
value: "",
|
||||
action: "show",
|
||||
};
|
||||
|
||||
export function ConditionalProperties({
|
||||
component,
|
||||
onConfigChange,
|
||||
cardColumns,
|
||||
cardTableName,
|
||||
cardColumnLabels,
|
||||
rulesKey = "conditionalRules",
|
||||
ruleKey = "conditionalRule",
|
||||
}: Props) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const componentLabel = TYPE_LABELS[component.type] ?? component.type;
|
||||
const isCardMode = !!cardColumns;
|
||||
|
||||
const applyUpdate = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => {
|
||||
if (onConfigChange) onConfigChange(updates);
|
||||
else updateComponent(component.id, updates);
|
||||
},
|
||||
[onConfigChange, updateComponent, component.id],
|
||||
);
|
||||
|
||||
/** 단일 rule → 배열로 정규화 (rulesKey/ruleKey 기반) */
|
||||
const rules: ConditionalRule[] = useMemo(() => {
|
||||
const rulesArr = component[rulesKey] as ConditionalRule[] | undefined;
|
||||
const singleRule = component[ruleKey] as ConditionalRule | undefined;
|
||||
if (rulesArr && rulesArr.length > 0) {
|
||||
return rulesArr;
|
||||
}
|
||||
if (singleRule) {
|
||||
return [singleRule];
|
||||
}
|
||||
return [];
|
||||
}, [component, rulesKey, ruleKey]);
|
||||
|
||||
const action = rules.length > 0 ? rules[0].action : "show";
|
||||
|
||||
const syncRules = useCallback(
|
||||
(newRules: ConditionalRule[]) => {
|
||||
applyUpdate({
|
||||
[rulesKey]: newRules,
|
||||
[ruleKey]: newRules.length > 0 ? newRules[0] : undefined,
|
||||
});
|
||||
},
|
||||
[applyUpdate, rulesKey, ruleKey],
|
||||
);
|
||||
|
||||
const getQueryFields = (queryId: string): string[] => {
|
||||
const result = getQueryResult(queryId);
|
||||
return result ? result.fields : [];
|
||||
};
|
||||
|
||||
const labelMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
cardColumnLabels?.forEach((cl) => map.set(cl.columnName, cl.label));
|
||||
return map;
|
||||
}, [cardColumnLabels]);
|
||||
|
||||
const addRule = useCallback(() => {
|
||||
const newRule: ConditionalRule = {
|
||||
...DEFAULT_RULE,
|
||||
queryId: isCardMode ? "__card__" : (queries[0]?.id || ""),
|
||||
action,
|
||||
};
|
||||
syncRules([...rules, newRule]);
|
||||
}, [rules, isCardMode, queries, action, syncRules]);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(index: number, patch: Partial<ConditionalRule>) => {
|
||||
const updated = rules.map((r, i) => (i === index ? { ...r, ...patch } : r));
|
||||
syncRules(updated);
|
||||
},
|
||||
[rules, syncRules],
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(index: number) => {
|
||||
const updated = rules.filter((_, i) => i !== index);
|
||||
syncRules(updated);
|
||||
},
|
||||
[rules, syncRules],
|
||||
);
|
||||
|
||||
const removeAllRules = useCallback(() => {
|
||||
syncRules([]);
|
||||
}, [syncRules]);
|
||||
|
||||
const updateAction = useCallback(
|
||||
(newAction: "show" | "hide") => {
|
||||
const updated = rules.map((r) => ({ ...r, action: newAction }));
|
||||
syncRules(updated);
|
||||
},
|
||||
[rules, syncRules],
|
||||
);
|
||||
|
||||
const getFieldsForRule = useCallback(
|
||||
(rule: ConditionalRule): string[] => {
|
||||
if (isCardMode) {
|
||||
return cardColumnLabels?.map((cl) => cl.columnName) ?? [];
|
||||
}
|
||||
return rule.queryId ? getQueryFields(rule.queryId) : [];
|
||||
},
|
||||
[isCardMode, cardColumnLabels],
|
||||
);
|
||||
|
||||
const getFieldDisplayName = useCallback(
|
||||
(fieldName: string): string => labelMap.get(fieldName) || fieldName,
|
||||
[labelMap],
|
||||
);
|
||||
|
||||
const summaryText = useMemo(() => {
|
||||
const validRules = rules.filter((r) => r.field && (isCardMode || r.queryId));
|
||||
if (validRules.length === 0) return null;
|
||||
|
||||
const parts = validRules.map((rule) => {
|
||||
const op = OPERATORS.find((o) => o.value === rule.operator);
|
||||
if (!op) return null;
|
||||
const fieldLabel = labelMap.get(rule.field) || rule.field;
|
||||
const condPart = op.summary.replace("$V", rule.value || "?");
|
||||
return `${fieldLabel}${condPart}`;
|
||||
}).filter(Boolean);
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const conditionStr = parts.join(", ");
|
||||
const actionPart = action === "show"
|
||||
? `이 ${componentLabel}을(를) 보여줍니다`
|
||||
: `이 ${componentLabel}을(를) 숨깁니다`;
|
||||
|
||||
if (parts.length === 1) {
|
||||
return `${conditionStr} ${actionPart}`;
|
||||
}
|
||||
return `다음 조건을 모두 만족할 때 ${actionPart}: ${conditionStr}`;
|
||||
}, [rules, isCardMode, labelMap, componentLabel, action]);
|
||||
|
||||
const hasNoDataSource = isCardMode
|
||||
? !cardTableName || (cardColumnLabels?.length ?? 0) === 0
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-800">
|
||||
{action === "hide" ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
표시 조건
|
||||
{rules.length > 0 && (
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-blue-100 px-1.5 text-[10px] font-bold text-blue-700">
|
||||
{rules.length}
|
||||
</span>
|
||||
)}
|
||||
<TooltipProvider delayDuration={80}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="inline-flex">
|
||||
<HelpCircle className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="max-w-[300px] bg-gray-700 text-white text-xs leading-relaxed space-y-2 py-2.5"
|
||||
>
|
||||
<p>
|
||||
데이터 값에 따라 이 {componentLabel}을(를) 자동으로
|
||||
보이거나 숨길 수 있습니다.
|
||||
</p>
|
||||
<div className="border-t border-gray-500 pt-2 space-y-1">
|
||||
<p className="font-semibold text-gray-300">여러 조건 사용</p>
|
||||
<p>
|
||||
조건을 여러 개 추가하면 모든 조건을 동시에 만족해야
|
||||
결과가 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{rules.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={removeAllRules}
|
||||
className="h-6 px-2 text-xs text-red-500 hover:bg-red-50 gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
전체 삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{hasNoDataSource ? (
|
||||
<p className="text-xs text-gray-400 text-center py-4">
|
||||
{!cardTableName
|
||||
? "데이터 연결 탭에서 테이블을 먼저 선택해주세요."
|
||||
: "레이아웃 구성 탭에서 데이터 항목을 먼저 추가해주세요."}
|
||||
</p>
|
||||
) : rules.length === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={addRule}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
조건 추가
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{/* 결과 동작 (공통) */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 space-y-2.5">
|
||||
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">결과</p>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => updateAction(v as "show" | "hide")}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="show">
|
||||
조건을 만족하면 이 {componentLabel}을(를) 보여줍니다
|
||||
</SelectItem>
|
||||
<SelectItem value="hide">
|
||||
조건을 만족하면 이 {componentLabel}을(를) 숨깁니다
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조건 목록 */}
|
||||
{rules.map((rule, index) => (
|
||||
<RuleEditor
|
||||
key={index}
|
||||
index={index}
|
||||
rule={rule}
|
||||
isCardMode={isCardMode}
|
||||
queries={queries}
|
||||
fields={getFieldsForRule(rule)}
|
||||
getFieldDisplayName={getFieldDisplayName}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
showAndLabel={index > 0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 조건 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={addRule}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
표시 조건 추가
|
||||
</Button>
|
||||
|
||||
{/* 요약 */}
|
||||
{summaryText && (
|
||||
<div className="rounded-lg border border-blue-100 bg-blue-50/50 px-3 py-2.5">
|
||||
<p className="text-[11px] text-blue-500 mb-1 font-medium">요약</p>
|
||||
<p className="text-xs text-blue-700 leading-relaxed">{summaryText}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 개별 조건 편집기 */
|
||||
interface RuleEditorProps {
|
||||
index: number;
|
||||
rule: ConditionalRule;
|
||||
isCardMode: boolean;
|
||||
queries: { id: string; name: string }[];
|
||||
fields: string[];
|
||||
getFieldDisplayName: (fieldName: string) => string;
|
||||
onUpdate: (index: number, patch: Partial<ConditionalRule>) => void;
|
||||
onRemove: (index: number) => void;
|
||||
showAndLabel: boolean;
|
||||
}
|
||||
|
||||
function RuleEditor({
|
||||
index,
|
||||
rule,
|
||||
isCardMode,
|
||||
queries,
|
||||
fields,
|
||||
getFieldDisplayName,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
showAndLabel,
|
||||
}: RuleEditorProps) {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(rule.operator);
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{showAndLabel && (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
||||
<span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full">동시 만족</span>
|
||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 space-y-2.5 relative group">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
|
||||
조건 {index + 1}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="h-5 w-5 p-0 text-gray-400 hover:text-red-500 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isCardMode && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">데이터 출처</label>
|
||||
<Select
|
||||
value={rule.queryId || ""}
|
||||
onValueChange={(v) => onUpdate(index, { queryId: v, field: "" })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{queries.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>
|
||||
{q.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">확인할 항목</label>
|
||||
<Select
|
||||
value={rule.field || ""}
|
||||
onValueChange={(v) => onUpdate(index, { field: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="항목을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fields.map((f) => (
|
||||
<SelectItem key={f} value={f} className="text-xs">
|
||||
{getFieldDisplayName(f)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className={needsValue ? "grid grid-cols-2 gap-2" : ""}>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">조건</label>
|
||||
<Select
|
||||
value={rule.operator}
|
||||
onValueChange={(v) =>
|
||||
onUpdate(index, { operator: v as ConditionalRule["operator"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
{rule.operator ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center rounded bg-gray-100 text-[10px] font-bold text-gray-600">
|
||||
{OPERATORS.find((o) => o.value === rule.operator)?.symbol}
|
||||
</span>
|
||||
{OPERATORS.find((o) => o.value === rule.operator)?.label}
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="조건 선택" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATOR_GROUPS.map((group, gi) => (
|
||||
<div key={group.key}>
|
||||
{gi > 0 && <div className="mx-2 my-1 border-t border-gray-100" />}
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-gray-400">
|
||||
{group.label}
|
||||
</div>
|
||||
{OPERATORS.filter((op) => op.group === group.key).map((op) => (
|
||||
<SelectItem key={op.value} value={op.value} className="text-xs">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center rounded bg-gray-100 text-[10px] font-bold text-gray-600">
|
||||
{op.symbol}
|
||||
</span>
|
||||
{op.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{needsValue && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">기준 값</label>
|
||||
<Input
|
||||
value={rule.value}
|
||||
onChange={(e) => onUpdate(index, { value: e.target.value })}
|
||||
placeholder="값 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DividerProperties.tsx — 구분선 컴포넌트 설정
|
||||
*
|
||||
* - section="data": 구분선은 데이터 바인딩 없으므로 null 반환
|
||||
* - section="style": StyleAccordion 패턴 (방향 & 선 스타일 + 색상)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DividerProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
|
||||
if (section === "data") return null;
|
||||
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["line"]));
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showStyle && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 방향 & 선 스타일 */}
|
||||
<StyleAccordion label="방향 & 선 스타일" isOpen={openSections.has("line")} onToggle={() => toggleSection("line")}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">방향</Label>
|
||||
<Select
|
||||
value={component.orientation || "horizontal"}
|
||||
onValueChange={(value) => {
|
||||
const isToVertical = value === "vertical";
|
||||
const currentWidth = component.width;
|
||||
const currentHeight = component.height;
|
||||
update({
|
||||
orientation: value as "horizontal" | "vertical",
|
||||
width: isToVertical ? 10 : currentWidth > 50 ? currentWidth : 300,
|
||||
height: isToVertical ? (currentWidth > 50 ? currentWidth : 300) : 10,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horizontal">가로</SelectItem>
|
||||
<SelectItem value="vertical">세로</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">선 스타일</Label>
|
||||
<Select
|
||||
value={component.lineStyle || "solid"}
|
||||
onValueChange={(value) =>
|
||||
update({ lineStyle: value as "solid" | "dashed" | "dotted" | "double" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">파선</SelectItem>
|
||||
<SelectItem value="dotted">점선</SelectItem>
|
||||
<SelectItem value="double">이중선</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">선 두께</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
value={component.lineWidth ?? 1}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
if (!isNaN(val) && val >= 0.5) {
|
||||
update({ lineWidth: val });
|
||||
}
|
||||
}}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 색상 */}
|
||||
<StyleAccordion label="색상" isOpen={openSections.has("color")} onToggle={() => toggleSection("color")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">선 색상</Label>
|
||||
<ColorInput
|
||||
value={component.lineColor || "#000000"}
|
||||
onChange={(v) => update({ lineColor: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ImageProperties.tsx — 이미지 컴포넌트 설정
|
||||
*
|
||||
* - section="data": 모달 내 ImageLayoutTabs (업로드 / 자르기 / 맞춤 / 캡션 / 표시 조건)
|
||||
* - section="style": 우측 패널 — 투명도, 모서리, 회전/반전, 캡션 스타일
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { ChevronRight, FlipHorizontal, FlipVertical } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import { ImageLayoutTabs } from "../modals/ImageLayoutTabs";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["opacity"]));
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const update = (updates: Partial<ComponentConfig>) => {
|
||||
updateComponent(component.id, updates);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showData && <ImageLayoutTabs component={component} />}
|
||||
|
||||
{showStyle && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 투명도 & 모서리 */}
|
||||
<StyleAccordion label="투명도 & 모서리" isOpen={openSections.has("opacity")} onToggle={() => toggleSection("opacity")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">
|
||||
투명도 ({Math.round((component.imageOpacity ?? 1) * 100)}%)
|
||||
</Label>
|
||||
<Slider
|
||||
value={[component.imageOpacity ?? 1]}
|
||||
onValueChange={([v]) => update({ imageOpacity: v })}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">
|
||||
모서리 ({component.imageBorderRadius ?? 0}px)
|
||||
</Label>
|
||||
<Slider
|
||||
value={[component.imageBorderRadius ?? 0]}
|
||||
onValueChange={([v]) => update({ imageBorderRadius: v })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
<div className="mt-2 flex gap-1">
|
||||
{[0, 4, 8, 16, 50, 100].map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
variant={component.imageBorderRadius === v ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => update({ imageBorderRadius: v })}
|
||||
className="h-6 flex-1 px-1 text-[10px]"
|
||||
>
|
||||
{v === 0 ? "직각" : v === 100 ? "원형" : `${v}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 회전 & 반전 */}
|
||||
<StyleAccordion label="회전 & 반전" isOpen={openSections.has("transform")} onToggle={() => toggleSection("transform")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">
|
||||
회전 ({component.imageRotation || 0}°)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[component.imageRotation || 0]}
|
||||
onValueChange={([v]) => update({ imageRotation: v })}
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.imageRotation || 0}
|
||||
onChange={(e) => update({ imageRotation: parseInt(e.target.value) || 0 })}
|
||||
className="h-7 w-14 text-center text-[10px]"
|
||||
min={0}
|
||||
max={360}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={component.imageFlipH ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => update({ imageFlipH: !component.imageFlipH })}
|
||||
className="h-8 flex-1 text-xs"
|
||||
>
|
||||
<FlipHorizontal className="mr-1 h-3.5 w-3.5" />좌우
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={component.imageFlipV ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => update({ imageFlipV: !component.imageFlipV })}
|
||||
className="h-8 flex-1 text-xs"
|
||||
>
|
||||
<FlipVertical className="mr-1 h-3.5 w-3.5" />상하
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 캡션 스타일 (캡션 텍스트가 있을 때만) */}
|
||||
{component.imageCaption && (
|
||||
<StyleAccordion label="캡션 스타일" isOpen={openSections.has("caption")} onToggle={() => toggleSection("caption")}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">글자 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.imageCaptionFontSize || 12}
|
||||
onChange={(e) => update({ imageCaptionFontSize: parseInt(e.target.value) || 12 })}
|
||||
className="h-9 text-xs"
|
||||
min={8}
|
||||
max={32}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={component.imageCaptionColor || "#666666"}
|
||||
onChange={(e) => update({ imageCaptionColor: e.target.value })}
|
||||
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
||||
/>
|
||||
<Input
|
||||
value={component.imageCaptionColor || "#666666"}
|
||||
onChange={(e) => update({ imageCaptionColor: e.target.value })}
|
||||
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
"Malgun Gothic",
|
||||
"NanumGothic",
|
||||
"NanumMyeongjo",
|
||||
"굴림",
|
||||
"돋움",
|
||||
"바탕",
|
||||
"Times New Roman",
|
||||
"Arial",
|
||||
];
|
||||
|
||||
interface Props {
|
||||
componentIds: string[];
|
||||
components: ComponentConfig[];
|
||||
}
|
||||
|
||||
type EditableField =
|
||||
| "fontSize"
|
||||
| "fontFamily"
|
||||
| "fontColor"
|
||||
| "fontWeight"
|
||||
| "textAlign"
|
||||
| "backgroundColor"
|
||||
| "borderWidth"
|
||||
| "borderColor";
|
||||
|
||||
function getCommonValue<T>(components: ComponentConfig[], field: EditableField): T | "mixed" {
|
||||
const values = components.map((c) => c[field as keyof ComponentConfig]);
|
||||
const first = values[0];
|
||||
return values.every((v) => v === first) ? (first as T) : "mixed";
|
||||
}
|
||||
|
||||
export function MultiSelectProperties({ componentIds, components }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const selected = components.filter((c) => componentIds.includes(c.id));
|
||||
const count = selected.length;
|
||||
|
||||
const applyToAll = (patch: Partial<ComponentConfig>) => {
|
||||
componentIds.forEach((id) => updateComponent(id, patch));
|
||||
};
|
||||
|
||||
const commonFontSize = getCommonValue<number>(selected, "fontSize");
|
||||
const commonFontFamily = getCommonValue<string>(selected, "fontFamily");
|
||||
const commonFontColor = getCommonValue<string>(selected, "fontColor");
|
||||
const commonFontWeight = getCommonValue<string>(selected, "fontWeight");
|
||||
const commonTextAlign = getCommonValue<string>(selected, "textAlign");
|
||||
const commonBgColor = getCommonValue<string>(selected, "backgroundColor");
|
||||
const commonBorderWidth = getCommonValue<number>(selected, "borderWidth");
|
||||
const commonBorderColor = getCommonValue<string>(selected, "borderColor");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{count}개 컴포넌트 선택됨</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-500">공통 속성을 변경하면 선택된 모든 컴포넌트에 적용됩니다.</p>
|
||||
|
||||
{/* 글꼴 크기 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={commonFontSize === "mixed" ? "" : (commonFontSize as number) || 13}
|
||||
placeholder={commonFontSize === "mixed" ? "혼합" : undefined}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (val > 0) applyToAll({ fontSize: val });
|
||||
}}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 폰트 패밀리 */}
|
||||
<div>
|
||||
<Label className="text-xs">폰트 패밀리</Label>
|
||||
<Select
|
||||
value={commonFontFamily === "mixed" ? "" : (commonFontFamily as string) || "Malgun Gothic"}
|
||||
onValueChange={(value) => applyToAll({ fontFamily: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={commonFontFamily === "mixed" ? "혼합" : "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_FAMILIES.map((f) => (
|
||||
<SelectItem key={f} value={f} style={{ fontFamily: f }}>
|
||||
{f}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 글꼴 색상 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 색상</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={commonFontColor === "mixed" ? "#000000" : (commonFontColor as string) || "#000000"}
|
||||
onChange={(e) => applyToAll({ fontColor: e.target.value })}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={commonFontColor === "mixed" ? "" : (commonFontColor as string) || "#000000"}
|
||||
placeholder={commonFontColor === "mixed" ? "혼합" : undefined}
|
||||
onChange={(e) => applyToAll({ fontColor: e.target.value })}
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 글꼴 굵기 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 굵기</Label>
|
||||
<Select
|
||||
value={commonFontWeight === "mixed" ? "" : (commonFontWeight as string) || "normal"}
|
||||
onValueChange={(value) => applyToAll({ fontWeight: value as "normal" | "bold" })}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={commonFontWeight === "mixed" ? "혼합" : "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 정렬 */}
|
||||
<div>
|
||||
<Label className="text-xs">텍스트 정렬</Label>
|
||||
<Select
|
||||
value={commonTextAlign === "mixed" ? "" : (commonTextAlign as string) || "left"}
|
||||
onValueChange={(value) => applyToAll({ textAlign: value as "left" | "center" | "right" })}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={commonTextAlign === "mixed" ? "혼합" : "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 배경 색상 */}
|
||||
<div>
|
||||
<Label className="text-xs">배경 색상</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={commonBgColor === "mixed" ? "#ffffff" : (commonBgColor as string) || "#ffffff"}
|
||||
onChange={(e) => applyToAll({ backgroundColor: e.target.value })}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={commonBgColor === "mixed" ? "" : (commonBgColor as string) || "#ffffff"}
|
||||
placeholder={commonBgColor === "mixed" ? "혼합" : undefined}
|
||||
onChange={(e) => applyToAll({ backgroundColor: e.target.value })}
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테두리 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">테두리 두께</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={commonBorderWidth === "mixed" ? "" : (commonBorderWidth as number) || 0}
|
||||
placeholder={commonBorderWidth === "mixed" ? "혼합" : undefined}
|
||||
onChange={(e) => applyToAll({ borderWidth: parseInt(e.target.value) || 0 })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">테두리 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={commonBorderColor === "mixed" ? "#cccccc" : (commonBorderColor as string) || "#cccccc"}
|
||||
onChange={(e) => applyToAll({ borderColor: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Hash } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function PageNumberProperties({ component }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-purple-200 bg-purple-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-purple-700">
|
||||
<Hash className="h-4 w-4" />
|
||||
페이지 번호 설정
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">표시 형식</Label>
|
||||
<Select
|
||||
value={component.pageNumberFormat || "number"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, {
|
||||
pageNumberFormat: value as "number" | "numberTotal" | "koreanNumber",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="number">숫자만 (1, 2, 3...)</SelectItem>
|
||||
<SelectItem value="numberTotal">현재/전체 (1 / 3)</SelectItem>
|
||||
<SelectItem value="koreanNumber">한글 (1 페이지)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
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 { Loader2, PenLine, Upload } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { SignatureGenerator } from "../SignatureGenerator";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
export function SignatureProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
const [signatureMethod, setSignatureMethod] = useState<"upload" | "generate">("upload");
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const handleImageUpload = 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 > 10 * 1024 * 1024) {
|
||||
toast({ title: "오류", description: "파일 크기는 10MB 이하여야 합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
const result = await reportApi.uploadImage(file);
|
||||
if (result.success) {
|
||||
updateComponent(component.id, { imageUrl: result.data.fileUrl });
|
||||
toast({ title: "성공", description: "이미지가 업로드되었습니다." });
|
||||
}
|
||||
} catch {
|
||||
toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive" });
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 서명란 스타일 — 우측 패널(section="style")에서 표시 */}
|
||||
{showStyle && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-indigo-200 bg-indigo-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-indigo-700">
|
||||
<PenLine className="h-4 w-4" />
|
||||
{component.type === "signature" ? "서명란 스타일" : "도장란 스타일"}
|
||||
</div>
|
||||
|
||||
{/* 맞춤 방식 */}
|
||||
<div>
|
||||
<Label className="text-xs">맞춤 방식</Label>
|
||||
<Select
|
||||
value={component.objectFit || "contain"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { objectFit: value as "contain" | "cover" | "fill" | "none" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<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={component.showLabel !== false}
|
||||
onChange={(e) => updateComponent(component.id, { showLabel: e.target.checked })}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="showLabel" className="text-xs">
|
||||
레이블 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 레이블 텍스트 */}
|
||||
{component.showLabel !== false && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">레이블 텍스트</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.labelText || (component.type === "signature" ? "서명:" : "(인)")}
|
||||
onChange={(e) => updateComponent(component.id, { labelText: e.target.value })}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 레이블 위치 (서명란만) */}
|
||||
{component.type === "signature" && (
|
||||
<div>
|
||||
<Label className="text-xs">레이블 위치</Label>
|
||||
<Select
|
||||
value={component.labelPosition || "left"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, {
|
||||
labelPosition: value as "top" | "left" | "bottom" | "right",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">위</SelectItem>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="bottom">아래</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 서명 입력 — 모달(section="data")에서 표시 */}
|
||||
{showData && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-indigo-200 bg-indigo-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-indigo-700">
|
||||
<PenLine className="h-4 w-4" />
|
||||
{component.type === "signature" ? "서명 입력" : "도장 이미지"}
|
||||
</div>
|
||||
|
||||
{component.type === "signature" ? (
|
||||
<>
|
||||
{/* 서명 방식 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">서명 방식</Label>
|
||||
<Select
|
||||
value={signatureMethod}
|
||||
onValueChange={(value: "upload" | "generate") => setSignatureMethod(value)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="upload">이미지 업로드</SelectItem>
|
||||
<SelectItem value="generate">서명 만들기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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" />
|
||||
{component.imageUrl ? "파일 변경" : "파일 선택"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">JPG, PNG, GIF, WEBP (최대 10MB)</p>
|
||||
{component.imageUrl && !component.imageUrl.startsWith("data:") && (
|
||||
<p className="truncate text-xs text-indigo-600">현재: {component.imageUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 서명 만들기 */}
|
||||
{signatureMethod === "generate" && (
|
||||
<div className="mt-3">
|
||||
<SignatureGenerator
|
||||
onSignatureSelect={(dataUrl) => {
|
||||
updateComponent(component.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" />
|
||||
{component.imageUrl ? "파일 변경" : "파일 선택"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">JPG, PNG, GIF, WEBP (최대 10MB)</p>
|
||||
{component.imageUrl && !component.imageUrl.startsWith("data:") && (
|
||||
<p className="mt-2 truncate text-xs text-indigo-600">현재: {component.imageUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이름 입력 (도장란만) */}
|
||||
<div>
|
||||
<Label className="text-xs">이름</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.personName || ""}
|
||||
onChange={(e) => updateComponent(component.id, { personName: e.target.value })}
|
||||
placeholder="예: 홍길동"
|
||||
className="h-9"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">도장 옆에 표시될 이름</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TableProperties.tsx — 테이블 컴포넌트 설정
|
||||
*
|
||||
* - section="data": TableLayoutTabs (컬럼 구성 / 요약 설정 탭)
|
||||
* - section="style": StyleAccordion 패턴 (프리셋 + 헤더 / 셀 / 테두리)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { TableLayoutTabs } from "../modals/TableLayoutTabs";
|
||||
import type { ComponentConfig, GridCell } from "@/types/report";
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
// ─── 프리셋 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TABLE_STYLE_PRESETS = {
|
||||
default: {
|
||||
headerBackgroundColor: "#f3f4f6",
|
||||
headerTextColor: "#111827",
|
||||
showBorder: true,
|
||||
rowHeight: 32,
|
||||
},
|
||||
dark: {
|
||||
headerBackgroundColor: "#1e293b",
|
||||
headerTextColor: "#ffffff",
|
||||
showBorder: true,
|
||||
rowHeight: 32,
|
||||
},
|
||||
blue: {
|
||||
headerBackgroundColor: "#1d4ed8",
|
||||
headerTextColor: "#ffffff",
|
||||
showBorder: true,
|
||||
rowHeight: 32,
|
||||
},
|
||||
minimal: {
|
||||
headerBackgroundColor: "#f8fafc",
|
||||
headerTextColor: "#374151",
|
||||
showBorder: false,
|
||||
rowHeight: 28,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ─── StyleAccordion ────────────────────────────────────────────────────────────
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ColorInput ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 컴포넌트 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function TableProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["preset"]));
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(key: keyof typeof TABLE_STYLE_PRESETS) => {
|
||||
const preset = TABLE_STYLE_PRESETS[key];
|
||||
const updates: Partial<ComponentConfig> = { ...preset };
|
||||
|
||||
if (component.gridMode && component.gridCells) {
|
||||
const headerRows = component.gridHeaderRows ?? 1;
|
||||
const headerCols = component.gridHeaderCols ?? 1;
|
||||
const newCells = component.gridCells.map((cell: GridCell) => {
|
||||
if (cell.merged) return cell;
|
||||
const isHeader = cell.row < headerRows || cell.col < headerCols;
|
||||
if (!isHeader) return cell;
|
||||
return {
|
||||
...cell,
|
||||
backgroundColor: preset.headerBackgroundColor,
|
||||
textColor: preset.headerTextColor,
|
||||
};
|
||||
});
|
||||
updates.gridCells = newCells;
|
||||
}
|
||||
|
||||
update(updates);
|
||||
},
|
||||
[update, component.gridMode, component.gridCells, component.gridHeaderRows, component.gridHeaderCols],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showData && <TableLayoutTabs component={component} />}
|
||||
|
||||
{showStyle && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 프리셋 */}
|
||||
<StyleAccordion label="프리셋" isOpen={openSections.has("preset")} onToggle={() => toggleSection("preset")}>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(["default", "dark", "blue", "minimal"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => applyPreset(key)}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-gray-200 bg-white p-2 text-[10px] font-medium text-gray-700 transition-all hover:border-blue-300 hover:bg-blue-50/50"
|
||||
>
|
||||
<div
|
||||
className="h-5 w-full rounded"
|
||||
style={{
|
||||
backgroundColor: TABLE_STYLE_PRESETS[key].headerBackgroundColor,
|
||||
border: TABLE_STYLE_PRESETS[key].showBorder ? "1px solid #d1d5db" : "none",
|
||||
}}
|
||||
/>
|
||||
{{ default: "기본", dark: "다크", blue: "블루", minimal: "미니멀" }[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 헤더 스타일 */}
|
||||
<StyleAccordion label="헤더 스타일" isOpen={openSections.has("header")} onToggle={() => toggleSection("header")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">배경색</Label>
|
||||
<ColorInput
|
||||
value={component.headerBackgroundColor || "#f3f4f6"}
|
||||
onChange={(v) => update({ headerBackgroundColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">텍스트 색상</Label>
|
||||
<ColorInput
|
||||
value={component.headerTextColor || "#111827"}
|
||||
onChange={(v) => update({ headerTextColor: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 셀 스타일 */}
|
||||
<StyleAccordion label="셀 스타일" isOpen={openSections.has("cell")} onToggle={() => toggleSection("cell")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">행 높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={20}
|
||||
max={100}
|
||||
value={component.rowHeight || 32}
|
||||
onChange={(e) => update({ rowHeight: parseInt(e.target.value) || 32 })}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">글자 크기</Label>
|
||||
<Select
|
||||
value={String(component.fontSize || 12)}
|
||||
onValueChange={(v) => update({ fontSize: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10px</SelectItem>
|
||||
<SelectItem value="11">11px</SelectItem>
|
||||
<SelectItem value="12">12px (기본)</SelectItem>
|
||||
<SelectItem value="13">13px</SelectItem>
|
||||
<SelectItem value="14">14px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 테두리 */}
|
||||
<StyleAccordion label="테두리" isOpen={openSections.has("border")} onToggle={() => toggleSection("border")}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
<Label className="text-xs text-gray-500">테두리 표시</Label>
|
||||
<Switch
|
||||
checked={component.showBorder !== false}
|
||||
onCheckedChange={(checked) => update({ showBorder: checked })}
|
||||
/>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TextProperties.tsx — 텍스트/레이블 컴포넌트 설정
|
||||
*
|
||||
* - section="data": TextLayoutTabs (데이터 바인딩 / 텍스트 서식 / 표시 조건)
|
||||
* - section="style": StyleAccordion 패턴 (프리셋 + 폰트 + 색상 + 정렬 + 테두리)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
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 { ChevronRight, Bold, Italic, Underline, Strikethrough, AlignLeft, AlignCenter, AlignRight } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { TextLayoutTabs } from "../modals/TextLayoutTabs";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
"Malgun Gothic",
|
||||
"NanumGothic",
|
||||
"NanumMyeongjo",
|
||||
"굴림",
|
||||
"돋움",
|
||||
"바탕",
|
||||
"Times New Roman",
|
||||
"Arial",
|
||||
];
|
||||
|
||||
const TEXT_STYLE_PRESETS = {
|
||||
title: {
|
||||
label: "제목",
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
fontColor: "#111827",
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
subtitle: {
|
||||
label: "부제목",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
fontColor: "#374151",
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
body: {
|
||||
label: "본문",
|
||||
fontSize: 12,
|
||||
fontWeight: "normal",
|
||||
fontColor: "#374151",
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
caption: {
|
||||
label: "캡션",
|
||||
fontSize: 10,
|
||||
fontWeight: "normal",
|
||||
fontColor: "#6b7280",
|
||||
fontStyle: "italic" as const,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
header: {
|
||||
label: "헤더",
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
fontColor: "#1e40af",
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: "#eff6ff",
|
||||
borderWidth: 1,
|
||||
borderColor: "#bfdbfe",
|
||||
padding: 8,
|
||||
},
|
||||
footer: {
|
||||
label: "푸터",
|
||||
fontSize: 9,
|
||||
fontWeight: "normal",
|
||||
fontColor: "#9ca3af",
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleBtn({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md border transition-colors ${
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||||
: "border-gray-200 bg-white text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["preset"]));
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(key: keyof typeof TEXT_STYLE_PRESETS) => {
|
||||
const { label, ...values } = TEXT_STYLE_PRESETS[key];
|
||||
update({ ...values, textPreset: key });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 모달(section="data")에서 표시: TextLayoutTabs 3탭 구조 */}
|
||||
{showData && <TextLayoutTabs component={component} />}
|
||||
|
||||
{/* 우측 패널(section="style")에서 표시: StyleAccordion 패턴 */}
|
||||
{showStyle && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 프리셋 */}
|
||||
<StyleAccordion label="프리셋" isOpen={openSections.has("preset")} onToggle={() => toggleSection("preset")}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.entries(TEXT_STYLE_PRESETS) as [keyof typeof TEXT_STYLE_PRESETS, typeof TEXT_STYLE_PRESETS[keyof typeof TEXT_STYLE_PRESETS]][]).map(
|
||||
([key, preset]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => applyPreset(key)}
|
||||
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-[10px] font-medium transition-all hover:border-blue-300 hover:bg-blue-50/50 ${
|
||||
component.textPreset === key
|
||||
? "border-blue-400 bg-blue-50 text-blue-700"
|
||||
: "border-gray-200 bg-white text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="flex w-full items-center justify-center rounded px-1 py-0.5"
|
||||
style={{
|
||||
fontSize: `${Math.min(preset.fontSize, 16)}px`,
|
||||
fontWeight: preset.fontWeight,
|
||||
color: preset.fontColor,
|
||||
lineHeight: "1.2",
|
||||
backgroundColor: ("backgroundColor" in preset) ? (preset as Record<string, unknown>).backgroundColor as string : undefined,
|
||||
}}
|
||||
>
|
||||
Aa
|
||||
</span>
|
||||
{preset.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 폰트 */}
|
||||
<StyleAccordion label="폰트" isOpen={openSections.has("font")} onToggle={() => toggleSection("font")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">폰트 패밀리</Label>
|
||||
<Select
|
||||
value={component.fontFamily || "Malgun Gothic"}
|
||||
onValueChange={(v) => update({ fontFamily: v })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_FAMILIES.map((f) => (
|
||||
<SelectItem key={f} value={f} style={{ fontFamily: f }}>{f}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={6}
|
||||
max={120}
|
||||
value={component.fontSize || 12}
|
||||
onChange={(e) => update({ fontSize: parseInt(e.target.value) || 12 })}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">줄 간격</Label>
|
||||
<Select
|
||||
value={String(component.lineHeight || 1.5)}
|
||||
onValueChange={(v) => update({ lineHeight: parseFloat(v) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1.0</SelectItem>
|
||||
<SelectItem value="1.2">1.2</SelectItem>
|
||||
<SelectItem value="1.5">1.5</SelectItem>
|
||||
<SelectItem value="1.8">1.8</SelectItem>
|
||||
<SelectItem value="2">2.0</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">스타일</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<ToggleBtn
|
||||
active={component.fontWeight === "bold" || component.fontWeight === "700"}
|
||||
onClick={() => update({ fontWeight: component.fontWeight === "bold" || component.fontWeight === "700" ? "normal" : "bold" })}
|
||||
title="굵게"
|
||||
>
|
||||
<Bold className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn
|
||||
active={component.fontStyle === "italic"}
|
||||
onClick={() => update({ fontStyle: component.fontStyle === "italic" ? "normal" : "italic" })}
|
||||
title="기울임"
|
||||
>
|
||||
<Italic className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn
|
||||
active={component.textDecoration === "underline"}
|
||||
onClick={() => update({ textDecoration: component.textDecoration === "underline" ? "none" : "underline" })}
|
||||
title="밑줄"
|
||||
>
|
||||
<Underline className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn
|
||||
active={component.textDecoration === "line-through"}
|
||||
onClick={() => update({ textDecoration: component.textDecoration === "line-through" ? "none" : "line-through" })}
|
||||
title="취소선"
|
||||
>
|
||||
<Strikethrough className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 색상 */}
|
||||
<StyleAccordion label="색상" isOpen={openSections.has("color")} onToggle={() => toggleSection("color")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">글자 색상</Label>
|
||||
<ColorInput
|
||||
value={component.fontColor || "#000000"}
|
||||
onChange={(v) => update({ fontColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">배경색</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorInput
|
||||
value={component.backgroundColor || "#ffffff"}
|
||||
onChange={(v) => update({ backgroundColor: v })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-xs text-gray-400"
|
||||
onClick={() => update({ backgroundColor: undefined })}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 정렬 & 간격 */}
|
||||
<StyleAccordion label="정렬 & 간격" isOpen={openSections.has("align")} onToggle={() => toggleSection("align")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">수평 정렬</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<ToggleBtn active={(component.textAlign || "left") === "left"} onClick={() => update({ textAlign: "left" })} title="왼쪽">
|
||||
<AlignLeft className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn active={component.textAlign === "center"} onClick={() => update({ textAlign: "center" })} title="가운데">
|
||||
<AlignCenter className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn active={component.textAlign === "right"} onClick={() => update({ textAlign: "right" })} title="오른쪽">
|
||||
<AlignRight className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">자간 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={-5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
value={component.letterSpacing ?? 0}
|
||||
onChange={(e) => update({ letterSpacing: parseFloat(e.target.value) || 0 })}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">안쪽 여백</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
value={component.padding ?? 0}
|
||||
onChange={(e) => update({ padding: parseInt(e.target.value) || 0 })}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 테두리 */}
|
||||
<StyleAccordion label="테두리" isOpen={openSections.has("border")} onToggle={() => toggleSection("border")}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">두께</Label>
|
||||
<Select
|
||||
value={String(component.borderWidth ?? 0)}
|
||||
onValueChange={(v) => update({ borderWidth: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">없음</SelectItem>
|
||||
<SelectItem value="1">1px</SelectItem>
|
||||
<SelectItem value="2">2px</SelectItem>
|
||||
<SelectItem value="3">3px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">모서리</Label>
|
||||
<Select
|
||||
value={String(component.borderRadius ?? 0)}
|
||||
onValueChange={(v) => update({ borderRadius: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">없음</SelectItem>
|
||||
<SelectItem value="4">4px</SelectItem>
|
||||
<SelectItem value="8">8px</SelectItem>
|
||||
<SelectItem value="12">12px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{(component.borderWidth ?? 0) > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
||||
<ColorInput
|
||||
value={component.borderColor || "#d1d5db"}
|
||||
onChange={(v) => update({ borderColor: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Database, Columns3, FunctionSquare, Play, Plus, Trash2, Code, Loader2 } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig, VisualQuery, VisualQueryFormulaColumn } from "@/types/report";
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SchemaTable {
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}
|
||||
|
||||
interface SchemaColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
// ─── 컴포넌트 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function VisualQueryBuilder({ component }: Props) {
|
||||
const { updateComponent, setQueryResult } = useReportDesigner();
|
||||
|
||||
const vq: VisualQuery = component.visualQuery ?? {
|
||||
tableName: "",
|
||||
limit: 100,
|
||||
columns: [],
|
||||
formulaColumns: [],
|
||||
};
|
||||
|
||||
// ─── 스키마 상태 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const [tables, setTables] = useState<SchemaTable[]>([]);
|
||||
const [columns, setColumns] = useState<SchemaColumn[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [generatedSql, setGeneratedSql] = useState("");
|
||||
const [previewError, setPreviewError] = useState("");
|
||||
|
||||
// ─── 테이블 목록 로드 ──────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadTables = async () => {
|
||||
setTablesLoading(true);
|
||||
try {
|
||||
const res = await reportApi.getSchemaTableList();
|
||||
if (!cancelled && res.success) setTables(res.data);
|
||||
} catch {
|
||||
/* 무시 */
|
||||
} finally {
|
||||
if (!cancelled) setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// ─── 테이블 변경 시 컬럼 로드 ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!vq.tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const loadColumns = async () => {
|
||||
setColumnsLoading(true);
|
||||
try {
|
||||
const res = await reportApi.getSchemaTableColumns(vq.tableName);
|
||||
if (!cancelled && res.success) setColumns(res.data);
|
||||
} catch {
|
||||
/* 무시 */
|
||||
} finally {
|
||||
if (!cancelled) setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
return () => { cancelled = true; };
|
||||
}, [vq.tableName]);
|
||||
|
||||
// ─── visualQuery 업데이트 헬퍼 ────────────────────────────────────────────────
|
||||
|
||||
const updateVQ = useCallback(
|
||||
(patch: Partial<VisualQuery>) => {
|
||||
updateComponent(component.id, { visualQuery: { ...vq, ...patch } });
|
||||
},
|
||||
[component.id, vq, updateComponent],
|
||||
);
|
||||
|
||||
// ─── 테이블 선택 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(tableName: string) => {
|
||||
updateComponent(component.id, {
|
||||
visualQuery: {
|
||||
tableName: tableName === "none" ? "" : tableName,
|
||||
limit: vq.limit ?? 100,
|
||||
columns: [],
|
||||
formulaColumns: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
[component.id, vq.limit, updateComponent],
|
||||
);
|
||||
|
||||
// ─── 컬럼 토글 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const toggleColumn = useCallback(
|
||||
(colName: string) => {
|
||||
const selected = vq.columns.includes(colName)
|
||||
? vq.columns.filter((c) => c !== colName)
|
||||
: [...vq.columns, colName];
|
||||
updateVQ({ columns: selected });
|
||||
},
|
||||
[vq.columns, updateVQ],
|
||||
);
|
||||
|
||||
const selectAllColumns = useCallback(() => {
|
||||
updateVQ({ columns: columns.map((c) => c.column_name) });
|
||||
}, [columns, updateVQ]);
|
||||
|
||||
const deselectAllColumns = useCallback(() => {
|
||||
updateVQ({ columns: [] });
|
||||
}, [updateVQ]);
|
||||
|
||||
// ─── 수식 컬럼 관리 ────────────────────────────────────────────────────────────
|
||||
|
||||
const addFormulaColumn = useCallback(() => {
|
||||
const fc: VisualQueryFormulaColumn = {
|
||||
alias: `calc_${(vq.formulaColumns.length + 1)}`,
|
||||
header: `수식 ${vq.formulaColumns.length + 1}`,
|
||||
expression: "",
|
||||
};
|
||||
updateVQ({ formulaColumns: [...vq.formulaColumns, fc] });
|
||||
}, [vq.formulaColumns, updateVQ]);
|
||||
|
||||
const updateFormulaColumn = useCallback(
|
||||
(idx: number, patch: Partial<VisualQueryFormulaColumn>) => {
|
||||
const updated = vq.formulaColumns.map((fc, i) => (i === idx ? { ...fc, ...patch } : fc));
|
||||
updateVQ({ formulaColumns: updated });
|
||||
},
|
||||
[vq.formulaColumns, updateVQ],
|
||||
);
|
||||
|
||||
const removeFormulaColumn = useCallback(
|
||||
(idx: number) => {
|
||||
updateVQ({ formulaColumns: vq.formulaColumns.filter((_, i) => i !== idx) });
|
||||
},
|
||||
[vq.formulaColumns, updateVQ],
|
||||
);
|
||||
|
||||
// ─── SQL 미리보기 빌드 ────────────────────────────────────────────────────────
|
||||
|
||||
const previewSql = useMemo(() => {
|
||||
if (!vq.tableName || (vq.columns.length === 0 && vq.formulaColumns.length === 0)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const col of vq.columns) parts.push(`"${col}"`);
|
||||
for (const fc of vq.formulaColumns) {
|
||||
if (fc.expression && fc.alias) parts.push(`(${fc.expression}) AS "${fc.alias}"`);
|
||||
}
|
||||
if (parts.length === 0) return "";
|
||||
const limit = Math.min(Math.max(vq.limit ?? 100, 1), 10000);
|
||||
return `SELECT ${parts.join(", ")} FROM "${vq.tableName}" LIMIT ${limit}`;
|
||||
}, [vq]);
|
||||
|
||||
// ─── 미리보기 실행 ────────────────────────────────────────────────────────────
|
||||
|
||||
const resultKey = `visual_${component.id}`;
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!vq.tableName || (vq.columns.length === 0 && vq.formulaColumns.length === 0)) return;
|
||||
setPreviewLoading(true);
|
||||
setPreviewError("");
|
||||
try {
|
||||
const res = await reportApi.previewVisualQuery(vq);
|
||||
if (res.success) {
|
||||
setQueryResult(resultKey, res.data.fields, res.data.rows);
|
||||
setGeneratedSql(res.data.sql);
|
||||
} else {
|
||||
setPreviewError("쿼리 실행에 실패했습니다.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPreviewError(err?.response?.data?.message || err.message || "오류가 발생했습니다.");
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [vq, resultKey, setQueryResult]);
|
||||
|
||||
const hasSelection = vq.columns.length > 0 || vq.formulaColumns.length > 0;
|
||||
|
||||
// ─── 렌더링 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* [1] 데이터 소스 */}
|
||||
<Card className="border-blue-200 bg-blue-50">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<CardTitle className="text-xs text-blue-900">데이터 소스</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">테이블</Label>
|
||||
<Select
|
||||
value={vq.tableName || "none"}
|
||||
onValueChange={handleTableChange}
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 bg-white text-xs">
|
||||
<SelectValue placeholder={tablesLoading ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.table_name} value={t.table_name}>
|
||||
{t.table_name}
|
||||
<span className="ml-1 text-[10px] text-gray-400">
|
||||
{t.table_type === "VIEW" ? "(뷰)" : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">최대 행 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={vq.limit ?? 100}
|
||||
onChange={(e) => updateVQ({ limit: parseInt(e.target.value) || 100 })}
|
||||
className="h-8 bg-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* [2] 컬럼 선택 */}
|
||||
{vq.tableName && (
|
||||
<Card className="border-green-200 bg-green-50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Columns3 className="h-4 w-4 text-green-600" />
|
||||
<CardTitle className="text-xs text-green-900">
|
||||
컬럼 선택
|
||||
{vq.columns.length > 0 && (
|
||||
<span className="ml-1 font-normal text-green-600">
|
||||
({vq.columns.length}/{columns.length})
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={selectAllColumns}
|
||||
>
|
||||
전체 선택
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={deselectAllColumns}
|
||||
>
|
||||
전체 해제
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{columnsLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-green-600" />
|
||||
<span className="ml-2 text-xs text-green-700">컬럼 로딩 중...</span>
|
||||
</div>
|
||||
) : columns.length > 0 ? (
|
||||
<div className="grid max-h-48 grid-cols-2 gap-1 overflow-y-auto">
|
||||
{columns.map((col) => {
|
||||
const checked = vq.columns.includes(col.column_name);
|
||||
return (
|
||||
<label
|
||||
key={col.column_name}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors ${
|
||||
checked ? "bg-green-200/60 text-green-900" : "text-gray-600 hover:bg-green-100/40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleColumn(col.column_name)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-green-600"
|
||||
/>
|
||||
<span className="truncate font-mono">{col.column_name}</span>
|
||||
<span className="ml-auto shrink-0 text-[9px] text-gray-400">
|
||||
{col.data_type}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-xs text-gray-500">컬럼 정보가 없습니다.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* [3] 수식 컬럼 */}
|
||||
{vq.tableName && (
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FunctionSquare className="h-4 w-4 text-amber-600" />
|
||||
<CardTitle className="text-xs text-amber-900">
|
||||
수식 컬럼
|
||||
{vq.formulaColumns.length > 0 && (
|
||||
<span className="ml-1 font-normal text-amber-600">
|
||||
({vq.formulaColumns.length})
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 bg-white px-2 text-[10px]"
|
||||
onClick={addFormulaColumn}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{vq.formulaColumns.length > 0 ? (
|
||||
vq.formulaColumns.map((fc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-1 rounded border border-amber-200 bg-white p-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={fc.alias}
|
||||
onChange={(e) => updateFormulaColumn(idx, { alias: e.target.value })}
|
||||
placeholder="필드명"
|
||||
className="h-7 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={fc.header}
|
||||
onChange={(e) => updateFormulaColumn(idx, { header: e.target.value })}
|
||||
placeholder="표시 헤더"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={fc.expression}
|
||||
onChange={(e) => updateFormulaColumn(idx, { expression: e.target.value })}
|
||||
placeholder='SQL 표현식 (예: "price" * "quantity")'
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-0.5 h-6 w-6 shrink-0 p-0 text-gray-400 hover:text-red-500"
|
||||
onClick={() => removeFormulaColumn(idx)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="py-2 text-center text-[10px] text-gray-500">
|
||||
계산이 필요한 컬럼을 추가하세요. (예: price * quantity)
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* [4] SQL 미리보기 + 실행 */}
|
||||
{vq.tableName && hasSelection && (
|
||||
<Card className="border-gray-200 bg-gray-50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-gray-600" />
|
||||
<CardTitle className="text-xs text-gray-700">생성 SQL</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 bg-blue-600 px-3 text-xs hover:bg-blue-700"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
미리보기
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-[10px] leading-relaxed text-green-300">
|
||||
{generatedSql || previewSql || "컬럼을 선택하세요."}
|
||||
</pre>
|
||||
{previewError && (
|
||||
<p className="mt-2 text-xs text-red-600">{previewError}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import JsBarcode from "jsbarcode";
|
||||
import QRCode from "qrcode";
|
||||
import type { BarcodeRendererProps as BarcodeCanvasRendererProps } from "./types";
|
||||
|
||||
interface BarcodeProps {
|
||||
value: string;
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
displayValue: boolean;
|
||||
lineColor: string;
|
||||
background: string;
|
||||
margin: number;
|
||||
}
|
||||
|
||||
function Barcode1D({ value, format, width, height, displayValue, lineColor, background, margin }: BarcodeProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !value) return;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let isValid = true;
|
||||
let errorMsg = "";
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (format === "EAN13" && !/^\d{12,13}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "EAN-13: 12~13자리 숫자 필요";
|
||||
} else if (format === "EAN8" && !/^\d{7,8}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "EAN-8: 7~8자리 숫자 필요";
|
||||
} else if (format === "UPC" && !/^\d{11,12}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "UPC: 11~12자리 숫자 필요";
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
setError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const barcodeFormat = format.toLowerCase();
|
||||
const bgColor = background === "transparent" ? "" : background;
|
||||
|
||||
JsBarcode(svgRef.current, trimmedValue, {
|
||||
format: barcodeFormat,
|
||||
width: 2,
|
||||
height: Math.max(30, height - (displayValue ? 30 : 10)),
|
||||
displayValue,
|
||||
lineColor,
|
||||
background: bgColor,
|
||||
margin,
|
||||
fontSize: 12,
|
||||
textMargin: 2,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "바코드 생성 실패");
|
||||
}
|
||||
}, [value, format, width, height, displayValue, lineColor, background, margin]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<svg ref={svgRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
|
||||
<span>{error}</span>
|
||||
<span className="mt-1 text-gray-400">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QRProps {
|
||||
value: string;
|
||||
size: number;
|
||||
fgColor: string;
|
||||
bgColor: string;
|
||||
level: "L" | "M" | "Q" | "H";
|
||||
}
|
||||
|
||||
function QR({ value, size, fgColor, bgColor, level }: QRProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !value) return;
|
||||
setError(null);
|
||||
const lightColor = bgColor === "transparent" ? "#ffffff" : bgColor;
|
||||
|
||||
QRCode.toCanvas(
|
||||
canvasRef.current,
|
||||
value,
|
||||
{
|
||||
width: Math.max(50, size),
|
||||
margin: 2,
|
||||
color: { dark: fgColor, light: lightColor },
|
||||
errorCorrectionLevel: level,
|
||||
},
|
||||
(err) => {
|
||||
if (err) setError(err.message || "QR코드 생성 실패");
|
||||
},
|
||||
);
|
||||
}, [value, size, fgColor, bgColor, level]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<canvas ref={canvasRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
|
||||
<span>{error}</span>
|
||||
<span className="mt-1 text-gray-400">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodeCanvasRenderer({ component, getQueryResult }: BarcodeCanvasRendererProps) {
|
||||
const barcodeType = component.barcodeType || "CODE128";
|
||||
const showBarcodeText = component.showBarcodeText !== false;
|
||||
const barcodeColor = component.barcodeColor || "#000000";
|
||||
const barcodeBackground = component.barcodeBackground || "transparent";
|
||||
const barcodeMargin = component.barcodeMargin ?? 10;
|
||||
const qrErrorLevel = component.qrErrorCorrectionLevel || "M";
|
||||
|
||||
const getBarcodeValue = (): string => {
|
||||
if (
|
||||
barcodeType === "QR" &&
|
||||
component.qrUseMultiField &&
|
||||
component.qrDataFields &&
|
||||
component.qrDataFields.length > 0 &&
|
||||
component.queryId
|
||||
) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
if (component.qrIncludeAllRows) {
|
||||
const allRowsData: Record<string, string>[] = [];
|
||||
queryResult.rows.forEach((row) => {
|
||||
const rowData: Record<string, string> = {};
|
||||
component.qrDataFields!.forEach((field) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
allRowsData.push(rowData);
|
||||
});
|
||||
return JSON.stringify(allRowsData);
|
||||
}
|
||||
const row = queryResult.rows[0];
|
||||
const jsonData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
return JSON.stringify(jsonData);
|
||||
}
|
||||
const placeholderData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field) => {
|
||||
if (field.label) placeholderData[field.label] = `{${field.fieldName || "field"}}`;
|
||||
});
|
||||
return component.qrIncludeAllRows
|
||||
? JSON.stringify([placeholderData, { "...": "..." }])
|
||||
: JSON.stringify(placeholderData);
|
||||
}
|
||||
|
||||
if (component.barcodeFieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
||||
const allValues = queryResult.rows
|
||||
.map((row) => {
|
||||
const val = row[component.barcodeFieldName!];
|
||||
return val !== null && val !== undefined ? String(val) : "";
|
||||
})
|
||||
.filter((v) => v !== "");
|
||||
return JSON.stringify(allValues);
|
||||
}
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[component.barcodeFieldName];
|
||||
if (val !== null && val !== undefined) return String(val);
|
||||
}
|
||||
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
||||
return JSON.stringify([`{${component.barcodeFieldName}}`, "..."]);
|
||||
}
|
||||
return `{${component.barcodeFieldName}}`;
|
||||
}
|
||||
return component.barcodeValue || "SAMPLE123";
|
||||
};
|
||||
|
||||
const barcodeValue = getBarcodeValue();
|
||||
const isQR = barcodeType === "QR";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center overflow-hidden"
|
||||
style={{ backgroundColor: barcodeBackground }}
|
||||
>
|
||||
{isQR ? (
|
||||
<QR
|
||||
value={barcodeValue}
|
||||
size={Math.min(component.width, component.height) - 10}
|
||||
fgColor={barcodeColor}
|
||||
bgColor={barcodeBackground}
|
||||
level={qrErrorLevel}
|
||||
/>
|
||||
) : (
|
||||
<Barcode1D
|
||||
value={barcodeValue}
|
||||
format={barcodeType}
|
||||
width={component.width}
|
||||
height={component.height}
|
||||
displayValue={showBarcodeText}
|
||||
lineColor={barcodeColor}
|
||||
background={barcodeBackground}
|
||||
margin={barcodeMargin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import type { CalculationRendererProps } from "./types";
|
||||
|
||||
export function CalculationRenderer({ component, getQueryResult }: CalculationRendererProps) {
|
||||
const calcItems = component.calcItems || [];
|
||||
const resultLabel = component.resultLabel || "합계";
|
||||
const calcLabelWidth = component.labelWidth || 120;
|
||||
const calcLabelFontSize = component.labelFontSize || 13;
|
||||
const calcValueFontSize = component.valueFontSize || 13;
|
||||
const calcResultFontSize = component.resultFontSize || 16;
|
||||
const calcLabelColor = component.labelColor || "#374151";
|
||||
const calcValueColor = component.valueColor || "#000000";
|
||||
const calcResultColor = component.resultColor || "#2563eb";
|
||||
const numberFormat = component.numberFormat || "currency";
|
||||
const currencySuffix = component.currencySuffix || "원";
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (numberFormat === "none") return String(num);
|
||||
if (numberFormat === "comma") return num.toLocaleString();
|
||||
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
|
||||
return String(num);
|
||||
};
|
||||
|
||||
const getCalcItemValue = (item: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
operator: string;
|
||||
fieldName?: string;
|
||||
}): number => {
|
||||
if (item.fieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[item.fieldName];
|
||||
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
|
||||
}
|
||||
}
|
||||
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
|
||||
};
|
||||
|
||||
const calculateResult = (): number => {
|
||||
if (calcItems.length === 0) return 0;
|
||||
let result = getCalcItemValue(
|
||||
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
|
||||
);
|
||||
for (let i = 1; i < calcItems.length; i++) {
|
||||
const item = calcItems[i];
|
||||
const val = getCalcItemValue(
|
||||
item as { label: string; value: number | string; operator: string; fieldName?: string },
|
||||
);
|
||||
switch (item.operator) {
|
||||
case "+":
|
||||
result += val;
|
||||
break;
|
||||
case "-":
|
||||
result -= val;
|
||||
break;
|
||||
case "x":
|
||||
result *= val;
|
||||
break;
|
||||
case "÷":
|
||||
result = val !== 0 ? result / val : result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const calcResult = calculateResult();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto px-2 py-1">
|
||||
{calcItems.map(
|
||||
(item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => {
|
||||
const itemValue = getCalcItemValue(item);
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between py-1">
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
style={{ width: `${calcLabelWidth}px`, fontSize: `${calcLabelFontSize}px`, color: calcLabelColor }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-right" style={{ fontSize: `${calcValueFontSize}px`, color: calcValueColor }}>
|
||||
{formatNumber(itemValue)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-1 flex-shrink-0 border-t" style={{ borderColor: component.borderColor || "#374151" }} />
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ width: `${calcLabelWidth}px`, fontSize: `${calcResultFontSize}px`, color: calcLabelColor }}
|
||||
>
|
||||
{resultLabel}
|
||||
</span>
|
||||
<span className="text-right font-bold" style={{ fontSize: `${calcResultFontSize}px`, color: calcResultColor }}>
|
||||
{formatNumber(calcResult)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
"use client";
|
||||
|
||||
import type { CardRendererProps, QueryResult } from "./types";
|
||||
import type {
|
||||
CardLayoutConfig,
|
||||
CardLayoutRow,
|
||||
CardElement,
|
||||
CardHeaderElement,
|
||||
CardDataCellElement,
|
||||
CardDividerElement,
|
||||
CardBadgeElement,
|
||||
CardImageElement,
|
||||
CardNumberElement,
|
||||
CardDateElement,
|
||||
CardLinkElement,
|
||||
CardStatusElement,
|
||||
CardSpacerElement,
|
||||
CardStaticTextElement,
|
||||
} from "@/types/report";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 기존 cardItems 방식 렌더러 (하위 호환)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function CardListRenderer({ component, getQueryResult }: CardRendererProps) {
|
||||
const cardTitle = component.cardTitle || "정보 카드";
|
||||
const cardItems = component.cardItems || [];
|
||||
const labelWidth = component.labelWidth || 80;
|
||||
const showCardTitle = component.showCardTitle !== false;
|
||||
const titleFontSize = component.titleFontSize || 14;
|
||||
const labelFontSize = component.labelFontSize || 13;
|
||||
const valueFontSize = component.valueFontSize || 13;
|
||||
const titleColor = component.titleColor || "#1e40af";
|
||||
const labelColor = component.labelColor || "#374151";
|
||||
const valueColor = component.valueColor || "#000000";
|
||||
|
||||
const getCardItemValue = (item: {
|
||||
label: string;
|
||||
value: string;
|
||||
fieldName?: string;
|
||||
}) => {
|
||||
if (item.fieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
return row[item.fieldName] !== undefined
|
||||
? String(row[item.fieldName])
|
||||
: item.value;
|
||||
}
|
||||
}
|
||||
return item.value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{showCardTitle && (
|
||||
<>
|
||||
<div
|
||||
className="flex-shrink-0 px-2 py-1 font-semibold"
|
||||
style={{ fontSize: `${titleFontSize}px`, color: titleColor }}
|
||||
>
|
||||
{cardTitle}
|
||||
</div>
|
||||
<div
|
||||
className="mx-1 flex-shrink-0 border-b"
|
||||
style={{ borderColor: component.borderColor || "#e5e7eb" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto px-2 py-1">
|
||||
{cardItems.map(
|
||||
(
|
||||
item: { label: string; value: string; fieldName?: string },
|
||||
index: number,
|
||||
) => (
|
||||
<div key={index} className="flex py-0.5">
|
||||
<span
|
||||
className="flex-shrink-0 font-medium"
|
||||
style={{
|
||||
width: `${labelWidth}px`,
|
||||
fontSize: `${labelFontSize}px`,
|
||||
color: labelColor,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className="flex-1"
|
||||
style={{ fontSize: `${valueFontSize}px`, color: valueColor }}
|
||||
>
|
||||
{getCardItemValue(item)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// v3 요소별 렌더러
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ElementRendererProps {
|
||||
element: CardElement;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
queryId?: string;
|
||||
config: CardLayoutConfig;
|
||||
}
|
||||
|
||||
function CardHeaderRenderer({
|
||||
element,
|
||||
config,
|
||||
}: {
|
||||
element: CardHeaderElement;
|
||||
config: CardLayoutConfig;
|
||||
}) {
|
||||
const titleFontSize =
|
||||
element.titleFontSize || config.headerTitleFontSize || 14;
|
||||
const titleColor = element.titleColor || config.headerTitleColor || "#1e40af";
|
||||
const iconColor = element.iconColor || titleColor;
|
||||
|
||||
const IconComponent = element.icon
|
||||
? (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string; style?: React.CSSProperties }>>)[element.icon]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 py-1"
|
||||
style={{ gridColumn: `span ${element.colspan || 1}` }}
|
||||
>
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
className="w-4 h-4 flex-shrink-0"
|
||||
style={{ color: iconColor }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ fontSize: `${titleFontSize}px`, color: titleColor }}
|
||||
>
|
||||
{element.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDataCellRenderer({
|
||||
element,
|
||||
getQueryResult,
|
||||
queryId,
|
||||
config,
|
||||
}: {
|
||||
element: CardDataCellElement;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
queryId?: string;
|
||||
config: CardLayoutConfig;
|
||||
}) {
|
||||
const labelFontSize =
|
||||
element.labelFontSize || config.labelFontSize || 13;
|
||||
const labelColor = element.labelColor || config.labelColor || "#374151";
|
||||
const valueFontSize =
|
||||
element.valueFontSize || config.valueFontSize || 13;
|
||||
const valueColor = element.valueColor || config.valueColor || "#000000";
|
||||
|
||||
const getValue = (): string => {
|
||||
if (element.columnName && queryId) {
|
||||
const queryResult = getQueryResult(queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
const value = row[element.columnName];
|
||||
return value !== undefined && value !== null ? String(value) : "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const value = getValue() || "-";
|
||||
|
||||
if (element.direction === "vertical") {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col py-1"
|
||||
style={{ gridColumn: `span ${element.colspan || 1}` }}
|
||||
>
|
||||
<span
|
||||
className="font-medium mb-0.5"
|
||||
style={{ fontSize: `${labelFontSize}px`, color: labelColor }}
|
||||
>
|
||||
{element.label}
|
||||
</span>
|
||||
<span style={{ fontSize: `${valueFontSize}px`, color: valueColor }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center py-1"
|
||||
style={{ gridColumn: `span ${element.colspan || 1}` }}
|
||||
>
|
||||
<span
|
||||
className="font-medium flex-shrink-0"
|
||||
style={{
|
||||
width: element.labelWidth ? `${element.labelWidth}px` : "auto",
|
||||
minWidth: "60px",
|
||||
fontSize: `${labelFontSize}px`,
|
||||
color: labelColor,
|
||||
}}
|
||||
>
|
||||
{element.label}
|
||||
</span>
|
||||
<span
|
||||
className="flex-1"
|
||||
style={{ fontSize: `${valueFontSize}px`, color: valueColor }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDividerRenderer({
|
||||
element,
|
||||
config,
|
||||
}: {
|
||||
element: CardDividerElement;
|
||||
config: CardLayoutConfig;
|
||||
}) {
|
||||
const thickness = element.thickness || config.dividerThickness || 1;
|
||||
const color = element.color || config.dividerColor || "#e5e7eb";
|
||||
const style = element.style || "solid";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="py-1"
|
||||
style={{ gridColumn: `span ${element.colspan || 1}` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderTopWidth: `${thickness}px`,
|
||||
borderTopStyle: style,
|
||||
borderTopColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardBadgeRenderer({
|
||||
element,
|
||||
getQueryResult,
|
||||
queryId,
|
||||
}: {
|
||||
element: CardBadgeElement;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
queryId?: string;
|
||||
config: CardLayoutConfig;
|
||||
}) {
|
||||
const getValue = (): string => {
|
||||
if (element.columnName && queryId) {
|
||||
const queryResult = getQueryResult(queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
const value = row[element.columnName];
|
||||
return value !== undefined && value !== null ? String(value) : "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const value = getValue();
|
||||
const bgColor = element.colorMap?.[value] || "#e5e7eb";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 py-1"
|
||||
style={{ gridColumn: `span ${element.colspan || 1}` }}
|
||||
>
|
||||
{element.label && (
|
||||
<span className="text-sm text-gray-600">{element.label}</span>
|
||||
)}
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{value || "-"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getFieldValue(
|
||||
columnName: string | undefined,
|
||||
queryId: string | undefined,
|
||||
getQueryResult: (id: string) => QueryResult | null,
|
||||
): string {
|
||||
if (!columnName || !queryId) return "";
|
||||
const result = getQueryResult(queryId);
|
||||
if (result?.rows?.length) {
|
||||
const val = result.rows[0][columnName];
|
||||
return val !== undefined && val !== null ? String(val) : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatNumber(raw: string, format?: string, suffix?: string): string {
|
||||
const num = parseFloat(raw);
|
||||
if (isNaN(num)) return raw || "-";
|
||||
if (format === "comma" || format === "currency") {
|
||||
const formatted = num.toLocaleString("ko-KR");
|
||||
return format === "currency" ? `${formatted}${suffix || "원"}` : formatted;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function CardImageRenderer({
|
||||
element,
|
||||
getQueryResult,
|
||||
queryId,
|
||||
}: {
|
||||
element: CardImageElement;
|
||||
getQueryResult: (id: string) => QueryResult | null;
|
||||
queryId?: string;
|
||||
}) {
|
||||
const url = getFieldValue(element.columnName, queryId, getQueryResult);
|
||||
return (
|
||||
<div style={{ gridColumn: `span ${element.colspan || 1}`, height: element.height || 80 }}>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={element.altText || ""}
|
||||
style={{ width: "100%", height: "100%", objectFit: element.objectFit || "contain" }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full bg-gray-100 text-xs text-gray-400 rounded">
|
||||
이미지
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardNumberRenderer({
|
||||
element,
|
||||
getQueryResult,
|
||||
queryId,
|
||||
config,
|
||||
}: {
|
||||
element: CardNumberElement;
|
||||
getQueryResult: (id: string) => QueryResult | null;
|
||||
queryId?: string;
|
||||
config: CardLayoutConfig;
|
||||
}) {
|
||||
const raw = getFieldValue(element.columnName, queryId, getQueryResult);
|
||||
const value = formatNumber(raw, element.numberFormat, element.currencySuffix);
|
||||
const labelFontSize = element.labelFontSize || config.labelFontSize || 13;
|
||||
const labelColor = element.labelColor || config.labelColor || "#374151";
|
||||
const valueFontSize = element.valueFontSize || config.valueFontSize || 13;
|
||||
const valueColor = element.valueColor || config.valueColor || "#000000";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-1" style={{ gridColumn: `span ${element.colspan || 1}` }}>
|
||||
<span className="font-medium mb-0.5" style={{ fontSize: `${labelFontSize}px`, color: labelColor }}>
|
||||
{element.label}
|
||||
</span>
|
||||
<span style={{ fontSize: `${valueFontSize}px`, color: valueColor }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDateRenderer({
|
||||
element,
|
||||
getQueryResult,
|
||||
queryId,
|
||||
config,
|
||||
}: {
|
||||
element: CardDateElement;
|
||||
getQueryResult: (id: string) => QueryResult | null;
|
||||
queryId?: string;
|
||||
config: CardLayoutConfig;
|
||||
}) {
|
||||
const raw = getFieldValue(element.columnName, queryId, getQueryResult);
|
||||
const labelFontSize = element.labelFontSize || config.labelFontSize || 13;
|
||||
const labelColor = element.labelColor || config.labelColor || "#374151";
|
||||
const valueFontSize = element.valueFontSize || config.valueFontSize || 13;
|
||||
const valueColor = element.valueColor || config.valueColor || "#000000";
|
||||
|
||||
let displayValue = raw || "-";
|
||||
if (raw && element.dateFormat) {
|
||||
try {
|
||||
const d = new Date(raw);
|
||||
if (!isNaN(d.getTime())) {
|
||||
displayValue = element.dateFormat
|
||||
.replace("YYYY", String(d.getFullYear()))
|
||||
.replace("MM", String(d.getMonth() + 1).padStart(2, "0"))
|
||||
.replace("DD", String(d.getDate()).padStart(2, "0"));
|
||||
}
|
||||
} catch {
|
||||
displayValue = raw;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-1" style={{ gridColumn: `span ${element.colspan || 1}` }}>
|
||||
<span className="font-medium mb-0.5" style={{ fontSize: `${labelFontSize}px`, color: labelColor }}>
|
||||
{element.label}
|
||||
</span>
|
||||
<span style={{ fontSize: `${valueFontSize}px`, color: valueColor }}>{displayValue}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardLinkRenderer({
|
||||
element,
|
||||
getQueryResult,
|
||||
queryId,
|
||||
}: {
|
||||
element: CardLinkElement;
|
||||
getQueryResult: (id: string) => QueryResult | null;
|
||||
queryId?: string;
|
||||
}) {
|
||||
const url = getFieldValue(element.columnName, queryId, getQueryResult);
|
||||
const text = element.linkText || url || element.label;
|
||||
|
||||
return (
|
||||
<div className="flex items-center py-1" style={{ gridColumn: `span ${element.colspan || 1}` }}>
|
||||
<span className="text-sm text-gray-600 mr-2">{element.label}</span>
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target={element.openInNewTab ? "_blank" : undefined}
|
||||
rel={element.openInNewTab ? "noopener noreferrer" : undefined}
|
||||
className="text-sm text-blue-600 hover:underline truncate"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardStatusRenderer({
|
||||
element,
|
||||
getQueryResult,
|
||||
queryId,
|
||||
}: {
|
||||
element: CardStatusElement;
|
||||
getQueryResult: (id: string) => QueryResult | null;
|
||||
queryId?: string;
|
||||
}) {
|
||||
const value = getFieldValue(element.columnName, queryId, getQueryResult);
|
||||
const mapping = element.statusMappings?.find((m) => m.value === value);
|
||||
const dotColor = mapping?.color || "#9ca3af";
|
||||
const label = mapping?.label || value || "-";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1" style={{ gridColumn: `span ${element.colspan || 1}` }}>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: dotColor }}
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardStaticTextRenderer({ element }: { element: CardStaticTextElement }) {
|
||||
return (
|
||||
<div style={{ gridColumn: `span ${element.colspan || 1}` }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${element.fontSize || 13}px`,
|
||||
color: element.color || "#000000",
|
||||
fontWeight: element.fontWeight || "normal",
|
||||
textAlign: element.textAlign || "left",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{element.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardElementRenderer({
|
||||
element,
|
||||
getQueryResult,
|
||||
queryId,
|
||||
config,
|
||||
}: ElementRendererProps) {
|
||||
switch (element.type) {
|
||||
case "header":
|
||||
return <CardHeaderRenderer element={element} config={config} />;
|
||||
case "dataCell":
|
||||
return (
|
||||
<CardDataCellRenderer
|
||||
element={element}
|
||||
getQueryResult={getQueryResult}
|
||||
queryId={queryId}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
case "divider":
|
||||
return <CardDividerRenderer element={element} config={config} />;
|
||||
case "badge":
|
||||
return (
|
||||
<CardBadgeRenderer
|
||||
element={element}
|
||||
getQueryResult={getQueryResult}
|
||||
queryId={queryId}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
case "image":
|
||||
return <CardImageRenderer element={element as CardImageElement} getQueryResult={getQueryResult} queryId={queryId} />;
|
||||
case "number":
|
||||
return <CardNumberRenderer element={element as CardNumberElement} getQueryResult={getQueryResult} queryId={queryId} config={config} />;
|
||||
case "date":
|
||||
return <CardDateRenderer element={element as CardDateElement} getQueryResult={getQueryResult} queryId={queryId} config={config} />;
|
||||
case "link":
|
||||
return <CardLinkRenderer element={element as CardLinkElement} getQueryResult={getQueryResult} queryId={queryId} />;
|
||||
case "status":
|
||||
return <CardStatusRenderer element={element as CardStatusElement} getQueryResult={getQueryResult} queryId={queryId} />;
|
||||
case "spacer":
|
||||
return <div key={element.id} style={{ height: (element as CardSpacerElement).height || 16, gridColumn: `span ${element.colspan || 1}` }} />;
|
||||
case "staticText":
|
||||
return <CardStaticTextRenderer element={element as CardStaticTextElement} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// v3 그리드 렌더러
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function CardGridRenderer({
|
||||
config,
|
||||
getQueryResult,
|
||||
queryId,
|
||||
}: {
|
||||
config: CardLayoutConfig;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
queryId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full flex-col overflow-hidden"
|
||||
style={{
|
||||
padding: config.padding || "8px",
|
||||
backgroundColor: config.backgroundColor || "#ffffff",
|
||||
borderRadius: config.borderRadius || "0",
|
||||
border: config.borderStyle !== "none"
|
||||
? `${config.borderWidth || 1}px ${config.borderStyle || "solid"} ${config.borderColor || "#e5e7eb"}`
|
||||
: "none",
|
||||
...(config.accentBorderWidth && config.accentBorderWidth > 0
|
||||
? { borderLeft: `${config.accentBorderWidth}px solid ${config.accentBorderColor || "#3b82f6"}` }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
style={{ display: "flex", flexDirection: "column", gap: config.gap || "0px" }}
|
||||
>
|
||||
{config.rows.map((row: CardLayoutRow) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${row.gridColumns}, 1fr)`,
|
||||
height: row.height || "auto",
|
||||
gap: config.gap || "0px",
|
||||
marginBottom: row.marginBottom || undefined,
|
||||
}}
|
||||
>
|
||||
{row.elements.map((element: CardElement) => (
|
||||
<CardElementRenderer
|
||||
key={element.id}
|
||||
element={element}
|
||||
getQueryResult={getQueryResult}
|
||||
queryId={queryId}
|
||||
config={config}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 메인 컴포넌트
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export function CardRenderer({ component, getQueryResult }: CardRendererProps) {
|
||||
const effectiveQueryId = component.queryId || `card_${component.id}`;
|
||||
|
||||
if (component.cardLayoutConfig) {
|
||||
return (
|
||||
<CardGridRenderer
|
||||
config={component.cardLayoutConfig}
|
||||
getQueryResult={getQueryResult}
|
||||
queryId={effectiveQueryId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardListRenderer component={component} getQueryResult={getQueryResult} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import type { CheckboxRendererProps } from "./types";
|
||||
|
||||
export function CheckboxRenderer({ component, getQueryResult }: CheckboxRendererProps) {
|
||||
const checkboxSize = component.checkboxSize || 18;
|
||||
const checkboxColor = component.checkboxColor || "#2563eb";
|
||||
const checkboxBorderColor = component.checkboxBorderColor || "#6b7280";
|
||||
const checkboxLabelPosition = component.checkboxLabelPosition || "right";
|
||||
const checkboxLabel = component.checkboxLabel || "";
|
||||
|
||||
const getCheckboxValue = (): boolean => {
|
||||
if (component.checkboxFieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[component.checkboxFieldName];
|
||||
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") return true;
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return component.checkboxChecked === true;
|
||||
};
|
||||
|
||||
const isChecked = getCheckboxValue();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full w-full items-center gap-2 ${
|
||||
checkboxLabelPosition === "left" ? "flex-row-reverse justify-end" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center rounded-sm border-2 transition-colors"
|
||||
style={{
|
||||
width: `${checkboxSize}px`,
|
||||
height: `${checkboxSize}px`,
|
||||
borderColor: isChecked ? checkboxColor : checkboxBorderColor,
|
||||
backgroundColor: isChecked ? checkboxColor : "transparent",
|
||||
}}
|
||||
>
|
||||
{isChecked && (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ width: `${checkboxSize * 0.7}px`, height: `${checkboxSize * 0.7}px` }}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{checkboxLabel && (
|
||||
<span style={{ fontSize: `${component.fontSize || 14}px`, color: component.fontColor || "#374151" }}>
|
||||
{checkboxLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import type { DividerRendererProps } from "./types";
|
||||
|
||||
export function DividerRenderer({ component }: DividerRendererProps) {
|
||||
const lineWidth = component.lineWidth || 1;
|
||||
const lineColor = component.lineColor || "#000000";
|
||||
const isHorizontal = component.orientation !== "vertical";
|
||||
|
||||
return (
|
||||
<div className={`flex h-full w-full ${isHorizontal ? "items-center" : "justify-center"}`}>
|
||||
<div
|
||||
style={{
|
||||
width: isHorizontal ? "100%" : `${lineWidth}px`,
|
||||
height: isHorizontal ? `${lineWidth}px` : "100%",
|
||||
backgroundColor: lineColor,
|
||||
...(component.lineStyle === "dashed" && {
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
${isHorizontal ? "90deg" : "0deg"},
|
||||
${lineColor} 0px,
|
||||
${lineColor} 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)`,
|
||||
backgroundColor: "transparent",
|
||||
}),
|
||||
...(component.lineStyle === "dotted" && {
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
${isHorizontal ? "90deg" : "0deg"},
|
||||
${lineColor} 0px,
|
||||
${lineColor} 3px,
|
||||
transparent 3px,
|
||||
transparent 10px
|
||||
)`,
|
||||
backgroundColor: "transparent",
|
||||
}),
|
||||
...(component.lineStyle === "double" && {
|
||||
boxShadow: isHorizontal ? `0 ${lineWidth * 2}px 0 0 ${lineColor}` : `${lineWidth * 2}px 0 0 0 ${lineColor}`,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import type { ImageRendererProps } from "./types";
|
||||
|
||||
export function ImageRenderer({ component }: ImageRendererProps) {
|
||||
const {
|
||||
imageUrl,
|
||||
objectFit = "contain",
|
||||
imageOpacity = 1,
|
||||
imageBorderRadius = 0,
|
||||
imageCaption,
|
||||
imageCaptionPosition = "bottom",
|
||||
imageCaptionFontSize = 12,
|
||||
imageCaptionColor = "#666666",
|
||||
imageCaptionAlign = "center",
|
||||
imageAlt = "이미지",
|
||||
imageRotation = 0,
|
||||
imageFlipH = false,
|
||||
imageFlipV = false,
|
||||
imageCropX,
|
||||
imageCropY,
|
||||
imageCropWidth,
|
||||
imageCropHeight,
|
||||
} = component;
|
||||
|
||||
const hasCaption = !!imageCaption;
|
||||
const hasCrop = imageCropWidth != null && imageCropHeight != null && imageCropWidth < 100;
|
||||
|
||||
const transformParts = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
if (imageRotation) parts.push(`rotate(${imageRotation}deg)`);
|
||||
if (imageFlipH) parts.push("scaleX(-1)");
|
||||
if (imageFlipV) parts.push("scaleY(-1)");
|
||||
return parts.length > 0 ? parts.join(" ") : undefined;
|
||||
}, [imageRotation, imageFlipH, imageFlipV]);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
borderRadius: imageBorderRadius,
|
||||
overflow: "hidden" as const,
|
||||
}),
|
||||
[imageBorderRadius],
|
||||
);
|
||||
|
||||
const imageStyle = useMemo(() => {
|
||||
if (hasCrop) {
|
||||
const scaleX = 100 / (imageCropWidth ?? 100);
|
||||
const scaleY = 100 / (imageCropHeight ?? 100);
|
||||
const translateX = -(imageCropX ?? 0) * scaleX;
|
||||
const translateY = -(imageCropY ?? 0) * scaleY;
|
||||
|
||||
const cropTransform = `translate(${translateX}%, ${translateY}%) scale(${scaleX}, ${scaleY})`;
|
||||
const extraTransform = transformParts ? ` ${transformParts}` : "";
|
||||
|
||||
return {
|
||||
display: "block" as const,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "none" as const,
|
||||
objectPosition: "0% 0%",
|
||||
opacity: imageOpacity,
|
||||
transform: cropTransform + extraTransform,
|
||||
transformOrigin: "0% 0%",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
display: "block" as const,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: objectFit,
|
||||
opacity: imageOpacity,
|
||||
transform: transformParts,
|
||||
};
|
||||
}, [objectFit, imageOpacity, transformParts, hasCrop, imageCropX, imageCropY, imageCropWidth, imageCropHeight]);
|
||||
|
||||
if (!imageUrl) {
|
||||
const isSmall = component.width < 100 || component.height < 80;
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full flex-col items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
style={{ borderRadius: imageBorderRadius, gap: isSmall ? 0 : 8, overflow: "hidden" }}
|
||||
>
|
||||
<svg
|
||||
className={`${isSmall ? "h-6 w-6" : "h-10 w-10"} text-gray-300`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"
|
||||
/>
|
||||
</svg>
|
||||
{!isSmall && <span className="text-xs text-gray-400">이미지를 업로드하세요</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const captionElement = hasCaption && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: imageCaptionFontSize,
|
||||
color: imageCaptionColor,
|
||||
textAlign: imageCaptionAlign,
|
||||
padding: "4px 8px",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{imageCaption}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col" style={containerStyle}>
|
||||
{imageCaptionPosition === "top" && captionElement}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<img
|
||||
src={getFullImageUrl(imageUrl)}
|
||||
alt={imageAlt}
|
||||
style={imageStyle}
|
||||
/>
|
||||
</div>
|
||||
{imageCaptionPosition === "bottom" && captionElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { PageNumberRendererProps } from "./types";
|
||||
|
||||
export function PageNumberRenderer({ component, currentPageId, layoutConfig }: PageNumberRendererProps) {
|
||||
const format = component.pageNumberFormat || "number";
|
||||
const sortedPages = [...layoutConfig.pages].sort((a, b) => a.page_order - b.page_order);
|
||||
const currentPageIndex = sortedPages.findIndex((p) => p.page_id === currentPageId);
|
||||
const totalPages = sortedPages.length;
|
||||
const currentPageNum = currentPageIndex + 1;
|
||||
|
||||
let pageNumberText = "";
|
||||
switch (format) {
|
||||
case "number":
|
||||
pageNumberText = `${currentPageNum}`;
|
||||
break;
|
||||
case "numberTotal":
|
||||
pageNumberText = `${currentPageNum} / ${totalPages}`;
|
||||
break;
|
||||
case "koreanNumber":
|
||||
pageNumberText = `${currentPageNum} 페이지`;
|
||||
break;
|
||||
default:
|
||||
pageNumberText = `${currentPageNum}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
style={{
|
||||
fontSize: `${component.fontSize}px`,
|
||||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
}}
|
||||
>
|
||||
{pageNumberText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import type { SignatureRendererProps, StampRendererProps } from "./types";
|
||||
|
||||
export function SignatureRenderer({ component }: SignatureRendererProps) {
|
||||
const labelPos = component.labelPosition || "left";
|
||||
const showLabel = component.showLabel !== false;
|
||||
const labelText = component.labelText || "서명:";
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div
|
||||
className={`flex h-full gap-2 ${
|
||||
labelPos === "top"
|
||||
? "flex-col"
|
||||
: labelPos === "bottom"
|
||||
? "flex-col-reverse"
|
||||
: labelPos === "right"
|
||||
? "flex-row-reverse"
|
||||
: "flex-row"
|
||||
}`}
|
||||
>
|
||||
{showLabel && (
|
||||
<div
|
||||
className="flex items-center justify-center text-xs font-medium"
|
||||
style={{
|
||||
width: labelPos === "left" || labelPos === "right" ? "auto" : "100%",
|
||||
minWidth: labelPos === "left" || labelPos === "right" ? "40px" : "auto",
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative flex-1">
|
||||
{component.imageUrl ? (
|
||||
<img
|
||||
src={getFullImageUrl(component.imageUrl)}
|
||||
alt="서명"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: component.objectFit || "contain",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
|
||||
style={{ borderColor: component.borderColor || "#cccccc" }}
|
||||
>
|
||||
서명 이미지
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StampRenderer({ component }: StampRendererProps) {
|
||||
const showLabel = component.showLabel !== false;
|
||||
const labelText = component.labelText || "(인)";
|
||||
const personName = component.personName || "";
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full gap-2">
|
||||
{personName && <div className="flex items-center text-xs font-medium">{personName}</div>}
|
||||
<div className="relative flex-1">
|
||||
{component.imageUrl ? (
|
||||
<img
|
||||
src={getFullImageUrl(component.imageUrl)}
|
||||
alt="도장"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: component.objectFit || "contain",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
|
||||
style={{
|
||||
borderColor: component.borderColor || "#cccccc",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
>
|
||||
도장 이미지
|
||||
</div>
|
||||
)}
|
||||
{showLabel && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center text-xs font-medium"
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{labelText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import type { TableRendererProps } from "./types";
|
||||
import type { ComponentConfig, GridCell } from "@/types/report";
|
||||
|
||||
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
||||
|
||||
// ─── 헬퍼 함수 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function applyNumberFormat(value: string, format?: string, suffix?: string): string {
|
||||
if (!format || format === "none") return value;
|
||||
const num = parseFloat(value.replace(/,/g, ""));
|
||||
if (isNaN(num)) return value;
|
||||
const formatted = num.toLocaleString("ko-KR");
|
||||
return format === "currency" ? `${formatted}${suffix ?? "원"}` : formatted;
|
||||
}
|
||||
|
||||
function getCellValue(col: TableColumn, row: Record<string, unknown>): string {
|
||||
const raw = String(row[col.field] ?? "");
|
||||
return applyNumberFormat(raw, col.numberFormat, col.currencySuffix);
|
||||
}
|
||||
|
||||
function calcSummary(col: TableColumn, rows: Record<string, unknown>[]): string {
|
||||
if (!col.summaryType || col.summaryType === "NONE") return "";
|
||||
|
||||
if (col.summaryType === "COUNT") {
|
||||
return applyNumberFormat(String(rows.length), col.numberFormat, col.currencySuffix);
|
||||
}
|
||||
|
||||
const values = rows
|
||||
.map((row) => {
|
||||
const raw = String(row[col.field] ?? "");
|
||||
return parseFloat(raw.replace(/,/g, ""));
|
||||
})
|
||||
.filter((v) => !isNaN(v));
|
||||
|
||||
if (values.length === 0) return "";
|
||||
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
const result = col.summaryType === "AVG" ? sum / values.length : sum;
|
||||
|
||||
return applyNumberFormat(
|
||||
parseFloat(result.toFixed(4)).toString(),
|
||||
col.numberFormat,
|
||||
col.currencySuffix,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 그리드 셀 값 계산 ──────────────────────────────────────────────────────
|
||||
|
||||
function getGridCellValue(
|
||||
cell: GridCell,
|
||||
row?: Record<string, unknown>,
|
||||
): string {
|
||||
if (cell.cellType === "static") return cell.value ?? "";
|
||||
|
||||
if (cell.cellType === "field" && cell.field && row) {
|
||||
const raw = String(row[cell.field] ?? "");
|
||||
return applyNumberFormat(raw, cell.numberFormat, cell.currencySuffix);
|
||||
}
|
||||
|
||||
return cell.value ?? "";
|
||||
}
|
||||
|
||||
// ─── 그리드 테이블 렌더러 ────────────────────────────────────────────────────
|
||||
|
||||
function GridTableRenderer({ component, getQueryResult }: TableRendererProps) {
|
||||
const cells = component.gridCells ?? [];
|
||||
const rowCount = component.gridRowCount ?? 0;
|
||||
const colCount = component.gridColCount ?? 0;
|
||||
const colWidths = component.gridColWidths ?? [];
|
||||
const rowHeights = component.gridRowHeights ?? [];
|
||||
const headerRows = component.gridHeaderRows ?? 1;
|
||||
const headerCols = component.gridHeaderCols ?? 1;
|
||||
|
||||
if (cells.length === 0 || rowCount === 0 || colCount === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
격자 양식을 구성하세요
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resultKey = component.visualQuery?.tableName
|
||||
? `visual_${component.id}`
|
||||
: component.queryId;
|
||||
const queryResult = resultKey ? getQueryResult(resultKey) : null;
|
||||
const dataRow = queryResult?.rows?.[0] as Record<string, unknown> | undefined;
|
||||
|
||||
const totalConfigWidth = colWidths.reduce((a, b) => a + b, 0) || 1;
|
||||
const totalConfigHeight = rowHeights.reduce((a, b) => a + b, 0) || 1;
|
||||
|
||||
const hdrBg = component.headerBackgroundColor || "#f3f4f6";
|
||||
const hdrColor = component.headerTextColor || "#111827";
|
||||
|
||||
const findCell = (row: number, col: number) =>
|
||||
cells.find((c) => c.row === row && c.col === col);
|
||||
|
||||
const tableRows: React.ReactNode[] = [];
|
||||
|
||||
for (let r = 0; r < rowCount; r++) {
|
||||
const tds: React.ReactNode[] = [];
|
||||
const rowHPct = ((rowHeights[r] ?? 32) / totalConfigHeight) * 100;
|
||||
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
const cell = findCell(r, c);
|
||||
if (!cell || cell.merged) continue;
|
||||
|
||||
const rSpan = cell.rowSpan ?? 1;
|
||||
const cSpan = cell.colSpan ?? 1;
|
||||
const borderW =
|
||||
cell.borderStyle === "medium" ? 2 : cell.borderStyle === "thick" ? 3 : cell.borderStyle === "none" ? 0 : 1;
|
||||
|
||||
const isHeader = r < headerRows || c < headerCols;
|
||||
const cellBg = cell.backgroundColor || (isHeader ? hdrBg : "white");
|
||||
const cellColor = cell.textColor || (isHeader ? hdrColor : "#111827");
|
||||
|
||||
const displayValue = getGridCellValue(cell, dataRow);
|
||||
|
||||
tds.push(
|
||||
<td
|
||||
key={cell.id}
|
||||
rowSpan={rSpan > 1 ? rSpan : undefined}
|
||||
colSpan={cSpan > 1 ? cSpan : undefined}
|
||||
style={{
|
||||
backgroundColor: cellBg,
|
||||
border: `${borderW}px solid #d1d5db`,
|
||||
padding: "2px 4px",
|
||||
fontSize: cell.fontSize ?? 12,
|
||||
fontWeight: cell.fontWeight === "bold" ? 700 : (isHeader ? 600 : 400),
|
||||
color: cellColor,
|
||||
textAlign: cell.align || "center",
|
||||
verticalAlign: cell.verticalAlign || "middle",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "pre-line",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</td>,
|
||||
);
|
||||
}
|
||||
|
||||
tableRows.push(
|
||||
<tr key={`grid-row-${r}`} style={{ height: `${rowHPct.toFixed(2)}%` }}>
|
||||
{tds}
|
||||
</tr>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<table
|
||||
className="border-collapse"
|
||||
style={{ width: "100%", height: "100%", tableLayout: "fixed" }}
|
||||
>
|
||||
<colgroup>
|
||||
{colWidths.map((w, i) => {
|
||||
const pct = (w / totalConfigWidth) * 100;
|
||||
return <col key={i} style={{ width: `${pct.toFixed(2)}%` }} />;
|
||||
})}
|
||||
</colgroup>
|
||||
<tbody>{tableRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 기존 테이블 렌더러 ─────────────────────────────────────────────────────
|
||||
|
||||
function ClassicTableRenderer({ component, getQueryResult }: TableRendererProps) {
|
||||
const resultKey = component.visualQuery?.tableName
|
||||
? `visual_${component.id}`
|
||||
: component.queryId;
|
||||
|
||||
const queryResult = resultKey ? getQueryResult(resultKey) : null;
|
||||
const hasData = queryResult && queryResult.rows.length > 0;
|
||||
const hasColumns = component.tableColumns && component.tableColumns.length > 0;
|
||||
|
||||
if (!hasData && !hasColumns) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
테이블을 구성하세요
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allColumns = hasColumns
|
||||
? component.tableColumns!
|
||||
: queryResult!.fields.map((field) => ({
|
||||
field,
|
||||
header: field,
|
||||
width: undefined as number | undefined,
|
||||
align: "left" as const,
|
||||
visible: true,
|
||||
summaryType: undefined as "SUM" | "AVG" | "COUNT" | "NONE" | undefined,
|
||||
}));
|
||||
|
||||
const visibleColumns = allColumns.filter((col) => col.visible !== false);
|
||||
const dataRows = hasData ? queryResult!.rows : [];
|
||||
const previewRowCount = component.tableRowCount ?? 3;
|
||||
|
||||
const hasSummaryRow =
|
||||
component.showFooter &&
|
||||
visibleColumns.some((col) => col.summaryType && col.summaryType !== "NONE");
|
||||
|
||||
const borderClass = component.showBorder !== false ? "border border-gray-300" : "";
|
||||
const rowH = component.rowHeight ?? 28;
|
||||
|
||||
// 열 너비: 설정된 비율을 유지하면서 컴포넌트 전체 너비에 맞게 스케일
|
||||
const totalConfigWidth = visibleColumns.reduce((s, c) => s + (c.width || 120), 0);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<table className="w-full border-collapse text-xs" style={{ tableLayout: "fixed" }}>
|
||||
<colgroup>
|
||||
{visibleColumns.map((col, idx) => {
|
||||
const ratio = (col.width || 120) / totalConfigWidth;
|
||||
return <col key={idx} style={{ width: `${(ratio * 100).toFixed(2)}%` }} />;
|
||||
})}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr
|
||||
style={{
|
||||
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
|
||||
color: component.headerTextColor || "#111827",
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map((col, idx) => (
|
||||
<th
|
||||
key={`h_${col.field || col.header}_${idx}`}
|
||||
className={borderClass}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
textAlign: col.align || "left",
|
||||
fontWeight: "600",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataRows.length > 0
|
||||
? dataRows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
{visibleColumns.map((col, colIdx) => (
|
||||
<td
|
||||
key={`r${rowIdx}_c${col.field || col.header}_${colIdx}`}
|
||||
className={borderClass}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
textAlign: col.align || "left",
|
||||
height: `${rowH}px`,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{getCellValue(col, row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: Array.from({ length: previewRowCount }).map((_, rowIdx) => (
|
||||
<tr key={`empty-${rowIdx}`}>
|
||||
{visibleColumns.map((col, colIdx) => (
|
||||
<td
|
||||
key={`e${rowIdx}_${colIdx}`}
|
||||
className={borderClass}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
textAlign: col.align || "left",
|
||||
height: `${rowH}px`,
|
||||
color: "#d1d5db",
|
||||
}}
|
||||
>
|
||||
{col.field ? `{${col.field}}` : "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
{hasSummaryRow && dataRows.length > 0 && (
|
||||
<tfoot>
|
||||
<tr
|
||||
style={{
|
||||
backgroundColor: "#f3f4f6",
|
||||
fontWeight: 600,
|
||||
borderTop: "2px solid #d1d5db",
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map((col, idx) => (
|
||||
<td
|
||||
key={`f_${col.field || col.header}_${idx}`}
|
||||
className={borderClass}
|
||||
style={{ padding: "4px 6px", textAlign: col.align || "right" }}
|
||||
>
|
||||
{calcSummary(col, dataRows)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 export ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function TableRenderer(props: TableRendererProps) {
|
||||
if (props.component.gridMode) {
|
||||
return <GridTableRenderer {...props} />;
|
||||
}
|
||||
return <ClassicTableRenderer {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { TextRendererProps } from "./types";
|
||||
|
||||
const VERTICAL_ALIGN_MAP: Record<string, string> = {
|
||||
top: "flex-start",
|
||||
middle: "center",
|
||||
bottom: "flex-end",
|
||||
};
|
||||
|
||||
interface ConditionalStyleRule {
|
||||
value: string;
|
||||
backgroundColor?: string;
|
||||
fontColor?: string;
|
||||
}
|
||||
|
||||
function resolveConditionalStyles(
|
||||
component: TextRendererProps["component"],
|
||||
displayValue: string,
|
||||
): { backgroundColor?: string; fontColor?: string } {
|
||||
const rules = component.conditionalStyles as ConditionalStyleRule[] | undefined;
|
||||
if (!rules || !Array.isArray(rules) || rules.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const normalizedValue = displayValue.trim();
|
||||
const matched = rules.find((r) => r.value === normalizedValue);
|
||||
if (!matched) return {};
|
||||
return {
|
||||
backgroundColor: matched.backgroundColor,
|
||||
fontColor: matched.fontColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function TextRenderer({ component, displayValue }: TextRendererProps) {
|
||||
const isOverflowHidden =
|
||||
component.textOverflow === "clip" || component.textOverflow === "ellipsis";
|
||||
|
||||
const conditionalOverrides = useMemo(
|
||||
() => resolveConditionalStyles(component, displayValue),
|
||||
[component, displayValue],
|
||||
);
|
||||
|
||||
const bgColor = conditionalOverrides.backgroundColor || component.backgroundColor || undefined;
|
||||
const fgColor = conditionalOverrides.fontColor || component.fontColor || "#000000";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: VERTICAL_ALIGN_MAP[component.verticalAlign || "top"] || "flex-start",
|
||||
backgroundColor: bgColor,
|
||||
border:
|
||||
(component.borderWidth ?? 0) > 0
|
||||
? `${component.borderWidth}px solid ${component.borderColor || "#d1d5db"}`
|
||||
: undefined,
|
||||
borderRadius: component.borderRadius ? `${component.borderRadius}px` : undefined,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
fontSize: `${component.fontSize || 12}px`,
|
||||
fontFamily: component.fontFamily || "Malgun Gothic",
|
||||
color: fgColor,
|
||||
fontWeight: component.fontWeight || "normal",
|
||||
fontStyle: component.fontStyle || "normal",
|
||||
textAlign: (component.textAlign as "left" | "center" | "right") || "left",
|
||||
textDecoration: component.textDecoration || "none",
|
||||
lineHeight: component.lineHeight || 1.4,
|
||||
letterSpacing: component.letterSpacing ? `${component.letterSpacing}px` : undefined,
|
||||
whiteSpace: isOverflowHidden ? "nowrap" : "pre-wrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: component.textOverflow === "ellipsis" ? "ellipsis" : undefined,
|
||||
wordBreak: !isOverflowHidden ? "break-word" : undefined,
|
||||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { TextRenderer } from "./TextRenderer";
|
||||
export { TableRenderer } from "./TableRenderer";
|
||||
export { ImageRenderer } from "./ImageRenderer";
|
||||
export { DividerRenderer } from "./DividerRenderer";
|
||||
export { SignatureRenderer, StampRenderer } from "./SignatureRenderer";
|
||||
export { PageNumberRenderer } from "./PageNumberRenderer";
|
||||
export { CardRenderer } from "./CardRenderer";
|
||||
export { CalculationRenderer } from "./CalculationRenderer";
|
||||
export { BarcodeCanvasRenderer } from "./BarcodeCanvasRenderer";
|
||||
export { CheckboxRenderer } from "./CheckboxRenderer";
|
||||
export type * from "./types";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
|
||||
export interface QueryResult {
|
||||
fields: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface BaseRendererProps {
|
||||
component: ComponentConfig;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
}
|
||||
|
||||
export interface TextRendererProps extends BaseRendererProps {
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
export interface TableRendererProps extends BaseRendererProps {}
|
||||
|
||||
export interface ImageRendererProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export interface DividerRendererProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export interface SignatureRendererProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export interface StampRendererProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export interface PageNumberRendererProps {
|
||||
component: ComponentConfig;
|
||||
currentPageId: string | null;
|
||||
layoutConfig: { pages: Array<{ page_id: string; page_order: number }> };
|
||||
}
|
||||
|
||||
export interface CardRendererProps extends BaseRendererProps {}
|
||||
|
||||
export interface CalculationRendererProps extends BaseRendererProps {}
|
||||
|
||||
export interface BarcodeRendererProps extends BaseRendererProps {}
|
||||
|
||||
export interface CheckboxRendererProps extends BaseRendererProps {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @module internalTypes
|
||||
* @description report-designer 훅들 내부에서만 사용하는 공유 타입.
|
||||
* 외부에 노출하지 않으며, 훅 간 인터페이스 통일에 사용한다.
|
||||
*/
|
||||
|
||||
import type { useToast } from "@/hooks/use-toast";
|
||||
|
||||
/** useToast()가 반환하는 toast 함수의 타입 */
|
||||
export type ToastFunction = ReturnType<typeof useToast>["toast"];
|
||||
|
||||
/** 현재 페이지 컴포넌트 배열 업데이트 함수 타입 */
|
||||
export type SetComponentsFn = (
|
||||
updater: import("@/types/report").ComponentConfig[] | ((prev: import("@/types/report").ComponentConfig[]) => import("@/types/report").ComponentConfig[]),
|
||||
) => void;
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @module types
|
||||
* @description ReportDesigner 전체에서 공유되는 도메인 타입 정의.
|
||||
* Context, Hook, 컴포넌트 전반에서 일관된 타입을 사용하기 위해 이 파일에 집중한다.
|
||||
*/
|
||||
|
||||
import type { VisualDataSource } from "@/types/report";
|
||||
|
||||
/** 리포트 디자이너에서 관리하는 SQL 쿼리 단위 */
|
||||
export interface ReportQuery {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
/** 외부 DB 연결 ID (null이면 내부 DB 사용) */
|
||||
externalConnectionId?: number | null;
|
||||
/** 비주얼 데이터 소스 빌더 UI 설정 원본 */
|
||||
visualDataSource?: VisualDataSource;
|
||||
}
|
||||
|
||||
/** 쿼리 실행 결과 캐시 단위 */
|
||||
export interface QueryResult {
|
||||
queryId: string;
|
||||
fields: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @module useAlignmentActions
|
||||
* @description 선택된 컴포넌트들의 위치/크기를 일괄 조정하는 정렬 기능을 제공한다.
|
||||
* - 엣지 정렬: 좌/우/상/하 기준으로 정렬
|
||||
* - 중앙 정렬: 가로/세로 중앙으로 정렬
|
||||
* - 균등 배치: 컴포넌트 간 간격을 동일하게 분배 (최소 3개 필요)
|
||||
* - 크기 동일화: 첫 번째 선택 컴포넌트 기준으로 너비/높이/둘 다 통일
|
||||
*
|
||||
* 모든 함수는 2개 이상(균등 배치는 3개 이상) 선택된 경우에만 동작한다.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import type { SetComponentsFn, ToastFunction } from "./internalTypes";
|
||||
|
||||
export interface AlignmentActions {
|
||||
alignLeft: () => void;
|
||||
alignRight: () => void;
|
||||
alignTop: () => void;
|
||||
alignBottom: () => void;
|
||||
alignCenterHorizontal: () => void;
|
||||
alignCenterVertical: () => void;
|
||||
distributeHorizontal: () => void;
|
||||
distributeVertical: () => void;
|
||||
makeSameWidth: () => void;
|
||||
makeSameHeight: () => void;
|
||||
makeSameSize: () => void;
|
||||
}
|
||||
|
||||
interface AlignmentDeps {
|
||||
components: ComponentConfig[];
|
||||
selectedComponentIds: string[];
|
||||
setComponents: SetComponentsFn;
|
||||
toast: ToastFunction;
|
||||
}
|
||||
|
||||
/** 선택된 컴포넌트를 업데이트 맵에서 찾아 교체하는 공통 헬퍼 */
|
||||
function applyUpdates(
|
||||
prev: ComponentConfig[],
|
||||
updates: ComponentConfig[],
|
||||
): ComponentConfig[] {
|
||||
const updateMap = new Map(updates.map((u) => [u.id, u]));
|
||||
return prev.map((c) => updateMap.get(c.id) ?? c);
|
||||
}
|
||||
|
||||
export function useAlignmentActions({
|
||||
components,
|
||||
selectedComponentIds,
|
||||
setComponents,
|
||||
toast,
|
||||
}: AlignmentDeps): AlignmentActions {
|
||||
/** 선택된 컴포넌트 배열 반환 (정렬 함수 내부 공통 사용) */
|
||||
const getSelected = useCallback(
|
||||
() => components.filter((c) => selectedComponentIds.includes(c.id)),
|
||||
[components, selectedComponentIds],
|
||||
);
|
||||
|
||||
/** 모든 선택 컴포넌트의 x를 가장 왼쪽 x 기준으로 정렬 */
|
||||
const alignLeft = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 2) return;
|
||||
const minX = Math.min(...selected.map((c) => c.x));
|
||||
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, x: minX }))));
|
||||
toast({ title: "정렬 완료", description: "왼쪽 정렬되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 모든 선택 컴포넌트의 오른쪽 엣지를 가장 오른쪽 엣지 기준으로 정렬 */
|
||||
const alignRight = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 2) return;
|
||||
const maxRight = Math.max(...selected.map((c) => c.x + c.width));
|
||||
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, x: maxRight - c.width }))));
|
||||
toast({ title: "정렬 완료", description: "오른쪽 정렬되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 모든 선택 컴포넌트의 y를 가장 위쪽 y 기준으로 정렬 */
|
||||
const alignTop = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 2) return;
|
||||
const minY = Math.min(...selected.map((c) => c.y));
|
||||
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, y: minY }))));
|
||||
toast({ title: "정렬 완료", description: "위쪽 정렬되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 모든 선택 컴포넌트의 아래쪽 엣지를 가장 아래쪽 엣지 기준으로 정렬 */
|
||||
const alignBottom = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 2) return;
|
||||
const maxBottom = Math.max(...selected.map((c) => c.y + c.height));
|
||||
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, y: maxBottom - c.height }))));
|
||||
toast({ title: "정렬 완료", description: "아래쪽 정렬되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 선택 컴포넌트들의 수평 중앙을 전체 범위의 중앙으로 정렬 */
|
||||
const alignCenterHorizontal = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 2) return;
|
||||
const minX = Math.min(...selected.map((c) => c.x));
|
||||
const maxRight = Math.max(...selected.map((c) => c.x + c.width));
|
||||
const centerX = (minX + maxRight) / 2;
|
||||
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, x: centerX - c.width / 2 }))));
|
||||
toast({ title: "정렬 완료", description: "가로 중앙 정렬되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 선택 컴포넌트들의 수직 중앙을 전체 범위의 중앙으로 정렬 */
|
||||
const alignCenterVertical = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 2) return;
|
||||
const minY = Math.min(...selected.map((c) => c.y));
|
||||
const maxBottom = Math.max(...selected.map((c) => c.y + c.height));
|
||||
const centerY = (minY + maxBottom) / 2;
|
||||
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, y: centerY - c.height / 2 }))));
|
||||
toast({ title: "정렬 완료", description: "세로 중앙 정렬되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 3개 이상 선택된 컴포넌트를 가로 방향으로 균등하게 배치 */
|
||||
const distributeHorizontal = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 3) return;
|
||||
const sorted = [...selected].sort((a, b) => a.x - b.x);
|
||||
const totalWidth = sorted.reduce((sum, c) => sum + c.width, 0);
|
||||
const span = sorted[sorted.length - 1].x + sorted[sorted.length - 1].width - sorted[0].x;
|
||||
const gap = (span - totalWidth) / (sorted.length - 1);
|
||||
let currentX = sorted[0].x;
|
||||
const updates = sorted.map((c) => {
|
||||
const updated = { ...c, x: currentX };
|
||||
currentX += c.width + gap;
|
||||
return updated;
|
||||
});
|
||||
setComponents((prev) => applyUpdates(prev, updates));
|
||||
toast({ title: "정렬 완료", description: "가로 균등 배치되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 3개 이상 선택된 컴포넌트를 세로 방향으로 균등하게 배치 */
|
||||
const distributeVertical = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 3) return;
|
||||
const sorted = [...selected].sort((a, b) => a.y - b.y);
|
||||
const totalHeight = sorted.reduce((sum, c) => sum + c.height, 0);
|
||||
const span = sorted[sorted.length - 1].y + sorted[sorted.length - 1].height - sorted[0].y;
|
||||
const gap = (span - totalHeight) / (sorted.length - 1);
|
||||
let currentY = sorted[0].y;
|
||||
const updates = sorted.map((c) => {
|
||||
const updated = { ...c, y: currentY };
|
||||
currentY += c.height + gap;
|
||||
return updated;
|
||||
});
|
||||
setComponents((prev) => applyUpdates(prev, updates));
|
||||
toast({ title: "정렬 완료", description: "세로 균등 배치되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 선택된 모든 컴포넌트의 너비를 첫 번째 컴포넌트 너비로 통일 */
|
||||
const makeSameWidth = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 2) return;
|
||||
const targetWidth = selected[0].width;
|
||||
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, width: targetWidth }))));
|
||||
toast({ title: "크기 조정 완료", description: "같은 너비로 조정되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 선택된 모든 컴포넌트의 높이를 첫 번째 컴포넌트 높이로 통일 */
|
||||
const makeSameHeight = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 2) return;
|
||||
const targetHeight = selected[0].height;
|
||||
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, height: targetHeight }))));
|
||||
toast({ title: "크기 조정 완료", description: "같은 높이로 조정되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
/** 선택된 모든 컴포넌트의 너비/높이를 첫 번째 컴포넌트 크기로 통일 */
|
||||
const makeSameSize = useCallback(() => {
|
||||
const selected = getSelected();
|
||||
if (selected.length < 2) return;
|
||||
const { width: targetWidth, height: targetHeight } = selected[0];
|
||||
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, width: targetWidth, height: targetHeight }))));
|
||||
toast({ title: "크기 조정 완료", description: "같은 크기로 조정되었습니다." });
|
||||
}, [getSelected, setComponents, toast]);
|
||||
|
||||
return {
|
||||
alignLeft,
|
||||
alignRight,
|
||||
alignTop,
|
||||
alignBottom,
|
||||
alignCenterHorizontal,
|
||||
alignCenterVertical,
|
||||
distributeHorizontal,
|
||||
distributeVertical,
|
||||
makeSameWidth,
|
||||
makeSameHeight,
|
||||
makeSameSize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @module useClipboardActions
|
||||
* @description 컴포넌트 복사/붙여넣기/복제와 스타일 전달 기능을 제공한다.
|
||||
*
|
||||
* - copyComponents / pasteComponents: 전체 컴포넌트 복사·붙여넣기 (Ctrl+C/V)
|
||||
* - duplicateComponents: 선택 컴포넌트 즉시 복제 + 20px 오프셋 (Ctrl+D)
|
||||
* - copyStyles / pasteStyles: 스타일 속성만 복사·붙여넣기 (Ctrl+Shift+C/V)
|
||||
* - duplicateAtPosition: Alt+드래그 시 지정 위치에 복제
|
||||
* - fitSelectedToContent: 텍스트 컴포넌트 크기를 내용에 맞게 자동 조정 (Ctrl+Shift+F)
|
||||
*
|
||||
* 잠긴 컴포넌트는 모든 복사/복제 대상에서 제외된다.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type { ComponentConfig, ReportPage, ReportLayoutConfig } from "@/types/report";
|
||||
import type { SetComponentsFn, ToastFunction } from "./internalTypes";
|
||||
import { MM_TO_PX, generateComponentId } from "@/lib/report/constants";
|
||||
|
||||
/** fitSelectedToContent에서 텍스트 너비 측정 시 추가하는 수평 패딩 (px) */
|
||||
const HORIZONTAL_PADDING_PX = 24;
|
||||
/** fitSelectedToContent에서 높이 계산 시 추가하는 수직 패딩 (px) */
|
||||
const VERTICAL_PADDING_PX = 20;
|
||||
|
||||
/** 스타일 복사/붙여넣기 대상 속성 목록 (ComponentConfig에 실제로 존재하는 스타일 필드만 포함) */
|
||||
const STYLE_PROPERTY_KEYS: (keyof ComponentConfig)[] = [
|
||||
"fontSize",
|
||||
"fontColor",
|
||||
"fontWeight",
|
||||
"fontFamily",
|
||||
"textAlign",
|
||||
"backgroundColor",
|
||||
"borderWidth",
|
||||
"borderColor",
|
||||
"borderRadius",
|
||||
"padding",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
];
|
||||
|
||||
|
||||
export interface ClipboardActions {
|
||||
copyComponents: () => void;
|
||||
pasteComponents: () => void;
|
||||
duplicateComponents: () => void;
|
||||
copyStyles: () => void;
|
||||
pasteStyles: () => void;
|
||||
duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[];
|
||||
fitSelectedToContent: () => void;
|
||||
}
|
||||
|
||||
interface ClipboardDeps {
|
||||
components: ComponentConfig[];
|
||||
selectedComponentId: string | null;
|
||||
selectedComponentIds: string[];
|
||||
setComponents: SetComponentsFn;
|
||||
/** fitSelectedToContent에 필요한 현재 페이지 정보 */
|
||||
currentPage: ReportPage | undefined;
|
||||
currentPageId: string | null;
|
||||
setLayoutConfig: React.Dispatch<React.SetStateAction<ReportLayoutConfig>>;
|
||||
snapToGrid: boolean;
|
||||
gridSize: number;
|
||||
toast: ToastFunction;
|
||||
}
|
||||
|
||||
export function useClipboardActions({
|
||||
components,
|
||||
selectedComponentId,
|
||||
selectedComponentIds,
|
||||
setComponents,
|
||||
currentPage,
|
||||
currentPageId,
|
||||
setLayoutConfig,
|
||||
snapToGrid,
|
||||
gridSize,
|
||||
toast,
|
||||
}: ClipboardDeps): ClipboardActions {
|
||||
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
|
||||
const [styleClipboard, setStyleClipboard] = useState<Partial<ComponentConfig> | null>(null);
|
||||
|
||||
/**
|
||||
* 선택된 컴포넌트를 클립보드에 저장한다.
|
||||
* 잠긴 컴포넌트는 복사에서 제외된다.
|
||||
*/
|
||||
const copyComponents = useCallback(() => {
|
||||
const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : [];
|
||||
const toCopy = components.filter((c) => targetIds.includes(c.id) && !c.locked);
|
||||
|
||||
if (toCopy.length === 0) {
|
||||
toast({ title: "복사 불가", description: "잠긴 컴포넌트는 복사할 수 없습니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setClipboard(toCopy);
|
||||
toast({ title: "복사 완료", description: `${toCopy.length}개의 컴포넌트가 복사되었습니다.` });
|
||||
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||
|
||||
/**
|
||||
* 클립보드의 컴포넌트를 20px 오프셋으로 붙여넣는다.
|
||||
* 붙여넣기된 컴포넌트는 자동으로 선택 상태가 된다.
|
||||
*/
|
||||
const pasteComponents = useCallback(() => {
|
||||
if (clipboard.length === 0) return;
|
||||
|
||||
const newComponents = clipboard.map((comp) => ({
|
||||
...comp,
|
||||
id: generateComponentId(),
|
||||
x: comp.x + 20,
|
||||
y: comp.y + 20,
|
||||
zIndex: components.length,
|
||||
}));
|
||||
|
||||
setComponents((prev) => [...prev, ...newComponents]);
|
||||
toast({ title: "붙여넣기 완료", description: `${newComponents.length}개의 컴포넌트가 추가되었습니다.` });
|
||||
}, [clipboard, components.length, setComponents, toast]);
|
||||
|
||||
/**
|
||||
* 선택된 컴포넌트를 즉시 복제하고 20px 오프셋으로 배치한다.
|
||||
* 복제된 컴포넌트는 잠금 해제 상태로 생성된다.
|
||||
*/
|
||||
const duplicateComponents = useCallback(() => {
|
||||
const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : [];
|
||||
const toDuplicate = components.filter((c) => targetIds.includes(c.id) && !c.locked);
|
||||
|
||||
if (toDuplicate.length === 0) {
|
||||
toast({ title: "복제 불가", description: "복제할 컴포넌트가 없거나 잠긴 컴포넌트입니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const newComponents = toDuplicate.map((comp) => ({
|
||||
...comp,
|
||||
id: generateComponentId(),
|
||||
x: comp.x + 20,
|
||||
y: comp.y + 20,
|
||||
zIndex: components.length,
|
||||
locked: false,
|
||||
}));
|
||||
|
||||
setComponents((prev) => [...prev, ...newComponents]);
|
||||
toast({ title: "복제 완료", description: `${newComponents.length}개의 컴포넌트가 복제되었습니다.` });
|
||||
}, [selectedComponentId, selectedComponentIds, components, setComponents, toast]);
|
||||
|
||||
/**
|
||||
* 선택된 컴포넌트의 스타일 속성만 스타일 클립보드에 저장한다.
|
||||
* 단일 컴포넌트 기준으로 복사하며, 위치/크기/타입 등 레이아웃 속성은 제외된다.
|
||||
*/
|
||||
const copyStyles = useCallback(() => {
|
||||
const targetId = selectedComponentId ?? selectedComponentIds[0];
|
||||
if (!targetId) {
|
||||
toast({ title: "스타일 복사 불가", description: "컴포넌트를 선택해주세요.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const component = components.find((c) => c.id === targetId);
|
||||
if (!component) return;
|
||||
|
||||
const styleProps: Partial<ComponentConfig> = {};
|
||||
STYLE_PROPERTY_KEYS.forEach((key) => {
|
||||
const value = component[key];
|
||||
if (value !== undefined) {
|
||||
(styleProps as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
setStyleClipboard(styleProps);
|
||||
toast({ title: "스타일 복사 완료", description: "스타일이 복사되었습니다. Ctrl+Shift+V로 적용할 수 있습니다." });
|
||||
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||
|
||||
/**
|
||||
* 스타일 클립보드의 스타일을 선택된 컴포넌트에 적용한다.
|
||||
* 잠긴 컴포넌트는 제외된다.
|
||||
*/
|
||||
const pasteStyles = useCallback(() => {
|
||||
if (!styleClipboard) {
|
||||
toast({ title: "스타일 붙여넣기 불가", description: "먼저 Ctrl+Shift+C로 스타일을 복사해주세요.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : [];
|
||||
const applicableIds = targetIds.filter((id) => {
|
||||
const comp = components.find((c) => c.id === id);
|
||||
return comp && !comp.locked;
|
||||
});
|
||||
|
||||
if (applicableIds.length === 0) {
|
||||
toast({ title: "스타일 붙여넣기 불가", description: "스타일을 적용할 컴포넌트를 선택해주세요.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setComponents((prev) =>
|
||||
prev.map((c) => (applicableIds.includes(c.id) ? { ...c, ...styleClipboard } : c)),
|
||||
);
|
||||
toast({ title: "스타일 적용 완료", description: `${applicableIds.length}개의 컴포넌트에 스타일이 적용되었습니다.` });
|
||||
}, [styleClipboard, selectedComponentId, selectedComponentIds, components, setComponents, toast]);
|
||||
|
||||
/**
|
||||
* Alt+드래그 복제용. 지정된 오프셋 위치에 컴포넌트를 복제하고 새 ID 배열을 반환한다.
|
||||
* 잠긴 컴포넌트는 복제되지 않는다.
|
||||
*/
|
||||
const duplicateAtPosition = useCallback(
|
||||
(componentIds: string[], offsetX = 0, offsetY = 0): string[] => {
|
||||
const toDuplicate = components.filter((c) => componentIds.includes(c.id) && !c.locked);
|
||||
if (toDuplicate.length === 0) return [];
|
||||
|
||||
const newComponents = toDuplicate.map((comp) => ({
|
||||
...comp,
|
||||
id: generateComponentId(),
|
||||
x: comp.x + offsetX,
|
||||
y: comp.y + offsetY,
|
||||
zIndex: components.length,
|
||||
locked: false,
|
||||
}));
|
||||
|
||||
setComponents((prev) => [...prev, ...newComponents]);
|
||||
return newComponents.map((c) => c.id);
|
||||
},
|
||||
[components, setComponents],
|
||||
);
|
||||
|
||||
/**
|
||||
* 선택된 텍스트/레이블 컴포넌트의 크기를 내용 텍스트에 맞게 자동 조정한다.
|
||||
* DOM에 임시 요소를 생성하여 실제 렌더링 너비를 측정한 뒤, 적절한 크기로 업데이트한다.
|
||||
* snapToGrid가 활성화된 경우 최종 크기도 그리드에 맞춰 반올림된다.
|
||||
*/
|
||||
const fitSelectedToContent = useCallback(() => {
|
||||
if (!currentPage || !currentPageId) return;
|
||||
|
||||
const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : [];
|
||||
const textComponents = components.filter(
|
||||
(c) => targetIds.includes(c.id) && (c.type === "text" || c.type === "label") && !c.locked,
|
||||
);
|
||||
|
||||
if (textComponents.length === 0) {
|
||||
toast({ title: "크기 조정 불가", description: "선택된 텍스트 컴포넌트가 없습니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasWidthPx = currentPage.width * MM_TO_PX;
|
||||
const canvasHeightPx = currentPage.height * MM_TO_PX;
|
||||
const marginRightPx = (currentPage.margins?.right ?? 10) * MM_TO_PX;
|
||||
const marginBottomPx = (currentPage.margins?.bottom ?? 10) * MM_TO_PX;
|
||||
|
||||
textComponents.forEach((comp) => {
|
||||
const displayValue = comp.defaultValue || (comp.type === "text" ? "텍스트 입력" : "레이블 텍스트");
|
||||
const fontSize = comp.fontSize || 14;
|
||||
const maxWidth = canvasWidthPx - marginRightPx - comp.x;
|
||||
const maxHeight = canvasHeightPx - marginBottomPx - comp.y;
|
||||
|
||||
// DOM에 임시 요소를 추가하여 각 줄의 실제 렌더링 너비 측정
|
||||
const lines = displayValue.split("\n");
|
||||
let maxLineWidth = 0;
|
||||
lines.forEach((line: string) => {
|
||||
const el = document.createElement("span");
|
||||
el.style.cssText = `position:absolute;visibility:hidden;white-space:nowrap;font-size:${fontSize}px;font-weight:${comp.fontWeight || "normal"};font-family:system-ui,-apple-system,sans-serif`;
|
||||
el.textContent = line || " ";
|
||||
document.body.appendChild(el);
|
||||
maxLineWidth = Math.max(maxLineWidth, el.getBoundingClientRect().width);
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
|
||||
const lineHeight = fontSize * 1.5;
|
||||
const rawWidth = Math.min(maxLineWidth + HORIZONTAL_PADDING_PX, maxWidth);
|
||||
const rawHeight = Math.min(lines.length * lineHeight + VERTICAL_PADDING_PX, maxHeight);
|
||||
const finalWidth = Math.max(50, snapToGrid ? Math.round(rawWidth / gridSize) * gridSize : rawWidth);
|
||||
const finalHeight = Math.max(30, snapToGrid ? Math.round(rawHeight / gridSize) * gridSize : rawHeight);
|
||||
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: prev.pages.map((p) =>
|
||||
p.page_id === currentPageId
|
||||
? {
|
||||
...p,
|
||||
components: p.components.map((c) =>
|
||||
c.id === comp.id ? { ...c, width: finalWidth, height: finalHeight } : c,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
toast({ title: "크기 조정 완료", description: `${textComponents.length}개의 컴포넌트 크기가 조정되었습니다.` });
|
||||
}, [
|
||||
selectedComponentId,
|
||||
selectedComponentIds,
|
||||
components,
|
||||
currentPage,
|
||||
currentPageId,
|
||||
setLayoutConfig,
|
||||
snapToGrid,
|
||||
gridSize,
|
||||
toast,
|
||||
]);
|
||||
|
||||
return {
|
||||
copyComponents,
|
||||
pasteComponents,
|
||||
duplicateComponents,
|
||||
copyStyles,
|
||||
pasteStyles,
|
||||
duplicateAtPosition,
|
||||
fitSelectedToContent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @module useHistoryManager
|
||||
* @description 컴포넌트 변경 이력을 관리하여 Undo/Redo 기능을 제공한다.
|
||||
* - 최대 50개의 히스토리 스냅샷 유지 (메모리 제한)
|
||||
* - 300ms 디바운스로 연속 변경을 하나의 히스토리 항목으로 묶음
|
||||
* - Undo/Redo 실행 중에는 히스토리 저장을 건너뜀 (isUndoRedoing 플래그)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import type { SetComponentsFn, ToastFunction } from "./internalTypes";
|
||||
|
||||
/** 히스토리에 유지할 최대 스냅샷 수 */
|
||||
const MAX_HISTORY_SIZE = 50;
|
||||
/** 컴포넌트 변경 후 히스토리 저장까지 대기 시간 (ms) */
|
||||
const HISTORY_DEBOUNCE_MS = 300;
|
||||
|
||||
export interface HistoryManagerResult {
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
/** 외부에서 히스토리 초기화가 필요할 때 사용 (레이아웃 최초 로드 시) */
|
||||
initHistory: (components: ComponentConfig[]) => void;
|
||||
isUndoRedoing: boolean;
|
||||
}
|
||||
|
||||
export function useHistoryManager(
|
||||
components: ComponentConfig[],
|
||||
setComponents: SetComponentsFn,
|
||||
isLoading: boolean,
|
||||
toast: ToastFunction,
|
||||
): HistoryManagerResult {
|
||||
const [history, setHistory] = useState<ComponentConfig[][]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [isUndoRedoing, setIsUndoRedoing] = useState(false);
|
||||
|
||||
/**
|
||||
* 현재 컴포넌트 배열을 히스토리에 저장한다.
|
||||
* 현재 인덱스 이후의 미래 이력은 제거하여 새 분기를 시작한다.
|
||||
*/
|
||||
const saveToHistory = useCallback(
|
||||
(newComponents: ComponentConfig[]) => {
|
||||
setHistory((prev) => {
|
||||
const truncated = prev.slice(0, historyIndex + 1);
|
||||
truncated.push(JSON.parse(JSON.stringify(newComponents)));
|
||||
return truncated.slice(-MAX_HISTORY_SIZE);
|
||||
});
|
||||
setHistoryIndex((prev) => Math.min(prev + 1, MAX_HISTORY_SIZE - 1));
|
||||
},
|
||||
[historyIndex],
|
||||
);
|
||||
|
||||
/** 최초 레이아웃 로드 후 히스토리를 초기 상태로 설정한다. */
|
||||
const initHistory = useCallback((initialComponents: ComponentConfig[]) => {
|
||||
setHistory([JSON.parse(JSON.stringify(initialComponents))]);
|
||||
setHistoryIndex(0);
|
||||
}, []);
|
||||
|
||||
/** 컴포넌트 변경 감지 → 300ms 디바운스 후 히스토리에 저장 */
|
||||
useEffect(() => {
|
||||
if (components.length === 0 || isLoading || isUndoRedoing) return;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
setHistory((prev) => {
|
||||
const lastSnapshot = prev[historyIndex];
|
||||
const isDifferent = !lastSnapshot || JSON.stringify(lastSnapshot) !== JSON.stringify(components);
|
||||
if (isDifferent) {
|
||||
saveToHistory(components);
|
||||
}
|
||||
return prev; // saveToHistory가 내부에서 setState를 호출함
|
||||
});
|
||||
}, HISTORY_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [components, isUndoRedoing, isLoading]);
|
||||
|
||||
/** 이전 상태로 되돌린다. */
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex <= 0) return;
|
||||
|
||||
setIsUndoRedoing(true);
|
||||
const prevIndex = historyIndex - 1;
|
||||
setHistoryIndex(prevIndex);
|
||||
setComponents(JSON.parse(JSON.stringify(history[prevIndex])));
|
||||
setTimeout(() => setIsUndoRedoing(false), 100);
|
||||
|
||||
toast({ title: "실행 취소", description: "이전 상태로 되돌렸습니다." });
|
||||
}, [historyIndex, history, setComponents, toast]);
|
||||
|
||||
/** 취소한 작업을 다시 실행한다. */
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndex >= history.length - 1) return;
|
||||
|
||||
setIsUndoRedoing(true);
|
||||
const nextIndex = historyIndex + 1;
|
||||
setHistoryIndex(nextIndex);
|
||||
setComponents(JSON.parse(JSON.stringify(history[nextIndex])));
|
||||
setTimeout(() => setIsUndoRedoing(false), 100);
|
||||
|
||||
toast({ title: "다시 실행", description: "다음 상태로 이동했습니다." });
|
||||
}, [historyIndex, history, setComponents, toast]);
|
||||
|
||||
return {
|
||||
undo,
|
||||
redo,
|
||||
canUndo: historyIndex > 0,
|
||||
canRedo: historyIndex < history.length - 1,
|
||||
initHistory,
|
||||
isUndoRedoing,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @module useLayerActions
|
||||
* @description 캔버스에서 컴포넌트의 레이어 순서, 잠금, 그룹화를 관리한다.
|
||||
*
|
||||
* - 레이어 순서: bringToFront / sendToBack / bringForward / sendBackward
|
||||
* zIndex를 직접 조작하며, 앞/뒤 한 단계 이동 시 전체 컴포넌트를 재정렬하여
|
||||
* zIndex가 1부터 연속되도록 유지한다.
|
||||
*
|
||||
* - 잠금 관리: toggleLock / lockComponents / unlockComponents
|
||||
* 잠긴 컴포넌트는 이동/편집/복사에서 제외된다.
|
||||
*
|
||||
* - 그룹화: groupComponents / ungroupComponents
|
||||
* groupId 필드로 그룹을 식별하며, 그룹 해제 시 해당 groupId를 가진
|
||||
* 모든 컴포넌트에서 groupId를 제거한다.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import type { SetComponentsFn, ToastFunction } from "./internalTypes";
|
||||
|
||||
export interface LayerActions {
|
||||
bringToFront: () => void;
|
||||
sendToBack: () => void;
|
||||
bringForward: () => void;
|
||||
sendBackward: () => void;
|
||||
toggleLock: () => void;
|
||||
lockComponents: () => void;
|
||||
unlockComponents: () => void;
|
||||
groupComponents: () => void;
|
||||
ungroupComponents: () => void;
|
||||
}
|
||||
|
||||
interface LayerDeps {
|
||||
components: ComponentConfig[];
|
||||
selectedComponentId: string | null;
|
||||
selectedComponentIds: string[];
|
||||
setComponents: SetComponentsFn;
|
||||
toast: ToastFunction;
|
||||
}
|
||||
|
||||
export function useLayerActions({
|
||||
components,
|
||||
selectedComponentId,
|
||||
selectedComponentIds,
|
||||
setComponents,
|
||||
toast,
|
||||
}: LayerDeps): LayerActions {
|
||||
/**
|
||||
* 단일/다중 선택 모두를 지원하는 대상 ID 배열 반환.
|
||||
* selectedComponentIds가 비어 있으면 selectedComponentId를 사용한다.
|
||||
*/
|
||||
const getTargetIds = useCallback((): string[] => {
|
||||
if (selectedComponentIds.length > 0) return selectedComponentIds;
|
||||
return selectedComponentId ? [selectedComponentId] : [];
|
||||
}, [selectedComponentId, selectedComponentIds]);
|
||||
|
||||
/** 선택된 컴포넌트를 모든 컴포넌트 중 가장 앞으로 이동 */
|
||||
const bringToFront = useCallback(() => {
|
||||
const ids = getTargetIds();
|
||||
if (ids.length === 0) return;
|
||||
const maxZIndex = Math.max(...components.map((c) => c.zIndex));
|
||||
setComponents((prev) =>
|
||||
prev.map((c) => (ids.includes(c.id) ? { ...c, zIndex: maxZIndex + 1 } : c)),
|
||||
);
|
||||
toast({ title: "레이어 변경", description: "맨 앞으로 이동했습니다." });
|
||||
}, [getTargetIds, components, setComponents, toast]);
|
||||
|
||||
/** 선택된 컴포넌트를 모든 컴포넌트 중 가장 뒤로 이동 (최소 zIndex = 1) */
|
||||
const sendToBack = useCallback(() => {
|
||||
const ids = getTargetIds();
|
||||
if (ids.length === 0) return;
|
||||
const minZIndex = Math.min(...components.map((c) => c.zIndex));
|
||||
setComponents((prev) =>
|
||||
prev.map((c) => (ids.includes(c.id) ? { ...c, zIndex: Math.max(1, minZIndex - 1) } : c)),
|
||||
);
|
||||
toast({ title: "레이어 변경", description: "맨 뒤로 이동했습니다." });
|
||||
}, [getTargetIds, components, setComponents, toast]);
|
||||
|
||||
/** 선택된 컴포넌트를 한 단계 앞으로 이동. 전체 zIndex를 재정렬하여 연속성 유지. */
|
||||
const bringForward = useCallback(() => {
|
||||
const ids = getTargetIds();
|
||||
if (ids.length === 0) return;
|
||||
setComponents((prev) => {
|
||||
const sorted = [...prev].sort((a, b) => a.zIndex - b.zIndex);
|
||||
const reindexed = sorted.map((c, i) => ({ ...c, zIndex: i }));
|
||||
return reindexed.map((c) =>
|
||||
ids.includes(c.id) ? { ...c, zIndex: Math.min(c.zIndex + 1, reindexed.length - 1) } : c,
|
||||
);
|
||||
});
|
||||
toast({ title: "레이어 변경", description: "한 단계 앞으로 이동했습니다." });
|
||||
}, [getTargetIds, setComponents, toast]);
|
||||
|
||||
/** 선택된 컴포넌트를 한 단계 뒤로 이동. 전체 zIndex를 재정렬하여 연속성 유지. */
|
||||
const sendBackward = useCallback(() => {
|
||||
const ids = getTargetIds();
|
||||
if (ids.length === 0) return;
|
||||
setComponents((prev) => {
|
||||
const sorted = [...prev].sort((a, b) => a.zIndex - b.zIndex);
|
||||
const reindexed = sorted.map((c, i) => ({ ...c, zIndex: i + 1 }));
|
||||
return reindexed.map((c) =>
|
||||
ids.includes(c.id) ? { ...c, zIndex: Math.max(c.zIndex - 1, 1) } : c,
|
||||
);
|
||||
});
|
||||
toast({ title: "레이어 변경", description: "한 단계 뒤로 이동했습니다." });
|
||||
}, [getTargetIds, setComponents, toast]);
|
||||
|
||||
/** 선택된 컴포넌트의 잠금 상태를 토글한다. */
|
||||
const toggleLock = useCallback(() => {
|
||||
const ids = getTargetIds();
|
||||
if (ids.length === 0) return;
|
||||
const isCurrentlyLocked = components.find((c) => ids.includes(c.id))?.locked === true;
|
||||
setComponents((prev) =>
|
||||
prev.map((c) => (ids.includes(c.id) ? { ...c, locked: !c.locked } : c)),
|
||||
);
|
||||
toast({
|
||||
title: isCurrentlyLocked ? "잠금 해제" : "잠금 설정",
|
||||
description: isCurrentlyLocked
|
||||
? "선택된 컴포넌트의 잠금이 해제되었습니다."
|
||||
: "선택된 컴포넌트가 잠겼습니다.",
|
||||
});
|
||||
}, [getTargetIds, components, setComponents, toast]);
|
||||
|
||||
/** 선택된 컴포넌트를 잠근다. */
|
||||
const lockComponents = useCallback(() => {
|
||||
const ids = getTargetIds();
|
||||
if (ids.length === 0) return;
|
||||
setComponents((prev) => prev.map((c) => (ids.includes(c.id) ? { ...c, locked: true } : c)));
|
||||
toast({ title: "잠금 설정", description: "선택된 컴포넌트가 잠겼습니다." });
|
||||
}, [getTargetIds, setComponents, toast]);
|
||||
|
||||
/** 선택된 컴포넌트의 잠금을 해제한다. */
|
||||
const unlockComponents = useCallback(() => {
|
||||
const ids = getTargetIds();
|
||||
if (ids.length === 0) return;
|
||||
setComponents((prev) => prev.map((c) => (ids.includes(c.id) ? { ...c, locked: false } : c)));
|
||||
toast({ title: "잠금 해제", description: "선택된 컴포넌트의 잠금이 해제되었습니다." });
|
||||
}, [getTargetIds, setComponents, toast]);
|
||||
|
||||
/** 2개 이상 선택된 컴포넌트에 동일한 groupId를 부여하여 그룹으로 묶는다. */
|
||||
const groupComponents = useCallback(() => {
|
||||
if (selectedComponentIds.length < 2) {
|
||||
toast({ title: "그룹화 불가", description: "2개 이상의 컴포넌트를 선택해야 합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const newGroupId = `group_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
setComponents((prev) =>
|
||||
prev.map((c) => (selectedComponentIds.includes(c.id) ? { ...c, groupId: newGroupId } : c)),
|
||||
);
|
||||
toast({ title: "그룹화 완료", description: `${selectedComponentIds.length}개의 컴포넌트가 그룹화되었습니다.` });
|
||||
}, [selectedComponentIds, setComponents, toast]);
|
||||
|
||||
/**
|
||||
* 선택된 컴포넌트가 속한 그룹 전체를 해제한다.
|
||||
* 같은 groupId를 가진 컴포넌트 모두에서 groupId가 제거된다.
|
||||
*/
|
||||
const ungroupComponents = useCallback(() => {
|
||||
const ids = getTargetIds();
|
||||
if (ids.length === 0) {
|
||||
toast({ title: "그룹 해제 불가", description: "그룹을 해제할 컴포넌트를 선택해주세요.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const groupIds = new Set<string>();
|
||||
components.forEach((c) => {
|
||||
if (ids.includes(c.id) && c.groupId) groupIds.add(c.groupId);
|
||||
});
|
||||
|
||||
if (groupIds.size === 0) {
|
||||
toast({ title: "그룹 해제 불가", description: "선택된 컴포넌트 중 그룹화된 것이 없습니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setComponents((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.groupId && groupIds.has(c.groupId)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { groupId, ...rest } = c;
|
||||
return rest as ComponentConfig;
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
);
|
||||
|
||||
toast({ title: "그룹 해제 완료", description: `${groupIds.size}개의 그룹이 해제되었습니다.` });
|
||||
}, [getTargetIds, components, setComponents, toast]);
|
||||
|
||||
return {
|
||||
bringToFront,
|
||||
sendToBack,
|
||||
bringForward,
|
||||
sendBackward,
|
||||
toggleLock,
|
||||
lockComponents,
|
||||
unlockComponents,
|
||||
groupComponents,
|
||||
ungroupComponents,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* @module useLayoutIO
|
||||
* @description 리포트 레이아웃의 서버 저장/로드 및 템플릿 적용을 담당한다.
|
||||
*
|
||||
* - loadLayout: 서버에서 리포트 상세 정보와 레이아웃을 불러온다.
|
||||
* - 기존 단일 페이지 구조를 다중 페이지 구조로 자동 마이그레이션한다.
|
||||
* - 레이아웃이 없으면 A4 portrait 기본 페이지를 생성한다.
|
||||
*
|
||||
* - saveLayout: 현재 레이아웃과 쿼리, 메뉴 연결 정보를 서버에 저장한다.
|
||||
* - reportId가 "new"이면 먼저 리포트를 생성하고 URL을 교체한다.
|
||||
*
|
||||
* - saveLayoutWithMenus: 메뉴 연결 정보를 갱신한 뒤 레이아웃을 저장한다.
|
||||
*
|
||||
* - applyTemplate: DB에서 템플릿을 조회하여 현재 페이지에 적용한다.
|
||||
* 컴포넌트와 쿼리 ID를 재생성하여 충돌을 방지한다.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import type { ReportDetail, ReportLayout, ReportLayoutConfig, ReportPage, ComponentConfig } from "@/types/report";
|
||||
import type { ReportQuery } from "./types";
|
||||
import type { ToastFunction, SetComponentsFn } from "./internalTypes";
|
||||
|
||||
/** A4 기본 페이지 설정 */
|
||||
const DEFAULT_PAGE: Omit<ReportPage, "page_id"> = {
|
||||
page_name: "페이지 1",
|
||||
page_order: 0,
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: "portrait",
|
||||
margins: { top: 10, bottom: 10, left: 10, right: 10 },
|
||||
background_color: "#ffffff",
|
||||
components: [],
|
||||
};
|
||||
|
||||
/** A4 기본 페이지 객체를 새 ID와 함께 생성한다. */
|
||||
function createDefaultPage(overrides: Partial<ReportPage> = {}): ReportPage {
|
||||
return { ...DEFAULT_PAGE, page_id: uuidv4(), ...overrides };
|
||||
}
|
||||
|
||||
export interface LayoutIOActions {
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
loadLayout: () => Promise<void>;
|
||||
saveLayout: () => Promise<void>;
|
||||
saveLayoutWithMenus: (menuObjids: number[]) => Promise<void>;
|
||||
applyTemplate: (templateId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface LayoutIODeps {
|
||||
reportId: string;
|
||||
layoutConfig: ReportLayoutConfig;
|
||||
queries: ReportQuery[];
|
||||
menuObjids: number[];
|
||||
setMenuObjids: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsSaving: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setReportDetail: React.Dispatch<React.SetStateAction<ReportDetail | null>>;
|
||||
setLayout: React.Dispatch<React.SetStateAction<ReportLayout | null>>;
|
||||
setLayoutConfig: React.Dispatch<React.SetStateAction<ReportLayoutConfig>>;
|
||||
setCurrentPageId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setQueries: (queries: ReportQuery[]) => void;
|
||||
/** 현재 페이지의 컴포넌트를 업데이트하는 헬퍼 */
|
||||
setComponents: SetComponentsFn;
|
||||
/** applyTemplate에서 현재 페이지 ID를 안정적으로 읽기 위한 ref */
|
||||
currentPageIdRef: React.MutableRefObject<string | null>;
|
||||
toast: ToastFunction;
|
||||
}
|
||||
|
||||
/** 공통 저장 페이로드를 빌드하는 헬퍼 */
|
||||
function buildSavePayload(
|
||||
layoutConfig: ReportLayoutConfig,
|
||||
queries: ReportQuery[],
|
||||
menuObjids: number[],
|
||||
) {
|
||||
return {
|
||||
layoutConfig,
|
||||
queries: queries.map((q) => ({
|
||||
...q,
|
||||
externalConnectionId: q.externalConnectionId || undefined,
|
||||
})),
|
||||
menuObjids,
|
||||
};
|
||||
}
|
||||
|
||||
/** 백엔드 쿼리 응답을 ReportQuery 형태로 변환한다 (snake_case → camelCase). */
|
||||
function mapBackendQuery(q: Record<string, unknown>): ReportQuery {
|
||||
return {
|
||||
id: q.query_id as string,
|
||||
name: q.query_name as string,
|
||||
type: q.query_type as "MASTER" | "DETAIL",
|
||||
sqlQuery: q.sql_query as string,
|
||||
parameters: Array.isArray(q.parameters) ? (q.parameters as string[]) : [],
|
||||
externalConnectionId: (q.external_connection_id as number | null) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useLayoutIO({
|
||||
reportId,
|
||||
layoutConfig,
|
||||
queries,
|
||||
menuObjids,
|
||||
setMenuObjids,
|
||||
setIsLoading,
|
||||
setIsSaving,
|
||||
setReportDetail,
|
||||
setLayout,
|
||||
setLayoutConfig,
|
||||
setCurrentPageId,
|
||||
setQueries,
|
||||
setComponents,
|
||||
currentPageIdRef,
|
||||
toast,
|
||||
}: LayoutIODeps): Omit<LayoutIOActions, "isLoading" | "isSaving"> {
|
||||
/**
|
||||
* 서버에서 리포트 상세 정보와 레이아웃을 불러온다.
|
||||
* reportId가 "new"이면 기본 페이지만 생성한다.
|
||||
*/
|
||||
const loadLayout = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (reportId === "new") {
|
||||
setLayoutConfig({ pages: [createDefaultPage()] });
|
||||
setCurrentPageId((prev) => prev ?? uuidv4());
|
||||
return;
|
||||
}
|
||||
|
||||
// 리포트 상세 조회
|
||||
const detailResponse = await reportApi.getReportById(reportId);
|
||||
if (detailResponse.success && detailResponse.data) {
|
||||
setReportDetail(detailResponse.data);
|
||||
|
||||
if (detailResponse.data.queries?.length > 0) {
|
||||
setQueries((detailResponse.data.queries as unknown as Record<string, unknown>[]).map(mapBackendQuery));
|
||||
}
|
||||
|
||||
setMenuObjids(detailResponse.data.menuObjids ?? []);
|
||||
}
|
||||
|
||||
// 레이아웃 조회
|
||||
try {
|
||||
const layoutResponse = await reportApi.getLayout(reportId);
|
||||
if (layoutResponse.success && layoutResponse.data) {
|
||||
const layoutData = layoutResponse.data;
|
||||
setLayout(layoutData);
|
||||
|
||||
// 다중 페이지 구조 감지
|
||||
const storedConfig = layoutData.components;
|
||||
const topLevelPages = layoutData.pages;
|
||||
const nestedPages =
|
||||
storedConfig && typeof storedConfig === "object" && !Array.isArray(storedConfig)
|
||||
? (storedConfig as Record<string, unknown>).pages
|
||||
: null;
|
||||
|
||||
const pages =
|
||||
Array.isArray(topLevelPages) && topLevelPages.length > 0
|
||||
? topLevelPages
|
||||
: Array.isArray(nestedPages) && (nestedPages as unknown[]).length > 0
|
||||
? (nestedPages as ReportPage[])
|
||||
: null;
|
||||
|
||||
if (pages) {
|
||||
// 다중 페이지 구조 로드
|
||||
const watermark =
|
||||
(layoutData as unknown as Record<string, unknown>).watermark ||
|
||||
(storedConfig as unknown as Record<string, unknown>)?.watermark;
|
||||
setLayoutConfig({ pages, watermark: watermark as ReportLayoutConfig["watermark"] });
|
||||
setCurrentPageId((pages[0] as ReportPage).page_id);
|
||||
} else {
|
||||
// 기존 단일 페이지 구조 → 다중 페이지로 자동 마이그레이션
|
||||
const oldComponents = Array.isArray(layoutData.components)
|
||||
? (layoutData.components as ComponentConfig[])
|
||||
: [];
|
||||
|
||||
if (oldComponents.length > 0) {
|
||||
const migratedPage = createDefaultPage({
|
||||
width: layoutData.canvas_width || 210,
|
||||
height: layoutData.canvas_height || 297,
|
||||
orientation: (layoutData.page_orientation as "portrait" | "landscape") || "portrait",
|
||||
margins: {
|
||||
top: layoutData.margin_top || 20,
|
||||
bottom: layoutData.margin_bottom || 20,
|
||||
left: layoutData.margin_left || 20,
|
||||
right: layoutData.margin_right || 20,
|
||||
},
|
||||
components: oldComponents,
|
||||
});
|
||||
setLayoutConfig({ pages: [migratedPage] });
|
||||
setCurrentPageId(migratedPage.page_id);
|
||||
} else {
|
||||
const defaultPage = createDefaultPage();
|
||||
setLayoutConfig({ pages: [defaultPage] });
|
||||
setCurrentPageId(defaultPage.page_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 레이아웃이 없으면 기본 페이지 생성
|
||||
const defaultPage = createDefaultPage();
|
||||
setLayoutConfig({ pages: [defaultPage] });
|
||||
setCurrentPageId(defaultPage.page_id);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error instanceof Error ? error.message : "리포트를 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [reportId, setIsLoading, setLayoutConfig, setCurrentPageId, setReportDetail, setQueries, setMenuObjids, setLayout, toast]);
|
||||
|
||||
/**
|
||||
* 현재 레이아웃, 쿼리, 메뉴 연결 정보를 서버에 저장한다.
|
||||
* reportId가 "new"이면 먼저 리포트를 생성하고 URL을 업데이트한다.
|
||||
*/
|
||||
const saveLayout = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let actualReportId = reportId;
|
||||
|
||||
if (reportId === "new") {
|
||||
const createResponse = await reportApi.createReport({
|
||||
reportNameKor: "새 리포트",
|
||||
reportType: "BASIC",
|
||||
description: "새로 생성된 리포트입니다.",
|
||||
});
|
||||
if (!createResponse.success || !createResponse.data) throw new Error("리포트 생성에 실패했습니다.");
|
||||
actualReportId = createResponse.data.reportId;
|
||||
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
|
||||
}
|
||||
|
||||
await reportApi.saveLayout(actualReportId, buildSavePayload(layoutConfig, queries, menuObjids));
|
||||
toast({ title: "성공", description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다." });
|
||||
|
||||
if (reportId === "new") await loadLayout();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error instanceof Error ? error.message : "저장에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [reportId, layoutConfig, queries, menuObjids, setIsSaving, loadLayout, toast]);
|
||||
|
||||
/**
|
||||
* 메뉴 연결 정보를 갱신한 뒤 레이아웃을 저장한다.
|
||||
* 상태 업데이트와 API 호출을 함께 수행한다.
|
||||
*/
|
||||
const saveLayoutWithMenus = useCallback(
|
||||
async (selectedMenuObjids: number[]) => {
|
||||
setMenuObjids(selectedMenuObjids);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let actualReportId = reportId;
|
||||
|
||||
if (reportId === "new") {
|
||||
const createResponse = await reportApi.createReport({
|
||||
reportNameKor: "새 리포트",
|
||||
reportType: "BASIC",
|
||||
description: "새로 생성된 리포트입니다.",
|
||||
});
|
||||
if (!createResponse.success || !createResponse.data) throw new Error("리포트 생성에 실패했습니다.");
|
||||
actualReportId = createResponse.data.reportId;
|
||||
window.history.replaceState({}, "", `/admin/report/designer/${actualReportId}`);
|
||||
}
|
||||
|
||||
await reportApi.saveLayout(actualReportId, buildSavePayload(layoutConfig, queries, selectedMenuObjids));
|
||||
toast({ title: "성공", description: reportId === "new" ? "리포트가 생성되었습니다." : "레이아웃이 저장되었습니다." });
|
||||
|
||||
if (reportId === "new") await loadLayout();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error instanceof Error ? error.message : "저장에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[reportId, layoutConfig, queries, setMenuObjids, setIsSaving, loadLayout, toast],
|
||||
);
|
||||
|
||||
/**
|
||||
* DB에서 템플릿을 조회하여 현재 페이지에 적용한다.
|
||||
* 컴포넌트와 쿼리의 ID를 재생성하고, queryId 매핑을 통해 컴포넌트-쿼리 연결을 유지한다.
|
||||
* 템플릿에 pageSettings가 포함된 경우 현재 페이지 설정도 업데이트한다.
|
||||
*/
|
||||
const applyTemplate = useCallback(
|
||||
async (templateId: string) => {
|
||||
try {
|
||||
if (!confirm("현재 레이아웃을 덮어씁니다. 계속하시겠습니까?")) return;
|
||||
|
||||
const response = await reportApi.getTemplates();
|
||||
if (!response.success || !response.data) throw new Error("템플릿 목록을 불러올 수 없습니다.");
|
||||
|
||||
const allTemplates = [...(response.data.system ?? []), ...(response.data.custom ?? [])];
|
||||
const template = allTemplates.find((t: { template_id: string }) => t.template_id === templateId);
|
||||
if (!template) throw new Error("템플릿을 찾을 수 없습니다.");
|
||||
|
||||
// layout_config 파싱
|
||||
let parsedLayout: { components?: ComponentConfig[]; pageSettings?: Record<string, unknown> } = {};
|
||||
try {
|
||||
parsedLayout = template.layout_config
|
||||
? typeof template.layout_config === "string"
|
||||
? JSON.parse(template.layout_config)
|
||||
: template.layout_config
|
||||
: {};
|
||||
} catch {
|
||||
parsedLayout = { components: [] };
|
||||
}
|
||||
|
||||
// default_queries 파싱
|
||||
let defaultQueries: Record<string, unknown>[] = [];
|
||||
try {
|
||||
if (template.default_queries) {
|
||||
const raw = typeof template.default_queries === "string"
|
||||
? JSON.parse(template.default_queries)
|
||||
: template.default_queries;
|
||||
defaultQueries = Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
} catch {
|
||||
defaultQueries = [];
|
||||
}
|
||||
|
||||
// 쿼리 ID 재생성 및 원본→신규 매핑 생성
|
||||
const queryIdMap = new Map<string, string>();
|
||||
const newQueries: ReportQuery[] = defaultQueries
|
||||
.filter((q): q is Record<string, unknown> => typeof q === "object" && q !== null)
|
||||
.map((q) => {
|
||||
const oldId = (q.id as string) || (q.name as string) || "";
|
||||
const newId = `query-${Date.now()}-${Math.random()}`;
|
||||
if (oldId) queryIdMap.set(oldId, newId);
|
||||
return {
|
||||
id: newId,
|
||||
name: (q.name as string) ?? "",
|
||||
type: ((q.type as string) || "MASTER") as "MASTER" | "DETAIL",
|
||||
sqlQuery: (q.sqlQuery as string) ?? "",
|
||||
parameters: Array.isArray(q.parameters) ? (q.parameters as string[]) : [],
|
||||
externalConnectionId: (q.externalConnectionId as number | null) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// 컴포넌트 ID 재생성 + queryId 매핑
|
||||
const newComponents: ComponentConfig[] = (parsedLayout.components ?? []).map((comp) => ({
|
||||
...comp,
|
||||
id: `comp-${Date.now()}-${Math.random()}`,
|
||||
queryId: comp.queryId ? (queryIdMap.get(comp.queryId) ?? comp.queryId) : comp.queryId,
|
||||
}));
|
||||
|
||||
// 페이지 설정 적용 (템플릿에 pageSettings가 있는 경우)
|
||||
const pageSettings = parsedLayout.pageSettings;
|
||||
if (pageSettings) {
|
||||
setLayoutConfig((prev) => {
|
||||
const pageId = currentPageIdRef.current;
|
||||
if (!pageId) return prev;
|
||||
return {
|
||||
...prev,
|
||||
pages: prev.pages.map((p) =>
|
||||
p.page_id === pageId
|
||||
? {
|
||||
...p,
|
||||
width: (pageSettings.width as number) ?? p.width,
|
||||
height: (pageSettings.height as number) ?? p.height,
|
||||
orientation: (pageSettings.orientation as "portrait" | "landscape") ?? p.orientation,
|
||||
margins: (pageSettings.margins as ReportPage["margins"]) ?? p.margins,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setComponents(newComponents);
|
||||
setQueries(newQueries);
|
||||
|
||||
const description =
|
||||
newComponents.length === 0
|
||||
? "템플릿이 적용되었습니다. (빈 템플릿)"
|
||||
: `템플릿이 적용되었습니다. (컴포넌트 ${newComponents.length}개, 쿼리 ${newQueries.length}개)`;
|
||||
|
||||
toast({ title: "성공", description });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error instanceof Error ? error.message : "템플릿 적용에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
// currentPageIdRef는 ref이므로 deps에 포함하지 않음
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[setLayoutConfig, setComponents, setQueries, toast],
|
||||
);
|
||||
|
||||
return { loadLayout, saveLayout, saveLayoutWithMenus, applyTemplate };
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* @module usePageManager
|
||||
* @description 리포트 레이아웃의 페이지 단위 CRUD 및 설정 변경을 담당한다.
|
||||
* - 페이지 추가/삭제/복제/순서 변경
|
||||
* - 현재 활성 페이지 전환 (selectPage)
|
||||
* - 개별 페이지 설정(크기, 방향, 여백 등) 업데이트
|
||||
* - 전체 페이지 공유 워터마크 설정
|
||||
*
|
||||
* 페이지 크기 변경 시 기존 컴포넌트 위치/크기를 비율에 따라 자동 재계산한다.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { ReportLayoutConfig, ReportPage, WatermarkConfig } from "@/types/report";
|
||||
import type { ToastFunction } from "./internalTypes";
|
||||
import { generateComponentId } from "@/lib/report/constants";
|
||||
|
||||
export interface PageManagerActions {
|
||||
addPage: (name?: string) => void;
|
||||
deletePage: (pageId: string) => void;
|
||||
duplicatePage: (pageId: string) => void;
|
||||
reorderPages: (sourceIndex: number, targetIndex: number) => void;
|
||||
selectPage: (pageId: string) => void;
|
||||
updatePageSettings: (pageId: string, settings: Partial<ReportPage>) => void;
|
||||
updateWatermark: (watermark: WatermarkConfig | undefined) => void;
|
||||
}
|
||||
|
||||
interface PageManagerDeps {
|
||||
layoutConfig: ReportLayoutConfig;
|
||||
setLayoutConfig: React.Dispatch<React.SetStateAction<ReportLayoutConfig>>;
|
||||
currentPageId: string | null;
|
||||
setCurrentPageId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
/** 페이지 전환 시 선택 상태를 초기화하는 콜백 */
|
||||
clearSelection: () => void;
|
||||
toast: ToastFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 크기가 변경될 때 기존 컴포넌트의 위치/크기를 비율에 맞춰 재계산한다.
|
||||
* 소수점 2자리까지만 유지하여 부동소수점 오차를 최소화한다.
|
||||
*/
|
||||
function recalculateComponentPositions(
|
||||
components: ReportPage["components"],
|
||||
oldWidth: number,
|
||||
oldHeight: number,
|
||||
newWidth: number,
|
||||
newHeight: number,
|
||||
): ReportPage["components"] {
|
||||
if (oldWidth === newWidth && oldHeight === newHeight) return components;
|
||||
|
||||
const widthRatio = newWidth / oldWidth;
|
||||
const heightRatio = newHeight / oldHeight;
|
||||
|
||||
return components.map((comp) => ({
|
||||
...comp,
|
||||
x: Math.round(comp.x * widthRatio * 100) / 100,
|
||||
y: Math.round(comp.y * heightRatio * 100) / 100,
|
||||
width: Math.round(comp.width * widthRatio * 100) / 100,
|
||||
height: Math.round(comp.height * heightRatio * 100) / 100,
|
||||
}));
|
||||
}
|
||||
|
||||
export function usePageManager({
|
||||
layoutConfig,
|
||||
setLayoutConfig,
|
||||
currentPageId,
|
||||
setCurrentPageId,
|
||||
clearSelection,
|
||||
toast,
|
||||
}: PageManagerDeps): PageManagerActions {
|
||||
/** 새 페이지를 마지막에 추가하고 해당 페이지로 자동 전환한다. */
|
||||
const addPage = useCallback(
|
||||
(name?: string) => {
|
||||
const newPageId = uuidv4();
|
||||
const newPage: ReportPage = {
|
||||
page_id: newPageId,
|
||||
page_name: name ?? `페이지 ${layoutConfig.pages.length + 1}`,
|
||||
page_order: layoutConfig.pages.length,
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: "portrait",
|
||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||||
background_color: "#ffffff",
|
||||
components: [],
|
||||
};
|
||||
|
||||
setLayoutConfig((prev) => ({ pages: [...prev.pages, newPage] }));
|
||||
setCurrentPageId(newPageId);
|
||||
toast({ title: "페이지 추가", description: `${newPage.page_name}이(가) 추가되었습니다.` });
|
||||
},
|
||||
[layoutConfig.pages.length, setLayoutConfig, setCurrentPageId, toast],
|
||||
);
|
||||
|
||||
/**
|
||||
* 지정한 페이지를 삭제한다.
|
||||
* 마지막 페이지는 삭제 불가. 현재 페이지 삭제 시 나머지 첫 번째 페이지로 이동한다.
|
||||
*/
|
||||
const deletePage = useCallback(
|
||||
(pageId: string) => {
|
||||
if (layoutConfig.pages.length <= 1) {
|
||||
toast({ title: "삭제 불가", description: "최소 1개의 페이지는 필요합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
const pageIndex = layoutConfig.pages.findIndex((p) => p.page_id === pageId);
|
||||
if (pageIndex === -1) return;
|
||||
|
||||
setLayoutConfig((prev) => ({
|
||||
pages: prev.pages
|
||||
.filter((p) => p.page_id !== pageId)
|
||||
.map((p, idx) => ({ ...p, page_order: idx })),
|
||||
}));
|
||||
|
||||
if (currentPageId === pageId) {
|
||||
const remaining = layoutConfig.pages.filter((p) => p.page_id !== pageId);
|
||||
setCurrentPageId(remaining[0]?.page_id ?? null);
|
||||
}
|
||||
|
||||
toast({ title: "페이지 삭제", description: "페이지가 삭제되었습니다." });
|
||||
},
|
||||
[layoutConfig.pages, currentPageId, setLayoutConfig, setCurrentPageId, toast],
|
||||
);
|
||||
|
||||
/** 지정한 페이지를 복제하고 복제된 페이지로 이동한다. 컴포넌트에도 새 ID가 부여된다. */
|
||||
const duplicatePage = useCallback(
|
||||
(pageId: string) => {
|
||||
const sourcePage = layoutConfig.pages.find((p) => p.page_id === pageId);
|
||||
if (!sourcePage) return;
|
||||
|
||||
const newPageId = uuidv4();
|
||||
const newPage: ReportPage = {
|
||||
...sourcePage,
|
||||
page_id: newPageId,
|
||||
page_name: `${sourcePage.page_name} (복사)`,
|
||||
page_order: layoutConfig.pages.length,
|
||||
components: sourcePage.components.map((comp) => ({
|
||||
...comp,
|
||||
id: generateComponentId(),
|
||||
})),
|
||||
};
|
||||
|
||||
setLayoutConfig((prev) => ({ pages: [...prev.pages, newPage] }));
|
||||
setCurrentPageId(newPageId);
|
||||
toast({ title: "페이지 복제", description: `${newPage.page_name}이(가) 생성되었습니다.` });
|
||||
},
|
||||
[layoutConfig.pages, setLayoutConfig, setCurrentPageId, toast],
|
||||
);
|
||||
|
||||
/** 드래그&드롭으로 페이지 순서를 변경한다. page_order는 인덱스와 동기화된다. */
|
||||
const reorderPages = useCallback(
|
||||
(sourceIndex: number, targetIndex: number) => {
|
||||
if (sourceIndex === targetIndex) return;
|
||||
|
||||
const newPages = [...layoutConfig.pages];
|
||||
const [movedPage] = newPages.splice(sourceIndex, 1);
|
||||
newPages.splice(targetIndex, 0, movedPage);
|
||||
newPages.forEach((page, idx) => { page.page_order = idx; });
|
||||
|
||||
setLayoutConfig({ pages: newPages });
|
||||
},
|
||||
[layoutConfig.pages, setLayoutConfig],
|
||||
);
|
||||
|
||||
/** 지정한 페이지로 전환하고, 기존에 선택된 컴포넌트를 초기화한다. */
|
||||
const selectPage = useCallback(
|
||||
(pageId: string) => {
|
||||
setCurrentPageId(pageId);
|
||||
clearSelection();
|
||||
},
|
||||
[setCurrentPageId, clearSelection],
|
||||
);
|
||||
|
||||
/**
|
||||
* 페이지의 크기, 방향, 여백, 배경색 등을 업데이트한다.
|
||||
* 페이지 크기가 변경될 경우 기존 컴포넌트 위치/크기를 비율에 따라 재계산한다.
|
||||
*/
|
||||
const updatePageSettings = useCallback(
|
||||
(pageId: string, settings: Partial<ReportPage>) => {
|
||||
setLayoutConfig((prev) => {
|
||||
const targetPage = prev.pages.find((p) => p.page_id === pageId);
|
||||
if (!targetPage) return prev;
|
||||
|
||||
const isWidthChanging = settings.width !== undefined && settings.width !== targetPage.width;
|
||||
const isHeightChanging = settings.height !== undefined && settings.height !== targetPage.height;
|
||||
|
||||
const updatedComponents =
|
||||
isWidthChanging || isHeightChanging
|
||||
? recalculateComponentPositions(
|
||||
targetPage.components,
|
||||
targetPage.width,
|
||||
targetPage.height,
|
||||
settings.width ?? targetPage.width,
|
||||
settings.height ?? targetPage.height,
|
||||
)
|
||||
: targetPage.components;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
pages: prev.pages.map((page) =>
|
||||
page.page_id === pageId ? { ...page, ...settings, components: updatedComponents } : page,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
[setLayoutConfig],
|
||||
);
|
||||
|
||||
/**
|
||||
* 전체 페이지에 공유되는 워터마크를 설정한다.
|
||||
* undefined를 전달하면 워터마크가 제거된다.
|
||||
*/
|
||||
const updateWatermark = useCallback(
|
||||
(watermark: WatermarkConfig | undefined) => {
|
||||
setLayoutConfig((prev) => ({ ...prev, watermark }));
|
||||
},
|
||||
[setLayoutConfig],
|
||||
);
|
||||
|
||||
return {
|
||||
addPage,
|
||||
deletePage,
|
||||
duplicatePage,
|
||||
reorderPages,
|
||||
selectPage,
|
||||
updatePageSettings,
|
||||
updateWatermark,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @module useQueryManager
|
||||
* @description 리포트에 연결된 SQL 쿼리 목록과 실행 결과 캐시를 관리한다.
|
||||
* - queries: 저장/편집 대상 쿼리 정의 목록
|
||||
* - queryResults: 각 쿼리의 마지막 실행 결과 (캐시)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type { ReportQuery, QueryResult } from "./types";
|
||||
|
||||
export interface QueryManagerState {
|
||||
queries: ReportQuery[];
|
||||
setQueries: (queries: ReportQuery[]) => void;
|
||||
queryResults: QueryResult[];
|
||||
setQueryResult: (queryId: string, fields: string[], rows: Record<string, unknown>[]) => void;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
}
|
||||
|
||||
export function useQueryManager(): QueryManagerState {
|
||||
const [queries, setQueries] = useState<ReportQuery[]>([]);
|
||||
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
||||
|
||||
/**
|
||||
* 특정 쿼리의 실행 결과를 캐시에 저장/갱신한다.
|
||||
* 동일 queryId가 이미 있으면 덮어쓰고, 없으면 추가한다.
|
||||
*/
|
||||
const setQueryResult = useCallback((queryId: string, fields: string[], rows: Record<string, unknown>[]) => {
|
||||
setQueryResults((prev) => {
|
||||
const exists = prev.some((r) => r.queryId === queryId);
|
||||
if (exists) {
|
||||
return prev.map((r) => (r.queryId === queryId ? { queryId, fields, rows } : r));
|
||||
}
|
||||
return [...prev, { queryId, fields, rows }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** 쿼리 ID로 캐시된 실행 결과를 조회한다. 없으면 null 반환. */
|
||||
const getQueryResult = useCallback(
|
||||
(queryId: string): QueryResult | null => {
|
||||
return queryResults.find((r) => r.queryId === queryId) ?? null;
|
||||
},
|
||||
[queryResults],
|
||||
);
|
||||
|
||||
return { queries, setQueries, queryResults, setQueryResult, getQueryResult };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @module useSelectionState
|
||||
* @description 캔버스에서 선택된 컴포넌트 상태를 관리한다.
|
||||
* 단일 선택(selectedComponentId)과 다중 선택(selectedComponentIds)을 동기화하여
|
||||
* 항상 일관된 선택 상태를 유지한다.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export interface SelectionState {
|
||||
selectedComponentId: string | null;
|
||||
selectedComponentIds: string[];
|
||||
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
|
||||
selectMultipleComponents: (ids: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
setSelectedComponentId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setSelectedComponentIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
export function useSelectionState(): SelectionState {
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||
const [selectedComponentIds, setSelectedComponentIds] = useState<string[]>([]);
|
||||
|
||||
/**
|
||||
* 단일 또는 다중(Ctrl+클릭) 선택 처리.
|
||||
* id가 null이면 전체 선택 해제.
|
||||
*/
|
||||
const selectComponent = useCallback((id: string | null, isMultiSelect = false) => {
|
||||
if (id === null) {
|
||||
setSelectedComponentId(null);
|
||||
setSelectedComponentIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMultiSelect) {
|
||||
setSelectedComponentIds((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
// 이미 선택된 항목 제거
|
||||
const next = prev.filter((compId) => compId !== id);
|
||||
setSelectedComponentId(next.length > 0 ? next[0] : null);
|
||||
return next;
|
||||
}
|
||||
setSelectedComponentId(id);
|
||||
return [...prev, id];
|
||||
});
|
||||
} else {
|
||||
setSelectedComponentId(id);
|
||||
setSelectedComponentIds([id]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 마퀴(드래그) 선택 등 여러 컴포넌트를 한 번에 선택할 때 사용. */
|
||||
const selectMultipleComponents = useCallback((ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
setSelectedComponentId(null);
|
||||
setSelectedComponentIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedComponentId(ids[0]);
|
||||
setSelectedComponentIds(ids);
|
||||
}, []);
|
||||
|
||||
/** 선택 전체 해제 (페이지 전환 등에서 사용). */
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedComponentId(null);
|
||||
setSelectedComponentIds([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedComponentId,
|
||||
selectedComponentIds,
|
||||
selectComponent,
|
||||
selectMultipleComponents,
|
||||
clearSelection,
|
||||
setSelectedComponentId,
|
||||
setSelectedComponentIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* @module useUIState
|
||||
* @description 리포트 디자이너의 UI 전용 상태를 관리한다.
|
||||
* - 그리드/스냅/눈금자: 캔버스 보조 도구 표시 여부와 크기
|
||||
* - 줌(zoom): 캔버스 확대/축소 배율
|
||||
* - 정렬 가이드라인: 드래그 중 다른 컴포넌트와의 정렬선 계산
|
||||
* - 패널 접기/펼치기: 좌·우·페이지 리스트 패널 상태
|
||||
* - 컴포넌트 설정 모달: 더블클릭 시 열리는 인캔버스 설정 모달 대상 ID
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import { MM_TO_PX } from "@/lib/report/constants";
|
||||
|
||||
/** 캔버스에 표시할 정렬 가이드선 좌표 (px) */
|
||||
interface AlignmentGuides {
|
||||
vertical: number[];
|
||||
horizontal: number[];
|
||||
}
|
||||
|
||||
/** 정렬 가이드 계산에 필요한 캔버스 치수 (mm 단위) */
|
||||
interface CanvasDimensions {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
export interface UIStateResult {
|
||||
gridSize: number;
|
||||
setGridSize: (size: number) => void;
|
||||
showGrid: boolean;
|
||||
setShowGrid: (show: boolean) => void;
|
||||
snapToGrid: boolean;
|
||||
setSnapToGrid: (snap: boolean) => void;
|
||||
snapValueToGrid: (value: number) => number;
|
||||
showRuler: boolean;
|
||||
setShowRuler: (show: boolean) => void;
|
||||
zoom: number;
|
||||
setZoom: (zoom: number) => void;
|
||||
fitTrigger: number;
|
||||
fitToScreen: () => void;
|
||||
alignmentGuides: AlignmentGuides;
|
||||
calculateAlignmentGuides: (draggingId: string, x: number, y: number, width: number, height: number) => void;
|
||||
clearAlignmentGuides: () => void;
|
||||
isPageListCollapsed: boolean;
|
||||
setIsPageListCollapsed: (v: boolean) => void;
|
||||
isLeftPanelCollapsed: boolean;
|
||||
setIsLeftPanelCollapsed: (v: boolean) => void;
|
||||
isRightPanelCollapsed: boolean;
|
||||
setIsRightPanelCollapsed: (v: boolean) => void;
|
||||
componentModalTargetId: string | null;
|
||||
openComponentModal: (componentId: string) => void;
|
||||
closeComponentModal: () => void;
|
||||
}
|
||||
|
||||
/** 드래그 중 정렬 가이드를 계산할 때 사용하는 오차 허용 범위 (px) */
|
||||
const ALIGNMENT_THRESHOLD_PX = 5;
|
||||
|
||||
export function useUIState(
|
||||
/** 정렬 가이드 계산에 필요한 현재 페이지 컴포넌트 목록 */
|
||||
components: ComponentConfig[],
|
||||
/** 정렬 가이드 계산에 필요한 캔버스 치수 */
|
||||
dimensions: CanvasDimensions,
|
||||
): UIStateResult {
|
||||
const { canvasWidth, canvasHeight } = dimensions;
|
||||
|
||||
const [gridSize, setGridSize] = useState(10);
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [snapToGrid, setSnapToGrid] = useState(true);
|
||||
const [showRuler, setShowRuler] = useState(true);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [fitTrigger, setFitTrigger] = useState(0);
|
||||
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuides>({ vertical: [], horizontal: [] });
|
||||
const [isPageListCollapsed, setIsPageListCollapsed] = useState(false);
|
||||
const [isLeftPanelCollapsed, setIsLeftPanelCollapsed] = useState(false);
|
||||
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false);
|
||||
const [componentModalTargetId, setComponentModalTargetId] = useState<string | null>(null);
|
||||
|
||||
/** fitToScreen 호출 시 트리거 값을 증가시켜 Canvas가 화면 맞춤을 실행하게 한다. */
|
||||
const fitToScreen = useCallback(() => setFitTrigger((n) => n + 1), []);
|
||||
|
||||
/** 인캔버스 설정 모달을 열고 대상 컴포넌트 ID를 등록한다. */
|
||||
const openComponentModal = useCallback((componentId: string) => {
|
||||
setComponentModalTargetId(componentId);
|
||||
}, []);
|
||||
|
||||
/** 인캔버스 설정 모달을 닫는다. */
|
||||
const closeComponentModal = useCallback(() => {
|
||||
setComponentModalTargetId(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 그리드 스냅이 활성화된 경우 value를 gridSize 단위로 반올림한다.
|
||||
* 비활성화된 경우 원래 값을 그대로 반환한다.
|
||||
*/
|
||||
const snapValueToGrid = useCallback(
|
||||
(value: number): number => {
|
||||
if (!snapToGrid) return value;
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
},
|
||||
[snapToGrid, gridSize],
|
||||
);
|
||||
|
||||
/**
|
||||
* 드래그 중인 컴포넌트의 위치/크기를 기준으로
|
||||
* 다른 컴포넌트 및 캔버스 중앙선과의 정렬 가이드라인을 계산한다.
|
||||
*
|
||||
* @param draggingId 현재 드래그 중인 컴포넌트 ID (자기 자신 제외용)
|
||||
* @param x 드래그 중인 컴포넌트의 현재 x 좌표 (px)
|
||||
* @param y 드래그 중인 컴포넌트의 현재 y 좌표 (px)
|
||||
* @param width 드래그 중인 컴포넌트의 너비 (px)
|
||||
* @param height 드래그 중인 컴포넌트의 높이 (px)
|
||||
*/
|
||||
const calculateAlignmentGuides = useCallback(
|
||||
(draggingId: string, x: number, y: number, width: number, height: number) => {
|
||||
const verticalLines: number[] = [];
|
||||
const horizontalLines: number[] = [];
|
||||
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
const canvasCenterX = canvasWidthPx / 2;
|
||||
const canvasCenterY = canvasHeightPx / 2;
|
||||
|
||||
const left = x;
|
||||
const right = x + width;
|
||||
const centerX = x + width / 2;
|
||||
const top = y;
|
||||
const bottom = y + height;
|
||||
const centerY = y + height / 2;
|
||||
|
||||
// 캔버스 중앙선 체크
|
||||
if (Math.abs(centerX - canvasCenterX) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(canvasCenterX);
|
||||
if (Math.abs(centerY - canvasCenterY) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(canvasCenterY);
|
||||
|
||||
// 다른 컴포넌트와 비교
|
||||
components.forEach((comp) => {
|
||||
if (comp.id === draggingId) return;
|
||||
|
||||
const cLeft = comp.x;
|
||||
const cRight = comp.x + comp.width;
|
||||
const cCenterX = comp.x + comp.width / 2;
|
||||
const cTop = comp.y;
|
||||
const cBottom = comp.y + comp.height;
|
||||
const cCenterY = comp.y + comp.height / 2;
|
||||
|
||||
// 세로 정렬선 (left, right, centerX)
|
||||
if (Math.abs(left - cLeft) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cLeft);
|
||||
if (Math.abs(left - cRight) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cRight);
|
||||
if (Math.abs(right - cLeft) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cLeft);
|
||||
if (Math.abs(right - cRight) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cRight);
|
||||
if (Math.abs(centerX - cCenterX) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cCenterX);
|
||||
|
||||
// 가로 정렬선 (top, bottom, centerY)
|
||||
if (Math.abs(top - cTop) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cTop);
|
||||
if (Math.abs(top - cBottom) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cBottom);
|
||||
if (Math.abs(bottom - cTop) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cTop);
|
||||
if (Math.abs(bottom - cBottom) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cBottom);
|
||||
if (Math.abs(centerY - cCenterY) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cCenterY);
|
||||
});
|
||||
|
||||
setAlignmentGuides({
|
||||
vertical: Array.from(new Set(verticalLines)),
|
||||
horizontal: Array.from(new Set(horizontalLines)),
|
||||
});
|
||||
},
|
||||
[components, canvasWidth, canvasHeight],
|
||||
);
|
||||
|
||||
/** 드래그 종료 후 정렬 가이드라인을 초기화한다. */
|
||||
const clearAlignmentGuides = useCallback(() => {
|
||||
setAlignmentGuides({ vertical: [], horizontal: [] });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
gridSize,
|
||||
setGridSize,
|
||||
showGrid,
|
||||
setShowGrid,
|
||||
snapToGrid,
|
||||
setSnapToGrid,
|
||||
snapValueToGrid,
|
||||
showRuler,
|
||||
setShowRuler,
|
||||
zoom,
|
||||
setZoom,
|
||||
fitTrigger,
|
||||
fitToScreen,
|
||||
alignmentGuides,
|
||||
calculateAlignmentGuides,
|
||||
clearAlignmentGuides,
|
||||
isPageListCollapsed,
|
||||
setIsPageListCollapsed,
|
||||
isLeftPanelCollapsed,
|
||||
setIsLeftPanelCollapsed,
|
||||
isRightPanelCollapsed,
|
||||
setIsRightPanelCollapsed,
|
||||
componentModalTargetId,
|
||||
openComponentModal,
|
||||
closeComponentModal,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,41 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { ReportMaster, GetReportsParams } from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type SearchField = "report_name" | "created_by" | "report_type" | "updated_at" | "created_at";
|
||||
|
||||
export function useReportList() {
|
||||
const [reports, setReports] = useState<ReportMaster[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [typeSummary, setTypeSummary] = useState<Array<{ type: string; count: number }>>([]);
|
||||
const [allTypes, setAllTypes] = useState<string[]>([]);
|
||||
const [recentActivity, setRecentActivity] = useState<Array<{ date: string; count: number }>>([]);
|
||||
const [recentTotal, setRecentTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(20);
|
||||
const [limit, setLimit] = useState(8);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [searchField, setSearchField] = useState<SearchField>("report_name");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [reportType, setReportType] = useState("");
|
||||
const { toast } = useToast();
|
||||
const toastRef = useRef(toast);
|
||||
toastRef.current = toast;
|
||||
|
||||
const fetchReports = async () => {
|
||||
const fetchReports = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const isDateSearch = searchField === "created_at" || searchField === "updated_at";
|
||||
const params: GetReportsParams = {
|
||||
page,
|
||||
limit,
|
||||
searchText,
|
||||
searchText: isDateSearch ? undefined : searchText || undefined,
|
||||
searchField: (isDateSearch && startDate && endDate) || searchText ? searchField : undefined,
|
||||
startDate: isDateSearch && startDate ? startDate : undefined,
|
||||
endDate: isDateSearch && endDate ? endDate : undefined,
|
||||
reportType: reportType || undefined,
|
||||
useYn: "Y",
|
||||
sortBy: "created_at",
|
||||
sortOrder: "DESC",
|
||||
@@ -29,37 +46,67 @@ export function useReportList() {
|
||||
if (response.success && response.data) {
|
||||
setReports(response.data.items);
|
||||
setTotal(response.data.total);
|
||||
setTypeSummary(response.data.typeSummary ?? []);
|
||||
setAllTypes(response.data.allTypes ?? []);
|
||||
setRecentActivity(response.data.recentActivity ?? []);
|
||||
setRecentTotal(response.data.recentTotal ?? 0);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("리포트 목록 조회 에러:", error);
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 목록을 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : "리포트 목록을 불러오는데 실패했습니다.";
|
||||
toastRef.current({ title: "오류", description: msg, variant: "destructive" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, limit, searchText, searchField, startDate, endDate, reportType]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, searchText]);
|
||||
}, [fetchReports]);
|
||||
|
||||
const handleSearch = (text: string) => {
|
||||
const handleSearch = useCallback((text: string) => {
|
||||
setSearchText(text);
|
||||
setPage(1);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearchFieldChange = useCallback((field: SearchField) => {
|
||||
setSearchField(field);
|
||||
if (field !== "created_at" && field !== "updated_at") {
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
}
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleDateRangeChange = useCallback((start: string, end: string) => {
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleTypeFilter = useCallback((type: string) => {
|
||||
setReportType(type);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
reports,
|
||||
total,
|
||||
typeSummary,
|
||||
allTypes,
|
||||
recentActivity,
|
||||
recentTotal,
|
||||
page,
|
||||
limit,
|
||||
isLoading,
|
||||
searchField,
|
||||
startDate,
|
||||
endDate,
|
||||
refetch: fetchReports,
|
||||
setPage,
|
||||
setLimit,
|
||||
handleSearch,
|
||||
handleSearchFieldChange,
|
||||
handleDateRangeChange,
|
||||
handleTypeFilter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { apiClient } from "./client";
|
||||
import {
|
||||
ReportMaster,
|
||||
ReportDetail,
|
||||
GetReportsParams,
|
||||
GetReportsResponse,
|
||||
GetReportsByMenuResponse,
|
||||
CreateReportRequest,
|
||||
UpdateReportRequest,
|
||||
SaveLayoutRequest,
|
||||
GetTemplatesResponse,
|
||||
CreateTemplateRequest,
|
||||
ReportLayout,
|
||||
VisualQuery,
|
||||
} from "@/types/report";
|
||||
|
||||
const BASE_URL = "/admin/reports";
|
||||
|
||||
export const reportApi = {
|
||||
// 리포트 목록 조회
|
||||
getReports: async (params: GetReportsParams) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -24,7 +24,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 상세 조회
|
||||
getReportById: async (reportId: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -33,7 +32,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 생성
|
||||
createReport: async (data: CreateReportRequest) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
@@ -43,7 +41,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 수정
|
||||
updateReport: async (reportId: string, data: UpdateReportRequest) => {
|
||||
const response = await apiClient.put<{
|
||||
success: boolean;
|
||||
@@ -52,7 +49,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 삭제
|
||||
deleteReport: async (reportId: string) => {
|
||||
const response = await apiClient.delete<{
|
||||
success: boolean;
|
||||
@@ -61,17 +57,15 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 복사
|
||||
copyReport: async (reportId: string) => {
|
||||
copyReport: async (reportId: string, newName?: string) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { reportId: string };
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${reportId}/copy`);
|
||||
}>(`${BASE_URL}/${reportId}/copy`, newName ? { newName } : {});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 레이아웃 조회
|
||||
getLayout: async (reportId: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -80,7 +74,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 레이아웃 저장
|
||||
saveLayout: async (reportId: string, data: SaveLayoutRequest) => {
|
||||
const response = await apiClient.put<{
|
||||
success: boolean;
|
||||
@@ -89,7 +82,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 템플릿 목록 조회
|
||||
getTemplates: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -98,7 +90,14 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 템플릿 생성
|
||||
getCategories: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: string[];
|
||||
}>(`${BASE_URL}/categories`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createTemplate: async (data: CreateTemplateRequest) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
@@ -108,7 +107,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 템플릿 삭제
|
||||
deleteTemplate: async (templateId: string) => {
|
||||
const response = await apiClient.delete<{
|
||||
success: boolean;
|
||||
@@ -117,7 +115,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 쿼리 실행
|
||||
executeQuery: async (
|
||||
reportId: string,
|
||||
queryId: string,
|
||||
@@ -139,7 +136,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 외부 DB 연결 목록 조회
|
||||
getExternalConnections: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -148,7 +144,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 현재 리포트를 템플릿으로 저장
|
||||
saveAsTemplate: async (
|
||||
reportId: string,
|
||||
data: {
|
||||
@@ -165,7 +160,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||
createTemplateFromLayout: async (data: {
|
||||
templateNameKor: string;
|
||||
templateNameEng?: string;
|
||||
@@ -200,7 +194,53 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이미지 업로드
|
||||
getReportsByMenuObjid: async (menuObjid: number) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: GetReportsByMenuResponse;
|
||||
}>(`${BASE_URL}/by-menu/${menuObjid}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ─── 비주얼 쿼리 빌더 API ─────────────────────────────────────────────────
|
||||
|
||||
getSchemaTableList: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: Array<{ table_name: string; table_type: string }>;
|
||||
}>(`${BASE_URL}/schema/tables`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSchemaTableColumns: async (tableName: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: Array<{ column_name: string; data_type: string; is_nullable: string }>;
|
||||
}>(`${BASE_URL}/schema/tables/${encodeURIComponent(tableName)}/columns`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSchemaTableForeignKeys: async (tableName: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: Array<{
|
||||
constraint_name: string;
|
||||
columns: string[];
|
||||
foreign_table: string;
|
||||
foreign_columns: string[];
|
||||
}>;
|
||||
}>(`${BASE_URL}/schema/tables/${encodeURIComponent(tableName)}/foreign-keys`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
previewVisualQuery: async (visualQuery: VisualQuery) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { fields: string[]; rows: any[]; sql: string };
|
||||
}>(`${BASE_URL}/schema/preview`, { visualQuery });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadImage: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ReportParamMapping, ReportViewerConfig } from "./types";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { ReportMaster } from "@/types/report";
|
||||
import { ReportListPreviewModal } from "@/components/report/ReportListPreviewModal";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* paramMappings가 있으면 명시적 매핑, 없으면 formData 그대로 전달 (휴리스틱 유지)
|
||||
*/
|
||||
function buildContextParams(
|
||||
formData: Record<string, unknown>,
|
||||
mappings: ReportParamMapping[],
|
||||
): Record<string, unknown> {
|
||||
if (mappings.length === 0) return formData;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const { param, formField } of mappings) {
|
||||
if (param && formField) {
|
||||
result[param] = formData[formField] ?? null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface ReportViewerComponentProps extends ComponentRendererProps {
|
||||
config?: ReportViewerConfig;
|
||||
formData?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const ReportViewerComponent: React.FC<ReportViewerComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
formData,
|
||||
}) => {
|
||||
const screenContext = useScreenContextOptional();
|
||||
const menuObjid = screenContext?.menuObjid;
|
||||
const contextFormData = screenContext?.formData ?? formData ?? {};
|
||||
|
||||
const config = (component?.componentConfig ?? component?.overrides ?? {}) as ReportViewerConfig;
|
||||
const title = config.title || "리포트";
|
||||
const paramMappings = config.paramMappings ?? [];
|
||||
|
||||
const resolvedContextParams = buildContextParams(contextFormData, paramMappings);
|
||||
|
||||
const [reports, setReports] = useState<ReportMaster[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedReport, setSelectedReport] = useState<ReportMaster | null>(null);
|
||||
|
||||
const fetchReports = useCallback(async () => {
|
||||
if (!menuObjid) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await reportApi.getReportsByMenuObjid(menuObjid);
|
||||
if (res.success) setReports(res.data.items);
|
||||
} catch {
|
||||
// 실패 시 빈 목록 유지
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [menuObjid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesignMode) return;
|
||||
fetchReports();
|
||||
}, [isDesignMode, fetchReports]);
|
||||
|
||||
// 디자인 모드: 플레이스홀더
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 rounded border border-dashed border-blue-300 bg-blue-50 text-blue-600">
|
||||
<FileText className="h-8 w-8 opacity-60" />
|
||||
<span className="text-sm font-medium">{title} (리포트 뷰어)</span>
|
||||
<span className="text-xs opacity-60">실행 시 메뉴에 연결된 리포트 목록이 표시됩니다</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// menuObjid 없음
|
||||
if (!menuObjid) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-gray-400">
|
||||
연결된 메뉴가 없습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-sm text-gray-400">
|
||||
<FileText className="h-8 w-8 opacity-30" />
|
||||
<span>연결된 리포트가 없습니다</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col gap-1 overflow-auto p-2">
|
||||
{reports.map((report) => (
|
||||
<Button
|
||||
key={report.report_id}
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 text-left text-sm"
|
||||
onClick={() => setSelectedReport(report)}
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate">{report.report_name_kor}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ReportListPreviewModal
|
||||
report={selectedReport}
|
||||
onClose={() => setSelectedReport(null)}
|
||||
contextParams={resolvedContextParams}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { ReportParamMapping, ReportViewerConfig } from "./types";
|
||||
|
||||
interface ReportViewerConfigPanelProps {
|
||||
config: ReportViewerConfig;
|
||||
onChange: (config: Partial<ReportViewerConfig>) => void;
|
||||
}
|
||||
|
||||
export const ReportViewerConfigPanel: React.FC<ReportViewerConfigPanelProps> = ({ config, onChange }) => {
|
||||
const mappings = config.paramMappings ?? [];
|
||||
|
||||
const handleAddMapping = useCallback(() => {
|
||||
onChange({ paramMappings: [...mappings, { param: "", formField: "" }] });
|
||||
}, [mappings, onChange]);
|
||||
|
||||
const handleRemoveMapping = useCallback(
|
||||
(index: number) => {
|
||||
onChange({ paramMappings: mappings.filter((_, i) => i !== index) });
|
||||
},
|
||||
[mappings, onChange],
|
||||
);
|
||||
|
||||
const handleMappingChange = useCallback(
|
||||
(index: number, field: keyof ReportParamMapping, value: string) => {
|
||||
const updated = mappings.map((m, i) => (i === index ? { ...m, [field]: value } : m));
|
||||
onChange({ paramMappings: updated });
|
||||
},
|
||||
[mappings, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{/* 컴포넌트 제목 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs font-medium text-gray-600">컴포넌트 제목</Label>
|
||||
<Input
|
||||
value={config.title ?? "리포트"}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
placeholder="리포트"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">디자인 모드에서 표시되는 제목입니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 파라미터 매핑 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-gray-600">파라미터 매핑</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddMapping}
|
||||
className="h-6 gap-1 px-2 text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">매핑 없음 — 폼 데이터가 순서대로 자동 주입됩니다.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-1 text-xs text-gray-400">
|
||||
<span>쿼리 파라미터</span>
|
||||
<span>폼 데이터 필드</span>
|
||||
<span />
|
||||
</div>
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_1fr_auto] items-center gap-1">
|
||||
<Input
|
||||
value={mapping.param}
|
||||
onChange={(e) => handleMappingChange(index, "param", e.target.value)}
|
||||
placeholder="예: $1"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={mapping.formField}
|
||||
onChange={(e) => handleMappingChange(index, "formField", e.target.value)}
|
||||
placeholder="예: orderId"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
className="h-7 w-7 p-0 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
쿼리 파라미터($1, $2 또는 이름)와 현재 화면 폼 데이터 필드를 연결합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded border border-blue-100 bg-blue-50 p-3 text-xs text-blue-600">
|
||||
메뉴에 연결된 리포트는 리포트 관리 페이지에서 설정합니다.
|
||||
<br />
|
||||
매핑이 없으면 폼 데이터가 자동으로 순서 기반 주입됩니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { V2ReportViewerDefinition } from "./index";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent(V2ReportViewerDefinition);
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { ReportViewerComponent } from "./ReportViewerComponent";
|
||||
import { ReportViewerConfigPanel } from "./ReportViewerConfigPanel";
|
||||
import type { ReportViewerConfig } from "./types";
|
||||
|
||||
export const V2ReportViewerDefinition = createComponentDefinition({
|
||||
id: "v2-report-viewer",
|
||||
name: "리포트 뷰어",
|
||||
nameEng: "Report Viewer",
|
||||
description: "메뉴에 연결된 리포트 목록을 표시하고 미리보기/PDF 다운로드를 제공하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: ReportViewerComponent,
|
||||
defaultConfig: {
|
||||
title: "리포트",
|
||||
} as Partial<ReportViewerConfig>,
|
||||
defaultSize: { width: 300, height: 200 },
|
||||
configPanel: ReportViewerConfigPanel as any,
|
||||
icon: "FileText",
|
||||
tags: ["리포트", "report", "PDF", "미리보기", "뷰어"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
export type { ReportViewerConfig } from "./types";
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/** 쿼리 파라미터 → formData 필드 명시적 매핑 항목 */
|
||||
export interface ReportParamMapping {
|
||||
/** 쿼리 파라미터명 (예: $1, orderNo) */
|
||||
param: string;
|
||||
/** formData에서 가져올 필드명 */
|
||||
formField: string;
|
||||
}
|
||||
|
||||
export interface ReportViewerConfig extends ComponentConfig {
|
||||
/** 표시할 리포트 목록의 제목 */
|
||||
title?: string;
|
||||
/** 파라미터 명시적 매핑 (설정 없으면 휴리스틱 자동 매핑) */
|
||||
paramMappings?: ReportParamMapping[];
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 조건부 표시 규칙 평가 유틸
|
||||
*
|
||||
* [사용처]
|
||||
* - CanvasComponent.tsx : 캔버스 내 컴포넌트 조건부 표시
|
||||
* - ReportPreviewModal.tsx : 미리보기 시 조건부 표시
|
||||
* - ReportListPreviewModal.tsx : 목록 미리보기 시 조건부 표시
|
||||
*/
|
||||
|
||||
import type { ConditionalRule } from "@/types/report";
|
||||
export type { ConditionalRule };
|
||||
|
||||
type QueryResultGetter = (
|
||||
queryId: string
|
||||
) => { fields: string[]; rows: Record<string, unknown>[] } | null;
|
||||
|
||||
/**
|
||||
* 단일 조건 규칙을 평가한다.
|
||||
* 쿼리 결과의 첫 번째 행에서 필드 값을 가져와 연산자로 비교한다.
|
||||
*/
|
||||
export function evaluateSingleRule(
|
||||
rule: ConditionalRule,
|
||||
getQueryResult: QueryResultGetter
|
||||
): boolean {
|
||||
const queryResult = getQueryResult(rule.queryId);
|
||||
if (!queryResult?.rows?.length) return false;
|
||||
|
||||
const rawValue = queryResult.rows[0][rule.field];
|
||||
const fieldStr =
|
||||
rawValue !== null && rawValue !== undefined ? String(rawValue) : "";
|
||||
const fieldNum = parseFloat(fieldStr);
|
||||
const compareNum = parseFloat(rule.value);
|
||||
|
||||
switch (rule.operator) {
|
||||
case "eq":
|
||||
return fieldStr === rule.value;
|
||||
case "ne":
|
||||
return fieldStr !== rule.value;
|
||||
case "gt":
|
||||
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum > compareNum;
|
||||
case "lt":
|
||||
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum < compareNum;
|
||||
case "gte":
|
||||
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum >= compareNum;
|
||||
case "lte":
|
||||
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum <= compareNum;
|
||||
case "contains":
|
||||
return fieldStr.includes(rule.value);
|
||||
case "notEmpty":
|
||||
return fieldStr !== "";
|
||||
case "empty":
|
||||
return fieldStr === "";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 조건 규칙을 AND로 평가한다.
|
||||
* 모든 규칙이 충족되면 action에 따라 표시/숨김을 결정한다.
|
||||
*/
|
||||
export function evaluateConditionalRules(
|
||||
rules: ConditionalRule[] | undefined | null,
|
||||
getQueryResult: QueryResultGetter
|
||||
): boolean {
|
||||
if (!rules || rules.length === 0) return true;
|
||||
|
||||
const validRules = rules.filter(
|
||||
(r) => r.queryId && r.field && r.operator
|
||||
);
|
||||
if (validRules.length === 0) return true;
|
||||
|
||||
const action = validRules[0].action || "show";
|
||||
const allMet = validRules.every((r) =>
|
||||
evaluateSingleRule(r, getQueryResult)
|
||||
);
|
||||
|
||||
return action === "show" ? allMet : !allMet;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user