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

525 lines
19 KiB
TypeScript

"use client";
import { Button } from "@/components/ui/button";
import {
Save,
Eye,
RotateCcw,
ArrowLeft,
Loader2,
BookTemplate,
Grid3x3,
Undo2,
Redo2,
AlignLeft,
AlignRight,
AlignVerticalJustifyStart,
AlignVerticalJustifyEnd,
AlignCenterHorizontal,
AlignCenterVertical,
AlignHorizontalDistributeCenter,
AlignVerticalDistributeCenter,
RectangleHorizontal,
RectangleVertical,
Square,
ChevronDown,
ChevronsDown,
ChevronsUp,
ChevronUp,
Lock,
Unlock,
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
import { MenuSelectModal } from "./MenuSelectModal";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { ReportPreviewModal } from "./ReportPreviewModal";
export function ReportDesignerToolbar() {
const router = useRouter();
const {
reportId,
reportDetail,
saveLayoutWithMenus,
isSaving,
loadLayout,
components,
canvasWidth,
canvasHeight,
queries,
snapToGrid,
setSnapToGrid,
showGrid,
setShowGrid,
undo,
redo,
canUndo,
canRedo,
selectedComponentIds,
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontal,
distributeVertical,
makeSameWidth,
makeSameHeight,
makeSameSize,
bringToFront,
sendToBack,
bringForward,
sendBackward,
toggleLock,
lockComponents,
unlockComponents,
showRuler,
setShowRuler,
groupComponents,
ungroupComponents,
menuObjids,
zoom,
setZoom,
fitToScreen,
isPageListCollapsed,
isLeftPanelCollapsed,
isRightPanelCollapsed,
} = useReportDesigner();
const [showPreview, setShowPreview] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showBackConfirm, setShowBackConfirm] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showMenuSelect, setShowMenuSelect] = useState(false);
const [pendingSaveAndClose, setPendingSaveAndClose] = useState(false);
const { toast } = useToast();
// 버튼 활성화 조건
const canAlign = selectedComponentIds && selectedComponentIds.length >= 2;
const canDistribute = selectedComponentIds && selectedComponentIds.length >= 3;
const hasSelection = selectedComponentIds && selectedComponentIds.length >= 1;
const canGroup = selectedComponentIds && selectedComponentIds.length >= 2;
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
const canSaveAsTemplate = components.length > 0;
// Grid 토글 (Snap과 Grid 표시 함께 제어)
const handleToggleGrid = () => {
const newValue = !snapToGrid;
setSnapToGrid(newValue);
setShowGrid(newValue);
};
const handleSave = async () => {
if (reportId !== "new") {
await saveLayoutWithMenus(menuObjids);
return;
}
setPendingSaveAndClose(false);
setShowMenuSelect(true);
};
const handleSaveAndClose = async () => {
if (reportId !== "new") {
await saveLayoutWithMenus(menuObjids);
router.push("/admin/screenMng/reportList");
return;
}
setPendingSaveAndClose(true);
setShowMenuSelect(true);
};
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
await saveLayoutWithMenus(selectedMenuObjids);
if (pendingSaveAndClose) {
router.push("/admin/screenMng/reportList");
}
};
const handleResetConfirm = async () => {
setShowResetConfirm(false);
await loadLayout();
};
const handleBackConfirm = () => {
setShowBackConfirm(false);
router.push("/admin/screenMng/reportList");
};
const handleSaveAsTemplate = async (data: {
templateNameKor: string;
templateNameEng?: string;
description?: string;
}) => {
try {
// 현재 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
const response = await reportApi.createTemplateFromLayout({
templateNameKor: data.templateNameKor,
templateNameEng: data.templateNameEng,
templateType: reportDetail?.report?.report_type || "GENERAL",
description: data.description,
layoutConfig: {
width: canvasWidth,
height: canvasHeight,
orientation: "portrait",
margins: {
top: 10,
bottom: 10,
left: 10,
right: 10,
},
components: components,
},
defaultQueries: queries.map((q, index) => ({
name: q.name,
type: q.type,
sqlQuery: q.sqlQuery,
parameters: q.parameters,
externalConnectionId: q.externalConnectionId || null,
displayOrder: index,
})),
});
if (response.success) {
toast({
title: "성공",
description: "템플릿이 생성되었습니다.",
});
setShowSaveAsTemplate(false);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error && "response" in error
? (error as { response?: { data?: { message?: string } } }).response?.data?.message ||
"템플릿 생성에 실패했습니다."
: "템플릿 생성에 실패했습니다.";
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
throw error;
}
};
const leftToolbarWidth = (isPageListCollapsed ? 40 : 160) + (isLeftPanelCollapsed ? 40 : 260);
const rightToolbarWidth = isRightPanelCollapsed ? 40 : 340;
return (
<>
<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="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 || "리포트 디자이너"}
</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 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" className="h-9 gap-2" title="정렬 및 배치 도구">
<AlignLeft className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<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 />
<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>
<DropdownMenuSeparator />
<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>
<DropdownMenuSeparator />
<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={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>
<DropdownMenuItem onClick={groupComponents} disabled={!canGroup}>
<Group className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={ungroupComponents} disabled={!hasSelection}>
<Ungroup className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 우측: 액션 버튼들 (패널 너비에 연동) */}
<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="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="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="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="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>
<ReportPreviewModal isOpen={showPreview} onClose={() => setShowPreview(false)} />
<SaveAsTemplateModal
isOpen={showSaveAsTemplate}
onClose={() => setShowSaveAsTemplate(false)}
onSave={handleSaveAsTemplate}
/>
<MenuSelectModal
isOpen={showMenuSelect}
onClose={() => setShowMenuSelect(false)}
onConfirm={handleMenuSelectConfirm}
selectedMenuObjids={menuObjids}
/>
{/* 목록으로 돌아가기 확인 모달 */}
<AlertDialog open={showBackConfirm} onOpenChange={setShowBackConfirm}>
<AlertDialogContent className="max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
.
<br />
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBackConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 초기화 확인 모달 */}
<AlertDialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<AlertDialogContent className="max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleResetConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}