ERP-node/frontend/components/screen/MenuAssignmentModal.tsx

661 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogHeader,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Search, Monitor, Settings, X, Plus } from "lucide-react";
import { menuScreenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import type { MenuItem } from "@/lib/api/menu";
import { ScreenDefinition } from "@/types/screen";
interface MenuAssignmentModalProps {
isOpen: boolean;
onClose: () => void;
screenInfo: ScreenDefinition | null;
onAssignmentComplete?: () => void;
onBackToList?: () => void; // 화면 목록으로 돌아가는 콜백 추가
}
export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
isOpen,
onClose,
screenInfo,
onAssignmentComplete,
onBackToList,
}) => {
const [menus, setMenus] = useState<MenuItem[]>([]);
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
const [selectedMenu, setSelectedMenu] = useState<MenuItem | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(false);
const [assigning, setAssigning] = useState(false);
const [existingScreens, setExistingScreens] = useState<ScreenDefinition[]>([]);
const [showReplaceDialog, setShowReplaceDialog] = useState(false);
const [assignmentSuccess, setAssignmentSuccess] = useState(false);
const [assignmentMessage, setAssignmentMessage] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const autoRedirectTimerRef = useRef<NodeJS.Timeout | null>(null);
// 메뉴 목록 로드 (관리자 메뉴 + 사용자 메뉴)
const loadMenus = async () => {
try {
setLoading(true);
// 관리자 메뉴 가져오기
const adminResponse = await apiClient.get("/admin/menus", { params: { menuType: "0" } });
const adminMenus = adminResponse.data?.data || [];
// 사용자 메뉴 가져오기
const userResponse = await apiClient.get("/admin/menus", { params: { menuType: "1" } });
const userMenus = userResponse.data?.data || [];
// 메뉴 정규화 함수
const normalizeMenu = (menu: any) => ({
objid: menu.objid || menu.OBJID,
parent_obj_id: menu.parent_obj_id || menu.PARENT_OBJ_ID,
menu_name_kor: menu.menu_name_kor || menu.MENU_NAME_KOR,
menu_url: menu.menu_url || menu.MENU_URL,
menu_desc: menu.menu_desc || menu.MENU_DESC,
seq: menu.seq || menu.SEQ,
menu_type: menu.menu_type || menu.MENU_TYPE,
status: menu.status || menu.STATUS,
lev: menu.lev || menu.LEV,
company_code: menu.company_code || menu.COMPANY_CODE,
company_name: menu.company_name || menu.COMPANY_NAME,
});
// 관리자 메뉴 정규화
const normalizedAdminMenus = adminMenus.map((menu: any) => normalizeMenu(menu));
// 사용자 메뉴 정규화
const normalizedUserMenus = userMenus.map((menu: any) => normalizeMenu(menu));
// 모든 메뉴 합치기
const allMenus = [...normalizedAdminMenus, ...normalizedUserMenus];
// console.log("로드된 전체 메뉴 목록:", {
// totalAdmin: normalizedAdminMenus.length,
// totalUser: normalizedUserMenus.length,
// total: allMenus.length,
// });
setMenus(allMenus);
} catch (error) {
// console.error("메뉴 목록 로드 실패:", error);
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
// 모달이 열릴 때 메뉴 목록 로드 및 정리
useEffect(() => {
if (isOpen) {
loadMenus();
setSelectedMenuId("");
setSelectedMenu(null);
setSearchTerm("");
setAssignmentSuccess(false);
setAssignmentMessage("");
} else {
// 모달이 닫힐 때 타이머 정리
if (autoRedirectTimerRef.current) {
clearTimeout(autoRedirectTimerRef.current);
autoRedirectTimerRef.current = null;
}
}
// 컴포넌트 언마운트 시 타이머 정리
return () => {
if (autoRedirectTimerRef.current) {
clearTimeout(autoRedirectTimerRef.current);
autoRedirectTimerRef.current = null;
}
};
}, [isOpen]);
// 메뉴 선택 처리
const handleMenuSelect = async (menuId: string) => {
// 유효하지 않은 메뉴 ID인 경우 처리하지 않음
if (!menuId || menuId === "no-menu") {
setSelectedMenuId("");
setSelectedMenu(null);
setExistingScreens([]);
return;
}
setSelectedMenuId(menuId);
const menu = menus.find((m) => m.objid?.toString() === menuId);
setSelectedMenu(menu || null);
// 선택된 메뉴에 할당된 화면들 확인
if (menu) {
try {
const menuObjid = parseInt(menu.objid?.toString() || "0");
if (menuObjid > 0) {
const screens = await menuScreenApi.getScreensByMenu(menuObjid);
setExistingScreens(screens);
// console.log(`메뉴 "${menu.menu_name_kor}"에 할당된 화면:`, screens);
}
} catch (error) {
// console.error("할당된 화면 조회 실패:", error);
setExistingScreens([]);
}
}
};
// 화면 할당 처리
const handleAssignScreen = async () => {
if (!selectedMenu || !screenInfo) {
toast.error("메뉴와 화면 정보가 필요합니다.");
return;
}
// 기존에 할당된 화면이 있는지 확인
if (existingScreens.length > 0) {
// 이미 같은 화면이 할당되어 있는지 확인
const alreadyAssigned = existingScreens.some((screen) => screen.screenId === screenInfo.screenId);
if (alreadyAssigned) {
toast.info("이미 해당 메뉴에 할당된 화면입니다.");
return;
}
// 다른 화면이 할당되어 있으면 교체 확인
setShowReplaceDialog(true);
return;
}
// 기존 화면이 없으면 바로 할당
await performAssignment();
};
// 실제 할당 수행
const performAssignment = async (replaceExisting = false) => {
if (!selectedMenu || !screenInfo) return;
try {
setAssigning(true);
const menuObjid = parseInt(selectedMenu.objid?.toString() || "0");
if (menuObjid === 0) {
toast.error("유효하지 않은 메뉴 ID입니다.");
return;
}
// 기존 화면 교체인 경우 기존 화면들 먼저 제거
if (replaceExisting && existingScreens.length > 0) {
// console.log("기존 화면들 제거 중...", existingScreens);
for (const existingScreen of existingScreens) {
try {
await menuScreenApi.unassignScreenFromMenu(existingScreen.screenId, menuObjid);
// console.log(`기존 화면 "${existingScreen.screenName}" 제거 완료`);
} catch (error) {
// console.error(`기존 화면 "${existingScreen.screenName}" 제거 실패:`, error);
}
}
}
// 새 화면 할당
await menuScreenApi.assignScreenToMenu(screenInfo.screenId, menuObjid);
const successMessage = replaceExisting
? `기존 화면을 제거하고 "${screenInfo.screenName}" 화면이 "${selectedMenu.menu_name_kor}" 메뉴에 할당되었습니다.`
: `"${screenInfo.screenName}" 화면이 "${selectedMenu.menu_name_kor}" 메뉴에 성공적으로 할당되었습니다.`;
// 성공 상태 설정
setAssignmentSuccess(true);
setAssignmentMessage(successMessage);
// 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
if (onAssignmentComplete) {
onAssignmentComplete();
}
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
autoRedirectTimerRef.current = setTimeout(() => {
onClose(); // 모달 닫기
if (onBackToList) {
onBackToList();
}
}, 3000);
} catch (error: any) {
// console.error("화면 할당 실패:", error);
const errorMessage = error.response?.data?.message || "화면 할당에 실패했습니다.";
toast.error(errorMessage);
} finally {
setAssigning(false);
}
};
// "나중에 할당" 처리 - 시각적 효과 포함
const handleAssignLater = () => {
if (!screenInfo) return;
// 성공 상태 설정 (나중에 할당 메시지)
setAssignmentSuccess(true);
setAssignmentMessage(`"${screenInfo.screenName}" 화면이 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다.`);
// 할당 완료 콜백 호출 (모달은 아직 열린 상태 유지)
if (onAssignmentComplete) {
onAssignmentComplete();
}
// 3초 후 자동으로 모달 닫고 화면 목록으로 이동
autoRedirectTimerRef.current = setTimeout(() => {
onClose(); // 모달 닫기
if (onBackToList) {
onBackToList();
}
}, 3000);
};
// 필터된 메뉴 목록
const filteredMenus = menus.filter((menu) => {
if (!searchTerm) return true;
const searchLower = searchTerm.toLowerCase();
return (
menu.menu_name_kor?.toLowerCase().includes(searchLower) ||
menu.menu_url?.toLowerCase().includes(searchLower) ||
menu.menu_desc?.toLowerCase().includes(searchLower)
);
});
// 메뉴 옵션 생성 (계층 구조 표시, 타입별 그룹화)
const getMenuOptions = (): React.ReactNode[] => {
if (loading) {
return [
<SelectItem key="loading" value="loading" disabled>
...
</SelectItem>,
];
}
if (filteredMenus.length === 0) {
return [
<SelectItem key="no-menu" value="no-menu" disabled>
{searchTerm ? `"${searchTerm}"에 대한 검색 결과가 없습니다` : "메뉴가 없습니다"}
</SelectItem>,
];
}
// 관리자 메뉴와 사용자 메뉴 분리
const adminMenus = filteredMenus.filter(
(menu) => menu.menu_type === "0" && menu.objid && menu.objid.toString().trim() !== "",
);
const userMenus = filteredMenus.filter(
(menu) => menu.menu_type === "1" && menu.objid && menu.objid.toString().trim() !== "",
);
const options: React.ReactNode[] = [];
// 관리자 메뉴 섹션
if (adminMenus.length > 0) {
options.push(
<div key="admin-header" className="bg-blue-50 px-2 py-1.5 text-xs font-semibold text-blue-600">
👤
</div>,
);
adminMenus.forEach((menu) => {
const indent = " ".repeat(Math.max(0, menu.lev || 0));
const menuId = menu.objid!.toString();
options.push(
<SelectItem key={menuId} value={menuId}>
{indent}
{menu.menu_name_kor}
</SelectItem>,
);
});
}
// 사용자 메뉴 섹션
if (userMenus.length > 0) {
if (adminMenus.length > 0) {
options.push(<div key="separator" className="my-1 border-t" />);
}
options.push(
<div key="user-header" className="bg-green-50 px-2 py-1.5 text-xs font-semibold text-green-600">
👥
</div>,
);
userMenus.forEach((menu) => {
const indent = " ".repeat(Math.max(0, menu.lev || 0));
const menuId = menu.objid!.toString();
options.push(
<SelectItem key={menuId} value={menuId}>
{indent}
{menu.menu_name_kor}
</SelectItem>,
);
});
}
return options;
};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
{assignmentSuccess ? (
// 성공 화면
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
{assignmentMessage.includes("나중에") ? "화면 저장 완료" : "화면 할당 완료"}
</DialogTitle>
<DialogDescription>
{assignmentMessage.includes("나중에")
? "화면이 성공적으로 저장되었습니다. 나중에 메뉴에 할당할 수 있습니다."
: "화면이 성공적으로 메뉴에 할당되었습니다."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border bg-green-50 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
<Monitor className="h-5 w-5 text-green-600" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-green-900">{assignmentMessage}</p>
<p className="mt-1 text-xs text-green-700">3 ...</p>
</div>
</div>
</div>
<div className="flex items-center justify-center space-x-2">
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.3s]"></div>
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500 [animation-delay:-0.15s]"></div>
<div className="h-2 w-2 animate-bounce rounded-full bg-green-500"></div>
</div>
</div>
<DialogFooter>
<Button
onClick={() => {
// 타이머 정리
if (autoRedirectTimerRef.current) {
clearTimeout(autoRedirectTimerRef.current);
autoRedirectTimerRef.current = null;
}
// 화면 목록으로 이동
if (onBackToList) {
onBackToList();
} else {
onClose();
}
}}
className="bg-green-600 text-white hover:bg-green-700"
>
<Monitor className="mr-2 h-4 w-4" />
</Button>
</DialogFooter>
</>
) : (
// 기본 할당 화면
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
.
</DialogDescription>
{screenInfo && (
<div className="bg-accent mt-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
<Monitor className="text-primary h-4 w-4" />
<span className="font-medium text-blue-900">{screenInfo.screenName}</span>
<Badge variant="outline" className="font-mono text-xs">
{screenInfo.screenCode}
</Badge>
</div>
{screenInfo.description && <p className="mt-1 text-sm text-blue-700">{screenInfo.description}</p>}
</div>
)}
</DialogHeader>
<div className="space-y-4">
{/* 메뉴 선택 (검색 기능 포함) */}
<div>
<Label htmlFor="menu-select"> </Label>
<Select
value={selectedMenuId}
onValueChange={handleMenuSelect}
disabled={loading}
onOpenChange={(open) => {
if (open) {
// Select가 열릴 때 검색창에 포커스
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
}
}}
>
<SelectTrigger>
<SelectValue placeholder={loading ? "메뉴 로딩 중..." : "메뉴를 선택하세요"} />
</SelectTrigger>
<SelectContent className="max-h-64">
{/* 검색 입력 필드 */}
<div
className="sticky top-0 z-10 border-b bg-white p-2"
onKeyDown={(e) => {
// 이 div 내에서 발생하는 모든 키 이벤트를 차단
e.stopPropagation();
}}
>
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<input
ref={searchInputRef}
type="text"
placeholder="메뉴명, URL, 설명으로 검색..."
value={searchTerm}
autoFocus
onChange={(e) => {
e.stopPropagation();
setSearchTerm(e.target.value);
}}
onKeyDown={(e) => {
// 이벤트가 Select로 전파되지 않도록 완전 차단
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-2 pr-8 pl-10 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
{searchTerm && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSearchTerm("");
}}
className="hover:text-muted-foreground absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 transform text-gray-400"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* 메뉴 옵션들 */}
<div className="max-h-48 overflow-y-auto">{getMenuOptions()}</div>
</SelectContent>
</Select>
</div>
{/* 선택된 메뉴 정보 */}
{selectedMenu && (
<div className="rounded-lg border bg-gray-50 p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{selectedMenu.menu_name_kor}</h4>
<Badge variant={selectedMenu.menu_type === "0" ? "default" : "secondary"}>
{selectedMenu.menu_type === "0" ? "관리자" : "사용자"}
</Badge>
<Badge variant={selectedMenu.status === "active" ? "default" : "outline"}>
{selectedMenu.status === "active" ? "활성" : "비활성"}
</Badge>
</div>
<div className="text-muted-foreground mt-1 space-y-1 text-sm">
{selectedMenu.menu_url && <p>URL: {selectedMenu.menu_url}</p>}
{selectedMenu.menu_desc && <p>: {selectedMenu.menu_desc}</p>}
{selectedMenu.company_name && <p>: {selectedMenu.company_name}</p>}
</div>
{/* 기존 할당된 화면 정보 */}
{existingScreens.length > 0 && (
<div className="mt-3 rounded border bg-yellow-50 p-2">
<p className="text-sm font-medium text-yellow-800">
({existingScreens.length})
</p>
<div className="mt-1 space-y-1">
{existingScreens.map((screen) => (
<div key={screen.screenId} className="flex items-center gap-2 text-xs text-yellow-700">
<Monitor className="h-3 w-3" />
<span>{screen.screenName}</span>
<Badge variant="outline" className="text-xs">
{screen.screenCode}
</Badge>
</div>
))}
</div>
<p className="mt-1 text-xs text-yellow-600"> .</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleAssignLater} disabled={assigning}>
<X className="mr-2 h-4 w-4" />
</Button>
<Button
onClick={handleAssignScreen}
disabled={!selectedMenu || assigning}
className="bg-blue-600 text-white hover:bg-blue-700"
>
{assigning ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
<>
<Settings className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
{/* 화면 교체 확인 대화상자 */}
<Dialog open={showReplaceDialog} onOpenChange={setShowReplaceDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5 text-orange-600" />
</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 기존 화면 목록 */}
<div className="bg-destructive/10 rounded-lg border p-3">
<p className="mb-2 text-sm font-medium text-red-800"> ({existingScreens.length}):</p>
<div className="space-y-1">
{existingScreens.map((screen) => (
<div key={screen.screenId} className="flex items-center gap-2 text-sm text-red-700">
<X className="h-3 w-3" />
<span>{screen.screenName}</span>
<Badge variant="outline" className="text-xs">
{screen.screenCode}
</Badge>
</div>
))}
</div>
</div>
{/* 새로 할당될 화면 */}
{screenInfo && (
<div className="rounded-lg border bg-green-50 p-3">
<p className="mb-2 text-sm font-medium text-green-800"> :</p>
<div className="flex items-center gap-2 text-sm text-green-700">
<Plus className="h-3 w-3" />
<span>{screenInfo.screenName}</span>
<Badge variant="outline" className="text-xs">
{screenInfo.screenCode}
</Badge>
</div>
</div>
)}
<div className="rounded-lg border-l-4 border-orange-400 bg-orange-50 p-3">
<p className="text-sm text-orange-800">
<strong>:</strong> .
.
</p>
</div>
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setShowReplaceDialog(false)} disabled={assigning}>
</Button>
<Button
onClick={async () => {
setShowReplaceDialog(false);
await performAssignment(true);
}}
disabled={assigning}
className="bg-orange-600 text-white hover:bg-orange-700"
>
{assigning ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
<>
<Monitor className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};