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

564 lines
22 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 } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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 { Input } from "@/components/ui/input";
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 loadMenus = async () => {
try {
setLoading(true);
// 화면관리는 관리자 전용 기능이므로 관리자 메뉴만 가져오기
const adminResponse = await apiClient.get("/admin/menus", { params: { menuType: "0" } });
const adminMenus = adminResponse.data?.data || [];
// 관리자 메뉴 정규화
const normalizedAdminMenus = adminMenus.map((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: "0", // 관리자 메뉴
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,
}));
console.log("로드된 관리자 메뉴 목록:", {
total: normalizedAdminMenus.length,
sample: normalizedAdminMenus.slice(0, 3),
});
setMenus(normalizedAdminMenus);
} catch (error) {
console.error("메뉴 목록 로드 실패:", error);
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
// 모달이 열릴 때 메뉴 목록 로드
useEffect(() => {
if (isOpen) {
loadMenus();
setSelectedMenuId("");
setSelectedMenu(null);
setSearchTerm("");
setAssignmentSuccess(false);
setAssignmentMessage("");
}
}, [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초 후 자동으로 화면 목록으로 이동
setTimeout(() => {
if (onBackToList) {
onBackToList();
} else {
onClose();
}
}, 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초 후 자동으로 화면 목록으로 이동
setTimeout(() => {
if (onBackToList) {
onBackToList();
} else {
onClose();
}
}, 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 = (): JSX.Element[] => {
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>,
];
}
return filteredMenus
.filter((menu) => menu.objid && menu.objid.toString().trim() !== "") // objid가 유효한 메뉴만 필터링
.map((menu) => {
const indent = " ".repeat(Math.max(0, menu.lev || 0));
const menuId = menu.objid!.toString(); // 이미 필터링했으므로 non-null assertion 사용
return (
<SelectItem key={menuId} value={menuId}>
{indent}
{menu.menu_name_kor}
</SelectItem>
);
});
};
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 (onBackToList) {
onBackToList();
} else {
onClose();
}
}}
className="bg-green-600 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="mt-2 rounded-lg border bg-blue-50 p-3">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-blue-600" />
<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}>
<SelectTrigger>
<SelectValue placeholder={loading ? "메뉴 로딩 중..." : "메뉴를 선택하세요"} />
</SelectTrigger>
<SelectContent className="max-h-64">
{/* 검색 입력 필드 */}
<div className="sticky top-0 z-10 border-b bg-white p-2">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input
placeholder="메뉴명, URL, 설명으로 검색..."
value={searchTerm}
onChange={(e) => {
e.stopPropagation(); // 이벤트 전파 방지
setSearchTerm(e.target.value);
}}
onKeyDown={(e) => {
e.stopPropagation(); // 키보드 이벤트 전파 방지
}}
onClick={(e) => {
e.stopPropagation(); // 클릭 이벤트 전파 방지
}}
className="h-8 pr-8 pl-10 text-sm"
/>
{searchTerm && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSearchTerm("");
}}
className="absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 transform text-gray-400 hover:text-gray-600"
>
<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="default"></Badge>
<Badge variant={selectedMenu.status === "active" ? "default" : "outline"}>
{selectedMenu.status === "active" ? "활성" : "비활성"}
</Badge>
</div>
<div className="mt-1 space-y-1 text-sm text-gray-600">
{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 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="rounded-lg border bg-red-50 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 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>
</>
);
};