559 lines
18 KiB
TypeScript
559 lines
18 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,
|
|
} 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 {
|
|
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,
|
|
} = 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 = () => {
|
|
setPendingSaveAndClose(false);
|
|
setShowMenuSelect(true);
|
|
};
|
|
|
|
const handleSaveAndClose = () => {
|
|
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;
|
|
}
|
|
};
|
|
|
|
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" />
|
|
목록으로
|
|
</Button>
|
|
<div className="h-6 w-px bg-gray-300" />
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
{reportDetail?.report.report_name_kor || "리포트 디자이너"}
|
|
</h2>
|
|
{reportDetail?.report.report_name_eng && (
|
|
<p className="text-sm 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>
|
|
|
|
{/* 정렬 드롭다운 */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!canAlign}
|
|
className="gap-2"
|
|
title="정렬 (2개 이상 선택 필요)"
|
|
>
|
|
<AlignLeft className="h-4 w-4" />
|
|
정렬
|
|
</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>
|
|
<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>
|
|
|
|
{/* 배치 드롭다운 */}
|
|
<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>
|
|
|
|
{/* 잠금 드롭다운 */}
|
|
<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" />
|
|
잠금 설정
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={unlockComponents}>
|
|
<Unlock 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개 이상)
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={ungroupComponents} disabled={!hasSelection}>
|
|
<Ungroup className="mr-2 h-4 w-4" />
|
|
그룹 해제
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Button variant="outline" size="sm" onClick={() => setShowResetConfirm(true)} className="gap-2">
|
|
<RotateCcw className="h-4 w-4" />
|
|
초기화
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)} className="gap-2">
|
|
<Eye className="h-4 w-4" />
|
|
미리보기
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowSaveAsTemplate(true)}
|
|
disabled={!canSaveAsTemplate}
|
|
className="gap-2"
|
|
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>
|
|
<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>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|