2025-10-16 16:43:04 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from "react";
|
2025-11-05 16:36:32 +09:00
|
|
|
import {
|
|
|
|
|
ResizableDialog,
|
|
|
|
|
ResizableDialogContent,
|
|
|
|
|
ResizableDialogHeader,
|
|
|
|
|
ResizableDialogTitle,
|
|
|
|
|
ResizableDialogDescription,
|
|
|
|
|
ResizableDialogFooter,
|
|
|
|
|
} from "@/components/ui/resizable-dialog";
|
2025-10-16 16:43:04 +09:00
|
|
|
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) {
|
2025-10-17 10:38:22 +09:00
|
|
|
// console.error("메뉴 목록 로드 실패:", error);
|
2025-10-16 16:43:04 +09:00
|
|
|
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) {
|
2025-10-17 10:38:22 +09:00
|
|
|
// console.error("저장 실패:", error);
|
2025-10-16 16:43:04 +09:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const currentMenus = menuType === "admin" ? adminMenus : userMenus;
|
|
|
|
|
const flatMenus = flattenMenus(currentMenus);
|
|
|
|
|
|
|
|
|
|
return (
|
2025-11-05 16:36:32 +09:00
|
|
|
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
|
|
|
|
<ResizableDialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
|
|
|
|
<ResizableDialogHeader>
|
|
|
|
|
<ResizableDialogTitle>{isEditing ? "대시보드 수정" : "대시보드 저장"}</ResizableDialogTitle>
|
|
|
|
|
</ResizableDialogHeader>
|
2025-10-16 16:43:04 +09:00
|
|
|
|
|
|
|
|
<div className="space-y-6 py-4">
|
|
|
|
|
{/* 대시보드 이름 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="title">
|
2025-10-29 17:53:03 +09:00
|
|
|
대시보드 이름 <span className="text-destructive">*</span>
|
2025-10-16 16:43:04 +09:00
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="title"
|
|
|
|
|
value={title}
|
|
|
|
|
onChange={(e) => setTitle(e.target.value)}
|
2025-10-20 14:07:08 +09:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}}
|
2025-10-16 16:43:04 +09:00
|
|
|
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)}
|
2025-10-20 14:07:08 +09:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
// 모든 키보드 이벤트를 textarea 내부에서만 처리
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}}
|
2025-10-16 16:43:04 +09:00
|
|
|
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 && (
|
2025-10-29 17:53:03 +09:00
|
|
|
<div className="ml-6 space-y-4 border-l-2 border-border pl-4">
|
2025-10-16 16:43:04 +09:00
|
|
|
{/* 메뉴 타입 선택 */}
|
|
|
|
|
<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">
|
2025-10-29 17:53:03 +09:00
|
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
|
|
|
<span className="ml-2 text-sm text-muted-foreground">메뉴 목록 로딩 중...</span>
|
2025-10-16 16:43:04 +09:00
|
|
|
</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 ? (
|
2025-10-29 17:53:03 +09:00
|
|
|
<div className="px-2 py-3 text-sm text-muted-foreground">사용 가능한 메뉴가 없습니다.</div>
|
2025-10-16 16:43:04 +09:00
|
|
|
) : (
|
|
|
|
|
flatMenus.map((menu) => (
|
|
|
|
|
<SelectItem key={menu.uniqueKey} value={menu.id}>
|
|
|
|
|
{menu.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</SelectGroup>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{selectedMenuId && (
|
2025-10-29 17:53:03 +09:00
|
|
|
<div className="rounded-md bg-muted p-2 text-sm text-foreground">
|
2025-10-16 16:43:04 +09:00
|
|
|
선택된 메뉴:{" "}
|
|
|
|
|
<span className="font-medium">{flatMenus.find((m) => m.id === selectedMenuId)?.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{assignToMenu && selectedMenuId && (
|
2025-10-29 17:53:03 +09:00
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
2025-10-16 16:43:04 +09:00
|
|
|
선택한 메뉴의 URL이 이 대시보드로 자동 설정됩니다.
|
|
|
|
|
{menuType === "admin" && " (관리자 모드 파라미터 포함)"}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-05 16:36:32 +09:00
|
|
|
<ResizableDialogFooter>
|
2025-10-16 16:43:04 +09:00
|
|
|
<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>
|
2025-11-05 16:36:32 +09:00
|
|
|
</ResizableDialogFooter>
|
|
|
|
|
</ResizableDialogContent>
|
|
|
|
|
</ResizableDialog>
|
2025-10-16 16:43:04 +09:00
|
|
|
);
|
|
|
|
|
}
|