대시보드에 화면 할당 구현

This commit is contained in:
dohyeons 2025-10-16 16:43:04 +09:00
parent 8e2c66e2a4
commit 5093d336c0
3 changed files with 457 additions and 131 deletions

View File

@ -6,10 +6,14 @@ import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardTopMenu } from "./DashboardTopMenu";
import { ElementConfigModal } from "./ElementConfigModal";
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
import { MenuAssignmentModal } from "./MenuAssignmentModal";
import { DashboardSaveModal } from "./DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
import { useMenu } from "@/contexts/MenuContext";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { CheckCircle2 } from "lucide-react";
interface DashboardDesignerProps {
dashboardId?: string;
@ -24,6 +28,7 @@ interface DashboardDesignerProps {
*/
export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) {
const router = useRouter();
const { refreshMenus } = useMenu();
const [elements, setElements] = useState<DashboardElement[]>([]);
const [selectedElement, setSelectedElement] = useState<string | null>(null);
const [elementCounter, setElementCounter] = useState(0);
@ -34,9 +39,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
const canvasRef = useRef<HTMLDivElement>(null);
// 메뉴 할당 모달 상태
const [menuAssignmentModalOpen, setMenuAssignmentModalOpen] = useState(false);
const [savedDashboardInfo, setSavedDashboardInfo] = useState<{ id: string; title: string } | null>(null);
// 저장 모달 상태
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [dashboardDescription, setDashboardDescription] = useState<string>("");
const [successModalOpen, setSuccessModalOpen] = useState(false);
// 화면 해상도 자동 감지
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
@ -308,117 +314,93 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
);
// 레이아웃 저장
const saveLayout = useCallback(async () => {
const saveLayout = useCallback(() => {
if (elements.length === 0) {
alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.");
return;
}
try {
// 실제 API 호출
const { dashboardApi } = await import("@/lib/api/dashboard");
const elementsData = elements.map((el) => ({
id: el.id,
type: el.type,
subtype: el.subtype,
position: el.position,
size: el.size,
title: el.title,
content: el.content,
dataSource: el.dataSource,
chartConfig: el.chartConfig,
}));
let savedDashboard;
if (dashboardId) {
// 기존 대시보드 업데이트
console.log("💾 저장 시작 - 현재 resolution 상태:", resolution);
console.log("💾 저장 시작 - 현재 배경색 상태:", canvasBackgroundColor);
const updateData = {
elements: elementsData,
settings: {
resolution,
backgroundColor: canvasBackgroundColor,
},
};
console.log("💾 저장할 데이터:", updateData);
console.log("💾 저장할 settings:", updateData.settings);
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
console.log("✅ 저장된 대시보드:", savedDashboard);
console.log("✅ 저장된 settings:", (savedDashboard as any).settings);
// 메뉴 할당 모달 띄우기 (기존 대시보드 업데이트 시에도)
setSavedDashboardInfo({
id: savedDashboard.id,
title: savedDashboard.title,
});
setMenuAssignmentModalOpen(true);
} else {
// 새 대시보드 생성
const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드");
if (!title) return;
const description = prompt("대시보드 설명을 입력하세요 (선택사항):", "");
const dashboardData = {
title,
description: description || undefined,
isPublic: false,
elements: elementsData,
settings: {
resolution,
backgroundColor: canvasBackgroundColor,
},
};
savedDashboard = await dashboardApi.createDashboard(dashboardData);
// 대시보드 ID 설정 (다음 저장 시 업데이트 모드로 전환)
setDashboardId(savedDashboard.id);
setDashboardTitle(savedDashboard.title);
// 메뉴 할당 모달 띄우기
setSavedDashboardInfo({
id: savedDashboard.id,
title: savedDashboard.title,
});
setMenuAssignmentModalOpen(true);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
}
}, [elements, dashboardId, router, resolution, canvasBackgroundColor]);
// 메뉴 할당 처리
const handleMenuAssignment = useCallback(
async (assignToMenu: boolean, menuId?: string, menuType?: "0" | "1") => {
if (!savedDashboardInfo) return;
// 저장 모달 열기
setSaveModalOpen(true);
}, [elements]);
// 저장 처리
const handleSave = useCallback(
async (data: {
title: string;
description: string;
assignToMenu: boolean;
menuType?: "admin" | "user";
menuId?: string;
}) => {
try {
if (assignToMenu && menuId) {
// 메뉴 API를 통해 대시보드 URL 할당
const { dashboardApi } = await import("@/lib/api/dashboard");
const elementsData = elements.map((el) => ({
id: el.id,
type: el.type,
subtype: el.subtype,
position: el.position,
size: el.size,
title: el.title,
content: el.content,
dataSource: el.dataSource,
chartConfig: el.chartConfig,
listConfig: el.listConfig,
}));
let savedDashboard;
if (dashboardId) {
// 기존 대시보드 업데이트
const updateData = {
title: data.title,
description: data.description || undefined,
elements: elementsData,
settings: {
resolution,
backgroundColor: canvasBackgroundColor,
},
};
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
} else {
// 새 대시보드 생성
const dashboardData = {
title: data.title,
description: data.description || undefined,
isPublic: false,
elements: elementsData,
settings: {
resolution,
backgroundColor: canvasBackgroundColor,
},
};
savedDashboard = await dashboardApi.createDashboard(dashboardData);
setDashboardId(savedDashboard.id);
}
setDashboardTitle(savedDashboard.title);
setDashboardDescription(data.description);
// 메뉴 할당 처리
if (data.assignToMenu && data.menuId) {
const { menuApi } = await import("@/lib/api/menu");
// 대시보드 URL 생성 (관리자 메뉴면 mode=admin 추가)
let dashboardUrl = `/dashboard/${savedDashboardInfo.id}`;
if (menuType === "0") {
let dashboardUrl = `/dashboard/${savedDashboard.id}`;
if (data.menuType === "admin") {
dashboardUrl += "?mode=admin";
}
// 메뉴 정보 가져오기
const menuResponse = await menuApi.getMenuInfo(menuId);
const menuResponse = await menuApi.getMenuInfo(data.menuId);
if (menuResponse.success && menuResponse.data) {
const menu = menuResponse.data;
// 메뉴 URL 업데이트
await menuApi.updateMenu(menuId, {
const updateData = {
menuUrl: dashboardUrl,
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
@ -428,24 +410,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
status: menu.status || menu.STATUS || "active",
companyCode: menu.company_code || menu.COMPANY_CODE || "",
langKey: menu.lang_key || menu.LANG_KEY || "",
});
};
alert(`메뉴 "${menu.menu_name_kor || menu.MENU_NAME_KOR}"에 대시보드가 할당되었습니다!`);
// 메뉴 URL 업데이트
await menuApi.updateMenu(data.menuId, updateData);
// 메뉴 목록 새로고침
await refreshMenus();
}
}
// 모달 닫기
setMenuAssignmentModalOpen(false);
setSavedDashboardInfo(null);
// 대시보드 뷰어로 이동
router.push(`/dashboard/${savedDashboardInfo.id}`);
// 성공 모달 표시
setSuccessModalOpen(true);
} catch (error) {
console.error("메뉴 할당 오류:", error);
alert("메뉴 할당 중 오류가 발생했습니다.");
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`);
throw error;
}
},
[savedDashboardInfo, router],
[elements, dashboardId, resolution, canvasBackgroundColor, router],
);
// 로딩 중이면 로딩 화면 표시
@ -523,21 +506,44 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
</>
)}
{/* 메뉴 할당 모달 */}
{savedDashboardInfo && (
<MenuAssignmentModal
isOpen={menuAssignmentModalOpen}
onClose={() => {
setMenuAssignmentModalOpen(false);
setSavedDashboardInfo(null);
// 모달을 그냥 닫으면 대시보드 뷰어로 이동
router.push(`/dashboard/${savedDashboardInfo.id}`);
}}
onConfirm={handleMenuAssignment}
dashboardId={savedDashboardInfo.id}
dashboardTitle={savedDashboardInfo.title}
/>
)}
{/* 저장 모달 */}
<DashboardSaveModal
isOpen={saveModalOpen}
onClose={() => setSaveModalOpen(false)}
onSave={handleSave}
initialTitle={dashboardTitle}
initialDescription={dashboardDescription}
isEditing={!!dashboardId}
/>
{/* 저장 성공 모달 */}
<Dialog
open={successModalOpen}
onOpenChange={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<DialogTitle className="text-center"> </DialogTitle>
<DialogDescription className="text-center"> .</DialogDescription>
</DialogHeader>
<div className="flex justify-center pt-4">
<Button
onClick={() => {
setSuccessModalOpen(false);
router.push("/admin/dashboard");
}}
>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,321 @@
"use client";
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectGroup,
SelectLabel,
} from "@/components/ui/select";
import { Loader2, Save } from "lucide-react";
import { menuApi } from "@/lib/api/menu";
interface MenuItem {
id: string;
name: string;
url?: string;
parent_id?: string;
children?: MenuItem[];
}
interface DashboardSaveModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (data: {
title: string;
description: string;
assignToMenu: boolean;
menuType?: "admin" | "user";
menuId?: string;
}) => Promise<void>;
initialTitle?: string;
initialDescription?: string;
isEditing?: boolean;
}
export function DashboardSaveModal({
isOpen,
onClose,
onSave,
initialTitle = "",
initialDescription = "",
isEditing = false,
}: DashboardSaveModalProps) {
const [title, setTitle] = useState(initialTitle);
const [description, setDescription] = useState(initialDescription);
const [assignToMenu, setAssignToMenu] = useState(false);
const [menuType, setMenuType] = useState<"admin" | "user">("admin");
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
const [adminMenus, setAdminMenus] = useState<MenuItem[]>([]);
const [userMenus, setUserMenus] = useState<MenuItem[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMenus, setLoadingMenus] = useState(false);
useEffect(() => {
if (isOpen) {
setTitle(initialTitle);
setDescription(initialDescription);
setAssignToMenu(false);
setMenuType("admin");
setSelectedMenuId("");
loadMenus();
}
}, [isOpen, initialTitle, initialDescription]);
const loadMenus = async () => {
setLoadingMenus(true);
try {
const [adminData, userData] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]);
// API 응답이 배열인지 확인하고 처리
const adminMenuList = Array.isArray(adminData) ? adminData : adminData?.data || [];
const userMenuList = Array.isArray(userData) ? userData : userData?.data || [];
setAdminMenus(adminMenuList);
setUserMenus(userMenuList);
} catch (error) {
console.error("메뉴 목록 로드 실패:", error);
setAdminMenus([]);
setUserMenus([]);
} finally {
setLoadingMenus(false);
}
};
const flattenMenus = (
menus: MenuItem[],
prefix = "",
parentPath = "",
): { id: string; label: string; uniqueKey: string }[] => {
if (!Array.isArray(menus)) {
return [];
}
const result: { id: string; label: string; uniqueKey: string }[] = [];
menus.forEach((menu, index) => {
// 메뉴 ID 추출 (objid 또는 id)
const menuId = (menu as any).objid || menu.id || "";
const uniqueKey = `${parentPath}-${menuId}-${index}`;
// 메뉴 이름 추출
const menuName =
menu.name ||
(menu as any).menu_name_kor ||
(menu as any).MENU_NAME_KOR ||
(menu as any).menuNameKor ||
(menu as any).title ||
"이름없음";
// lev 필드로 레벨 확인 (lev > 1인 메뉴만 추가)
const menuLevel = (menu as any).lev || 0;
if (menuLevel > 1) {
result.push({
id: menuId,
label: prefix + menuName,
uniqueKey,
});
}
// 하위 메뉴가 있으면 재귀 호출
if (menu.children && Array.isArray(menu.children) && menu.children.length > 0) {
result.push(...flattenMenus(menu.children, prefix + menuName + " > ", uniqueKey));
}
});
return result;
};
const handleSave = async () => {
if (!title.trim()) {
alert("대시보드 이름을 입력해주세요.");
return;
}
if (assignToMenu && !selectedMenuId) {
alert("메뉴를 선택해주세요.");
return;
}
setLoading(true);
try {
await onSave({
title: title.trim(),
description: description.trim(),
assignToMenu,
menuType: assignToMenu ? menuType : undefined,
menuId: assignToMenu ? selectedMenuId : undefined,
});
onClose();
} catch (error) {
console.error("저장 실패:", error);
} finally {
setLoading(false);
}
};
const currentMenus = menuType === "admin" ? adminMenus : userMenus;
const flatMenus = flattenMenus(currentMenus);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEditing ? "대시보드 수정" : "대시보드 저장"}</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 대시보드 이름 */}
<div className="space-y-2">
<Label htmlFor="title">
<span className="text-red-500">*</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 생산 현황 대시보드"
className="w-full"
/>
</div>
{/* 대시보드 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="대시보드에 대한 간단한 설명을 입력하세요"
rows={3}
className="w-full resize-none"
/>
</div>
{/* 구분선 */}
<div className="border-t pt-4">
<h3 className="mb-3 text-sm font-semibold"> </h3>
{/* 메뉴 할당 여부 */}
<div className="space-y-4">
<RadioGroup
value={assignToMenu ? "yes" : "no"}
onValueChange={(value) => setAssignToMenu(value === "yes")}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="no" id="assign-no" />
<Label htmlFor="assign-no" className="cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="yes" id="assign-yes" />
<Label htmlFor="assign-yes" className="cursor-pointer">
</Label>
</div>
</RadioGroup>
{/* 메뉴 할당 옵션 */}
{assignToMenu && (
<div className="ml-6 space-y-4 border-l-2 border-gray-200 pl-4">
{/* 메뉴 타입 선택 */}
<div className="space-y-2">
<Label> </Label>
<RadioGroup value={menuType} onValueChange={(value) => setMenuType(value as "admin" | "user")}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="admin" id="menu-admin" />
<Label htmlFor="menu-admin" className="cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="user" id="menu-user" />
<Label htmlFor="menu-user" className="cursor-pointer">
</Label>
</div>
</RadioGroup>
</div>
{/* 메뉴 선택 */}
<div className="space-y-2">
<Label> </Label>
{loadingMenus ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-500"> ...</span>
</div>
) : (
<div className="space-y-2">
<Select value={selectedMenuId} onValueChange={setSelectedMenuId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="메뉴를 선택하세요" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel>{menuType === "admin" ? "관리자 메뉴" : "사용자 메뉴"}</SelectLabel>
{flatMenus.length === 0 ? (
<div className="px-2 py-3 text-sm text-gray-500"> .</div>
) : (
flatMenus.map((menu) => (
<SelectItem key={menu.uniqueKey} value={menu.id}>
{menu.label}
</SelectItem>
))
)}
</SelectGroup>
</SelectContent>
</Select>
{selectedMenuId && (
<div className="rounded-md bg-gray-50 p-2 text-sm text-gray-700">
:{" "}
<span className="font-medium">{flatMenus.find((m) => m.id === selectedMenuId)?.label}</span>
</div>
)}
</div>
)}
{assignToMenu && selectedMenuId && (
<p className="mt-1 text-xs text-gray-500">
URL이 .
{menuType === "admin" && " (관리자 모드 파라미터 포함)"}
</p>
)}
</div>
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -153,7 +153,8 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
// customTitle이 변경되었는지 확인
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
const canSave = isTitleChanged || // 제목만 변경해도 저장 가능
const canSave =
isTitleChanged || // 제목만 변경해도 저장 가능
(isSimpleWidget
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능
currentStep === 2 && queryResult && queryResult.rows.length > 0
@ -201,18 +202,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
<X className="h-5 w-5" />
</Button>
</div>
{/* 커스텀 제목 입력 */}
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
()
</label>
<label className="mb-1 block text-sm font-medium text-gray-700"> ()</label>
<input
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder={`예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)`}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={"예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"}
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">
💡 (: "maintenance_schedules 목록")