ERP-node/frontend/components/admin/MenuManagement.tsx

1137 lines
43 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, useMemo } from "react";
import { menuApi } from "@/lib/api/menu";
import type { MenuItem } from "@/lib/api/menu";
import { MenuTable } from "./MenuTable";
import { MenuFormModal } from "./MenuFormModal";
import { MenuCopyDialog } from "./MenuCopyDialog";
import { Button } from "@/components/ui/button";
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useMenu } from "@/contexts/MenuContext";
import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
import { useMultiLang } from "@/hooks/useMultiLang";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
type MenuType = "admin" | "user";
export const MenuManagement: React.FC = () => {
const { adminMenus, userMenus, refreshMenus } = useMenu();
const { user } = useAuth(); // 현재 사용자 정보 가져오기
const [selectedMenuType, setSelectedMenuType] = useState<MenuType>("admin");
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
const [formModalOpen, setFormModalOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
const [selectedMenuName, setSelectedMenuName] = useState<string>("");
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
// 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
const [localAdminMenus, setLocalAdminMenus] = useState<MenuItem[]>([]);
const [localUserMenus, setLocalUserMenus] = useState<MenuItem[]>([]);
// 다국어 텍스트 훅 사용
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
const { userLang } = useMultiLang({ companyCode: "*" });
// SUPER_ADMIN 여부 확인
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
// 다국어 텍스트 상태
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
const [uiTextsLoading, setUiTextsLoading] = useState(false);
// 회사 목록 상태
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
const [selectedCompany, setSelectedCompany] = useState("all");
const [searchText, setSearchText] = useState("");
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
const [companySearchText, setCompanySearchText] = useState("");
const [isCompanyDropdownOpen, setIsCompanyDropdownOpen] = useState(false);
const [formData, setFormData] = useState({
menuId: "",
parentId: "",
menuType: "",
level: 0,
parentCompanyCode: "",
});
// 언어별 텍스트 매핑 테이블 제거 - DB에서 직접 가져옴
// 메뉴관리 페이지에서 사용할 다국어 키들 (실제 DB에 등록된 키들)
const MENU_MANAGEMENT_LANG_KEYS = [
// 페이지 제목 및 설명
"menu.management.title",
"menu.management.description",
"menu.type.title",
"menu.type.admin",
"menu.type.user",
"menu.management.admin",
"menu.management.user",
"menu.management.admin.description",
"menu.management.user.description",
// 버튼
"button.add",
"button.add.top.level",
"button.add.sub",
"button.edit",
"button.delete",
"button.delete.selected",
"button.delete.selected.count",
"button.delete.processing",
"button.cancel",
"button.save",
"button.register",
"button.modify",
// 필터 및 검색
"filter.company",
"filter.company.all",
"filter.company.common",
"filter.company.search",
"filter.search",
"filter.search.placeholder",
"filter.reset",
// 테이블 헤더
"table.header.select",
"table.header.menu.name",
"table.header.menu.url",
"table.header.menu.type",
"table.header.status",
"table.header.company",
"table.header.sequence",
"table.header.actions",
// 상태
"status.active",
"status.inactive",
"status.unspecified",
// 폼
"form.menu.type",
"form.menu.type.admin",
"form.menu.type.user",
"form.company",
"form.company.select",
"form.company.common",
"form.company.submenu.note",
"form.lang.key",
"form.lang.key.select",
"form.lang.key.none",
"form.lang.key.search",
"form.lang.key.selected",
"form.menu.name",
"form.menu.name.placeholder",
"form.menu.url",
"form.menu.url.placeholder",
"form.menu.description",
"form.menu.description.placeholder",
"form.menu.sequence",
// 모달
"modal.menu.register.title",
"modal.menu.modify.title",
"modal.delete.title",
"modal.delete.description",
"modal.delete.batch.description",
// 메시지
"message.loading",
"message.menu.delete.processing",
"message.menu.save.success",
"message.menu.save.failed",
"message.menu.delete.success",
"message.menu.delete.failed",
"message.menu.delete.batch.success",
"message.menu.delete.batch.partial",
"message.menu.status.toggle.success",
"message.menu.status.toggle.failed",
"message.validation.menu.name.required",
"message.validation.company.required",
"message.validation.select.menu.delete",
"message.error.load.menu.list",
"message.error.load.menu.info",
"message.error.load.company.list",
"message.error.load.lang.key.list",
// 리스트 정보
"menu.list.title",
"menu.list.total",
"menu.list.search.result",
// UI
"ui.expand",
"ui.collapse",
"ui.menu.collapse",
"ui.language",
];
// 초기 로딩
useEffect(() => {
loadCompanies();
loadMenus(false); // 메뉴 목록 로드 (메뉴 관리 화면용 - 모든 상태 표시)
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
if (!userLang) {
initializeDefaultTexts();
}
}, [userLang]); // userLang 변경 시마다 실행
// 초기 기본 텍스트 설정 함수
const initializeDefaultTexts = () => {
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
// 기본 한국어 텍스트 제공
const defaultText = getDefaultText(key);
defaultTexts[key] = defaultText;
});
setUiTexts(defaultTexts);
// console.log("🌐 초기 기본 텍스트 설정 완료:", Object.keys(defaultTexts).length);
};
// 기본 텍스트 반환 함수
const getDefaultText = (key: string): string => {
const defaultTexts: Record<string, string> = {
"menu.management.title": "메뉴 관리",
"menu.management.description": "시스템의 메뉴 구조와 권한을 관리합니다.",
"menu.type.title": "메뉴 타입",
"menu.type.admin": "관리자",
"menu.type.user": "사용자",
"menu.management.admin": "관리자 메뉴",
"menu.management.user": "사용자 메뉴",
"menu.management.admin.description": "시스템 관리 및 설정 메뉴",
"menu.management.user.description": "일반 사용자 업무 메뉴",
"button.add": "추가",
"button.add.top.level": "최상위 메뉴 추가",
"button.add.sub": "하위 메뉴 추가",
"button.edit": "수정",
"button.delete": "삭제",
"button.delete.selected": "선택 삭제",
"button.delete.selected.count": "선택 삭제 ({count})",
"button.delete.processing": "삭제 중...",
"button.cancel": "취소",
"button.save": "저장",
"button.register": "등록",
"button.modify": "수정",
"filter.company": "회사",
"filter.company.all": "전체",
"filter.company.common": "공통",
"filter.company.search": "회사 검색",
"filter.search": "검색",
"filter.search.placeholder": "메뉴명 또는 URL로 검색...",
"filter.reset": "초기화",
"table.header.select": "선택",
"table.header.menu.name": "메뉴명",
"table.header.menu.url": "URL",
"table.header.menu.type": "메뉴 타입",
"table.header.status": "상태",
"table.header.company": "회사",
"table.header.sequence": "순서",
"table.header.actions": "작업",
"status.active": "활성화",
"status.inactive": "비활성화",
"status.unspecified": "미지정",
"form.menu.type": "메뉴 타입",
"form.menu.type.admin": "관리자",
"form.menu.type.user": "사용자",
"form.company": "회사",
"form.company.select": "회사를 선택하세요",
"form.company.common": "공통",
"form.company.submenu.note": "하위 메뉴는 상위 메뉴와 동일한 회사를 가져야 합니다.",
"form.lang.key": "다국어 키",
"form.lang.key.select": "다국어 키를 선택하세요",
"form.lang.key.none": "다국어 키 없음",
"form.lang.key.search": "다국어 키 검색...",
"form.lang.key.selected": "선택된 키: {key} - {description}",
"form.menu.name": "메뉴명",
"form.menu.name.placeholder": "메뉴명을 입력하세요",
"form.menu.url": "URL",
"form.menu.url.placeholder": "메뉴 URL을 입력하세요",
"form.menu.description": "설명",
"form.menu.description.placeholder": "메뉴 설명을 입력하세요",
"form.menu.sequence": "순서",
"modal.menu.register.title": "메뉴 등록",
"modal.menu.modify.title": "메뉴 수정",
"modal.delete.title": "메뉴 삭제",
"modal.delete.description": "해당 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"modal.delete.batch.description":
"선택된 {count}개의 메뉴를 영구적으로 삭제하시겠습니까?\n\n⚠ 주의: 상위 메뉴를 삭제하면 하위 메뉴들도 함께 삭제됩니다.\n이 작업은 되돌릴 수 없습니다.",
"message.loading": "로딩 중...",
"message.menu.delete.processing": "메뉴 삭제 중...",
"message.menu.save.success": "메뉴가 성공적으로 저장되었습니다.",
"message.menu.save.failed": "메뉴 저장에 실패했습니다.",
"message.menu.delete.success": "메뉴가 성공적으로 삭제되었습니다.",
"message.menu.delete.failed": "메뉴 삭제에 실패했습니다.",
"message.menu.delete.batch.success": "선택된 메뉴들이 성공적으로 삭제되었습니다.",
"message.menu.delete.batch.partial": "일부 메뉴 삭제에 실패했습니다.",
"message.menu.status.toggle.success": "메뉴 상태가 변경되었습니다.",
"message.menu.status.toggle.failed": "메뉴 상태 변경에 실패했습니다.",
"message.validation.menu.name.required": "메뉴명을 입력해주세요.",
"message.validation.company.required": "회사를 선택해주세요.",
"message.validation.select.menu.delete": "삭제할 메뉴를 선택해주세요.",
"message.error.load.menu.list": "메뉴 목록을 불러오는데 실패했습니다.",
"message.error.load.menu.info": "메뉴 정보를 불러오는데 실패했습니다.",
"message.error.load.company.list": "회사 목록을 불러오는데 실패했습니다.",
"message.error.load.lang.key.list": "다국어 키 목록을 불러오는데 실패했습니다.",
"menu.list.title": "메뉴 목록",
"menu.list.total": "총 {count}개",
"menu.list.search.result": "검색 결과: {count}개",
"ui.expand": "펼치기",
"ui.collapse": "접기",
"ui.menu.collapse": "메뉴 접기",
"ui.language": "언어",
};
return defaultTexts[key] || key;
};
// 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드
useEffect(() => {
if (userLang && !uiTextsLoading) {
loadUITexts();
}
}, [userLang]); // userLang 변경 시마다 실행
// uiTexts 상태 변경 감지
useEffect(() => {
// console.log("🔄 uiTexts 상태 변경됨:", {
// count: Object.keys(uiTexts).length,
// sampleKeys: Object.keys(uiTexts).slice(0, 5),
// sampleValues: Object.entries(uiTexts)
// .slice(0, 3)
// .map(([k, v]) => `${k}: ${v}`),
// });
}, [uiTexts]);
// 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
useEffect(() => {
const timer = setTimeout(() => {
if (userLang && !uiTextsLoading) {
// console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
loadUITexts();
}
}, 300); // 300ms 후 실행
return () => clearTimeout(timer);
}, [userLang]); // userLang이 설정된 후 실행
// 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드
useEffect(() => {
const fallbackTimer = setTimeout(() => {
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
// console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
if (!userLang) {
initializeDefaultTexts();
} else {
// 사용자 언어가 설정된 경우 다국어 텍스트 로드
loadUITexts();
}
}
}, 1000); // 1초 후 실행
return () => clearTimeout(fallbackTimer);
}, [userLang]); // userLang 변경 시마다 실행
// 번역 로드 이벤트 감지
useEffect(() => {
const handleTranslationLoaded = (event: CustomEvent) => {
const { key, text, userLang: loadedLang } = event.detail;
if (loadedLang === userLang) {
setUiTexts((prev) => ({ ...prev, [key]: text }));
}
};
window.addEventListener("translation-loaded", handleTranslationLoaded as EventListener);
return () => {
window.removeEventListener("translation-loaded", handleTranslationLoaded as EventListener);
};
}, [userLang]);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest(".company-dropdown")) {
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}
};
if (isCompanyDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isCompanyDropdownOpen]);
// 특정 메뉴 타입만 로드하는 함수
const loadMenusForType = async (type: MenuType, showLoading = true) => {
try {
if (showLoading) {
setLoading(true);
}
if (type === "admin") {
const adminResponse = await menuApi.getAdminMenusForManagement();
if (adminResponse.success && adminResponse.data) {
setLocalAdminMenus(adminResponse.data);
}
} else {
const userResponse = await menuApi.getUserMenusForManagement();
if (userResponse.success && userResponse.data) {
setLocalUserMenus(userResponse.data);
}
}
} catch (error) {
toast.error(getUITextSync("message.error.load.menu.list"));
} finally {
if (showLoading) {
setLoading(false);
}
}
};
const loadMenus = async (showLoading = true) => {
// console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
try {
if (showLoading) {
setLoading(true);
}
// 선택된 메뉴 타입에 해당하는 메뉴만 로드
if (selectedMenuType === "admin") {
const adminResponse = await menuApi.getAdminMenusForManagement();
if (adminResponse.success && adminResponse.data) {
setLocalAdminMenus(adminResponse.data);
}
} else {
const userResponse = await menuApi.getUserMenusForManagement();
if (userResponse.success && userResponse.data) {
setLocalUserMenus(userResponse.data);
}
}
// 전역 메뉴 상태도 업데이트 (좌측 사이드바용)
await refreshMenus();
// console.log("📋 메뉴 목록 조회 성공");
} catch (error) {
// console.error("❌ 메뉴 목록 조회 실패:", error);
toast.error(getUITextSync("message.error.load.menu.list"));
} finally {
if (showLoading) {
setLoading(false);
}
}
};
// 회사 목록 조회
const loadCompanies = async () => {
// console.log("🏢 회사 목록 조회 시작");
try {
const response = await apiClient.get("/admin/companies");
if (response.data.success) {
// console.log("🏢 회사 목록 응답:", response.data);
const companyList = response.data.data.map((company: any) => ({
code: company.company_code || company.companyCode,
name: company.company_name || company.companyName,
}));
// console.log("🏢 변환된 회사 목록:", companyList);
setCompanies(companyList);
}
} catch (error) {
// console.error("❌ 회사 목록 조회 실패:", error);
}
};
// 다국어 텍스트 로드 함수 - 배치 API 사용
const loadUITexts = async () => {
if (uiTextsLoading) return; // 이미 로딩 중이면 중단
// userLang이 설정되지 않았으면 기본값 설정
if (!userLang) {
// console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
});
setUiTexts(defaultTexts);
return;
}
// 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
if (Object.keys(uiTexts).length === 0) {
// console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
defaultTexts[key] = getDefaultText(key);
});
setUiTexts(defaultTexts);
}
// console.log("🌐 UI 다국어 텍스트 로드 시작", {
// userLang,
// apiParams: {
// companyCode: "*",
// menuCode: "menu.management",
// userLang: userLang,
// },
// });
setUiTextsLoading(true);
try {
// 배치 API를 사용하여 모든 다국어 키를 한 번에 조회
const response = await apiClient.post(
"/multilang/batch",
{
langKeys: MENU_MANAGEMENT_LANG_KEYS,
companyCode: "*", // 모든 회사
menuCode: "menu.management", // 메뉴관리 메뉴
userLang: userLang, // body에 포함
},
{
params: {}, // query params는 비움
},
);
if (response.data.success) {
const translations = response.data.data;
// console.log("🌐 배치 다국어 텍스트 응답:", translations);
// 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
const mergedTranslations = { ...uiTexts, ...translations };
// console.log("🔧 setUiTexts 호출 전:", {
// translationsCount: Object.keys(translations).length,
// mergedCount: Object.keys(mergedTranslations).length,
// });
setUiTexts(mergedTranslations);
// console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
// 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
setTranslationCache(userLang, mergedTranslations);
} else {
// console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
// API 실패 시에도 기존 uiTexts는 유지
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
}
} catch (error) {
// console.error("❌ UI 다국어 텍스트 로드 실패:", error);
// API 실패 시에도 기존 uiTexts는 유지
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
} finally {
setUiTextsLoading(false);
}
};
// UI 텍스트 가져오기 함수 (동기 버전만 사용)
// getUIText 함수는 제거 - getUITextSync만 사용
// 동기 버전 (DB에서 가져온 번역 텍스트 사용)
const getUITextSync = (key: string, params?: Record<string, string | number>, fallback?: string): string => {
// uiTexts에서 번역 텍스트 찾기
let text = uiTexts[key];
// uiTexts에 없으면 getMenuTextSync로 기본 한글 텍스트 가져오기
if (!text) {
text = getMenuTextSync(key, userLang) || fallback || key;
}
// 파라미터 치환
if (params && text) {
Object.entries(params).forEach(([paramKey, paramValue]) => {
text = text!.replace(`{${paramKey}}`, String(paramValue));
});
}
return text || key;
};
// 다국어 API 테스트 함수 (getUITextSync 사용)
const testMultiLangAPI = async () => {
// console.log("🧪 다국어 API 테스트 시작");
try {
const text = getUITextSync("menu.management.admin");
// console.log("🧪 다국어 API 테스트 결과:", text);
} catch (error) {
// console.error("❌ 다국어 API 테스트 실패:", error);
}
};
// 대문자 키를 소문자 키로 변환하는 함수
const convertMenuData = (data: any[]): MenuItem[] => {
return data.map((item) => ({
objid: item.OBJID || item.objid,
parent_obj_id: item.PARENT_OBJ_ID || item.parent_obj_id,
menu_name_kor: item.MENU_NAME_KOR || item.menu_name_kor,
menu_url: item.MENU_URL || item.menu_url,
menu_desc: item.MENU_DESC || item.menu_desc,
seq: item.SEQ || item.seq,
menu_type: item.MENU_TYPE || item.menu_type,
status: item.STATUS || item.status,
lev: item.LEV || item.lev,
lpad_menu_name_kor: item.LPAD_MENU_NAME_KOR || item.lpad_menu_name_kor,
status_title: item.STATUS_TITLE || item.status_title,
writer: item.WRITER || item.writer,
regdate: item.REGDATE || item.regdate,
company_code: item.COMPANY_CODE || item.company_code,
company_name: item.COMPANY_NAME || item.company_name,
}));
};
const handleAddTopLevelMenu = () => {
setFormData({
menuId: "",
parentId: "0", // 최상위 메뉴는 parentId가 0
menuType: getMenuTypeValue(),
level: 1, // 최상위 메뉴는 level 1
parentCompanyCode: "", // 최상위 메뉴는 상위 회사 정보 없음
});
setFormModalOpen(true);
};
const handleAddMenu = (parentId: string, menuType: string, level: number) => {
// 상위 메뉴의 회사 정보 찾기
const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
const parentMenu = currentMenus.find((menu) => menu.objid === parentId);
setFormData({
menuId: "",
parentId,
menuType,
level: level + 1,
parentCompanyCode: parentMenu?.company_code || "",
});
setFormModalOpen(true);
};
const handleEditMenu = (menuId: string) => {
// console.log("🔧 메뉴 수정 시작 - menuId:", menuId);
// 현재 메뉴 정보 찾기
const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
const menuToEdit = currentMenus.find((menu) => (menu.objid || menu.OBJID) === menuId);
if (menuToEdit) {
// console.log("수정할 메뉴 정보:", menuToEdit);
setFormData({
menuId: menuId,
parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
menuType: selectedMenuType, // 현재 선택된 메뉴 타입
level: 0, // 기본값
parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
});
// console.log("설정된 formData:", {
// menuId: menuId,
// parentId: menuToEdit.parent_obj_id || menuToEdit.PARENT_OBJ_ID || "",
// menuType: selectedMenuType,
// level: 0,
// parentCompanyCode: menuToEdit.company_code || menuToEdit.COMPANY_CODE || "",
// });
} else {
// console.error("수정할 메뉴를 찾을 수 없음:", menuId);
}
setFormModalOpen(true);
};
const handleMenuSelectionChange = (menuId: string, checked: boolean) => {
const newSelected = new Set(selectedMenus);
if (checked) {
newSelected.add(menuId);
} else {
newSelected.delete(menuId);
}
setSelectedMenus(newSelected);
};
const handleSelectAllMenus = (checked: boolean) => {
const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
if (checked) {
// 모든 메뉴 선택 (최상위 메뉴 포함)
setSelectedMenus(new Set(currentMenus.map((menu) => menu.objid || menu.OBJID || "")));
} else {
setSelectedMenus(new Set());
}
};
const handleDeleteSelectedMenus = async () => {
if (selectedMenus.size === 0) {
toast.error(getUITextSync("message.validation.select.menu.delete"));
return;
}
if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) {
return;
}
setDeleting(true);
try {
const menuIds = Array.from(selectedMenus);
// console.log("삭제할 메뉴 IDs:", menuIds);
toast.info(getUITextSync("message.menu.delete.processing"));
const response = await menuApi.deleteMenusBatch(menuIds);
// console.log("삭제 API 응답:", response);
// console.log("응답 구조:", {
// success: response.success,
// data: response.data,
// message: response.message,
// });
if (response.success && response.data) {
const { deletedCount, failedCount } = response.data;
// console.log("삭제 결과:", { deletedCount, failedCount });
// 선택된 메뉴 초기화
setSelectedMenus(new Set());
// 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
// console.log("메뉴 목록 새로고침 시작");
await loadMenus(false);
// 전역 메뉴 상태도 업데이트
await refreshMenus();
// console.log("메뉴 목록 새로고침 완료");
// 삭제 결과 메시지
if (failedCount === 0) {
toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount }));
} else {
toast.success(
getUITextSync("message.menu.delete.batch.partial", {
success: deletedCount,
failed: failedCount,
}),
);
}
} else {
// console.error("삭제 실패:", response);
toast.error(response.message || "메뉴 삭제에 실패했습니다.");
}
} catch (error) {
// console.error("메뉴 삭제 중 오류:", error);
toast.error(getUITextSync("message.menu.delete.failed"));
} finally {
setDeleting(false);
}
};
const confirmDelete = async () => {
try {
const response = await menuApi.deleteMenu(selectedMenuId);
if (response.success) {
toast.success(response.message);
await loadMenus(false);
} else {
toast.error(response.message);
}
} catch (error) {
toast.error("메뉴 삭제에 실패했습니다.");
} finally {
setDeleteDialogOpen(false);
setSelectedMenuId("");
}
};
const handleCopyMenu = (menuId: string, menuName: string) => {
setSelectedMenuId(menuId);
setSelectedMenuName(menuName);
setCopyDialogOpen(true);
};
const handleCopyComplete = async () => {
// 복사 완료 후 메뉴 목록 새로고침
await loadMenus(false);
toast.success("메뉴 복사가 완료되었습니다");
};
const handleToggleStatus = async (menuId: string) => {
try {
const response = await menuApi.toggleMenuStatus(menuId);
if (response.success) {
toast.success(response.message);
await loadMenus(false); // 메뉴 목록 새로고침
// 전역 메뉴 상태도 업데이트
await refreshMenus();
} else {
toast.error(response.message);
}
} catch (error) {
// console.error("메뉴 상태 토글 오류:", error);
toast.error(getUITextSync("message.menu.status.toggle.failed"));
}
};
const handleFormSuccess = () => {
loadMenus(false);
// 전역 메뉴 상태도 업데이트
refreshMenus();
};
const getCurrentMenus = () => {
// 메뉴 관리 화면용: 모든 상태의 메뉴 표시 (localAdminMenus/localUserMenus 사용)
const currentMenus = selectedMenuType === "admin" ? localAdminMenus : localUserMenus;
// 검색어 필터링
let filteredMenus = currentMenus;
if (searchText.trim()) {
const searchLower = searchText.toLowerCase();
filteredMenus = currentMenus.filter((menu) => {
const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
const menuUrl = (menu.menu_url || menu.MENU_URL || "").toLowerCase();
return menuName.includes(searchLower) || menuUrl.includes(searchLower);
});
}
// 회사 필터링
if (selectedCompany !== "all") {
filteredMenus = filteredMenus.filter((menu) => {
const menuCompanyCode = menu.company_code || menu.COMPANY_CODE || "";
return menuCompanyCode === selectedCompany;
});
}
return filteredMenus;
};
// 메뉴 타입 변경 시 선택된 메뉴 초기화
const handleMenuTypeChange = (type: MenuType) => {
setSelectedMenuType(type);
setSelectedMenus(new Set()); // 선택된 메뉴 초기화
setExpandedMenus(new Set()); // 메뉴 타입 변경 시 확장 상태 초기화
// 선택한 메뉴 타입에 해당하는 메뉴만 로드
if (type === "admin" && localAdminMenus.length === 0) {
loadMenusForType("admin", false);
} else if (type === "user" && localUserMenus.length === 0) {
loadMenusForType("user", false);
}
};
const handleToggleExpand = (menuId: string) => {
const newExpandedMenus = new Set(expandedMenus);
if (newExpandedMenus.has(menuId)) {
newExpandedMenus.delete(menuId);
} else {
newExpandedMenus.add(menuId);
}
setExpandedMenus(newExpandedMenus);
};
const getMenuTypeString = () => {
return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user");
};
const getMenuTypeValue = () => {
return selectedMenuType === "admin" ? "0" : "1";
};
// uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산
const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]);
const adminMenusCount = useMemo(() => localAdminMenus?.length || 0, [localAdminMenus]);
const userMenusCount = useMemo(() => localUserMenus?.length || 0, [localUserMenus]);
// 디버깅을 위한 간단한 상태 표시
// console.log("🔍 MenuManagement 렌더링 상태:", {
// loading,
// uiTextsLoading,
// uiTextsCount,
// adminMenusCount,
// userMenusCount,
// selectedMenuType,
// userLang,
// });
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<LoadingOverlay isLoading={deleting} text={getUITextSync("button.delete.processing")}>
<div className="flex h-full gap-6">
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
<div className="w-[20%] border-r pr-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">{getUITextSync("menu.type.title")}</h3>
{/* 메뉴 타입 선택 카드들 */}
<div className="space-y-3">
<div
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-border"
}`}
onClick={() => handleMenuTypeChange("admin")}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.admin")}</h4>
<p className="mt-1 text-xs text-muted-foreground">
{getUITextSync("menu.management.admin.description")}
</p>
</div>
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
{localAdminMenus.length}
</Badge>
</div>
</div>
<div
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-border"
}`}
onClick={() => handleMenuTypeChange("user")}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.user")}</h4>
<p className="mt-1 text-xs text-muted-foreground">
{getUITextSync("menu.management.user.description")}
</p>
</div>
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>
{localUserMenus.length}
</Badge>
</div>
</div>
</div>
</div>
</div>
{/* 우측 메인 영역 - 메뉴 목록 (80%) */}
<div className="w-[80%] pl-0">
<div className="flex h-full flex-col space-y-4">
{/* 상단 헤더: 제목 + 검색 + 버튼 */}
<div className="relative flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 왼쪽: 제목 */}
<h2 className="text-xl font-semibold">
{getMenuTypeString()} {getUITextSync("menu.list.title")}
</h2>
{/* 오른쪽: 검색 + 버튼 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* 회사 선택 */}
<div className="w-full sm:w-[160px]">
<div className="company-dropdown relative">
<button
type="button"
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
{selectedCompany === "all"
? getUITextSync("filter.company.all")
: selectedCompany === "*"
? getUITextSync("filter.company.common")
: companies.find((c) => c.code === selectedCompany)?.name ||
getUITextSync("filter.company.all")}
</span>
<svg
className={`h-4 w-4 transition-transform ${isCompanyDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isCompanyDropdownOpen && (
<div className="absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border bg-popover text-popover-foreground shadow-lg">
<div className="border-b p-2">
<Input
placeholder={getUITextSync("filter.company.search")}
value={companySearchText}
onChange={(e) => setCompanySearchText(e.target.value)}
className="h-8 text-sm"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="max-h-48 overflow-y-auto">
<div
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setSelectedCompany("all");
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{getUITextSync("filter.company.all")}
</div>
<div
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setSelectedCompany("*");
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{getUITextSync("filter.company.common")}
</div>
{companies
.filter((company) => company.code && company.code.trim() !== "")
.filter(
(company) =>
company.name.toLowerCase().includes(companySearchText.toLowerCase()) ||
company.code.toLowerCase().includes(companySearchText.toLowerCase()),
)
.map((company, index) => (
<div
key={company.code || `company-${index}`}
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setSelectedCompany(company.code);
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{company.code === "*" ? getUITextSync("filter.company.common") : company.name}
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* 검색 입력 */}
<div className="w-full sm:w-[240px]">
<Input
id="search"
placeholder={getUITextSync("filter.search.placeholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-10 text-sm"
/>
</div>
{/* 초기화 버튼 */}
<Button
onClick={() => {
setSearchText("");
setSelectedCompany("all");
setCompanySearchText("");
}}
variant="outline"
className="h-10 text-sm font-medium"
>
{getUITextSync("filter.reset")}
</Button>
{/* 최상위 메뉴 추가 */}
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="h-10 gap-2 text-sm font-medium">
{getUITextSync("button.add.top.level")}
</Button>
{/* 선택 삭제 */}
{selectedMenus.size > 0 && (
<Button
variant="destructive"
onClick={handleDeleteSelectedMenus}
disabled={deleting}
className="h-10 gap-2 text-sm font-medium"
>
{deleting ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{getUITextSync("button.delete.processing")}
</>
) : (
getUITextSync("button.delete.selected.count", {
count: selectedMenus.size,
})
)}
</Button>
)}
</div>
</div>
{/* 테이블 영역 */}
<div className="flex-1 overflow-hidden">
<MenuTable
menus={getCurrentMenus()}
title=""
onAddMenu={handleAddMenu}
onEditMenu={handleEditMenu}
onCopyMenu={handleCopyMenu}
onToggleStatus={handleToggleStatus}
selectedMenus={selectedMenus}
onMenuSelectionChange={handleMenuSelectionChange}
onSelectAllMenus={handleSelectAllMenus}
expandedMenus={expandedMenus}
onToggleExpand={handleToggleExpand}
uiTexts={uiTexts}
isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달
/>
</div>
</div>
</div>
</div>
<MenuFormModal
isOpen={formModalOpen}
onClose={() => setFormModalOpen(false)}
onSuccess={handleFormSuccess}
menuId={formData.menuId}
parentId={formData.parentId}
menuType={formData.menuType}
level={formData.level}
parentCompanyCode={formData.parentCompanyCode}
uiTexts={uiTexts}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<MenuCopyDialog
menuObjid={selectedMenuId ? parseInt(selectedMenuId, 10) : null}
menuName={selectedMenuName}
open={copyDialogOpen}
onOpenChange={setCopyDialogOpen}
onCopyComplete={handleCopyComplete}
/>
</LoadingOverlay>
);
};