525 lines
19 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|