어드민 메뉴 관리 기능 개선
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run build:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,27 @@ export interface AdminMenu {
|
|||||||
menuUrl?: string;
|
menuUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 페이징 정보 인터페이스
|
||||||
|
export interface PageInfo {
|
||||||
|
pageNum: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalContent: number;
|
||||||
|
totalPage: number;
|
||||||
|
isFirstPage: boolean;
|
||||||
|
isLastPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이징된 메뉴 목록 응답 인터페이스
|
||||||
|
export interface AdminMenuPageResponse {
|
||||||
|
content: AdminMenu[];
|
||||||
|
pageNum: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalContent: number;
|
||||||
|
totalPage: number;
|
||||||
|
isFirstPage: boolean;
|
||||||
|
isLastPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// API 응답 인터페이스
|
// API 응답 인터페이스
|
||||||
export interface AdminMenuResponse {
|
export interface AdminMenuResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
@@ -17,16 +38,25 @@ export interface AdminMenuResponse {
|
|||||||
resultData?: any;
|
resultData?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 어드민 메뉴 목록 조회
|
// 어드민 메뉴 목록 조회 (페이징)
|
||||||
export const getAdminMenuList = async (): Promise<AdminMenu[]> => {
|
export const getAdminMenuList = async (pageNum: number = 1): Promise<AdminMenuPageResponse> => {
|
||||||
const response = await axios.get<AdminMenuResponse>('/admin/menu/list');
|
const response = await axios.get<AdminMenuResponse>(`/admin/menu/list?pageNum=${pageNum}`);
|
||||||
return response.data.resultData || [];
|
return response.data.resultData || { content: [], pageNum: 1, pageSize: 0, totalContent: 0, totalPage: 0, isFirstPage: true, isLastPage: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
// parentSeq로 어드민 메뉴 목록 조회
|
// parentSeq로 어드민 메뉴 목록 조회
|
||||||
export const getAdminMenuListByParentSeq = async (parentSeq: number): Promise<AdminMenu[]> => {
|
export const getAdminMenuListByParentSeq = async (parentSeq: number): Promise<AdminMenu[]> => {
|
||||||
const response = await axios.get<AdminMenuResponse>(`/admin/menu/listByParentSeq/${parentSeq}`);
|
const response = await axios.get<AdminMenuResponse>(`/admin/menu/listByParentSeq/${parentSeq}`);
|
||||||
return response.data.resultData || [];
|
const resultData = response.data.resultData;
|
||||||
|
// 페이징 형태의 응답인 경우 content 배열 반환
|
||||||
|
if (resultData && resultData.content && Array.isArray(resultData.content)) {
|
||||||
|
return resultData.content;
|
||||||
|
}
|
||||||
|
// 배열 형태의 응답인 경우 그대로 반환
|
||||||
|
if (Array.isArray(resultData)) {
|
||||||
|
return resultData;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// adminMenuSeq로 어드민 메뉴 조회
|
// adminMenuSeq로 어드민 메뉴 조회
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
CButton,
|
CButton,
|
||||||
CCard,
|
CCard,
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
CForm,
|
CForm,
|
||||||
CFormInput,
|
CFormInput,
|
||||||
CFormLabel,
|
CFormLabel,
|
||||||
|
CInputGroup,
|
||||||
|
CInputGroupText,
|
||||||
CModal,
|
CModal,
|
||||||
CModalBody,
|
CModalBody,
|
||||||
CModalFooter,
|
CModalFooter,
|
||||||
@@ -21,38 +23,190 @@ import {
|
|||||||
CTableHeaderCell,
|
CTableHeaderCell,
|
||||||
CTableRow,
|
CTableRow,
|
||||||
CBadge,
|
CBadge,
|
||||||
|
CFormSelect,
|
||||||
|
CPagination,
|
||||||
|
CPaginationItem,
|
||||||
} from '@coreui/react';
|
} from '@coreui/react';
|
||||||
import CIcon from '@coreui/icons-react';
|
import CIcon from '@coreui/icons-react';
|
||||||
import { cilPencil, cilTrash, cilPlus } from '@coreui/icons';
|
import {
|
||||||
|
cilPencil,
|
||||||
|
cilTrash,
|
||||||
|
cilPlus,
|
||||||
|
cilSpeedometer,
|
||||||
|
cilDrop,
|
||||||
|
cilStar,
|
||||||
|
cilMenu,
|
||||||
|
cilBell,
|
||||||
|
cilBrowser,
|
||||||
|
cilCalculator,
|
||||||
|
cilChartPie,
|
||||||
|
cilCursor,
|
||||||
|
cilDescription,
|
||||||
|
cilNotes,
|
||||||
|
cilPuzzle,
|
||||||
|
cilSettings,
|
||||||
|
cilUser,
|
||||||
|
cilPeople,
|
||||||
|
cilHome,
|
||||||
|
cilFolder,
|
||||||
|
cilFile,
|
||||||
|
cilEnvelopeClosed,
|
||||||
|
cilCalendar,
|
||||||
|
cilCart,
|
||||||
|
cilCreditCard,
|
||||||
|
cilGraph,
|
||||||
|
cilLayers,
|
||||||
|
cilList,
|
||||||
|
cilMap,
|
||||||
|
cilMediaPlay,
|
||||||
|
cilMoney,
|
||||||
|
cilLockLocked,
|
||||||
|
cilShieldAlt,
|
||||||
|
cilTask,
|
||||||
|
cilCloudDownload,
|
||||||
|
cilCode,
|
||||||
|
cilApplications,
|
||||||
|
cilGrid,
|
||||||
|
cilInfo,
|
||||||
|
cilWarning,
|
||||||
|
cilCheck,
|
||||||
|
cilX,
|
||||||
|
cilSearch,
|
||||||
|
cilImage,
|
||||||
|
cilCamera,
|
||||||
|
cilHeart,
|
||||||
|
cilThumbUp,
|
||||||
|
cilCog,
|
||||||
|
cilOptions,
|
||||||
|
} from '@coreui/icons';
|
||||||
|
|
||||||
|
// 선택 가능한 아이콘 목록
|
||||||
|
const availableIcons = [
|
||||||
|
{ name: 'cilSpeedometer', icon: cilSpeedometer, label: '대시보드' },
|
||||||
|
{ name: 'cilHome', icon: cilHome, label: '홈' },
|
||||||
|
{ name: 'cilMenu', icon: cilMenu, label: '메뉴' },
|
||||||
|
{ name: 'cilUser', icon: cilUser, label: '사용자' },
|
||||||
|
{ name: 'cilPeople', icon: cilPeople, label: '사람들' },
|
||||||
|
{ name: 'cilSettings', icon: cilSettings, label: '설정' },
|
||||||
|
{ name: 'cilCog', icon: cilCog, label: '톱니바퀴' },
|
||||||
|
{ name: 'cilOptions', icon: cilOptions, label: '옵션' },
|
||||||
|
{ name: 'cilFolder', icon: cilFolder, label: '폴더' },
|
||||||
|
{ name: 'cilFile', icon: cilFile, label: '파일' },
|
||||||
|
{ name: 'cilNotes', icon: cilNotes, label: '노트' },
|
||||||
|
{ name: 'cilDescription', icon: cilDescription, label: '문서' },
|
||||||
|
{ name: 'cilList', icon: cilList, label: '리스트' },
|
||||||
|
{ name: 'cilGrid', icon: cilGrid, label: '그리드' },
|
||||||
|
{ name: 'cilLayers', icon: cilLayers, label: '레이어' },
|
||||||
|
{ name: 'cilApplications', icon: cilApplications, label: '앱' },
|
||||||
|
{ name: 'cilBrowser', icon: cilBrowser, label: '브라우저' },
|
||||||
|
{ name: 'cilChartPie', icon: cilChartPie, label: '파이차트' },
|
||||||
|
{ name: 'cilGraph', icon: cilGraph, label: '그래프' },
|
||||||
|
{ name: 'cilCalculator', icon: cilCalculator, label: '계산기' },
|
||||||
|
{ name: 'cilCalendar', icon: cilCalendar, label: '달력' },
|
||||||
|
{ name: 'cilClock', icon: cilCalendar, label: '시계' },
|
||||||
|
{ name: 'cilBell', icon: cilBell, label: '알림' },
|
||||||
|
{ name: 'cilEnvelopeClosed', icon: cilEnvelopeClosed, label: '메일' },
|
||||||
|
{ name: 'cilCart', icon: cilCart, label: '장바구니' },
|
||||||
|
{ name: 'cilCreditCard', icon: cilCreditCard, label: '카드' },
|
||||||
|
{ name: 'cilMoney', icon: cilMoney, label: '돈' },
|
||||||
|
{ name: 'cilLockLocked', icon: cilLockLocked, label: '잠금' },
|
||||||
|
{ name: 'cilShieldAlt', icon: cilShieldAlt, label: '보안' },
|
||||||
|
{ name: 'cilTask', icon: cilTask, label: '작업' },
|
||||||
|
{ name: 'cilCloudDownload', icon: cilCloudDownload, label: '다운로드' },
|
||||||
|
{ name: 'cilCode', icon: cilCode, label: '코드' },
|
||||||
|
{ name: 'cilMap', icon: cilMap, label: '지도' },
|
||||||
|
{ name: 'cilMediaPlay', icon: cilMediaPlay, label: '재생' },
|
||||||
|
{ name: 'cilImage', icon: cilImage, label: '이미지' },
|
||||||
|
{ name: 'cilCamera', icon: cilCamera, label: '카메라' },
|
||||||
|
{ name: 'cilStar', icon: cilStar, label: '별' },
|
||||||
|
{ name: 'cilHeart', icon: cilHeart, label: '하트' },
|
||||||
|
{ name: 'cilThumbUp', icon: cilThumbUp, label: '좋아요' },
|
||||||
|
{ name: 'cilDrop', icon: cilDrop, label: '색상' },
|
||||||
|
{ name: 'cilPencil', icon: cilPencil, label: '편집' },
|
||||||
|
{ name: 'cilCursor', icon: cilCursor, label: '커서' },
|
||||||
|
{ name: 'cilPuzzle', icon: cilPuzzle, label: '퍼즐' },
|
||||||
|
{ name: 'cilSearch', icon: cilSearch, label: '검색' },
|
||||||
|
{ name: 'cilInfo', icon: cilInfo, label: '정보' },
|
||||||
|
{ name: 'cilWarning', icon: cilWarning, label: '경고' },
|
||||||
|
{ name: 'cilCheck', icon: cilCheck, label: '체크' },
|
||||||
|
{ name: 'cilX', icon: cilX, label: '닫기' },
|
||||||
|
{ name: 'cilPlus', icon: cilPlus, label: '추가' },
|
||||||
|
{ name: 'cilTrash', icon: cilTrash, label: '삭제' },
|
||||||
|
];
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAdminMenuList,
|
getAdminMenuList,
|
||||||
|
getAdminMenuListByParentSeq,
|
||||||
addAdminMenu,
|
addAdminMenu,
|
||||||
updateAdminMenu,
|
updateAdminMenu,
|
||||||
deleteAdminMenu,
|
deleteAdminMenu,
|
||||||
AdminMenu,
|
AdminMenu,
|
||||||
|
AdminMenuPageResponse,
|
||||||
} from 'src/services/adminMenuService';
|
} from 'src/services/adminMenuService';
|
||||||
|
|
||||||
|
// 아이콘 이름으로 아이콘 객체 찾기
|
||||||
|
const getIconByName = (name: string) => {
|
||||||
|
const found = availableIcons.find((item) => item.name === name);
|
||||||
|
return found ? found.icon : null;
|
||||||
|
};
|
||||||
|
|
||||||
const AdminMenuManagement: React.FC = () => {
|
const AdminMenuManagement: React.FC = () => {
|
||||||
const [menuList, setMenuList] = useState<AdminMenu[]>([]);
|
const [menuList, setMenuList] = useState<AdminMenu[]>([]);
|
||||||
|
const [topLevelMenuList, setTopLevelMenuList] = useState<AdminMenu[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
|
const [iconPickerVisible, setIconPickerVisible] = useState(false);
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [selectedMenu, setSelectedMenu] = useState<AdminMenu | null>(null);
|
const [selectedMenu, setSelectedMenu] = useState<AdminMenu | null>(null);
|
||||||
const [formData, setFormData] = useState<AdminMenu>({
|
const [formData, setFormData] = useState<AdminMenu>({
|
||||||
parentSeq: 0,
|
parentSeq: 0,
|
||||||
menuOrder: 0,
|
menuOrder: 1,
|
||||||
menuName: '',
|
menuName: '',
|
||||||
iconName: '',
|
iconName: '',
|
||||||
menuUrl: '',
|
menuUrl: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 페이징 상태
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageInfo, setPageInfo] = useState<Omit<AdminMenuPageResponse, 'content'>>({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 0,
|
||||||
|
totalContent: 0,
|
||||||
|
totalPage: 0,
|
||||||
|
isFirstPage: true,
|
||||||
|
isLastPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 반응형 상태 (화면 너비에 따라 열 표시/숨김)
|
||||||
|
const [isCompact, setIsCompact] = useState(false);
|
||||||
|
|
||||||
|
// 화면 너비 변경 감지
|
||||||
|
const handleResize = useCallback(() => {
|
||||||
|
setIsCompact(window.innerWidth < 992);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [handleResize]);
|
||||||
|
|
||||||
// 메뉴 목록 조회
|
// 메뉴 목록 조회
|
||||||
const fetchMenuList = async () => {
|
const fetchMenuList = async (page: number = 1) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getAdminMenuList();
|
const data = await getAdminMenuList(page);
|
||||||
setMenuList(data);
|
setMenuList(data.content);
|
||||||
|
setPageInfo({
|
||||||
|
pageNum: data.pageNum,
|
||||||
|
pageSize: data.pageSize,
|
||||||
|
totalContent: data.totalContent,
|
||||||
|
totalPage: data.totalPage,
|
||||||
|
isFirstPage: data.isFirstPage,
|
||||||
|
isLastPage: data.isLastPage,
|
||||||
|
});
|
||||||
|
setCurrentPage(data.pageNum);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('메뉴 목록 조회 실패:', error);
|
console.error('메뉴 목록 조회 실패:', error);
|
||||||
alert('메뉴 목록을 불러오는데 실패했습니다.');
|
alert('메뉴 목록을 불러오는데 실패했습니다.');
|
||||||
@@ -61,27 +215,48 @@ const AdminMenuManagement: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 최상위 메뉴 목록 조회 (parentSeq가 0인 메뉴들)
|
||||||
|
const fetchTopLevelMenuList = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getAdminMenuListByParentSeq(0);
|
||||||
|
setTopLevelMenuList(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('최상위 메뉴 목록 조회 실패:', error);
|
||||||
|
setTopLevelMenuList([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMenuList();
|
fetchMenuList();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 메뉴 추가 모달 열기
|
// 메뉴 추가 모달 열기
|
||||||
const handleAddClick = () => {
|
const handleAddClick = async () => {
|
||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
setFormData({
|
setFormData({
|
||||||
parentSeq: 0,
|
parentSeq: 0,
|
||||||
menuOrder: 0,
|
menuOrder: 1,
|
||||||
menuName: '',
|
menuName: '',
|
||||||
iconName: '',
|
iconName: '',
|
||||||
menuUrl: '',
|
menuUrl: '',
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await fetchTopLevelMenuList();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('최상위 메뉴 목록 조회 실패:', error);
|
||||||
|
}
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 메뉴 수정 모달 열기
|
// 메뉴 수정 모달 열기
|
||||||
const handleEditClick = (menu: AdminMenu) => {
|
const handleEditClick = async (menu: AdminMenu) => {
|
||||||
setIsEditMode(true);
|
setIsEditMode(true);
|
||||||
setFormData({ ...menu });
|
setFormData({ ...menu });
|
||||||
|
try {
|
||||||
|
await fetchTopLevelMenuList();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('최상위 메뉴 목록 조회 실패:', error);
|
||||||
|
}
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,6 +275,23 @@ const AdminMenuManagement: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 아이콘 선택 처리
|
||||||
|
const handleIconSelect = (iconName: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
iconName: iconName,
|
||||||
|
}));
|
||||||
|
setIconPickerVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 아이콘 선택 해제
|
||||||
|
const handleIconClear = () => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
iconName: '',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// 메뉴 저장 (추가/수정)
|
// 메뉴 저장 (추가/수정)
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -117,7 +309,7 @@ const AdminMenuManagement: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setModalVisible(false);
|
setModalVisible(false);
|
||||||
fetchMenuList();
|
fetchMenuList(currentPage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('메뉴 저장 실패:', error);
|
console.error('메뉴 저장 실패:', error);
|
||||||
alert('메뉴 저장에 실패했습니다.');
|
alert('메뉴 저장에 실패했습니다.');
|
||||||
@@ -132,43 +324,18 @@ const AdminMenuManagement: React.FC = () => {
|
|||||||
await deleteAdminMenu(selectedMenu.adminMenuSeq);
|
await deleteAdminMenu(selectedMenu.adminMenuSeq);
|
||||||
alert('메뉴가 삭제되었습니다.');
|
alert('메뉴가 삭제되었습니다.');
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
fetchMenuList();
|
fetchMenuList(currentPage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('메뉴 삭제 실패:', error);
|
console.error('메뉴 삭제 실패:', error);
|
||||||
alert('메뉴 삭제에 실패했습니다.');
|
alert('메뉴 삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 계층 구조 표시를 위한 메뉴 정렬
|
// 메뉴의 depth 계산 (계층 구조 표시용)
|
||||||
const getSortedMenuList = () => {
|
const getMenuDepth = (menu: AdminMenu): number => {
|
||||||
const sortedList: AdminMenu[] = [];
|
if (menu.parentSeq === 0) return 0;
|
||||||
const menuMap = new Map<number, AdminMenu[]>();
|
const parent = menuList.find((m) => m.adminMenuSeq === menu.parentSeq);
|
||||||
|
return parent ? getMenuDepth(parent) + 1 : 0;
|
||||||
// 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 (
|
return (
|
||||||
@@ -187,57 +354,89 @@ const AdminMenuManagement: React.FC = () => {
|
|||||||
</CButton>
|
</CButton>
|
||||||
</CCardHeader>
|
</CCardHeader>
|
||||||
<CCardBody>
|
<CCardBody>
|
||||||
<CTable align="middle" className="mb-0 border" hover responsive>
|
<CTable align="middle" className="mb-0 border" hover responsive style={{ tableLayout: 'fixed', minWidth: isCompact ? '450px' : '750px' }}>
|
||||||
<CTableHead className="text-nowrap">
|
<CTableHead className="text-nowrap">
|
||||||
<CTableRow>
|
<CTableRow>
|
||||||
<CTableHeaderCell className="bg-body-tertiary text-center">메뉴 번호</CTableHeaderCell>
|
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '80px' }}>메뉴 번호</CTableHeaderCell>
|
||||||
<CTableHeaderCell className="bg-body-tertiary">메뉴 이름</CTableHeaderCell>
|
{!isCompact && (
|
||||||
<CTableHeaderCell className="bg-body-tertiary text-center">부모 번호</CTableHeaderCell>
|
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '60px' }}>아이콘</CTableHeaderCell>
|
||||||
<CTableHeaderCell className="bg-body-tertiary text-center">정렬 순서</CTableHeaderCell>
|
)}
|
||||||
<CTableHeaderCell className="bg-body-tertiary text-center">아이콘</CTableHeaderCell>
|
<CTableHeaderCell className="bg-body-tertiary" style={{ width: 'auto' }}>메뉴 이름</CTableHeaderCell>
|
||||||
<CTableHeaderCell className="bg-body-tertiary">URL</CTableHeaderCell>
|
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '80px' }}>부모 번호</CTableHeaderCell>
|
||||||
<CTableHeaderCell className="bg-body-tertiary text-center">액션</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: '100px' }}>액션</CTableHeaderCell>
|
||||||
</CTableRow>
|
</CTableRow>
|
||||||
</CTableHead>
|
</CTableHead>
|
||||||
<CTableBody>
|
<CTableBody style={{ minHeight: '200px' }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<CTableRow>
|
<CTableRow>
|
||||||
<CTableDataCell colSpan={7} className="text-center">
|
<CTableDataCell colSpan={isCompact ? 5 : 7} className="text-center py-5">
|
||||||
로딩 중...
|
로딩 중...
|
||||||
</CTableDataCell>
|
</CTableDataCell>
|
||||||
</CTableRow>
|
</CTableRow>
|
||||||
) : getSortedMenuList().length === 0 ? (
|
) : menuList.length === 0 ? (
|
||||||
<CTableRow>
|
<CTableRow>
|
||||||
<CTableDataCell colSpan={7} className="text-center">
|
<CTableDataCell colSpan={isCompact ? 5 : 7} className="text-center">
|
||||||
등록된 메뉴가 없습니다.
|
등록된 메뉴가 없습니다.
|
||||||
</CTableDataCell>
|
</CTableDataCell>
|
||||||
</CTableRow>
|
</CTableRow>
|
||||||
) : (
|
) : (
|
||||||
getSortedMenuList().map((menu: any) => (
|
menuList.map((menu) => {
|
||||||
|
const depth = getMenuDepth(menu);
|
||||||
|
return (
|
||||||
<CTableRow key={menu.adminMenuSeq}>
|
<CTableRow key={menu.adminMenuSeq}>
|
||||||
<CTableDataCell className="text-center">
|
<CTableDataCell className="text-center">
|
||||||
<div className="fw-semibold">{menu.adminMenuSeq}</div>
|
<div className="fw-semibold">{menu.adminMenuSeq}</div>
|
||||||
</CTableDataCell>
|
</CTableDataCell>
|
||||||
|
{!isCompact && (
|
||||||
|
<CTableDataCell className="text-center">
|
||||||
|
{menu.iconName && getIconByName(menu.iconName) ? (
|
||||||
|
<CIcon icon={getIconByName(menu.iconName)!} />
|
||||||
|
) : (
|
||||||
|
<span className="text-body-secondary">-</span>
|
||||||
|
)}
|
||||||
|
</CTableDataCell>
|
||||||
|
)}
|
||||||
<CTableDataCell>
|
<CTableDataCell>
|
||||||
<div style={{ marginLeft: `${menu.depth * 20}px` }}>
|
<div
|
||||||
{menu.depth > 0 && '└ '}
|
style={{
|
||||||
|
marginLeft: `${depth * 20}px`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={menu.menuName}
|
||||||
|
>
|
||||||
|
{depth > 0 && '└ '}
|
||||||
{menu.menuName}
|
{menu.menuName}
|
||||||
</div>
|
</div>
|
||||||
</CTableDataCell>
|
</CTableDataCell>
|
||||||
<CTableDataCell className="text-center">
|
<CTableDataCell className="text-center">
|
||||||
{menu.parentSeq === 0 ? (
|
{menu.parentSeq === 0 ? (
|
||||||
<CBadge color="info">최상위</CBadge>
|
<CBadge color="info" size="sm">최상위</CBadge>
|
||||||
) : (
|
) : (
|
||||||
menu.parentSeq
|
menu.parentSeq
|
||||||
)}
|
)}
|
||||||
</CTableDataCell>
|
</CTableDataCell>
|
||||||
<CTableDataCell className="text-center">{menu.menuOrder}</CTableDataCell>
|
<CTableDataCell className="text-center">{menu.menuOrder}</CTableDataCell>
|
||||||
<CTableDataCell className="text-center">
|
{!isCompact && (
|
||||||
<div className="small text-body-secondary">{menu.iconName || '-'}</div>
|
|
||||||
</CTableDataCell>
|
|
||||||
<CTableDataCell>
|
<CTableDataCell>
|
||||||
<div className="small text-body-secondary text-nowrap">{menu.menuUrl || '-'}</div>
|
<div
|
||||||
|
className="small text-body-secondary"
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
title={menu.menuUrl || ''}
|
||||||
|
>
|
||||||
|
{menu.menuUrl || '-'}
|
||||||
|
</div>
|
||||||
</CTableDataCell>
|
</CTableDataCell>
|
||||||
|
)}
|
||||||
<CTableDataCell className="text-center">
|
<CTableDataCell className="text-center">
|
||||||
<CButton
|
<CButton
|
||||||
color="info"
|
color="info"
|
||||||
@@ -256,15 +455,70 @@ const AdminMenuManagement: React.FC = () => {
|
|||||||
</CButton>
|
</CButton>
|
||||||
</CTableDataCell>
|
</CTableDataCell>
|
||||||
</CTableRow>
|
</CTableRow>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</CTableBody>
|
</CTableBody>
|
||||||
</CTable>
|
</CTable>
|
||||||
|
|
||||||
|
{/* 페이징 */}
|
||||||
|
{pageInfo.totalPage > 0 && (
|
||||||
|
<div className="d-flex justify-content-between align-items-center mt-3">
|
||||||
|
<div className="text-muted small">
|
||||||
|
총 {pageInfo.totalContent}건 중 {(currentPage - 1) * pageInfo.pageSize + 1}-
|
||||||
|
{Math.min(currentPage * pageInfo.pageSize, pageInfo.totalContent)}건
|
||||||
|
</div>
|
||||||
|
<CPagination aria-label="Page navigation">
|
||||||
|
<CPaginationItem
|
||||||
|
aria-label="First"
|
||||||
|
disabled={pageInfo.isFirstPage}
|
||||||
|
onClick={() => fetchMenuList(1)}
|
||||||
|
style={{ cursor: pageInfo.isFirstPage ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
«
|
||||||
|
</CPaginationItem>
|
||||||
|
<CPaginationItem
|
||||||
|
aria-label="Previous"
|
||||||
|
disabled={pageInfo.isFirstPage}
|
||||||
|
onClick={() => fetchMenuList(currentPage - 1)}
|
||||||
|
style={{ cursor: pageInfo.isFirstPage ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</CPaginationItem>
|
||||||
|
{Array.from({ length: pageInfo.totalPage }, (_, i) => i + 1).map((page) => (
|
||||||
|
<CPaginationItem
|
||||||
|
key={page}
|
||||||
|
active={page === currentPage}
|
||||||
|
onClick={() => fetchMenuList(page)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</CPaginationItem>
|
||||||
|
))}
|
||||||
|
<CPaginationItem
|
||||||
|
aria-label="Next"
|
||||||
|
disabled={pageInfo.isLastPage}
|
||||||
|
onClick={() => fetchMenuList(currentPage + 1)}
|
||||||
|
style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</CPaginationItem>
|
||||||
|
<CPaginationItem
|
||||||
|
aria-label="Last"
|
||||||
|
disabled={pageInfo.isLastPage}
|
||||||
|
onClick={() => fetchMenuList(pageInfo.totalPage)}
|
||||||
|
style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</CPaginationItem>
|
||||||
|
</CPagination>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CCardBody>
|
</CCardBody>
|
||||||
</CCard>
|
</CCard>
|
||||||
|
|
||||||
{/* 추가/수정 모달 */}
|
{/* 추가/수정 모달 */}
|
||||||
<CModal visible={modalVisible} onClose={() => setModalVisible(false)}>
|
<CModal visible={modalVisible} onClose={() => { setModalVisible(false); setIconPickerVisible(false); }}>
|
||||||
<CModalHeader>
|
<CModalHeader>
|
||||||
<CModalTitle>{isEditMode ? '메뉴 수정' : '메뉴 추가'}</CModalTitle>
|
<CModalTitle>{isEditMode ? '메뉴 수정' : '메뉴 추가'}</CModalTitle>
|
||||||
</CModalHeader>
|
</CModalHeader>
|
||||||
@@ -282,38 +536,106 @@ const AdminMenuManagement: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<CFormLabel htmlFor="parentSeq">부모 번호</CFormLabel>
|
<CFormLabel htmlFor="parentSeq">부모 메뉴</CFormLabel>
|
||||||
<CFormInput
|
<CFormSelect
|
||||||
type="number"
|
|
||||||
id="parentSeq"
|
id="parentSeq"
|
||||||
name="parentSeq"
|
name="parentSeq"
|
||||||
value={formData.parentSeq}
|
value={String(formData.parentSeq)}
|
||||||
onChange={handleInputChange}
|
onChange={(e) => {
|
||||||
placeholder="0 (최상위 메뉴)"
|
setFormData((prev) => ({
|
||||||
/>
|
...prev,
|
||||||
<small className="text-muted">0이면 최상위 메뉴입니다.</small>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<CFormLabel htmlFor="menuOrder">정렬 순서</CFormLabel>
|
<CFormLabel htmlFor="menuOrder">정렬 순서 *</CFormLabel>
|
||||||
<CFormInput
|
<CFormInput
|
||||||
type="number"
|
type="number"
|
||||||
id="menuOrder"
|
id="menuOrder"
|
||||||
name="menuOrder"
|
name="menuOrder"
|
||||||
|
min={1}
|
||||||
value={formData.menuOrder}
|
value={formData.menuOrder}
|
||||||
onChange={handleInputChange}
|
onChange={(e) => {
|
||||||
placeholder="정렬 순서"
|
const value = parseInt(e.target.value) || 1;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuOrder: value < 1 ? 1 : value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="정렬 순서 (1 이상)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<CFormLabel htmlFor="iconName">아이콘 이름</CFormLabel>
|
<CFormLabel>아이콘 이름</CFormLabel>
|
||||||
|
<CInputGroup>
|
||||||
|
<CInputGroupText style={{ minWidth: '40px', justifyContent: 'center' }}>
|
||||||
|
{formData.iconName && getIconByName(formData.iconName) ? (
|
||||||
|
<CIcon icon={getIconByName(formData.iconName)!} />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">-</span>
|
||||||
|
)}
|
||||||
|
</CInputGroupText>
|
||||||
<CFormInput
|
<CFormInput
|
||||||
type="text"
|
type="text"
|
||||||
id="iconName"
|
|
||||||
name="iconName"
|
|
||||||
value={formData.iconName || ''}
|
value={formData.iconName || ''}
|
||||||
onChange={handleInputChange}
|
readOnly
|
||||||
placeholder="아이콘 이름 (선택사항)"
|
placeholder="아이콘을 선택하세요"
|
||||||
|
onClick={() => setIconPickerVisible(!iconPickerVisible)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
|
{formData.iconName && (
|
||||||
|
<CButton
|
||||||
|
color="secondary"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleIconClear}
|
||||||
|
>
|
||||||
|
<CIcon icon={cilX} />
|
||||||
|
</CButton>
|
||||||
|
)}
|
||||||
|
<CButton
|
||||||
|
color="primary"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIconPickerVisible(!iconPickerVisible)}
|
||||||
|
>
|
||||||
|
선택
|
||||||
|
</CButton>
|
||||||
|
</CInputGroup>
|
||||||
|
{iconPickerVisible && (
|
||||||
|
<div
|
||||||
|
className="border rounded p-2 mt-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cui-body-bg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="d-flex flex-wrap gap-1">
|
||||||
|
{availableIcons.map((item) => (
|
||||||
|
<CButton
|
||||||
|
key={item.name}
|
||||||
|
color={formData.iconName === item.name ? 'primary' : 'light'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleIconSelect(item.name)}
|
||||||
|
title={`${item.label} (${item.name})`}
|
||||||
|
style={{ width: '36px', height: '36px', padding: '4px' }}
|
||||||
|
>
|
||||||
|
<CIcon icon={item.icon} />
|
||||||
|
</CButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<small className="text-muted">클릭하여 아이콘을 선택하세요 (선택사항)</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<CFormLabel htmlFor="menuUrl">메뉴 URL</CFormLabel>
|
<CFormLabel htmlFor="menuUrl">메뉴 URL</CFormLabel>
|
||||||
@@ -329,7 +651,7 @@ const AdminMenuManagement: React.FC = () => {
|
|||||||
</CForm>
|
</CForm>
|
||||||
</CModalBody>
|
</CModalBody>
|
||||||
<CModalFooter>
|
<CModalFooter>
|
||||||
<CButton color="secondary" onClick={() => setModalVisible(false)}>
|
<CButton color="secondary" onClick={() => { setModalVisible(false); setIconPickerVisible(false); }}>
|
||||||
취소
|
취소
|
||||||
</CButton>
|
</CButton>
|
||||||
<CButton color="primary" onClick={handleSave}>
|
<CButton color="primary" onClick={handleSave}>
|
||||||
|
|||||||
Reference in New Issue
Block a user