어드민 메뉴 관리 목록을 계층형으로 개선

This commit is contained in:
2026-01-13 20:35:42 +09:00
parent 7a36ef999e
commit 5d8189ccf1
2 changed files with 215 additions and 98 deletions

View File

@@ -44,8 +44,31 @@ export const getAdminMenuList = async (pageNum: number = 1): Promise<AdminMenuPa
return response.data.resultData || { content: [], pageNum: 1, pageSize: 0, totalContent: 0, totalPage: 0, isFirstPage: true, isLastPage: true };
};
// parentSeq로 어드민 메뉴 목록 조회
export const getAdminMenuListByParentSeq = async (parentSeq: number): Promise<AdminMenu[]> => {
// parentSeq로 어드민 메뉴 목록 조회 (페이징 지원)
export const getAdminMenuListByParentSeq = async (parentSeq: number, pageNum: number = 1): Promise<AdminMenuPageResponse> => {
const response = await axios.get<AdminMenuResponse>(`/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<AdminMenu[]> => {
const response = await axios.get<AdminMenuResponse>(`/admin/menu/listByParentSeq/${parentSeq}`);
const resultData = response.data.resultData;
// 페이징 형태의 응답인 경우 content 배열 반환

View File

@@ -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<AdminMenu[]>([]);
const [topLevelMenuList, setTopLevelMenuList] = useState<AdminMenu[]>([]);
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<BreadcrumbItem[]>([]);
// 페이징 상태
const [currentPage, setCurrentPage] = useState(1);
const [pageInfo, setPageInfo] = useState<Omit<AdminMenuPageResponse, 'content'>>({
@@ -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<BreadcrumbItem[]> => {
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 (
<>
<CCard className="mb-4">
@@ -354,39 +426,73 @@ const AdminMenuManagement: React.FC = () => {
</CButton>
</CCardHeader>
<CCardBody>
<CTable align="middle" className="mb-0 border" hover responsive style={{ tableLayout: 'fixed', minWidth: isCompact ? '450px' : '750px' }}>
{/* 브레드크럼 네비게이션 */}
<nav aria-label="breadcrumb" className="mb-3">
<ol className="breadcrumb mb-0">
<li className={`breadcrumb-item ${breadcrumb.length === 0 ? 'active' : ''}`}>
{breadcrumb.length === 0 ? (
<span><CIcon icon={cilHome} className="me-1" /> </span>
) : (
<a
href="#"
onClick={(e) => { e.preventDefault(); handleBreadcrumbClick(-1); }}
style={{ textDecoration: 'none' }}
>
<CIcon icon={cilHome} className="me-1" />
</a>
)}
</li>
{breadcrumb.map((item, index) => (
<li
key={item.adminMenuSeq}
className={`breadcrumb-item ${index === breadcrumb.length - 1 ? 'active' : ''}`}
>
{index === breadcrumb.length - 1 ? (
<span>{item.menuName}</span>
) : (
<a
href="#"
onClick={(e) => { e.preventDefault(); handleBreadcrumbClick(index); }}
style={{ textDecoration: 'none' }}
>
{item.menuName}
</a>
)}
</li>
))}
</ol>
</nav>
<CTable align="middle" className="mb-0 border" hover responsive style={{ tableLayout: 'fixed', minWidth: isCompact ? '400px' : '650px' }}>
<CTableHead className="text-nowrap">
<CTableRow>
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '80px' }}> </CTableHeaderCell>
{!isCompact && (
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '60px' }}></CTableHeaderCell>
)}
<CTableHeaderCell className="bg-body-tertiary" style={{ width: 'auto' }}> </CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '80px' }}> </CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '70px' }}> </CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '180px' }}> </CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '70px' }}></CTableHeaderCell>
{!isCompact && (
<CTableHeaderCell className="bg-body-tertiary" style={{ width: '180px' }}>URL</CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: 'auto' }}>URL</CTableHeaderCell>
)}
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '100px' }}></CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '100px' }}></CTableHeaderCell>
</CTableRow>
</CTableHead>
<CTableBody style={{ minHeight: '200px' }}>
{loading ? (
<CTableRow>
<CTableDataCell colSpan={isCompact ? 5 : 7} className="text-center py-5">
<CTableDataCell colSpan={isCompact ? 4 : 6} className="text-center py-5">
...
</CTableDataCell>
</CTableRow>
) : menuList.length === 0 ? (
<CTableRow>
<CTableDataCell colSpan={isCompact ? 5 : 7} className="text-center">
<CTableDataCell colSpan={isCompact ? 4 : 6} className="text-center">
.
</CTableDataCell>
</CTableRow>
) : (
menuList.map((menu) => {
const depth = getMenuDepth(menu);
return (
menuList.map((menu) => (
<CTableRow key={menu.adminMenuSeq}>
<CTableDataCell className="text-center">
<div className="fw-semibold">{menu.adminMenuSeq}</div>
@@ -400,30 +506,28 @@ const AdminMenuManagement: React.FC = () => {
)}
</CTableDataCell>
)}
<CTableDataCell>
<CTableDataCell className="text-center">
<div
style={{
marginLeft: `${depth * 20}px`,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={menu.menuName}
title={`${menu.menuName} - 클릭하여 하위 메뉴 보기`}
>
<a
href="#"
onClick={(e) => { e.preventDefault(); handleMenuClick(menu); }}
style={{ textDecoration: 'none', color: 'inherit' }}
className="fw-semibold"
>
{depth > 0 && '└ '}
{menu.menuName}
</a>
</div>
</CTableDataCell>
<CTableDataCell className="text-center">
{menu.parentSeq === 0 ? (
<CBadge color="info" size="sm"></CBadge>
) : (
menu.parentSeq
)}
</CTableDataCell>
<CTableDataCell className="text-center">{menu.menuOrder}</CTableDataCell>
{!isCompact && (
<CTableDataCell>
<CTableDataCell className="text-center">
<div
className="small text-body-secondary"
style={{
@@ -443,6 +547,7 @@ const AdminMenuManagement: React.FC = () => {
size="sm"
className="me-2"
onClick={() => handleEditClick(menu)}
title="수정"
>
<CIcon icon={cilPencil} />
</CButton>
@@ -450,13 +555,13 @@ const AdminMenuManagement: React.FC = () => {
color="danger"
size="sm"
onClick={() => handleDeleteClick(menu)}
title="삭제"
>
<CIcon icon={cilTrash} />
</CButton>
</CTableDataCell>
</CTableRow>
);
})
))
)}
</CTableBody>
</CTable>
@@ -472,7 +577,7 @@ const AdminMenuManagement: React.FC = () => {
<CPaginationItem
aria-label="First"
disabled={pageInfo.isFirstPage}
onClick={() => fetchMenuList(1)}
onClick={() => handlePageChange(1)}
style={{ cursor: pageInfo.isFirstPage ? 'default' : 'pointer' }}
>
«
@@ -480,7 +585,7 @@ const AdminMenuManagement: React.FC = () => {
<CPaginationItem
aria-label="Previous"
disabled={pageInfo.isFirstPage}
onClick={() => fetchMenuList(currentPage - 1)}
onClick={() => handlePageChange(currentPage - 1)}
style={{ cursor: pageInfo.isFirstPage ? 'default' : 'pointer' }}
>
@@ -489,7 +594,7 @@ const AdminMenuManagement: React.FC = () => {
<CPaginationItem
key={page}
active={page === currentPage}
onClick={() => fetchMenuList(page)}
onClick={() => handlePageChange(page)}
style={{ cursor: 'pointer' }}
>
{page}
@@ -498,7 +603,7 @@ const AdminMenuManagement: React.FC = () => {
<CPaginationItem
aria-label="Next"
disabled={pageInfo.isLastPage}
onClick={() => fetchMenuList(currentPage + 1)}
onClick={() => handlePageChange(currentPage + 1)}
style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }}
>
@@ -506,7 +611,7 @@ const AdminMenuManagement: React.FC = () => {
<CPaginationItem
aria-label="Last"
disabled={pageInfo.isLastPage}
onClick={() => fetchMenuList(pageInfo.totalPage)}
onClick={() => handlePageChange(pageInfo.totalPage)}
style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }}
>
»
@@ -536,31 +641,20 @@ const AdminMenuManagement: React.FC = () => {
/>
</div>
<div className="mb-3">
<CFormLabel htmlFor="parentSeq"> </CFormLabel>
<CFormSelect
id="parentSeq"
name="parentSeq"
value={String(formData.parentSeq)}
onChange={(e) => {
setFormData((prev) => ({
...prev,
parentSeq: parseInt(e.target.value) || 0,
}));
}}
>
<option value="0"> ( )</option>
{Array.isArray(topLevelMenuList) && topLevelMenuList
.filter((menu) => menu.adminMenuSeq !== formData.adminMenuSeq)
.map((menu) => (
<option key={menu.adminMenuSeq} value={String(menu.adminMenuSeq)}>
{menu.menuName} (: {menu.adminMenuSeq})
</option>
))}
</CFormSelect>
<small className="text-muted"> .</small>
<CFormLabel> </CFormLabel>
<CFormInput
type="text"
value={
formData.parentSeq === 0
? '최상위 메뉴'
: breadcrumb.find(item => item.adminMenuSeq === formData.parentSeq)?.menuName || `메뉴 번호: ${formData.parentSeq}`
}
readOnly
disabled
/>
</div>
<div className="mb-3">
<CFormLabel htmlFor="menuOrder"> *</CFormLabel>
<CFormLabel htmlFor="menuOrder"> *</CFormLabel>
<CFormInput
type="number"
id="menuOrder"
@@ -574,7 +668,7 @@ const AdminMenuManagement: React.FC = () => {
menuOrder: value < 1 ? 1 : value,
}));
}}
placeholder="정렬 순서 (1 이상)"
placeholder="순번 (1 이상)"
/>
</div>
<div className="mb-3">