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

1102 lines
44 KiB
TypeScript
Raw Normal View History

2025-08-21 09:41:46 +09:00
"use client";
2025-08-25 17:22:20 +09:00
import React, { useState, useEffect, useMemo } from "react";
2025-08-21 09:41:46 +09:00
import { menuApi } from "@/lib/api/menu";
import type { MenuItem } from "@/lib/api/menu";
import { MenuTable } from "./MenuTable";
import { MenuFormModal } from "./MenuFormModal";
import { Button } from "@/components/ui/button";
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
2025-09-01 18:42:59 +09:00
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2025-08-21 09:41:46 +09:00
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useMenu } from "@/contexts/MenuContext";
2025-08-25 17:22:20 +09:00
import { useMenuManagementText, setTranslationCache } from "@/lib/utils/multilang";
2025-08-21 09:41:46 +09:00
import { useMultiLang } from "@/hooks/useMultiLang";
import { apiClient } from "@/lib/api/client";
2025-09-01 18:42:59 +09:00
import { ScreenAssignmentTab } from "./ScreenAssignmentTab";
2025-08-21 09:41:46 +09:00
type MenuType = "admin" | "user";
export const MenuManagement: React.FC = () => {
const { adminMenus, userMenus, refreshMenus } = useMenu();
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 [selectedMenuId, setSelectedMenuId] = useState<string>("");
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
// 다국어 텍스트 훅 사용
2025-08-25 17:22:20 +09:00
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
2025-08-21 09:41:46 +09:00
const { userLang } = useMultiLang({ companyCode: "*" });
// 다국어 텍스트 상태
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: "",
});
2025-08-25 17:22:20 +09:00
// 언어별 텍스트 매핑 테이블 제거 - 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",
];
2025-08-21 09:41:46 +09:00
// 초기 로딩
useEffect(() => {
loadCompanies();
2025-08-25 17:56:33 +09:00
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
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);
2025-08-25 17:56:33 +09:00
};
// 기본 텍스트 반환 함수
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;
};
2025-08-21 09:41:46 +09:00
// 컴포넌트 마운트 시 및 userLang 변경 시 다국어 텍스트 로드
useEffect(() => {
2025-08-25 17:56:33 +09:00
if (userLang && !uiTextsLoading) {
2025-08-21 09:41:46 +09:00
loadUITexts();
}
}, [userLang]); // userLang 변경 시마다 실행
2025-08-25 17:22:20 +09:00
// 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}`),
// });
2025-08-25 17:22:20 +09:00
}, [uiTexts]);
2025-08-25 17:56:33 +09:00
// 컴포넌트 마운트 후 다국어 텍스트 강제 로드 (userLang이 아직 설정되지 않았을 수 있음)
2025-08-21 09:41:46 +09:00
useEffect(() => {
const timer = setTimeout(() => {
2025-08-25 17:56:33 +09:00
if (userLang && !uiTextsLoading) {
// console.log("🔄 컴포넌트 마운트 후 다국어 텍스트 강제 로드");
2025-08-21 09:41:46 +09:00
loadUITexts();
}
2025-08-25 17:56:33 +09:00
}, 300); // 300ms 후 실행
2025-08-21 09:41:46 +09:00
return () => clearTimeout(timer);
2025-08-25 17:56:33 +09:00
}, [userLang]); // userLang이 설정된 후 실행
// 추가 안전장치: 컴포넌트 마운트 후 일정 시간이 지나면 강제로 다국어 텍스트 로드
useEffect(() => {
const fallbackTimer = setTimeout(() => {
if (!uiTextsLoading && Object.keys(uiTexts).length === 0) {
// console.log("🔄 안전장치: 컴포넌트 마운트 후 강제 다국어 텍스트 로드");
2025-08-25 17:56:33 +09:00
// 사용자 언어가 설정되지 않았을 때만 기본 텍스트 설정
if (!userLang) {
initializeDefaultTexts();
} else {
// 사용자 언어가 설정된 경우 다국어 텍스트 로드
loadUITexts();
}
}
}, 1000); // 1초 후 실행
return () => clearTimeout(fallbackTimer);
}, [userLang]); // userLang 변경 시마다 실행
2025-08-21 09:41:46 +09:00
// 번역 로드 이벤트 감지
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 loadMenus = async (showLoading = true) => {
// console.log(`📋 메뉴 목록 조회 시작 (showLoading: ${showLoading})`);
2025-08-21 09:41:46 +09:00
try {
if (showLoading) {
setLoading(true);
}
await refreshMenus();
// console.log("📋 메뉴 목록 조회 성공");
2025-08-21 09:41:46 +09:00
} catch (error) {
// console.error("❌ 메뉴 목록 조회 실패:", error);
2025-08-25 17:22:20 +09:00
toast.error(getUITextSync("message.error.load.menu.list"));
2025-08-21 09:41:46 +09:00
} finally {
if (showLoading) {
setLoading(false);
}
}
};
// 회사 목록 조회
const loadCompanies = async () => {
// console.log("🏢 회사 목록 조회 시작");
2025-08-21 09:41:46 +09:00
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);
2025-08-21 09:41:46 +09:00
}
} catch (error) {
// console.error("❌ 회사 목록 조회 실패:", error);
2025-08-21 09:41:46 +09:00
}
};
2025-08-25 17:22:20 +09:00
// 다국어 텍스트 로드 함수 - 배치 API 사용
2025-08-21 09:41:46 +09:00
const loadUITexts = async () => {
if (uiTextsLoading) return; // 이미 로딩 중이면 중단
2025-08-25 17:22:20 +09:00
// userLang이 설정되지 않았으면 기본값 설정
if (!userLang) {
// console.log("🌐 사용자 언어가 설정되지 않음, 기본값 설정");
2025-08-25 17:22:20 +09:00
const defaultTexts: Record<string, string> = {};
MENU_MANAGEMENT_LANG_KEYS.forEach((key) => {
2025-08-25 17:56:33 +09:00
defaultTexts[key] = getDefaultText(key); // 기본 한국어 텍스트 사용
2025-08-25 17:22:20 +09:00
});
setUiTexts(defaultTexts);
return;
}
2025-08-25 17:56:33 +09:00
// 사용자 언어가 설정된 경우, 기존 uiTexts가 비어있으면 기본 텍스트로 초기화
if (Object.keys(uiTexts).length === 0) {
// console.log("🌐 기존 uiTexts가 비어있음, 기본 텍스트로 초기화");
2025-08-25 17:56:33 +09:00
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,
// },
// });
2025-08-21 09:41:46 +09:00
setUiTextsLoading(true);
try {
2025-08-25 17:22:20 +09:00
// 배치 API를 사용하여 모든 다국어 키를 한 번에 조회
const response = await apiClient.post(
"/multilang/batch",
{
langKeys: MENU_MANAGEMENT_LANG_KEYS,
companyCode: "*", // 모든 회사
menuCode: "menu.management", // 메뉴관리 메뉴
userLang: userLang, // body에 포함
},
{
params: {}, // query params는 비움
},
);
2025-08-21 09:41:46 +09:00
2025-08-25 17:22:20 +09:00
if (response.data.success) {
const translations = response.data.data;
// console.log("🌐 배치 다국어 텍스트 응답:", translations);
2025-08-21 09:41:46 +09:00
2025-08-25 17:56:33 +09:00
// 번역 결과를 상태에 저장 (기존 uiTexts와 병합)
const mergedTranslations = { ...uiTexts, ...translations };
// console.log("🔧 setUiTexts 호출 전:", {
// translationsCount: Object.keys(translations).length,
// mergedCount: Object.keys(mergedTranslations).length,
// });
2025-08-25 17:56:33 +09:00
setUiTexts(mergedTranslations);
// console.log("🔧 setUiTexts 호출 후 - mergedTranslations:", mergedTranslations);
2025-08-21 09:41:46 +09:00
2025-08-25 17:22:20 +09:00
// 번역 캐시에 저장 (다른 컴포넌트에서도 사용할 수 있도록)
2025-08-25 17:56:33 +09:00
setTranslationCache(userLang, mergedTranslations);
2025-08-25 17:22:20 +09:00
} else {
// console.error("❌ 다국어 텍스트 배치 조회 실패:", response.data.message);
2025-08-25 17:56:33 +09:00
// API 실패 시에도 기존 uiTexts는 유지
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
2025-08-25 17:22:20 +09:00
}
2025-08-21 09:41:46 +09:00
} catch (error) {
// console.error("❌ UI 다국어 텍스트 로드 실패:", error);
2025-08-25 17:56:33 +09:00
// API 실패 시에도 기존 uiTexts는 유지
// console.log("🔄 API 실패로 인해 기존 uiTexts 유지");
2025-08-21 09:41:46 +09:00
} finally {
setUiTextsLoading(false);
}
};
2025-08-25 17:22:20 +09:00
// UI 텍스트 가져오기 함수 (동기 버전만 사용)
// getUIText 함수는 제거 - getUITextSync만 사용
2025-08-21 09:41:46 +09:00
2025-08-25 17:22:20 +09:00
// 동기 버전 (DB에서 가져온 번역 텍스트 사용)
2025-08-21 09:41:46 +09:00
const getUITextSync = (key: string, params?: Record<string, string | number>, fallback?: string): string => {
2025-08-25 17:22:20 +09:00
// uiTexts에서 번역 텍스트 찾기
2025-08-21 09:41:46 +09:00
let text = uiTexts[key];
2025-08-25 17:56:33 +09:00
// uiTexts에 없으면 fallback 또는 키 사용
2025-08-21 09:41:46 +09:00
if (!text) {
text = fallback || key;
}
// 파라미터 치환
if (params && text) {
Object.entries(params).forEach(([paramKey, paramValue]) => {
text = text!.replace(`{${paramKey}}`, String(paramValue));
});
}
return text || key;
};
2025-08-25 17:22:20 +09:00
// 다국어 API 테스트 함수 (getUITextSync 사용)
2025-08-21 09:41:46 +09:00
const testMultiLangAPI = async () => {
// console.log("🧪 다국어 API 테스트 시작");
2025-08-21 09:41:46 +09:00
try {
2025-08-25 17:22:20 +09:00
const text = getUITextSync("menu.management.admin");
// console.log("🧪 다국어 API 테스트 결과:", text);
2025-08-21 09:41:46 +09:00
} catch (error) {
// console.error("❌ 다국어 API 테스트 실패:", error);
2025-08-21 09:41:46 +09:00
}
};
// 대문자 키를 소문자 키로 변환하는 함수
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" ? adminMenus : userMenus;
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" ? adminMenus : userMenus;
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);
}
2025-08-21 09:41:46 +09:00
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" ? adminMenus : userMenus;
if (checked) {
// 모든 메뉴 선택 (최상위 메뉴 포함)
setSelectedMenus(new Set(currentMenus.map((menu) => menu.objid || menu.OBJID || "")));
} else {
setSelectedMenus(new Set());
}
};
const handleDeleteSelectedMenus = async () => {
if (selectedMenus.size === 0) {
2025-08-25 17:22:20 +09:00
toast.error(getUITextSync("message.validation.select.menu.delete"));
2025-08-21 09:41:46 +09:00
return;
}
2025-08-25 17:22:20 +09:00
if (!confirm(getUITextSync("modal.delete.batch.description", { count: selectedMenus.size }))) {
2025-08-21 09:41:46 +09:00
return;
}
setDeleting(true);
try {
const menuIds = Array.from(selectedMenus);
// console.log("삭제할 메뉴 IDs:", menuIds);
2025-08-21 09:41:46 +09:00
2025-08-25 17:22:20 +09:00
toast.info(getUITextSync("message.menu.delete.processing"));
2025-08-21 09:41:46 +09:00
const response = await menuApi.deleteMenusBatch(menuIds);
// console.log("삭제 API 응답:", response);
// console.log("응답 구조:", {
// success: response.success,
// data: response.data,
// message: response.message,
// });
2025-08-21 09:41:46 +09:00
if (response.success && response.data) {
const { deletedCount, failedCount } = response.data;
// console.log("삭제 결과:", { deletedCount, failedCount });
2025-08-21 09:41:46 +09:00
// 선택된 메뉴 초기화
setSelectedMenus(new Set());
// 메뉴 목록 즉시 새로고침 (로딩 상태 없이)
// console.log("메뉴 목록 새로고침 시작");
2025-08-21 09:41:46 +09:00
await loadMenus(false);
// 전역 메뉴 상태도 업데이트
await refreshMenus();
// console.log("메뉴 목록 새로고침 완료");
2025-08-21 09:41:46 +09:00
// 삭제 결과 메시지
if (failedCount === 0) {
2025-08-25 17:22:20 +09:00
toast.success(getUITextSync("message.menu.delete.batch.success", { count: deletedCount }));
2025-08-21 09:41:46 +09:00
} else {
toast.success(
2025-08-25 17:22:20 +09:00
getUITextSync("message.menu.delete.batch.partial", {
2025-08-21 09:41:46 +09:00
success: deletedCount,
failed: failedCount,
}),
);
}
} else {
// console.error("삭제 실패:", response);
2025-08-21 09:41:46 +09:00
toast.error(response.message || "메뉴 삭제에 실패했습니다.");
}
} catch (error) {
// console.error("메뉴 삭제 중 오류:", error);
2025-08-25 17:22:20 +09:00
toast.error(getUITextSync("message.menu.delete.failed"));
2025-08-21 09:41:46 +09:00
} 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 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);
2025-08-25 17:22:20 +09:00
toast.error(getUITextSync("message.menu.status.toggle.failed"));
2025-08-21 09:41:46 +09:00
}
};
const handleFormSuccess = () => {
loadMenus(false);
// 전역 메뉴 상태도 업데이트
refreshMenus();
};
const getCurrentMenus = () => {
const currentMenus = selectedMenuType === "admin" ? adminMenus : userMenus;
// 검색어 필터링
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()); // 메뉴 타입 변경 시 확장 상태 초기화
};
const handleToggleExpand = (menuId: string) => {
const newExpandedMenus = new Set(expandedMenus);
if (newExpandedMenus.has(menuId)) {
newExpandedMenus.delete(menuId);
} else {
newExpandedMenus.add(menuId);
}
setExpandedMenus(newExpandedMenus);
};
const getMenuTypeString = () => {
2025-08-25 17:22:20 +09:00
return selectedMenuType === "admin" ? getUITextSync("menu.type.admin") : getUITextSync("menu.type.user");
2025-08-21 09:41:46 +09:00
};
const getMenuTypeValue = () => {
return selectedMenuType === "admin" ? "0" : "1";
};
2025-08-25 17:22:20 +09:00
// uiTextsCount를 useMemo로 계산하여 상태 변경 시에만 재계산
const uiTextsCount = useMemo(() => Object.keys(uiTexts).length, [uiTexts]);
const adminMenusCount = useMemo(() => adminMenus?.length || 0, [adminMenus]);
const userMenusCount = useMemo(() => userMenus?.length || 0, [userMenus]);
// 디버깅을 위한 간단한 상태 표시
// console.log("🔍 MenuManagement 렌더링 상태:", {
// loading,
// uiTextsLoading,
// uiTextsCount,
// adminMenusCount,
// userMenusCount,
// selectedMenuType,
// userLang,
// });
2025-08-25 17:22:20 +09:00
2025-08-21 09:41:46 +09:00
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
2025-08-25 17:22:20 +09:00
<LoadingOverlay isLoading={deleting} text={getUITextSync("button.delete.processing")}>
2025-08-21 09:41:46 +09:00
<div className="flex h-full flex-col">
2025-09-01 18:42:59 +09:00
{/* 탭 컨테이너 */}
<Tabs defaultValue="menus" className="flex flex-1 flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="menus"> </TabsTrigger>
<TabsTrigger value="screen-assignment"> </TabsTrigger>
</TabsList>
{/* 메뉴 관리 탭 */}
<TabsContent value="menus" className="flex-1 overflow-hidden">
<div className="flex h-full">
{/* 메인 컨텐츠 - 2:8 비율 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
<div className="w-[20%] border-r bg-gray-50">
<div className="p-6">
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50 pb-3">
<CardTitle className="text-lg">{getUITextSync("menu.type.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 pt-4">
2025-09-01 18:42:59 +09:00
<Card
className={`cursor-pointer transition-all ${
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-gray-300"
2025-09-01 18:42:59 +09:00
}`}
onClick={() => handleMenuTypeChange("admin")}
2025-08-21 09:41:46 +09:00
>
2025-09-01 18:42:59 +09:00
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{getUITextSync("menu.management.admin")}</h3>
<p className="mt-1 text-sm text-muted-foreground">
2025-09-01 18:42:59 +09:00
{getUITextSync("menu.management.admin.description")}
</p>
</div>
<Badge variant={selectedMenuType === "admin" ? "default" : "secondary"}>
{adminMenus.length}
</Badge>
2025-08-21 09:41:46 +09:00
</div>
2025-09-01 18:42:59 +09:00
</CardContent>
</Card>
<Card
className={`cursor-pointer transition-all ${
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-gray-300"
2025-09-01 18:42:59 +09:00
}`}
onClick={() => handleMenuTypeChange("user")}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{getUITextSync("menu.management.user")}</h3>
<p className="mt-1 text-sm text-muted-foreground">
2025-09-01 18:42:59 +09:00
{getUITextSync("menu.management.user.description")}
</p>
2025-08-21 09:41:46 +09:00
</div>
2025-09-01 18:42:59 +09:00
<Badge variant={selectedMenuType === "user" ? "default" : "secondary"}>
{userMenus.length}
</Badge>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
2025-09-01 18:42:59 +09:00
</div>
</div>
{/* 우측 메인 영역 - 메뉴 목록 (80%) */}
<div className="w-[80%] overflow-hidden">
<div className="flex h-full flex-col p-6">
<Card className="flex-1 shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="text-xl">
{getMenuTypeString()} {getUITextSync("menu.list.title")}
</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
{/* 검색 및 필터 영역 */}
<div className="mb-4 flex-shrink-0">
2025-09-01 18:42:59 +09:00
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div>
<Label htmlFor="company">{getUITextSync("filter.company")}</Label>
<div className="company-dropdown relative">
<button
type="button"
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
2025-08-21 09:41:46 +09:00
>
2025-09-01 18:42:59 +09:00
<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="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-md">
{/* 검색 입력 */}
<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>
2025-08-21 09:41:46 +09:00
2025-09-01 18:42:59 +09:00
{/* 회사 목록 */}
<div className="max-h-48 overflow-y-auto">
<div
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => {
setSelectedCompany("all");
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{getUITextSync("filter.company.all")}
</div>
<div
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
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="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => {
setSelectedCompany(company.code);
setIsCompanyDropdownOpen(false);
setCompanySearchText("");
}}
>
{company.code === "*" ? getUITextSync("filter.company.common") : company.name}
</div>
))}
2025-08-21 09:41:46 +09:00
</div>
2025-09-01 18:42:59 +09:00
</div>
)}
2025-08-21 09:41:46 +09:00
</div>
</div>
2025-09-01 18:42:59 +09:00
<div>
<Label htmlFor="search">{getUITextSync("filter.search")}</Label>
<Input
placeholder={getUITextSync("filter.search.placeholder")}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
2025-08-21 09:41:46 +09:00
2025-09-01 18:42:59 +09:00
<div className="flex items-end">
<Button
onClick={() => {
setSearchText("");
setSelectedCompany("all");
setCompanySearchText("");
}}
variant="outline"
className="w-full"
>
{getUITextSync("filter.reset")}
</Button>
</div>
2025-08-21 09:41:46 +09:00
2025-09-01 18:42:59 +09:00
<div className="flex items-end">
<div className="text-sm text-muted-foreground">
2025-09-01 18:42:59 +09:00
{getUITextSync("menu.list.search.result", { count: getCurrentMenus().length })}
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
</div>
<div className="flex space-x-2">
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
{getUITextSync("button.add.top.level")}
</Button>
{selectedMenus.size > 0 && (
<Button
variant="destructive"
onClick={handleDeleteSelectedMenus}
disabled={deleting}
className="min-w-[120px]"
>
{deleting ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
{getUITextSync("button.delete.processing")}
</>
) : (
getUITextSync("button.delete.selected.count", {
count: selectedMenus.size,
})
)}
</Button>
2025-09-01 18:42:59 +09:00
)}
</div>
</div>
<MenuTable
menus={getCurrentMenus()}
title=""
onAddMenu={handleAddMenu}
onEditMenu={handleEditMenu}
onToggleStatus={handleToggleStatus}
selectedMenus={selectedMenus}
onMenuSelectionChange={handleMenuSelectionChange}
onSelectAllMenus={handleSelectAllMenus}
expandedMenus={expandedMenus}
onToggleExpand={handleToggleExpand}
uiTexts={uiTexts}
/>
2025-09-01 18:42:59 +09:00
</div>
</CardContent>
</Card>
2025-08-21 09:41:46 +09:00
</div>
</div>
</div>
</div>
2025-09-01 18:42:59 +09:00
</TabsContent>
{/* 화면 할당 탭 */}
<TabsContent value="screen-assignment" className="flex-1 overflow-hidden p-6">
<Card className="h-full shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="h-full overflow-hidden">
<ScreenAssignmentTab menus={[...adminMenus, ...userMenus]} />
</CardContent>
</Card>
2025-09-01 18:42:59 +09:00
</TabsContent>
</Tabs>
2025-08-21 09:41:46 +09:00
<MenuFormModal
isOpen={formModalOpen}
onClose={() => setFormModalOpen(false)}
onSuccess={handleFormSuccess}
menuId={formData.menuId}
parentId={formData.parentId}
menuType={formData.menuType}
level={formData.level}
parentCompanyCode={formData.parentCompanyCode}
2025-08-25 17:56:33 +09:00
uiTexts={uiTexts}
2025-08-21 09:41:46 +09:00
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</LoadingOverlay>
);
};