어드민 메뉴 관리 기능 추가
This commit is contained in:
11
src/_nav.tsx
11
src/_nav.tsx
@@ -15,6 +15,7 @@ import {
|
|||||||
cilPuzzle,
|
cilPuzzle,
|
||||||
cilSpeedometer,
|
cilSpeedometer,
|
||||||
cilStar,
|
cilStar,
|
||||||
|
cilMenu,
|
||||||
} from '@coreui/icons'
|
} from '@coreui/icons'
|
||||||
import { CNavGroup, CNavItem, CNavTitle } from '@coreui/react'
|
import { CNavGroup, CNavItem, CNavTitle } from '@coreui/react'
|
||||||
|
|
||||||
@@ -45,6 +46,16 @@ const _nav = [
|
|||||||
to: '/theme/typography',
|
to: '/theme/typography',
|
||||||
icon: <CIcon icon={cilPencil} customClassName="nav-icon" />,
|
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,
|
component: CNavTitle,
|
||||||
name: 'Extras',
|
name: 'Extras',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React from 'react'
|
|||||||
const Dashboard = React.lazy(() => import('src/views/dashboard/Dashboard'))
|
const Dashboard = React.lazy(() => import('src/views/dashboard/Dashboard'))
|
||||||
const Colors = React.lazy(() => import('src/views/theme/colors/Colors'))
|
const Colors = React.lazy(() => import('src/views/theme/colors/Colors'))
|
||||||
const Typography = React.lazy(() => import('src/views/theme/typography/Typography'))
|
const Typography = React.lazy(() => import('src/views/theme/typography/Typography'))
|
||||||
|
const AdminMenuManagement = React.lazy(() => import('src/views/admin/AdminMenuManagement'))
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', exact: true, name: 'Home' },
|
{ path: '/', exact: true, name: 'Home' },
|
||||||
@@ -10,6 +11,7 @@ const routes = [
|
|||||||
{ path: '/theme', name: 'Theme', element: Colors, exact: true },
|
{ path: '/theme', name: 'Theme', element: Colors, exact: true },
|
||||||
{ path: '/theme/colors', name: 'Colors', element: Colors },
|
{ path: '/theme/colors', name: 'Colors', element: Colors },
|
||||||
{ path: '/theme/typography', name: 'Typography', element: Typography },
|
{ path: '/theme/typography', name: 'Typography', element: Typography },
|
||||||
|
{ path: '/admin/menu', name: 'Admin Menu Management', element: AdminMenuManagement },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default routes
|
export default routes
|
||||||
|
|||||||
54
src/services/adminMenuService.ts
Normal file
54
src/services/adminMenuService.ts
Normal 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;
|
||||||
|
};
|
||||||
366
src/views/admin/AdminMenuManagement.tsx
Normal file
366
src/views/admin/AdminMenuManagement.tsx
Normal 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;
|
||||||
Reference in New Issue
Block a user