어드민 메뉴 관리 기능 개선
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;
|
||||
}
|
||||
|
||||
// 페이징 정보 인터페이스
|
||||
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 응답 인터페이스
|
||||
export interface AdminMenuResponse {
|
||||
resultCode: string;
|
||||
@@ -17,16 +38,25 @@ export interface AdminMenuResponse {
|
||||
resultData?: any;
|
||||
}
|
||||
|
||||
// 어드민 메뉴 목록 조회
|
||||
export const getAdminMenuList = async (): Promise<AdminMenu[]> => {
|
||||
const response = await axios.get<AdminMenuResponse>('/admin/menu/list');
|
||||
return response.data.resultData || [];
|
||||
// 어드민 메뉴 목록 조회 (페이징)
|
||||
export const getAdminMenuList = async (pageNum: number = 1): Promise<AdminMenuPageResponse> => {
|
||||
const response = await axios.get<AdminMenuResponse>(`/admin/menu/list?pageNum=${pageNum}`);
|
||||
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[]> => {
|
||||
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로 어드민 메뉴 조회
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
CButton,
|
||||
CCard,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
CForm,
|
||||
CFormInput,
|
||||
CFormLabel,
|
||||
CInputGroup,
|
||||
CInputGroupText,
|
||||
CModal,
|
||||
CModalBody,
|
||||
CModalFooter,
|
||||
@@ -21,38 +23,190 @@ import {
|
||||
CTableHeaderCell,
|
||||
CTableRow,
|
||||
CBadge,
|
||||
CFormSelect,
|
||||
CPagination,
|
||||
CPaginationItem,
|
||||
} from '@coreui/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 {
|
||||
getAdminMenuList,
|
||||
getAdminMenuListByParentSeq,
|
||||
addAdminMenu,
|
||||
updateAdminMenu,
|
||||
deleteAdminMenu,
|
||||
AdminMenu,
|
||||
AdminMenuPageResponse,
|
||||
} 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 [menuList, setMenuList] = useState<AdminMenu[]>([]);
|
||||
const [topLevelMenuList, setTopLevelMenuList] = useState<AdminMenu[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [iconPickerVisible, setIconPickerVisible] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [selectedMenu, setSelectedMenu] = useState<AdminMenu | null>(null);
|
||||
const [formData, setFormData] = useState<AdminMenu>({
|
||||
parentSeq: 0,
|
||||
menuOrder: 0,
|
||||
menuOrder: 1,
|
||||
menuName: '',
|
||||
iconName: '',
|
||||
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 {
|
||||
setLoading(true);
|
||||
const data = await getAdminMenuList();
|
||||
setMenuList(data);
|
||||
const data = await getAdminMenuList(page);
|
||||
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) {
|
||||
console.error('메뉴 목록 조회 실패:', error);
|
||||
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(() => {
|
||||
fetchMenuList();
|
||||
}, []);
|
||||
|
||||
// 메뉴 추가 모달 열기
|
||||
const handleAddClick = () => {
|
||||
const handleAddClick = async () => {
|
||||
setIsEditMode(false);
|
||||
setFormData({
|
||||
parentSeq: 0,
|
||||
menuOrder: 0,
|
||||
menuOrder: 1,
|
||||
menuName: '',
|
||||
iconName: '',
|
||||
menuUrl: '',
|
||||
});
|
||||
try {
|
||||
await fetchTopLevelMenuList();
|
||||
} catch (error) {
|
||||
console.error('최상위 메뉴 목록 조회 실패:', error);
|
||||
}
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// 메뉴 수정 모달 열기
|
||||
const handleEditClick = (menu: AdminMenu) => {
|
||||
const handleEditClick = async (menu: AdminMenu) => {
|
||||
setIsEditMode(true);
|
||||
setFormData({ ...menu });
|
||||
try {
|
||||
await fetchTopLevelMenuList();
|
||||
} catch (error) {
|
||||
console.error('최상위 메뉴 목록 조회 실패:', error);
|
||||
}
|
||||
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 () => {
|
||||
try {
|
||||
@@ -117,7 +309,7 @@ const AdminMenuManagement: React.FC = () => {
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
fetchMenuList();
|
||||
fetchMenuList(currentPage);
|
||||
} catch (error) {
|
||||
console.error('메뉴 저장 실패:', error);
|
||||
alert('메뉴 저장에 실패했습니다.');
|
||||
@@ -132,43 +324,18 @@ const AdminMenuManagement: React.FC = () => {
|
||||
await deleteAdminMenu(selectedMenu.adminMenuSeq);
|
||||
alert('메뉴가 삭제되었습니다.');
|
||||
setDeleteModalVisible(false);
|
||||
fetchMenuList();
|
||||
fetchMenuList(currentPage);
|
||||
} 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;
|
||||
// 메뉴의 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 (
|
||||
@@ -187,57 +354,89 @@ const AdminMenuManagement: React.FC = () => {
|
||||
</CButton>
|
||||
</CCardHeader>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
{!isCompact && (
|
||||
<CTableHeaderCell className="bg-body-tertiary" style={{ width: '180px' }}>URL</CTableHeaderCell>
|
||||
)}
|
||||
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '100px' }}>액션</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
<CTableBody>
|
||||
<CTableBody style={{ minHeight: '200px' }}>
|
||||
{loading ? (
|
||||
<CTableRow>
|
||||
<CTableDataCell colSpan={7} className="text-center">
|
||||
<CTableDataCell colSpan={isCompact ? 5 : 7} className="text-center py-5">
|
||||
로딩 중...
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
) : getSortedMenuList().length === 0 ? (
|
||||
) : menuList.length === 0 ? (
|
||||
<CTableRow>
|
||||
<CTableDataCell colSpan={7} className="text-center">
|
||||
<CTableDataCell colSpan={isCompact ? 5 : 7} className="text-center">
|
||||
등록된 메뉴가 없습니다.
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
) : (
|
||||
getSortedMenuList().map((menu: any) => (
|
||||
menuList.map((menu) => {
|
||||
const depth = getMenuDepth(menu);
|
||||
return (
|
||||
<CTableRow key={menu.adminMenuSeq}>
|
||||
<CTableDataCell className="text-center">
|
||||
<div className="fw-semibold">{menu.adminMenuSeq}</div>
|
||||
</CTableDataCell>
|
||||
{!isCompact && (
|
||||
<CTableDataCell className="text-center">
|
||||
{menu.iconName && getIconByName(menu.iconName) ? (
|
||||
<CIcon icon={getIconByName(menu.iconName)!} />
|
||||
) : (
|
||||
<span className="text-body-secondary">-</span>
|
||||
)}
|
||||
</CTableDataCell>
|
||||
)}
|
||||
<CTableDataCell>
|
||||
<div style={{ marginLeft: `${menu.depth * 20}px` }}>
|
||||
{menu.depth > 0 && '└ '}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: `${depth * 20}px`,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={menu.menuName}
|
||||
>
|
||||
{depth > 0 && '└ '}
|
||||
{menu.menuName}
|
||||
</div>
|
||||
</CTableDataCell>
|
||||
<CTableDataCell className="text-center">
|
||||
{menu.parentSeq === 0 ? (
|
||||
<CBadge color="info">최상위</CBadge>
|
||||
<CBadge color="info" size="sm">최상위</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>
|
||||
{!isCompact && (
|
||||
<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 className="text-center">
|
||||
<CButton
|
||||
color="info"
|
||||
@@ -256,15 +455,70 @@ const AdminMenuManagement: React.FC = () => {
|
||||
</CButton>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CTableBody>
|
||||
</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>
|
||||
</CCard>
|
||||
|
||||
{/* 추가/수정 모달 */}
|
||||
<CModal visible={modalVisible} onClose={() => setModalVisible(false)}>
|
||||
<CModal visible={modalVisible} onClose={() => { setModalVisible(false); setIconPickerVisible(false); }}>
|
||||
<CModalHeader>
|
||||
<CModalTitle>{isEditMode ? '메뉴 수정' : '메뉴 추가'}</CModalTitle>
|
||||
</CModalHeader>
|
||||
@@ -282,38 +536,106 @@ const AdminMenuManagement: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<CFormLabel htmlFor="parentSeq">부모 번호</CFormLabel>
|
||||
<CFormInput
|
||||
type="number"
|
||||
<CFormLabel htmlFor="parentSeq">부모 메뉴</CFormLabel>
|
||||
<CFormSelect
|
||||
id="parentSeq"
|
||||
name="parentSeq"
|
||||
value={formData.parentSeq}
|
||||
onChange={handleInputChange}
|
||||
placeholder="0 (최상위 메뉴)"
|
||||
/>
|
||||
<small className="text-muted">0이면 최상위 메뉴입니다.</small>
|
||||
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>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<CFormLabel htmlFor="menuOrder">정렬 순서</CFormLabel>
|
||||
<CFormLabel htmlFor="menuOrder">정렬 순서 *</CFormLabel>
|
||||
<CFormInput
|
||||
type="number"
|
||||
id="menuOrder"
|
||||
name="menuOrder"
|
||||
min={1}
|
||||
value={formData.menuOrder}
|
||||
onChange={handleInputChange}
|
||||
placeholder="정렬 순서"
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 1;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuOrder: value < 1 ? 1 : value,
|
||||
}));
|
||||
}}
|
||||
placeholder="정렬 순서 (1 이상)"
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
type="text"
|
||||
id="iconName"
|
||||
name="iconName"
|
||||
value={formData.iconName || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder="아이콘 이름 (선택사항)"
|
||||
readOnly
|
||||
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 className="mb-3">
|
||||
<CFormLabel htmlFor="menuUrl">메뉴 URL</CFormLabel>
|
||||
@@ -329,7 +651,7 @@ const AdminMenuManagement: React.FC = () => {
|
||||
</CForm>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" onClick={() => setModalVisible(false)}>
|
||||
<CButton color="secondary" onClick={() => { setModalVisible(false); setIconPickerVisible(false); }}>
|
||||
취소
|
||||
</CButton>
|
||||
<CButton color="primary" onClick={handleSave}>
|
||||
|
||||
Reference in New Issue
Block a user