Merge branch 'kwshin-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs
2026-03-31 11:19:55 +09:00
140 changed files with 27511 additions and 9366 deletions
@@ -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">&#9656;</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"
>
&larr;
</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}></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]">
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</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} />
</>
);
}
+342 -161
View File
@@ -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>
&quot;<span className="font-medium">{customCategory.trim()}</span>&quot;
</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>
);
}
+517 -127
View File
@@ -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
+8 -10
View File
@@ -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">&mdash;</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">&mdash;</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">&mdash;</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,
};
}
+63 -16
View File
@@ -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,
};
}
+59 -19
View File
@@ -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[];
}
+79
View File
@@ -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