From 5d8189ccf1ee81b71430a54b71c87f2da15c787b Mon Sep 17 00:00:00 2001 From: artwork21c Date: Tue, 13 Jan 2026 20:35:42 +0900 Subject: [PATCH] =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EA=B4=80=EB=A6=AC=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=ED=98=95=EC=9C=BC=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/adminMenuService.ts | 27 ++- src/views/admin/AdminMenuManagement.tsx | 286 ++++++++++++++++-------- 2 files changed, 215 insertions(+), 98 deletions(-) diff --git a/src/services/adminMenuService.ts b/src/services/adminMenuService.ts index 6912d69..649a89c 100644 --- a/src/services/adminMenuService.ts +++ b/src/services/adminMenuService.ts @@ -44,8 +44,31 @@ export const getAdminMenuList = async (pageNum: number = 1): Promise => { +// parentSeq로 어드민 메뉴 목록 조회 (페이징 지원) +export const getAdminMenuListByParentSeq = async (parentSeq: number, pageNum: number = 1): Promise => { + const response = await axios.get(`/admin/menu/listByParentSeq/${parentSeq}?pageNum=${pageNum}`); + const resultData = response.data.resultData; + // 페이징 형태의 응답인 경우 그대로 반환 + if (resultData && resultData.content && Array.isArray(resultData.content)) { + return resultData; + } + // 배열 형태의 응답인 경우 페이징 형태로 변환 + if (Array.isArray(resultData)) { + return { + content: resultData, + pageNum: 1, + pageSize: resultData.length, + totalContent: resultData.length, + totalPage: 1, + isFirstPage: true, + isLastPage: true, + }; + } + return { content: [], pageNum: 1, pageSize: 0, totalContent: 0, totalPage: 0, isFirstPage: true, isLastPage: true }; +}; + +// parentSeq로 어드민 메뉴 목록 조회 (페이징 없이 전체 목록) +export const getAdminMenuListByParentSeqAll = async (parentSeq: number): Promise => { const response = await axios.get(`/admin/menu/listByParentSeq/${parentSeq}`); const resultData = response.data.resultData; // 페이징 형태의 응답인 경우 content 배열 반환 diff --git a/src/views/admin/AdminMenuManagement.tsx b/src/views/admin/AdminMenuManagement.tsx index a1e45a3..5c8abd0 100644 --- a/src/views/admin/AdminMenuManagement.tsx +++ b/src/views/admin/AdminMenuManagement.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { CButton, CCard, CCardBody, CCardHeader, - CCol, CForm, CFormInput, CFormLabel, @@ -15,14 +15,12 @@ import { CModalFooter, CModalHeader, CModalTitle, - CRow, CTable, CTableBody, CTableDataCell, CTableHead, CTableHeaderCell, CTableRow, - CBadge, CFormSelect, CPagination, CPaginationItem, @@ -135,8 +133,8 @@ const availableIcons = [ ]; import { - getAdminMenuList, getAdminMenuListByParentSeq, + getAdminMenu, addAdminMenu, updateAdminMenu, deleteAdminMenu, @@ -144,6 +142,12 @@ import { AdminMenuPageResponse, } from 'src/services/adminMenuService'; +// 브레드크럼 아이템 인터페이스 +interface BreadcrumbItem { + adminMenuSeq: number; + menuName: string; +} + // 아이콘 이름으로 아이콘 객체 찾기 const getIconByName = (name: string) => { const found = availableIcons.find((item) => item.name === name); @@ -151,8 +155,8 @@ const getIconByName = (name: string) => { }; const AdminMenuManagement: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); const [menuList, setMenuList] = useState([]); - const [topLevelMenuList, setTopLevelMenuList] = useState([]); const [loading, setLoading] = useState(false); const [modalVisible, setModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false); @@ -167,6 +171,11 @@ const AdminMenuManagement: React.FC = () => { menuUrl: '', }); + // 현재 보고 있는 부모 메뉴 seq (0이면 최상위) + const [currentParentSeq, setCurrentParentSeq] = useState(0); + // 브레드크럼 (탐색 경로) + const [breadcrumb, setBreadcrumb] = useState([]); + // 페이징 상태 const [currentPage, setCurrentPage] = useState(1); const [pageInfo, setPageInfo] = useState>({ @@ -192,12 +201,24 @@ const AdminMenuManagement: React.FC = () => { return () => window.removeEventListener('resize', handleResize); }, [handleResize]); - // 메뉴 목록 조회 - const fetchMenuList = async (page: number = 1) => { + // URL 파라미터를 업데이트하는 함수 + const updateUrlParams = useCallback((parentSeq: number, pageNum: number) => { + const params = new URLSearchParams(); + if (parentSeq > 0) { + params.set('parentSeq', String(parentSeq)); + } + if (pageNum > 1) { + params.set('pageNum', String(pageNum)); + } + setSearchParams(params, { replace: false }); + }, [setSearchParams]); + + // 메뉴 목록 조회 (parentSeq 기준) + const fetchMenuList = async (parentSeq: number = 0, pageNum: number = 1) => { try { setLoading(true); - const data = await getAdminMenuList(page); - setMenuList(data.content); + const data = await getAdminMenuListByParentSeq(parentSeq, pageNum); + setMenuList(data.content || []); setPageInfo({ pageNum: data.pageNum, pageSize: data.pageSize, @@ -207,6 +228,7 @@ const AdminMenuManagement: React.FC = () => { isLastPage: data.isLastPage, }); setCurrentPage(data.pageNum); + setCurrentParentSeq(parentSeq); } catch (error) { console.error('메뉴 목록 조회 실패:', error); alert('메뉴 목록을 불러오는데 실패했습니다.'); @@ -215,48 +237,105 @@ const AdminMenuManagement: React.FC = () => { } }; - // 최상위 메뉴 목록 조회 (parentSeq가 0인 메뉴들) - const fetchTopLevelMenuList = async () => { - try { - const data = await getAdminMenuListByParentSeq(0); - setTopLevelMenuList(data || []); - } catch (error) { - console.error('최상위 메뉴 목록 조회 실패:', error); - setTopLevelMenuList([]); + // 메뉴 클릭 시 하위 메뉴로 이동 + const handleMenuClick = (menu: AdminMenu) => { + if (menu.adminMenuSeq) { + const newBreadcrumb = [...breadcrumb, { adminMenuSeq: menu.adminMenuSeq!, menuName: menu.menuName }]; + setBreadcrumb(newBreadcrumb); + updateUrlParams(menu.adminMenuSeq, 1); } }; + // 브레드크럼 클릭 시 해당 위치로 이동 + const handleBreadcrumbClick = (index: number) => { + if (index === -1) { + // 최상위로 이동 + setBreadcrumb([]); + updateUrlParams(0, 1); + } else { + // 특정 위치로 이동 + const targetItem = breadcrumb[index]; + const newBreadcrumb = breadcrumb.slice(0, index + 1); + setBreadcrumb(newBreadcrumb); + updateUrlParams(targetItem.adminMenuSeq, 1); + } + }; + + // 페이지 이동 함수 (URL 업데이트 포함) + const handlePageChange = (pageNum: number) => { + updateUrlParams(currentParentSeq, pageNum); + }; + + // 브레드크럼 경로를 API로 복원하는 함수 + const buildBreadcrumbPath = async (targetSeq: number): Promise => { + const path: BreadcrumbItem[] = []; + let currentSeq = targetSeq; + + while (currentSeq > 0) { + try { + const menu = await getAdminMenu(currentSeq); + path.unshift({ adminMenuSeq: menu.adminMenuSeq!, menuName: menu.menuName }); + currentSeq = menu.parentSeq; + } catch { + break; + } + } + + return path; + }; + + // URL 파라미터 변경 감지 및 초기 로드 useEffect(() => { - fetchMenuList(); - }, []); + const parentSeqParam = searchParams.get('parentSeq'); + const pageNumParam = searchParams.get('pageNum'); + + const parentSeq = parentSeqParam ? parseInt(parentSeqParam) : 0; + const pageNum = pageNumParam ? parseInt(pageNumParam) : 1; + + const loadData = async () => { + // 브레드크럼 복원 + const currentBreadcrumbParent = breadcrumb.length > 0 ? breadcrumb[breadcrumb.length - 1].adminMenuSeq : 0; + + if (parentSeq !== currentBreadcrumbParent) { + if (parentSeq === 0) { + setBreadcrumb([]); + } else { + // 브레드크럼에서 해당 parentSeq를 찾아서 그 위치까지만 유지 + const foundIndex = breadcrumb.findIndex(item => item.adminMenuSeq === parentSeq); + if (foundIndex >= 0) { + setBreadcrumb(breadcrumb.slice(0, foundIndex + 1)); + } else { + // 브레드크럼에 없으면 API로 경로 복원 (새로고침, 직접 URL 접근 등) + const newPath = await buildBreadcrumbPath(parentSeq); + setBreadcrumb(newPath); + } + } + } + + setCurrentParentSeq(parentSeq); + fetchMenuList(parentSeq, pageNum); + }; + + loadData(); + }, [searchParams]); // 메뉴 추가 모달 열기 - const handleAddClick = async () => { + const handleAddClick = () => { setIsEditMode(false); setFormData({ - parentSeq: 0, + parentSeq: currentParentSeq, menuOrder: 1, menuName: '', iconName: '', menuUrl: '', }); - try { - await fetchTopLevelMenuList(); - } catch (error) { - console.error('최상위 메뉴 목록 조회 실패:', error); - } setModalVisible(true); }; // 메뉴 수정 모달 열기 - const handleEditClick = async (menu: AdminMenu) => { + const handleEditClick = (menu: AdminMenu) => { setIsEditMode(true); setFormData({ ...menu }); - try { - await fetchTopLevelMenuList(); - } catch (error) { - console.error('최상위 메뉴 목록 조회 실패:', error); - } setModalVisible(true); }; @@ -309,7 +388,7 @@ const AdminMenuManagement: React.FC = () => { } setModalVisible(false); - fetchMenuList(currentPage); + fetchMenuList(currentParentSeq, currentPage); } catch (error) { console.error('메뉴 저장 실패:', error); alert('메뉴 저장에 실패했습니다.'); @@ -324,20 +403,13 @@ const AdminMenuManagement: React.FC = () => { await deleteAdminMenu(selectedMenu.adminMenuSeq); alert('메뉴가 삭제되었습니다.'); setDeleteModalVisible(false); - fetchMenuList(currentPage); + fetchMenuList(currentParentSeq, currentPage); } catch (error) { console.error('메뉴 삭제 실패:', error); alert('메뉴 삭제에 실패했습니다.'); } }; - // 메뉴의 depth 계산 (계층 구조 표시용) - const getMenuDepth = (menu: AdminMenu): number => { - if (menu.parentSeq === 0) return 0; - const parent = menuList.find((m) => m.adminMenuSeq === menu.parentSeq); - return parent ? getMenuDepth(parent) + 1 : 0; - }; - return ( <> @@ -354,39 +426,73 @@ const AdminMenuManagement: React.FC = () => { - + {/* 브레드크럼 네비게이션 */} + + + 메뉴 번호 {!isCompact && ( 아이콘 )} - 메뉴 이름 - 부모 번호 - 정렬 순서 + 메뉴 이름 + 순번 {!isCompact && ( - URL + URL )} - 액션 + 작업 {loading ? ( - + 로딩 중... ) : menuList.length === 0 ? ( - + 등록된 메뉴가 없습니다. ) : ( - menuList.map((menu) => { - const depth = getMenuDepth(menu); - return ( + menuList.map((menu) => (
{menu.adminMenuSeq}
@@ -400,30 +506,28 @@ const AdminMenuManagement: React.FC = () => { )}
)} - + - - {menu.parentSeq === 0 ? ( - 최상위 - ) : ( - menu.parentSeq - )} - {menu.menuOrder} {!isCompact && ( - +
{ size="sm" className="me-2" onClick={() => handleEditClick(menu)} + title="수정" > @@ -450,13 +555,13 @@ const AdminMenuManagement: React.FC = () => { color="danger" size="sm" onClick={() => handleDeleteClick(menu)} + title="삭제" > - ); - }) + )) )} @@ -472,7 +577,7 @@ const AdminMenuManagement: React.FC = () => { fetchMenuList(1)} + onClick={() => handlePageChange(1)} style={{ cursor: pageInfo.isFirstPage ? 'default' : 'pointer' }} > « @@ -480,7 +585,7 @@ const AdminMenuManagement: React.FC = () => { fetchMenuList(currentPage - 1)} + onClick={() => handlePageChange(currentPage - 1)} style={{ cursor: pageInfo.isFirstPage ? 'default' : 'pointer' }} > ‹ @@ -489,7 +594,7 @@ const AdminMenuManagement: React.FC = () => { fetchMenuList(page)} + onClick={() => handlePageChange(page)} style={{ cursor: 'pointer' }} > {page} @@ -498,7 +603,7 @@ const AdminMenuManagement: React.FC = () => { fetchMenuList(currentPage + 1)} + onClick={() => handlePageChange(currentPage + 1)} style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }} > › @@ -506,7 +611,7 @@ const AdminMenuManagement: React.FC = () => { fetchMenuList(pageInfo.totalPage)} + onClick={() => handlePageChange(pageInfo.totalPage)} style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }} > » @@ -536,31 +641,20 @@ const AdminMenuManagement: React.FC = () => { />
- 부모 메뉴 - { - setFormData((prev) => ({ - ...prev, - parentSeq: parseInt(e.target.value) || 0, - })); - }} - > - - {Array.isArray(topLevelMenuList) && topLevelMenuList - .filter((menu) => menu.adminMenuSeq !== formData.adminMenuSeq) - .map((menu) => ( - - ))} - - 최상위 메뉴를 선택하면 하위 메뉴로 등록됩니다. + 부모 메뉴 + item.adminMenuSeq === formData.parentSeq)?.menuName || `메뉴 번호: ${formData.parentSeq}` + } + readOnly + disabled + />
- 정렬 순서 * + 순번 * { menuOrder: value < 1 ? 1 : value, })); }} - placeholder="정렬 순서 (1 이상)" + placeholder="순번 (1 이상)" />