Files
vexplor/frontend/components/admin/MenuFormModal.tsx
SeongHyun Kim 3933f1e966 feat(pop): PC <-> POP 모드 전환 네비게이션 + POP 기본 화면(Landing) 기능
PC 모드에서 프로필 드롭다운을 통해 POP 화면으로 진입하고, POP에서 PC로
돌아오는 양방향 네비게이션을 구현한다. 기존 메뉴 시스템(menu_info)을 활용하여
POP 화면의 권한 제어와 회사별 관리가 가능하도록 한다.
[백엔드: POP 메뉴 조회 API]
- AdminService.getPopMenuList: L1 POP 메뉴(menu_desc [POP] 또는
  menu_name_kor POP 포함) 하위의 active L2 메뉴 조회
- company_code 필터링 적용 (L1 + L2 모두)
- landingMenu 반환: menu_desc에 [POP_LANDING] 태그가 있는 메뉴
- GET /admin/pop-menus 라우트 추가
[프론트: PC -> POP 진입]
- AppLayout: handlePopModeClick 함수 추가
  - landingMenu 있으면 해당 URL로 바로 이동
  - 없으면 childMenus 수에 따라 단일 화면/대시보드/안내 분기
- UserDropdown: onPopModeClick prop + "POP 모드" 메뉴 항목 추가
- 사이드바 하단 + 모바일 헤더 프로필 드롭다운 2곳 모두 적용
[프론트: POP -> PC 복귀]
- DashboardHeader: "PC 모드" 버튼 추가 (router.push "/")
- POP 개별 화면 page.tsx: 상단 네비게이션 바 추가
  (POP 대시보드 / PC 모드 버튼)
[프론트: POP 대시보드 동적 메뉴]
- PopDashboard: 하드코딩 MENU_ITEMS -> menuApi.getPopMenus() API 조회
- API 실패 시 하드코딩 fallback 유지
[프론트: POP 기본 화면 설정 (MenuFormModal)]
- L2 POP 화면 수정 시 "POP 기본 화면으로 설정" 체크박스 추가
- 체크 시 menu_desc에 [POP_LANDING] 태그 자동 추가/제거
- 회사당 1개만 설정 가능 (다른 메뉴에 이미 설정 시 비활성화)
[API 타입]
- PopMenuItem, PopMenuResponse(landingMenu 포함) 인터페이스 추가
- menuApi.getPopMenus() 함수 추가
2026-03-09 12:16:26 +09:00

1297 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. 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 { MenuItem, MenuFormData, menuApi, LangKey } from "@/lib/api/menu";
import { companyAPI } from "@/lib/api/company";
import { screenApi, menuScreenApi } from "@/lib/api/screen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { toast } from "sonner";
import { ChevronDown, Search } from "lucide-react";
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
import { ScreenDefinition } from "@/types/screen";
interface Company {
company_code: string;
company_name: string;
status: string;
}
interface MenuFormModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
menuId?: string;
parentId?: string;
menuType?: string;
level?: number;
parentCompanyCode?: string;
// 다국어 텍스트 props 추가
uiTexts: Record<string, string>;
}
export const MenuFormModal: React.FC<MenuFormModalProps> = ({
isOpen,
onClose,
onSuccess,
menuId,
parentId,
menuType,
level,
parentCompanyCode,
uiTexts,
}) => {
// console.log("🎯 MenuFormModal 렌더링 - Props:", {
// isOpen,
// menuId,
// parentId,
// menuType,
// level,
// parentCompanyCode,
// });
// 다국어 텍스트 가져오기 함수
const getText = (key: string, fallback?: string): string => {
return uiTexts[key] || fallback || key;
};
// console.log("🔍 MenuFormModal 컴포넌트 마운트됨");
const [formData, setFormData] = useState<MenuFormData>({
parentObjId: parentId || "0",
menuNameKor: "",
menuUrl: "",
menuDesc: "",
seq: 1,
menuType: "1",
status: "ACTIVE",
companyCode: parentCompanyCode || "none",
langKey: "",
});
// 화면 할당 관련 상태
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [screenSearchText, setScreenSearchText] = useState("");
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
// POP 화면 할당 관련 상태
const [selectedPopScreen, setSelectedPopScreen] = useState<ScreenDefinition | null>(null);
const [popScreenSearchText, setPopScreenSearchText] = useState("");
const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false);
const [isPopLanding, setIsPopLanding] = useState(false);
const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false);
// 대시보드 할당 관련 상태
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
const [dashboards, setDashboards] = useState<any[]>([]);
const [dashboardSearchText, setDashboardSearchText] = useState("");
const [isDashboardDropdownOpen, setIsDashboardDropdownOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [companies, setCompanies] = useState<Company[]>([]);
const [langKeys, setLangKeys] = useState<LangKey[]>([]);
const [isLangKeyDropdownOpen, setIsLangKeyDropdownOpen] = useState(false);
const [langKeySearchText, setLangKeySearchText] = useState("");
// 화면 목록 로드
const loadScreens = async () => {
try {
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
setScreens(response.data);
console.log("✅ 화면 목록 로드 완료:", response.data.length);
} catch (error) {
console.error("❌ 화면 목록 로드 실패:", error);
toast.error("화면 목록을 불러오는데 실패했습니다.");
}
};
// 대시보드 목록 로드
const loadDashboards = async () => {
try {
const { dashboardApi } = await import("@/lib/api/dashboard");
const response = await dashboardApi.getMyDashboards();
setDashboards(response.dashboards || []);
console.log("✅ 대시보드 목록 로드 완료:", response.dashboards?.length || 0);
} catch (error) {
console.error("❌ 대시보드 목록 로드 실패:", error);
toast.error("대시보드 목록을 불러오는데 실패했습니다.");
}
};
// 화면 선택 시 URL 자동 설정
const handleScreenSelect = (screen: ScreenDefinition) => {
// console.log("🖥️ 화면 선택 디버깅:", {
// screen,
// screenId: screen.screenId,
// screenIdType: typeof screen.screenId,
// legacyId: screen.id,
// allFields: Object.keys(screen),
// screenValues: Object.values(screen),
// });
// ScreenDefinition에서는 screenId 필드를 사용
const actualScreenId = screen.screenId || screen.id;
if (!actualScreenId) {
console.error("❌ 화면 ID를 찾을 수 없습니다:", screen);
toast.error("화면 ID를 찾을 수 없습니다. 다른 화면을 선택해주세요.");
return;
}
setSelectedScreen(screen);
setIsScreenDropdownOpen(false);
// 실제 라우팅 패턴에 맞게 URL 생성: /screens/[screenId] (복수형)
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
let screenUrl = `/screens/${actualScreenId}`;
// 현재 메뉴 타입이 관리자인지 확인 (0 또는 "admin")
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
if (isAdminMenu) {
screenUrl += "?mode=admin";
}
setFormData((prev) => ({
...prev,
menuUrl: screenUrl,
screenCode: screen.screenCode, // 화면 코드도 함께 저장
}));
// console.log("🖥️ 화면 선택 완료:", {
// screenId: screen.screenId,
// legacyId: screen.id,
// actualScreenId,
// screenName: screen.screenName,
// menuType: menuType,
// formDataMenuType: formData.menuType,
// isAdminMenu,
// generatedUrl: screenUrl,
// });
};
// 대시보드 선택 시 URL 자동 설정
const handleDashboardSelect = (dashboard: any) => {
setSelectedDashboard(dashboard);
setIsDashboardDropdownOpen(false);
// 대시보드 URL 생성
let dashboardUrl = `/dashboard/${dashboard.id}`;
// 현재 메뉴 타입이 관리자인지 확인 (0 또는 "admin")
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
if (isAdminMenu) {
dashboardUrl += "?mode=admin";
}
setFormData((prev) => ({ ...prev, menuUrl: dashboardUrl }));
toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`);
};
// POP 화면 선택 시 URL 자동 설정
const handlePopScreenSelect = (screen: ScreenDefinition) => {
const actualScreenId = screen.screenId || screen.id;
if (!actualScreenId) {
toast.error("화면 ID를 찾을 수 없습니다.");
return;
}
setSelectedPopScreen(screen);
setIsPopScreenDropdownOpen(false);
const popUrl = `/pop/screens/${actualScreenId}`;
setFormData((prev) => ({
...prev,
menuUrl: popUrl,
}));
};
// URL 타입 변경 시 처리
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => {
// console.log("🔄 URL 타입 변경:", {
// from: urlType,
// to: type,
// currentSelectedScreen: selectedScreen?.screenName,
// currentUrl: formData.menuUrl,
// });
setUrlType(type);
if (type === "direct") {
setSelectedScreen(null);
setSelectedPopScreen(null);
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined,
}));
} else if (type === "pop") {
setSelectedScreen(null);
if (selectedPopScreen) {
const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id;
setFormData((prev) => ({
...prev,
menuUrl: `/pop/screens/${actualScreenId}`,
}));
} else {
setFormData((prev) => ({
...prev,
menuUrl: "",
}));
}
} else if (type === "screen") {
setSelectedPopScreen(null);
if (selectedScreen) {
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
let screenUrl = `/screens/${actualScreenId}`;
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
if (isAdminMenu) {
screenUrl += "?mode=admin";
}
setFormData((prev) => ({
...prev,
menuUrl: screenUrl,
screenCode: selectedScreen.screenCode,
}));
} else {
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined,
}));
}
} else {
// dashboard
setSelectedScreen(null);
setSelectedPopScreen(null);
if (!selectedDashboard) {
setFormData((prev) => ({
...prev,
menuUrl: "",
screenCode: undefined,
}));
}
}
};
// loadMenuData 함수를 먼저 정의
const loadMenuData = async () => {
console.log("loadMenuData 호출됨 - menuId:", menuId);
if (!menuId) {
console.log("menuId가 없어서 loadMenuData 종료");
return;
}
try {
setLoading(true);
console.log("API 호출 시작 - menuId:", menuId);
// console.log("API URL:", `/admin/menus/${menuId}`);
const response = await menuApi.getMenuInfo(menuId);
console.log("메뉴 정보 조회 응답:", response);
console.log("응답 success:", response.success);
console.log("응답 data:", response.data);
console.log("응답 message:", response.message);
console.log("응답 errorCode:", response.errorCode);
if (response.success && response.data) {
const menu = response.data;
console.log("메뉴 데이터:", menu);
console.log("메뉴 데이터 키들:", Object.keys(menu));
// 대문자 키와 소문자 키 모두 처리
const menuType = menu.menu_type || menu.MENU_TYPE || "1";
const status = menu.status || menu.STATUS || "active";
const companyCode = menu.company_code || menu.COMPANY_CODE || "";
const langKey = menu.lang_key || menu.LANG_KEY || "";
// 메뉴 타입 변환 (admin/user -> 0/1)
let convertedMenuType = menuType;
if (menuType === "admin" || menuType === "0") {
convertedMenuType = "0";
} else if (menuType === "user" || menuType === "1") {
convertedMenuType = "1";
}
// 상태 변환 (active/inactive/inActive -> ACTIVE/INACTIVE)
let convertedStatus = status;
if (status === "active") {
convertedStatus = "ACTIVE";
} else if (status === "inactive" || status === "inActive") {
convertedStatus = "INACTIVE";
}
const menuUrl = menu.menu_url || menu.MENU_URL || "";
const isPopScreenUrl = menuUrl.startsWith("/pop/screens/");
const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/");
setFormData({
objid: menu.objid || menu.OBJID,
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
menuUrl: menuUrl,
menuDesc: menu.menu_desc || menu.MENU_DESC || "",
seq: menu.seq || menu.SEQ || 1,
menuType: convertedMenuType,
status: convertedStatus,
companyCode: companyCode,
langKey: langKey, // 다국어 키 설정
});
// URL 타입 설정
if (isScreenUrl) {
setUrlType("screen");
// "/screens/123" 또는 "/screens/123?mode=admin" 형태에서 ID 추출
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
if (screenId) {
// console.log("🔍 기존 메뉴에서 화면 ID 추출:", {
// menuUrl,
// screenId,
// hasAdminParam: menuUrl.includes("mode=admin"),
// currentScreensCount: screens.length,
// });
// 화면 설정 함수
const setScreenFromId = () => {
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
if (screen) {
setSelectedScreen(screen);
// console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", {
// screen,
// originalUrl: menuUrl,
// hasAdminParam: menuUrl.includes("mode=admin"),
// });
return true;
} else {
// console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", {
// screenId,
// availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })),
// });
return false;
}
};
// 화면 목록이 이미 있으면 즉시 설정, 없으면 로드 완료 대기
if (screens.length > 0) {
console.log("📋 화면 목록이 이미 로드됨 - 즉시 설정");
setScreenFromId();
} else {
console.log("⏳ 화면 목록 로드 대기 중...");
// 화면 ID를 저장해두고, 화면 목록 로드 완료 후 설정
setTimeout(() => {
console.log("🔄 재시도: 화면 목록 로드 후 설정");
setScreenFromId();
}, 500);
}
}
} else if (isPopScreenUrl) {
setUrlType("pop");
setSelectedScreen(null);
// [POP_LANDING] 태그 감지
const menuDesc = menu.menu_desc || menu.MENU_DESC || "";
setIsPopLanding(menuDesc.includes("[POP_LANDING]"));
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
if (popScreenId) {
const setPopScreenFromId = () => {
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
if (screen) {
setSelectedPopScreen(screen);
}
};
if (screens.length > 0) {
setPopScreenFromId();
} else {
setTimeout(setPopScreenFromId, 500);
}
}
} else if (menuUrl.startsWith("/dashboard/")) {
setUrlType("dashboard");
setSelectedScreen(null);
} else {
setUrlType("direct");
setSelectedScreen(null);
}
// console.log("설정된 폼 데이터:", {
// objid: menu.objid || menu.OBJID,
// parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
// menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
// menuUrl: menu.menu_url || menu.MENU_URL || "",
// menuDesc: menu.menu_desc || menu.MENU_DESC || "",
// seq: menu.seq || menu.SEQ || 1,
// menuType: convertedMenuType,
// status: convertedStatus,
// companyCode: companyCode,
// langKey: langKey,
// });
}
} catch (error: any) {
console.error("메뉴 정보 로딩 오류:", error);
// console.error("오류 상세 정보:", {
// message: error?.message,
// stack: error?.stack,
// response: error?.response,
// });
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO));
} finally {
setLoading(false);
}
};
// useEffect를 loadMenuData 함수 정의 후로 이동
useEffect(() => {
console.log("🚀 MenuFormModal useEffect 실행됨!");
console.log("📋 useEffect 파라미터:", { menuId, parentId, menuType });
console.log("MenuFormModal useEffect - menuId:", menuId, "parentId:", parentId, "menuType:", menuType);
if (menuId) {
console.log("메뉴 수정 모드 - menuId:", menuId);
setIsEdit(true);
loadMenuData();
} else {
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
setIsEdit(false);
setIsPopLanding(false);
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
let defaultMenuType = "1"; // 기본값은 사용자
if (menuType === "0" || menuType === "admin") {
defaultMenuType = "0"; // 관리자
} else if (menuType === "1" || menuType === "user") {
defaultMenuType = "1"; // 사용자
}
setFormData({
parentObjId: parentId || "0",
menuNameKor: "",
menuUrl: "",
menuDesc: "",
seq: 1,
menuType: defaultMenuType,
status: "ACTIVE",
companyCode: parentCompanyCode || "none",
langKey: "",
});
// console.log("메뉴 등록 기본값 설정:", {
// parentObjId: parentId || "0",
// menuType: defaultMenuType,
// status: "ACTIVE",
// companyCode: "",
// langKey: "",
// });
}
}, [menuId, parentId, menuType]);
// 강제로 useEffect 실행시키기 위한 별도 useEffect
useEffect(() => {
console.log("🔧 강제 useEffect 실행 - 컴포넌트 마운트됨");
console.log("🔧 현재 props:", { isOpen, menuId, parentId, menuType });
// isOpen이 true일 때만 실행
if (isOpen && menuId) {
console.log("🔧 모달이 열렸고 menuId가 있음 - 강제 실행");
// 약간의 지연 후 실행
setTimeout(() => {
console.log("🔧 setTimeout으로 loadMenuData 실행");
loadMenuData();
}, 100);
}
}, [isOpen]); // isOpen만 의존성으로 설정
// 회사 목록 로드
useEffect(() => {
if (isOpen) {
loadCompanies();
}
}, [isOpen]);
// 다국어 키 목록 로드
useEffect(() => {
if (isOpen && formData.companyCode) {
loadLangKeys();
}
}, [isOpen, formData.companyCode]);
// POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인
useEffect(() => {
if (!isOpen) return;
const checkOtherPopLanding = async () => {
try {
const res = await menuApi.getPopMenus();
if (res.success && res.data?.landingMenu) {
const landingObjId = res.data.landingMenu.objid?.toString();
const currentObjId = formData.objid?.toString();
// 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복
setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId);
} else {
setHasOtherPopLanding(false);
}
} catch {
setHasOtherPopLanding(false);
}
};
if (urlType === "pop") {
checkOtherPopLanding();
}
}, [isOpen, urlType, formData.objid]);
// 화면 목록 및 대시보드 목록 로드
useEffect(() => {
if (isOpen) {
loadScreens();
loadDashboards();
}
}, [isOpen]);
// 화면 목록 로드 완료 후 기존 메뉴의 할당된 화면 설정
useEffect(() => {
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "screen") {
const menuUrl = formData.menuUrl;
if (menuUrl.startsWith("/screens/")) {
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
if (screenId && !selectedScreen) {
console.log("🔄 화면 목록 로드 완료 - 기존 할당 화면 자동 설정");
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
if (screen) {
setSelectedScreen(screen);
// console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", {
// screenId,
// screenName: screen.screenName,
// menuUrl,
// });
}
}
}
}
}, [screens, isEdit, formData.menuUrl, urlType, selectedScreen]);
// 대시보드 목록 로드 완료 후 기존 메뉴의 할당된 대시보드 설정
useEffect(() => {
if (dashboards.length > 0 && isEdit && formData.menuUrl && urlType === "dashboard") {
const menuUrl = formData.menuUrl;
if (menuUrl.startsWith("/dashboard/")) {
const dashboardId = menuUrl.replace("/dashboard/", "");
if (dashboardId && !selectedDashboard) {
console.log("🔄 대시보드 목록 로드 완료 - 기존 할당 대시보드 자동 설정");
const dashboard = dashboards.find((d) => d.id === dashboardId);
if (dashboard) {
setSelectedDashboard(dashboard);
}
}
}
}
}, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]);
// POP 화면 목록 로드 완료 후 기존 할당 설정
useEffect(() => {
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") {
const menuUrl = formData.menuUrl;
if (menuUrl.startsWith("/pop/screens/")) {
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
if (popScreenId && !selectedPopScreen) {
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
if (screen) {
setSelectedPopScreen(screen);
}
}
}
}
}, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest(".langkey-dropdown")) {
setIsLangKeyDropdownOpen(false);
setLangKeySearchText("");
}
if (!target.closest(".screen-dropdown")) {
setIsScreenDropdownOpen(false);
setScreenSearchText("");
}
if (!target.closest(".dashboard-dropdown")) {
setIsDashboardDropdownOpen(false);
setDashboardSearchText("");
}
if (!target.closest(".pop-screen-dropdown")) {
setIsPopScreenDropdownOpen(false);
setPopScreenSearchText("");
}
};
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]);
const loadCompanies = async () => {
try {
const companyList = await companyAPI.getList({ status: "active" });
setCompanies(companyList);
} catch (error) {
console.error("회사 목록 로딩 오류:", error);
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_COMPANY_LIST));
}
};
const loadLangKeys = async () => {
console.log("🔤 다국어 키 목록 조회 시작 - companyCode:", formData.companyCode);
try {
const response = await menuApi.getLangKeys({
companyCode: formData.companyCode === "none" ? "*" : formData.companyCode,
});
if (response.success && response.data) {
// 활성화된 다국어 키만 필터링
const activeKeys = response.data.filter((key) => key.isActive === "Y");
console.log("🔤 다국어 키 목록 조회 성공:", activeKeys.length, "개 (활성화된 키)");
setLangKeys(activeKeys);
}
} catch (error) {
console.error("❌ 다국어 키 목록 로딩 오류:", error);
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_LANG_KEY_LIST));
setLangKeys([]);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.menuNameKor.trim()) {
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_MENU_NAME_REQUIRED));
return;
}
if (!formData.companyCode) {
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_VALIDATION_COMPANY_REQUIRED));
return;
}
try {
setLoading(true);
// POP 기본 화면 태그 처리
let finalMenuDesc = formData.menuDesc;
if (urlType === "pop") {
const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim();
finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag;
}
// 백엔드에 전송할 데이터 변환
const submitData = {
...formData,
menuDesc: finalMenuDesc,
status: formData.status.toLowerCase(),
};
console.log("저장할 데이터:", submitData);
let response;
if (isEdit && menuId) {
// 수정 모드: updateMenu API 호출
console.log("🔧 메뉴 수정 API 호출:", menuId);
response = await menuApi.updateMenu(menuId, submitData);
} else {
// 추가 모드: saveMenu API 호출
console.log(" 메뉴 추가 API 호출");
response = await menuApi.saveMenu(submitData);
}
if (response.success) {
// 화면 할당이 있는 경우 추가 처리
if (urlType === "screen" && selectedScreen) {
try {
// menuId는 response에서 반환되거나 기존 menuId 사용
const targetMenuId = menuId || response.data?.objid;
const menuObjid = parseInt(targetMenuId?.toString() || "0");
if (menuObjid > 0) {
console.log("📋 화면-메뉴 관계 테이블 업데이트 시작:", {
screenId: selectedScreen.screenId,
menuObjid,
});
// 1. 기존 할당된 화면들 먼저 조회
try {
const existingScreens = await menuScreenApi.getScreensByMenu(menuObjid);
console.log("📋 기존 할당된 화면:", existingScreens.length, "개");
// 2. 기존 화면들 모두 제거
for (const existingScreen of existingScreens) {
try {
await menuScreenApi.unassignScreenFromMenu(existingScreen.screenId, menuObjid);
console.log(`✅ 기존 화면 제거 완료: ${existingScreen.screenName}`);
} catch (unassignError) {
console.warn(`⚠️ 기존 화면 제거 실패: ${existingScreen.screenName}`, unassignError);
}
}
} catch (getError) {
console.warn("⚠️ 기존 화면 조회 실패 (계속 진행):", getError);
}
// 3. 새 화면 할당
await menuScreenApi.assignScreenToMenu(selectedScreen.screenId, menuObjid);
console.log("✅ 새 화면 할당 완료");
}
} catch (assignError) {
console.error("❌ 화면-메뉴 관계 테이블 할당 실패:", assignError);
// 할당 실패는 경고만 하고 메뉴 저장은 성공으로 처리
toast.warning("메뉴는 저장되었으나 화면 할당에 실패했습니다.");
}
}
toast.success(response.message);
onSuccess();
onClose();
} else {
toast.error(response.message);
}
} catch (error) {
console.error("메뉴 저장/수정 실패:", error);
toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_MENU_SAVE_FAILED));
} finally {
setLoading(false);
}
};
const handleInputChange = (field: keyof MenuFormData, value: string | number) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
// 선택된 다국어 키 정보 가져오기
const getSelectedLangKeyInfo = () => {
if (!formData.langKey) return null;
return langKeys.find((key) => key.langKey === formData.langKey);
};
const selectedLangKeyInfo = getSelectedLangKeyInfo();
// 전역 사용자 로케일 가져오기
const getCurrentUserLang = () => {
return (window as any).__GLOBAL_USER_LANG || localStorage.getItem("userLocale") || "KR";
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{isEdit
? getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE)
: getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="menuType">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE)}</Label>
<Select value={formData.menuType} onValueChange={(value) => handleInputChange("menuType", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_ADMIN)}</SelectItem>
<SelectItem value="1">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_TYPE_USER)}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">{getText(MENU_MANAGEMENT_KEYS.TABLE_HEADER_STATUS)}</Label>
<Select value={formData.status} onValueChange={(value) => handleInputChange("status", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE">{getText(MENU_MANAGEMENT_KEYS.STATUS_ACTIVE)}</SelectItem>
<SelectItem value="INACTIVE">{getText(MENU_MANAGEMENT_KEYS.STATUS_INACTIVE)}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="companyCode">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY)} *</Label>
<Select
value={formData.companyCode}
onValueChange={(value) => handleInputChange("companyCode", value)}
disabled={!isEdit && level !== 1} // 수정 모드가 아니고 최상위 메뉴가 아니면 비활성화
>
<SelectTrigger>
<SelectValue placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SELECT)} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_COMMON)}</SelectItem>
{companies.map((company) => (
<SelectItem key={company.company_code} value={company.company_code}>
{company.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
{!isEdit && level !== 1 && (
<p className="text-xs text-gray-500">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="langKey">{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY)}</Label>
<div className="langkey-dropdown relative">
<button
type="button"
onClick={() => setIsLangKeyDropdownOpen(!isLangKeyDropdownOpen)}
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"
disabled={!formData.companyCode}
>
<span className={!formData.langKey ? "text-muted-foreground" : ""}>
{formData.langKey || getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECT)}
</span>
<svg
className={`h-4 w-4 transition-transform ${isLangKeyDropdownOpen ? "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>
{isLangKeyDropdownOpen && (
<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={getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SEARCH)}
value={langKeySearchText}
onChange={(e) => setLangKeySearchText(e.target.value)}
className="h-8 text-sm"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* 다국어 키 목록 */}
<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={() => {
handleInputChange("langKey", "");
setIsLangKeyDropdownOpen(false);
setLangKeySearchText("");
}}
>
{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_NONE)}
</div>
{langKeys
.filter(
(key) =>
key.langKey.toLowerCase().includes(langKeySearchText.toLowerCase()) ||
key.description.toLowerCase().includes(langKeySearchText.toLowerCase()),
)
.map((key) => (
<div
key={key.keyId}
className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer flex-col px-2 py-1.5 text-sm"
onClick={() => {
handleInputChange("langKey", key.langKey);
setIsLangKeyDropdownOpen(false);
setLangKeySearchText("");
}}
>
<div className="font-medium">{key.langKey}</div>
{key.description && <div className="text-xs text-gray-500">{key.description}</div>}
</div>
))}
</div>
</div>
)}
</div>
{selectedLangKeyInfo && (
<p className="text-xs text-gray-500">
{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECTED)
.replace("{key}", selectedLangKeyInfo.langKey)
.replace("{description}", selectedLangKeyInfo.description)}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="menuNameKor">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME)} *</Label>
<Input
id="menuNameKor"
value={formData.menuNameKor}
onChange={(e) => handleInputChange("menuNameKor", e.target.value)}
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_NAME_PLACEHOLDER)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
{/* URL 타입 선택 */}
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex flex-wrap gap-x-6 gap-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="screen" id="screen" />
<Label htmlFor="screen" className="cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="dashboard" id="dashboard" />
<Label htmlFor="dashboard" className="cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pop" id="pop" />
<Label htmlFor="pop" className="cursor-pointer">
POP
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="direct" id="direct" />
<Label htmlFor="direct" className="cursor-pointer">
URL
</Label>
</div>
</RadioGroup>
{/* 화면 할당 */}
{urlType === "screen" && (
<div className="space-y-2">
{/* 화면 선택 드롭다운 */}
<div className="relative">
<Button
type="button"
variant="outline"
onClick={() => setIsScreenDropdownOpen(!isScreenDropdownOpen)}
className="w-full justify-between"
>
<span className="text-left">
{selectedScreen ? selectedScreen.screenName : "화면을 선택하세요"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{isScreenDropdownOpen && (
<div className="screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
{/* 검색 입력 */}
<div className="sticky top-0 border-b bg-white p-2">
<div className="relative">
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="화면 검색..."
value={screenSearchText}
onChange={(e) => setScreenSearchText(e.target.value)}
className="pl-8"
/>
</div>
</div>
{/* 화면 목록 */}
<div className="max-h-48 overflow-y-auto">
{screens
.filter(
(screen) =>
screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()),
)
.map((screen, index) => (
<div
key={`screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
onClick={() => handleScreenSelect(screen)}
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{screen.screenName}</div>
<div className="text-xs text-gray-500">{screen.screenCode}</div>
</div>
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
</div>
</div>
))}
{screens.filter(
(screen) =>
screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()),
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500"> .</div>}
</div>
</div>
)}
</div>
{/* 선택된 화면 정보 표시 */}
{selectedScreen && (
<div className="bg-accent rounded-md border p-3">
<div className="text-sm font-medium text-blue-900">{selectedScreen.screenName}</div>
<div className="text-primary text-xs">: {selectedScreen.screenCode}</div>
<div className="text-primary text-xs"> URL: {formData.menuUrl}</div>
</div>
)}
</div>
)}
{/* 대시보드 할당 */}
{urlType === "dashboard" && (
<div className="space-y-2">
{/* 대시보드 선택 드롭다운 */}
<div className="relative">
<Button
type="button"
variant="outline"
className="w-full justify-between"
onClick={() => setIsDashboardDropdownOpen(!isDashboardDropdownOpen)}
>
<span className="truncate">{selectedDashboard ? selectedDashboard.title : "대시보드 선택"}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
{/* 드롭다운 메뉴 */}
{isDashboardDropdownOpen && (
<div className="dashboard-dropdown absolute z-50 mt-1 max-h-60 w-full overflow-hidden rounded-md border bg-white shadow-lg">
{/* 검색창 */}
<div className="border-b p-2">
<div className="relative">
<Search className="absolute top-2.5 left-2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="대시보드 검색..."
value={dashboardSearchText}
onChange={(e) => setDashboardSearchText(e.target.value)}
className="pl-8"
/>
</div>
</div>
{/* 대시보드 목록 */}
<div className="max-h-48 overflow-y-auto">
{dashboards
.filter(
(dashboard) =>
dashboard.title.toLowerCase().includes(dashboardSearchText.toLowerCase()) ||
(dashboard.description &&
dashboard.description.toLowerCase().includes(dashboardSearchText.toLowerCase())),
)
.map((dashboard) => (
<div
key={dashboard.id}
onClick={() => handleDashboardSelect(dashboard)}
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{dashboard.title}</div>
{dashboard.description && (
<div className="text-xs text-gray-500">{dashboard.description}</div>
)}
</div>
</div>
</div>
))}
{dashboards.filter(
(dashboard) =>
dashboard.title.toLowerCase().includes(dashboardSearchText.toLowerCase()) ||
(dashboard.description &&
dashboard.description.toLowerCase().includes(dashboardSearchText.toLowerCase())),
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500"> .</div>}
</div>
</div>
)}
</div>
{/* 선택된 대시보드 정보 표시 */}
{selectedDashboard && (
<div className="bg-accent rounded-md border p-3">
<div className="text-sm font-medium text-blue-900">{selectedDashboard.title}</div>
{selectedDashboard.description && (
<div className="text-primary text-xs">: {selectedDashboard.description}</div>
)}
<div className="text-primary text-xs"> URL: {formData.menuUrl}</div>
</div>
)}
</div>
)}
{/* POP 화면 할당 */}
{urlType === "pop" && (
<div className="space-y-2">
<div className="relative">
<Button
type="button"
variant="outline"
onClick={() => setIsPopScreenDropdownOpen(!isPopScreenDropdownOpen)}
className="w-full justify-between"
>
<span className="text-left">
{selectedPopScreen ? selectedPopScreen.screenName : "POP 화면을 선택하세요"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{isPopScreenDropdownOpen && (
<div className="pop-screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
<div className="sticky top-0 border-b bg-white p-2">
<div className="relative">
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="POP 화면 검색..."
value={popScreenSearchText}
onChange={(e) => setPopScreenSearchText(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="max-h-48 overflow-y-auto">
{screens
.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
)
.map((screen, index) => (
<div
key={`pop-screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
onClick={() => handlePopScreenSelect(screen)}
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{screen.screenName}</div>
<div className="text-xs text-gray-500">{screen.screenCode}</div>
</div>
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
</div>
</div>
))}
{screens.filter(
(screen) =>
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500"> .</div>}
</div>
</div>
)}
</div>
{selectedPopScreen && (
<div className="bg-accent rounded-md border p-3">
<div className="text-sm font-medium text-blue-900">{selectedPopScreen.screenName}</div>
<div className="text-primary text-xs">: {selectedPopScreen.screenCode}</div>
<div className="text-primary text-xs"> URL: {formData.menuUrl}</div>
</div>
)}
{/* POP 기본 화면 설정 */}
<div className="flex items-center space-x-2 rounded-md border p-3">
<input
type="checkbox"
id="popLanding"
checked={isPopLanding}
disabled={!isPopLanding && hasOtherPopLanding}
onChange={(e) => setIsPopLanding(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
/>
<label
htmlFor="popLanding"
className={`text-sm font-medium ${!isPopLanding && hasOtherPopLanding ? "text-muted-foreground" : ""}`}
>
POP
</label>
{!isPopLanding && hasOtherPopLanding && (
<span className="text-xs text-muted-foreground">
( )
</span>
)}
</div>
{isPopLanding && (
<p className="text-xs text-muted-foreground">
POP .
</p>
)}
</div>
)}
{/* URL 직접 입력 */}
{urlType === "direct" && (
<Input
id="menuUrl"
value={formData.menuUrl}
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="menuDesc">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION)}</Label>
<Textarea
id="menuDesc"
value={formData.menuDesc}
onChange={(e) => handleInputChange("menuDesc", e.target.value)}
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_DESCRIPTION_PLACEHOLDER)}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="seq">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_SEQUENCE)}</Label>
<Input
id="seq"
type="number"
value={formData.seq}
onChange={(e) => handleInputChange("seq", parseInt(e.target.value) || 1)}
min="1"
/>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{getText(MENU_MANAGEMENT_KEYS.BUTTON_CANCEL)}
</Button>
<Button type="submit" disabled={loading}>
{loading
? getText(MENU_MANAGEMENT_KEYS.BUTTON_SAVE_PROCESSING)
: isEdit
? getText(MENU_MANAGEMENT_KEYS.BUTTON_MODIFY)
: getText(MENU_MANAGEMENT_KEYS.BUTTON_REGISTER)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};