From dadd49b98f947ebf305a46db34a2108b95dcd545 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 13 Oct 2025 19:18:01 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EB=A9=B4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 9 +- backend-node/src/services/adminService.ts | 18 ++- frontend/components/layout/AppLayout.tsx | 14 +- .../components/screen/CreateScreenModal.tsx | 101 ++++++++++-- .../components/screen/MenuAssignmentModal.tsx | 149 +++++++++++++----- frontend/lib/api/menu.ts | 4 +- 6 files changed, 228 insertions(+), 67 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a8096c17..bbef0e02 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -17,23 +17,28 @@ export async function getAdminMenus( res: Response ): Promise { try { - logger.info("=== 관리자 메뉴 목록 조회 시작 ==="); + logger.info("=== 메뉴 목록 조회 시작 ==="); // 현재 로그인한 사용자의 회사 코드와 로케일 가져오기 const userCompanyCode = req.user?.companyCode || "ILSHIN"; const userLang = (req.query.userLang as string) || "ko"; + const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가 logger.info(`사용자 회사 코드: ${userCompanyCode}`); logger.info(`사용자 로케일: ${userLang}`); + logger.info(`메뉴 타입: ${menuType || "전체"}`); const paramMap = { userCompanyCode, userLang, + menuType, // menuType 추가 }; const menuList = await AdminService.getAdminMenuList(paramMap); - logger.info(`관리자 메뉴 조회 결과: ${menuList.length}개`); + logger.info( + `메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"})` + ); if (menuList.length > 0) { logger.info("첫 번째 메뉴:", menuList[0]); } diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 55fbfa84..ebddba3f 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -9,7 +9,11 @@ export class AdminService { try { logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap); - const { userLang = "ko" } = paramMap; + const { userLang = "ko", menuType } = paramMap; + + // menuType에 따른 WHERE 조건 생성 + const menuTypeCondition = + menuType !== undefined ? `MENU_TYPE = ${parseInt(menuType)}` : "1 = 1"; // 기존 Java의 selectAdminMenuList 쿼리를 Raw Query로 포팅 // WITH RECURSIVE 쿼리 구현 @@ -91,7 +95,7 @@ export class AdminService { MENU.MENU_DESC ) FROM MENU_INFO MENU - WHERE MENU_TYPE = 0 + WHERE ${menuTypeCondition} AND NOT EXISTS ( SELECT 1 FROM MENU_INFO parent_menu WHERE parent_menu.OBJID = MENU.PARENT_OBJ_ID @@ -159,11 +163,7 @@ export class AdminService { ) SELECT LEVEL AS LEV, - CASE MENU_TYPE - WHEN '0' THEN 'admin' - WHEN '1' THEN 'user' - ELSE '' - END AS MENU_TYPE, + CAST(MENU_TYPE AS TEXT) AS MENU_TYPE, A.OBJID, A.PARENT_OBJ_ID, A.MENU_NAME_KOR, @@ -193,7 +193,9 @@ export class AdminService { [userLang] ); - logger.info(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`); + logger.info( + `메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"})` + ); if (menuList.length > 0) { logger.info("첫 번째 메뉴:", menuList[0]); } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 01f17dd4..3fee965f 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -240,8 +240,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin"; // 현재 모드에 따라 표시할 메뉴 결정 - // 관리자 모드에서는 관리자 메뉴 + 사용자 메뉴(툴 생성 메뉴 포함)를 모두 표시 - const currentMenus = isAdminMode ? [...adminMenus, ...userMenus] : userMenus; + // 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시 + const currentMenus = isAdminMode ? adminMenus : userMenus; // 메뉴 토글 함수 const toggleMenu = (menuId: string) => { @@ -324,7 +324,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
handleMenuClick(child)} @@ -376,7 +376,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { return (
-
+

로딩중...

@@ -423,7 +423,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${ isAdminMode ? "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100" - : "border border-primary/20 bg-accent text-blue-700 hover:bg-primary/20" + : "border-primary/20 bg-accent hover:bg-primary/20 border text-blue-700" }`} > {isAdminMode ? ( @@ -486,7 +486,7 @@ export function AppLayout({ children }: AppLayoutProps) { fallback={
-
+

로딩중...

diff --git a/frontend/components/screen/CreateScreenModal.tsx b/frontend/components/screen/CreateScreenModal.tsx index d1291328..82fd5ecd 100644 --- a/frontend/components/screen/CreateScreenModal.tsx +++ b/frontend/components/screen/CreateScreenModal.tsx @@ -1,10 +1,12 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Search, X } from "lucide-react"; import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { useAuth } from "@/hooks/useAuth"; @@ -24,6 +26,8 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre const [description, setDescription] = useState(""); const [tables, setTables] = useState>([]); const [submitting, setSubmitting] = useState(false); + const [tableSearchTerm, setTableSearchTerm] = useState(""); + const searchInputRef = useRef(null); // 화면 코드 자동 생성 const generateCode = async () => { try { @@ -65,6 +69,16 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0; }, [screenName, screenCode, tableName]); + // 테이블 필터링 + const filteredTables = useMemo(() => { + if (!tableSearchTerm) return tables; + const searchLower = tableSearchTerm.toLowerCase(); + return tables.filter( + (table) => + table.displayName.toLowerCase().includes(searchLower) || table.tableName.toLowerCase().includes(searchLower), + ); + }, [tables, tableSearchTerm]); + const handleSubmit = async () => { if (!isValid || submitting) return; try { @@ -124,19 +138,82 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
- + + + + + {/* 검색 입력 필드 */} +
{ + // 이 div 내에서 발생하는 모든 키 이벤트를 차단 + e.stopPropagation(); + }} + > +
+ + { + e.stopPropagation(); + setTableSearchTerm(e.target.value); + }} + onKeyDown={(e) => { + // 이벤트가 Select로 전파되지 않도록 완전 차단 + e.stopPropagation(); + }} + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-2 pr-8 pl-10 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" + /> + {tableSearchTerm && ( + + )} +
+
+ + {/* 테이블 옵션들 */} +
+ {filteredTables.length === 0 ? ( +
+ {tableSearchTerm ? `"${tableSearchTerm}"에 대한 검색 결과가 없습니다` : "테이블이 없습니다"} +
+ ) : ( + filteredTables.map((table) => ( + + {table.displayName} ({table.tableName}) + + )) + )} +
+
+
diff --git a/frontend/components/screen/MenuAssignmentModal.tsx b/frontend/components/screen/MenuAssignmentModal.tsx index 2c4f883f..e6685301 100644 --- a/frontend/components/screen/MenuAssignmentModal.tsx +++ b/frontend/components/screen/MenuAssignmentModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Dialog, DialogContent, @@ -12,7 +12,6 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; import { Search, Monitor, Settings, X, Plus } from "lucide-react"; @@ -46,36 +45,51 @@ export const MenuAssignmentModal: React.FC = ({ const [showReplaceDialog, setShowReplaceDialog] = useState(false); const [assignmentSuccess, setAssignmentSuccess] = useState(false); const [assignmentMessage, setAssignmentMessage] = useState(""); + const searchInputRef = useRef(null); - // 메뉴 목록 로드 (관리자 메뉴만) + // 메뉴 목록 로드 (관리자 메뉴 + 사용자 메뉴) const loadMenus = async () => { try { setLoading(true); - // 화면관리는 관리자 전용 기능이므로 관리자 메뉴만 가져오기 + // 관리자 메뉴 가져오기 const adminResponse = await apiClient.get("/admin/menus", { params: { menuType: "0" } }); const adminMenus = adminResponse.data?.data || []; - // 관리자 메뉴 정규화 - const normalizedAdminMenus = adminMenus.map((menu: any) => ({ + // 사용자 메뉴 가져오기 + const userResponse = await apiClient.get("/admin/menus", { params: { menuType: "1" } }); + const userMenus = userResponse.data?.data || []; + + // 메뉴 정규화 함수 + const normalizeMenu = (menu: any) => ({ objid: menu.objid || menu.OBJID, parent_obj_id: menu.parent_obj_id || menu.PARENT_OBJ_ID, menu_name_kor: menu.menu_name_kor || menu.MENU_NAME_KOR, menu_url: menu.menu_url || menu.MENU_URL, menu_desc: menu.menu_desc || menu.MENU_DESC, seq: menu.seq || menu.SEQ, - menu_type: "0", // 관리자 메뉴 + menu_type: menu.menu_type || menu.MENU_TYPE, status: menu.status || menu.STATUS, lev: menu.lev || menu.LEV, company_code: menu.company_code || menu.COMPANY_CODE, company_name: menu.company_name || menu.COMPANY_NAME, - })); + }); - // console.log("로드된 관리자 메뉴 목록:", { - // total: normalizedAdminMenus.length, - // sample: normalizedAdminMenus.slice(0, 3), + // 관리자 메뉴 정규화 + const normalizedAdminMenus = adminMenus.map((menu: any) => normalizeMenu(menu)); + + // 사용자 메뉴 정규화 + const normalizedUserMenus = userMenus.map((menu: any) => normalizeMenu(menu)); + + // 모든 메뉴 합치기 + const allMenus = [...normalizedAdminMenus, ...normalizedUserMenus]; + + // console.log("로드된 전체 메뉴 목록:", { + // totalAdmin: normalizedAdminMenus.length, + // totalUser: normalizedUserMenus.length, + // total: allMenus.length, // }); - setMenus(normalizedAdminMenus); + setMenus(allMenus); } catch (error) { // console.error("메뉴 목록 로드 실패:", error); toast.error("메뉴 목록을 불러오는데 실패했습니다."); @@ -244,8 +258,8 @@ export const MenuAssignmentModal: React.FC = ({ ); }); - // 메뉴 옵션 생성 (계층 구조 표시) - const getMenuOptions = (): JSX.Element[] => { + // 메뉴 옵션 생성 (계층 구조 표시, 타입별 그룹화) + const getMenuOptions = (): React.ReactNode[] => { if (loading) { return [ @@ -262,19 +276,58 @@ export const MenuAssignmentModal: React.FC = ({ ]; } - return filteredMenus - .filter((menu) => menu.objid && menu.objid.toString().trim() !== "") // objid가 유효한 메뉴만 필터링 - .map((menu) => { - const indent = " ".repeat(Math.max(0, menu.lev || 0)); - const menuId = menu.objid!.toString(); // 이미 필터링했으므로 non-null assertion 사용 + // 관리자 메뉴와 사용자 메뉴 분리 + const adminMenus = filteredMenus.filter( + (menu) => menu.menu_type === "0" && menu.objid && menu.objid.toString().trim() !== "", + ); + const userMenus = filteredMenus.filter( + (menu) => menu.menu_type === "1" && menu.objid && menu.objid.toString().trim() !== "", + ); - return ( + const options: React.ReactNode[] = []; + + // 관리자 메뉴 섹션 + if (adminMenus.length > 0) { + options.push( +
+ 👤 관리자 메뉴 +
, + ); + adminMenus.forEach((menu) => { + const indent = " ".repeat(Math.max(0, menu.lev || 0)); + const menuId = menu.objid!.toString(); + options.push( {indent} {menu.menu_name_kor} - +
, ); }); + } + + // 사용자 메뉴 섹션 + if (userMenus.length > 0) { + if (adminMenus.length > 0) { + options.push(
); + } + options.push( +
+ 👥 사용자 메뉴 +
, + ); + userMenus.forEach((menu) => { + const indent = " ".repeat(Math.max(0, menu.lev || 0)); + const menuId = menu.objid!.toString(); + options.push( + + {indent} + {menu.menu_name_kor} + , + ); + }); + } + + return options; }; return ( @@ -348,9 +401,9 @@ export const MenuAssignmentModal: React.FC = ({ 저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다. {screenInfo && ( -
+
- + {screenInfo.screenName} {screenInfo.screenCode} @@ -365,29 +418,51 @@ export const MenuAssignmentModal: React.FC = ({ {/* 메뉴 선택 (검색 기능 포함) */}
- { + if (open) { + // Select가 열릴 때 검색창에 포커스 + setTimeout(() => { + searchInputRef.current?.focus(); + }, 100); + } + }} + > {/* 검색 입력 필드 */} -
+
{ + // 이 div 내에서 발생하는 모든 키 이벤트를 차단 + e.stopPropagation(); + }} + >
- { - e.stopPropagation(); // 이벤트 전파 방지 + e.stopPropagation(); setSearchTerm(e.target.value); }} onKeyDown={(e) => { - e.stopPropagation(); // 키보드 이벤트 전파 방지 + // 이벤트가 Select로 전파되지 않도록 완전 차단 + e.stopPropagation(); }} - onClick={(e) => { - e.stopPropagation(); // 클릭 이벤트 전파 방지 - }} - className="h-8 pr-8 pl-10 text-sm" + onClick={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-2 pr-8 pl-10 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" /> {searchTerm && ( @@ -416,12 +491,14 @@ export const MenuAssignmentModal: React.FC = ({

{selectedMenu.menu_name_kor}

- 관리자 + + {selectedMenu.menu_type === "0" ? "관리자" : "사용자"} + {selectedMenu.status === "active" ? "활성" : "비활성"}
-
+
{selectedMenu.menu_url &&

URL: {selectedMenu.menu_url}

} {selectedMenu.menu_desc &&

설명: {selectedMenu.menu_desc}

} {selectedMenu.company_name &&

회사: {selectedMenu.company_name}

} @@ -494,7 +571,7 @@ export const MenuAssignmentModal: React.FC = ({
{/* 기존 화면 목록 */} -
+

제거될 화면 ({existingScreens.length}개):

{existingScreens.map((screen) => ( diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 3a87ce9e..5a0c5972 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -78,7 +78,7 @@ export interface ApiResponse { export const menuApi = { // 관리자 메뉴 목록 조회 getAdminMenus: async (): Promise> => { - const response = await apiClient.get("/admin/menus"); + const response = await apiClient.get("/admin/menus", { params: { menuType: "0" } }); if (response.data.success && response.data.data && response.data.data.length > 0) { } return response.data; @@ -86,7 +86,7 @@ export const menuApi = { // 사용자 메뉴 목록 조회 getUserMenus: async (): Promise> => { - const response = await apiClient.get("/admin/user-menus"); + const response = await apiClient.get("/admin/menus", { params: { menuType: "1" } }); return response.data; },