어드민 메뉴 관리 기능 추가

This commit is contained in:
2026-01-12 11:00:11 +09:00
parent 90bbb0dd50
commit 9d31cbbd10
4 changed files with 433 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ import {
cilPuzzle,
cilSpeedometer,
cilStar,
cilMenu,
} from '@coreui/icons'
import { CNavGroup, CNavItem, CNavTitle } from '@coreui/react'
@@ -45,6 +46,16 @@ const _nav = [
to: '/theme/typography',
icon: <CIcon icon={cilPencil} customClassName="nav-icon" />,
},
{
component: CNavTitle,
name: 'Admin',
},
{
component: CNavItem,
name: 'Menu Management',
to: '/admin/menu',
icon: <CIcon icon={cilMenu} customClassName="nav-icon" />,
},
{
component: CNavTitle,
name: 'Extras',

View File

@@ -3,6 +3,7 @@ import React from 'react'
const Dashboard = React.lazy(() => import('src/views/dashboard/Dashboard'))
const Colors = React.lazy(() => import('src/views/theme/colors/Colors'))
const Typography = React.lazy(() => import('src/views/theme/typography/Typography'))
const AdminMenuManagement = React.lazy(() => import('src/views/admin/AdminMenuManagement'))
const routes = [
{ path: '/', exact: true, name: 'Home' },
@@ -10,6 +11,7 @@ const routes = [
{ path: '/theme', name: 'Theme', element: Colors, exact: true },
{ path: '/theme/colors', name: 'Colors', element: Colors },
{ path: '/theme/typography', name: 'Typography', element: Typography },
{ path: '/admin/menu', name: 'Admin Menu Management', element: AdminMenuManagement },
]
export default routes

View File

@@ -0,0 +1,54 @@
import axios from 'src/axios/axios';
// 어드민 메뉴 인터페이스
export interface AdminMenu {
adminMenuSeq?: number;
parentSeq: number;
menuOrder: number;
menuName: string;
iconName?: string;
menuUrl?: string;
}
// API 응답 인터페이스
export interface AdminMenuResponse {
resultCode: string;
resultMessage: string;
resultData?: any;
}
// 어드민 메뉴 목록 조회
export const getAdminMenuList = async (): Promise<AdminMenu[]> => {
const response = await axios.get<AdminMenuResponse>('/admin/menu/list');
return response.data.resultData || [];
};
// parentSeq로 어드민 메뉴 목록 조회
export const getAdminMenuListByParentSeq = async (parentSeq: number): Promise<AdminMenu[]> => {
const response = await axios.get<AdminMenuResponse>(`/admin/menu/listByParentSeq/${parentSeq}`);
return response.data.resultData || [];
};
// adminMenuSeq로 어드민 메뉴 조회
export const getAdminMenu = async (adminMenuSeq: number): Promise<AdminMenu> => {
const response = await axios.get<AdminMenuResponse>(`/admin/menu/${adminMenuSeq}`);
return response.data.resultData;
};
// 어드민 메뉴 추가
export const addAdminMenu = async (menu: AdminMenu): Promise<AdminMenuResponse> => {
const response = await axios.post<AdminMenuResponse>('/admin/menu/add', menu);
return response.data;
};
// 어드민 메뉴 수정
export const updateAdminMenu = async (menu: AdminMenu): Promise<AdminMenuResponse> => {
const response = await axios.post<AdminMenuResponse>('/admin/menu/update', menu);
return response.data;
};
// 어드민 메뉴 삭제
export const deleteAdminMenu = async (adminMenuSeq: number): Promise<AdminMenuResponse> => {
const response = await axios.post<AdminMenuResponse>('/admin/menu/delete', { adminMenuSeq });
return response.data;
};

View File

@@ -0,0 +1,366 @@
import React, { useState, useEffect } from 'react';
import {
CButton,
CCard,
CCardBody,
CCardHeader,
CCol,
CForm,
CFormInput,
CFormLabel,
CModal,
CModalBody,
CModalFooter,
CModalHeader,
CModalTitle,
CRow,
CTable,
CTableBody,
CTableDataCell,
CTableHead,
CTableHeaderCell,
CTableRow,
CBadge,
} from '@coreui/react';
import CIcon from '@coreui/icons-react';
import { cilPencil, cilTrash, cilPlus } from '@coreui/icons';
import {
getAdminMenuList,
addAdminMenu,
updateAdminMenu,
deleteAdminMenu,
AdminMenu,
} from 'src/services/adminMenuService';
const AdminMenuManagement: React.FC = () => {
const [menuList, setMenuList] = useState<AdminMenu[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [selectedMenu, setSelectedMenu] = useState<AdminMenu | null>(null);
const [formData, setFormData] = useState<AdminMenu>({
parentSeq: 0,
menuOrder: 0,
menuName: '',
iconName: '',
menuUrl: '',
});
// 메뉴 목록 조회
const fetchMenuList = async () => {
try {
setLoading(true);
const data = await getAdminMenuList();
setMenuList(data);
} catch (error) {
console.error('메뉴 목록 조회 실패:', error);
alert('메뉴 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMenuList();
}, []);
// 메뉴 추가 모달 열기
const handleAddClick = () => {
setIsEditMode(false);
setFormData({
parentSeq: 0,
menuOrder: 0,
menuName: '',
iconName: '',
menuUrl: '',
});
setModalVisible(true);
};
// 메뉴 수정 모달 열기
const handleEditClick = (menu: AdminMenu) => {
setIsEditMode(true);
setFormData({ ...menu });
setModalVisible(true);
};
// 메뉴 삭제 모달 열기
const handleDeleteClick = (menu: AdminMenu) => {
setSelectedMenu(menu);
setDeleteModalVisible(true);
};
// 입력 필드 변경 처리
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: name === 'parentSeq' || name === 'menuOrder' ? parseInt(value) || 0 : value,
}));
};
// 메뉴 저장 (추가/수정)
const handleSave = async () => {
try {
if (!formData.menuName.trim()) {
alert('메뉴 이름을 입력해주세요.');
return;
}
if (isEditMode) {
await updateAdminMenu(formData);
alert('메뉴가 수정되었습니다.');
} else {
await addAdminMenu(formData);
alert('메뉴가 추가되었습니다.');
}
setModalVisible(false);
fetchMenuList();
} catch (error) {
console.error('메뉴 저장 실패:', error);
alert('메뉴 저장에 실패했습니다.');
}
};
// 메뉴 삭제
const handleDelete = async () => {
if (!selectedMenu || !selectedMenu.adminMenuSeq) return;
try {
await deleteAdminMenu(selectedMenu.adminMenuSeq);
alert('메뉴가 삭제되었습니다.');
setDeleteModalVisible(false);
fetchMenuList();
} catch (error) {
console.error('메뉴 삭제 실패:', error);
alert('메뉴 삭제에 실패했습니다.');
}
};
// 계층 구조 표시를 위한 메뉴 정렬
const getSortedMenuList = () => {
const sortedList: AdminMenu[] = [];
const menuMap = new Map<number, AdminMenu[]>();
// parentSeq별로 그룹화
menuList.forEach((menu) => {
const key = menu.parentSeq;
if (!menuMap.has(key)) {
menuMap.set(key, []);
}
menuMap.get(key)?.push(menu);
});
// 각 그룹 내에서 menuOrder로 정렬
menuMap.forEach((menus) => {
menus.sort((a, b) => a.menuOrder - b.menuOrder);
});
// 계층 구조로 정렬
const addChildren = (parentSeq: number, depth: number = 0) => {
const children = menuMap.get(parentSeq) || [];
children.forEach((menu) => {
sortedList.push({ ...menu, depth } as any);
addChildren(menu.adminMenuSeq || 0, depth + 1);
});
};
addChildren(0);
return sortedList;
};
return (
<>
<CCard className="mb-4">
<CCardHeader>
<strong> </strong>
<CButton
color="primary"
size="sm"
className="float-end"
onClick={handleAddClick}
>
<CIcon icon={cilPlus} className="me-1" />
</CButton>
</CCardHeader>
<CCardBody>
<CTable align="middle" className="mb-0 border" hover responsive>
<CTableHead className="text-nowrap">
<CTableRow>
<CTableHeaderCell className="bg-body-tertiary text-center"> </CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary"> </CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center"> </CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center"> </CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center"></CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary">URL</CTableHeaderCell>
<CTableHeaderCell className="bg-body-tertiary text-center"></CTableHeaderCell>
</CTableRow>
</CTableHead>
<CTableBody>
{loading ? (
<CTableRow>
<CTableDataCell colSpan={7} className="text-center">
...
</CTableDataCell>
</CTableRow>
) : getSortedMenuList().length === 0 ? (
<CTableRow>
<CTableDataCell colSpan={7} className="text-center">
.
</CTableDataCell>
</CTableRow>
) : (
getSortedMenuList().map((menu: any) => (
<CTableRow key={menu.adminMenuSeq}>
<CTableDataCell className="text-center">
<div className="fw-semibold">{menu.adminMenuSeq}</div>
</CTableDataCell>
<CTableDataCell>
<div style={{ marginLeft: `${menu.depth * 20}px` }}>
{menu.depth > 0 && '└ '}
{menu.menuName}
</div>
</CTableDataCell>
<CTableDataCell className="text-center">
{menu.parentSeq === 0 ? (
<CBadge color="info"></CBadge>
) : (
menu.parentSeq
)}
</CTableDataCell>
<CTableDataCell className="text-center">{menu.menuOrder}</CTableDataCell>
<CTableDataCell className="text-center">
<div className="small text-body-secondary">{menu.iconName || '-'}</div>
</CTableDataCell>
<CTableDataCell>
<div className="small text-body-secondary text-nowrap">{menu.menuUrl || '-'}</div>
</CTableDataCell>
<CTableDataCell className="text-center">
<CButton
color="info"
size="sm"
className="me-2"
onClick={() => handleEditClick(menu)}
>
<CIcon icon={cilPencil} />
</CButton>
<CButton
color="danger"
size="sm"
onClick={() => handleDeleteClick(menu)}
>
<CIcon icon={cilTrash} />
</CButton>
</CTableDataCell>
</CTableRow>
))
)}
</CTableBody>
</CTable>
</CCardBody>
</CCard>
{/* 추가/수정 모달 */}
<CModal visible={modalVisible} onClose={() => setModalVisible(false)}>
<CModalHeader>
<CModalTitle>{isEditMode ? '메뉴 수정' : '메뉴 추가'}</CModalTitle>
</CModalHeader>
<CModalBody>
<CForm>
<div className="mb-3">
<CFormLabel htmlFor="menuName"> *</CFormLabel>
<CFormInput
type="text"
id="menuName"
name="menuName"
value={formData.menuName}
onChange={handleInputChange}
placeholder="메뉴 이름을 입력하세요"
/>
</div>
<div className="mb-3">
<CFormLabel htmlFor="parentSeq"> </CFormLabel>
<CFormInput
type="number"
id="parentSeq"
name="parentSeq"
value={formData.parentSeq}
onChange={handleInputChange}
placeholder="0 (최상위 메뉴)"
/>
<small className="text-muted">0 .</small>
</div>
<div className="mb-3">
<CFormLabel htmlFor="menuOrder"> </CFormLabel>
<CFormInput
type="number"
id="menuOrder"
name="menuOrder"
value={formData.menuOrder}
onChange={handleInputChange}
placeholder="정렬 순서"
/>
</div>
<div className="mb-3">
<CFormLabel htmlFor="iconName"> </CFormLabel>
<CFormInput
type="text"
id="iconName"
name="iconName"
value={formData.iconName || ''}
onChange={handleInputChange}
placeholder="아이콘 이름 (선택사항)"
/>
</div>
<div className="mb-3">
<CFormLabel htmlFor="menuUrl"> URL</CFormLabel>
<CFormInput
type="text"
id="menuUrl"
name="menuUrl"
value={formData.menuUrl || ''}
onChange={handleInputChange}
placeholder="/admin/example (선택사항)"
/>
</div>
</CForm>
</CModalBody>
<CModalFooter>
<CButton color="secondary" onClick={() => setModalVisible(false)}>
</CButton>
<CButton color="primary" onClick={handleSave}>
{isEditMode ? '수정' : '추가'}
</CButton>
</CModalFooter>
</CModal>
{/* 삭제 확인 모달 */}
<CModal visible={deleteModalVisible} onClose={() => setDeleteModalVisible(false)}>
<CModalHeader>
<CModalTitle> </CModalTitle>
</CModalHeader>
<CModalBody>
<strong>{selectedMenu?.menuName}</strong> ?
<br />
<small className="text-danger">
. .
</small>
</CModalBody>
<CModalFooter>
<CButton color="secondary" onClick={() => setDeleteModalVisible(false)}>
</CButton>
<CButton color="danger" onClick={handleDelete}>
</CButton>
</CModalFooter>
</CModal>
</>
);
};
export default AdminMenuManagement;