828 lines
37 KiB
TypeScript
828 lines
37 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import {
|
||
CButton,
|
||
CCard,
|
||
CCardBody,
|
||
CCardHeader,
|
||
CForm,
|
||
CFormInput,
|
||
CFormLabel,
|
||
CInputGroup,
|
||
CInputGroupText,
|
||
CModal,
|
||
CModalBody,
|
||
CModalFooter,
|
||
CModalHeader,
|
||
CModalTitle,
|
||
CTable,
|
||
CTableBody,
|
||
CTableDataCell,
|
||
CTableHead,
|
||
CTableHeaderCell,
|
||
CTableRow,
|
||
CFormSelect,
|
||
CPagination,
|
||
CPaginationItem,
|
||
} from '@coreui/react';
|
||
import CIcon from '@coreui/icons-react';
|
||
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 {
|
||
getAdminMenuListByParentSeq,
|
||
getAdminMenu,
|
||
addAdminMenu,
|
||
updateAdminMenu,
|
||
deleteAdminMenu,
|
||
AdminMenu,
|
||
AdminMenuPageResponse,
|
||
} from 'src/services/adminMenuService';
|
||
|
||
// 브레드크럼 아이템 인터페이스
|
||
interface BreadcrumbItem {
|
||
adminMenuSeq: number;
|
||
menuName: string;
|
||
}
|
||
|
||
// 아이콘 이름으로 아이콘 객체 찾기
|
||
const getIconByName = (name: string) => {
|
||
const found = availableIcons.find((item) => item.name === name);
|
||
return found ? found.icon : null;
|
||
};
|
||
|
||
const AdminMenuManagement: React.FC = () => {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const [menuList, setMenuList] = useState<AdminMenu[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [modalVisible, setModalVisible] = useState(false);
|
||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||
const [hasChildMenus, setHasChildMenus] = 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: 1,
|
||
menuName: '',
|
||
iconName: '',
|
||
menuUrl: '',
|
||
level: 1000,
|
||
});
|
||
|
||
// 현재 보고 있는 부모 메뉴 seq (0이면 최상위)
|
||
const [currentParentSeq, setCurrentParentSeq] = useState(0);
|
||
// 브레드크럼 (탐색 경로)
|
||
const [breadcrumb, setBreadcrumb] = useState<BreadcrumbItem[]>([]);
|
||
|
||
// 페이징 상태
|
||
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]);
|
||
|
||
// 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 getAdminMenuListByParentSeq(parentSeq, pageNum);
|
||
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);
|
||
setCurrentParentSeq(parentSeq);
|
||
} catch (error) {
|
||
console.error('메뉴 목록 조회 실패:', error);
|
||
alert('메뉴 목록을 불러오는데 실패했습니다.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 메뉴 클릭 시 하위 메뉴로 이동
|
||
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(() => {
|
||
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 = () => {
|
||
setIsEditMode(false);
|
||
setFormData({
|
||
parentSeq: currentParentSeq,
|
||
menuOrder: 1,
|
||
menuName: '',
|
||
iconName: '',
|
||
menuUrl: '',
|
||
level: 1000,
|
||
});
|
||
setModalVisible(true);
|
||
};
|
||
|
||
// 메뉴 수정 모달 열기
|
||
const handleEditClick = (menu: AdminMenu) => {
|
||
setIsEditMode(true);
|
||
setFormData({ ...menu });
|
||
setModalVisible(true);
|
||
};
|
||
|
||
// 메뉴 삭제 모달 열기
|
||
const handleDeleteClick = async (menu: AdminMenu) => {
|
||
if (!menu.adminMenuSeq) return;
|
||
|
||
try {
|
||
// 하위 메뉴 존재 여부 확인
|
||
const childMenusResponse = await getAdminMenuListByParentSeq(menu.adminMenuSeq);
|
||
setHasChildMenus(childMenusResponse.totalContent > 0);
|
||
setSelectedMenu(menu);
|
||
setDeleteModalVisible(true);
|
||
} catch (error) {
|
||
console.error('하위 메뉴 확인 실패:', error);
|
||
// 에러 발생 시에도 모달은 열되, 삭제 불가로 처리
|
||
setHasChildMenus(true);
|
||
setSelectedMenu(menu);
|
||
setDeleteModalVisible(true);
|
||
}
|
||
};
|
||
|
||
// 입력 필드 변경 처리
|
||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const { name, value } = e.target;
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
[name]: name === 'parentSeq' || name === 'menuOrder' || name === 'level' ? parseInt(value) || 0 : value,
|
||
}));
|
||
};
|
||
|
||
// 아이콘 선택 처리
|
||
const handleIconSelect = (iconName: string) => {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
iconName: iconName,
|
||
}));
|
||
setIconPickerVisible(false);
|
||
};
|
||
|
||
// 아이콘 선택 해제
|
||
const handleIconClear = () => {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
iconName: '',
|
||
}));
|
||
};
|
||
|
||
// 메뉴 저장 (추가/수정)
|
||
const handleSave = async () => {
|
||
try {
|
||
if (!formData.menuName.trim()) {
|
||
alert('메뉴 이름을 입력해주세요.');
|
||
return;
|
||
}
|
||
|
||
if (isEditMode) {
|
||
await updateAdminMenu(formData);
|
||
alert('메뉴가 수정되었습니다.');
|
||
} else {
|
||
await addAdminMenu(formData);
|
||
alert('메뉴가 추가되었습니다.');
|
||
}
|
||
|
||
setModalVisible(false);
|
||
fetchMenuList(currentParentSeq, currentPage);
|
||
} catch (error) {
|
||
console.error('메뉴 저장 실패:', error);
|
||
alert('메뉴 저장에 실패했습니다.');
|
||
}
|
||
};
|
||
|
||
// 메뉴 삭제
|
||
const handleDelete = async () => {
|
||
if (!selectedMenu || !selectedMenu.adminMenuSeq) return;
|
||
|
||
try {
|
||
await deleteAdminMenu(selectedMenu.adminMenuSeq);
|
||
alert('메뉴가 삭제되었습니다.');
|
||
setDeleteModalVisible(false);
|
||
fetchMenuList(currentParentSeq, currentPage);
|
||
} catch (error) {
|
||
console.error('메뉴 삭제 실패:', error);
|
||
alert('메뉴 삭제에 실패했습니다.');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<CCard className="mb-4">
|
||
<CCardHeader>
|
||
<strong>어드민 메뉴 관리</strong>
|
||
</CCardHeader>
|
||
<CCardBody>
|
||
{/* 브레드크럼 네비게이션 */}
|
||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||
<nav aria-label="breadcrumb">
|
||
<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>
|
||
<CButton
|
||
color="primary"
|
||
size="sm"
|
||
onClick={handleAddClick}
|
||
>
|
||
<CIcon icon={cilPlus} className="me-1" />
|
||
메뉴 추가
|
||
</CButton>
|
||
</div>
|
||
|
||
<CTable align="middle" className="mb-0 border" hover responsive style={{ tableLayout: 'fixed', minWidth: isCompact ? 'auto' : '650px' }}>
|
||
<CTableHead className="text-nowrap">
|
||
<CTableRow>
|
||
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '70px' }}>순번</CTableHeaderCell>
|
||
{!isCompact && (
|
||
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '60px' }}>아이콘</CTableHeaderCell>
|
||
)}
|
||
{!isCompact && (
|
||
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '80px' }}>레벨</CTableHeaderCell>
|
||
)}
|
||
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: isCompact ? 'auto' : '180px' }}>메뉴 이름</CTableHeaderCell>
|
||
{!isCompact && (
|
||
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: 'auto' }}>URL</CTableHeaderCell>
|
||
)}
|
||
<CTableHeaderCell className="bg-body-tertiary text-center" style={{ width: '100px' }}>작업</CTableHeaderCell>
|
||
</CTableRow>
|
||
</CTableHead>
|
||
<CTableBody style={{ minHeight: '200px' }}>
|
||
{loading ? (
|
||
<CTableRow>
|
||
<CTableDataCell colSpan={isCompact ? 3 : 5} className="text-center py-5">
|
||
로딩 중...
|
||
</CTableDataCell>
|
||
</CTableRow>
|
||
) : menuList.length === 0 ? (
|
||
<CTableRow>
|
||
<CTableDataCell colSpan={isCompact ? 3 : 5} className="text-center">
|
||
등록된 메뉴가 없습니다.
|
||
</CTableDataCell>
|
||
</CTableRow>
|
||
) : (
|
||
menuList.map((menu) => (
|
||
<CTableRow key={menu.adminMenuSeq}>
|
||
<CTableDataCell className="text-center">{menu.menuOrder}</CTableDataCell>
|
||
{!isCompact && (
|
||
<CTableDataCell className="text-center">
|
||
{menu.iconName && getIconByName(menu.iconName) ? (
|
||
<CIcon icon={getIconByName(menu.iconName)!} />
|
||
) : (
|
||
<span className="text-body-secondary">-</span>
|
||
)}
|
||
</CTableDataCell>
|
||
)}
|
||
{!isCompact && (
|
||
<CTableDataCell className="text-center">{menu.level}</CTableDataCell>
|
||
)}
|
||
<CTableDataCell className="text-center">
|
||
<div
|
||
style={{
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
title={`${menu.menuName} - 클릭하여 하위 메뉴 보기`}
|
||
>
|
||
<a
|
||
href="#"
|
||
onClick={(e) => { e.preventDefault(); handleMenuClick(menu); }}
|
||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||
className="fw-semibold"
|
||
>
|
||
{menu.menuName}
|
||
</a>
|
||
</div>
|
||
</CTableDataCell>
|
||
{!isCompact && (
|
||
<CTableDataCell className="text-center">
|
||
<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"
|
||
size="sm"
|
||
className="me-2"
|
||
onClick={() => handleEditClick(menu)}
|
||
title="수정"
|
||
>
|
||
<CIcon icon={cilPencil} />
|
||
</CButton>
|
||
<CButton
|
||
color="danger"
|
||
size="sm"
|
||
onClick={() => handleDeleteClick(menu)}
|
||
title="삭제"
|
||
>
|
||
<CIcon icon={cilTrash} />
|
||
</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={() => handlePageChange(1)}
|
||
style={{ cursor: pageInfo.isFirstPage ? 'default' : 'pointer' }}
|
||
>
|
||
«
|
||
</CPaginationItem>
|
||
<CPaginationItem
|
||
aria-label="Previous"
|
||
disabled={pageInfo.isFirstPage}
|
||
onClick={() => handlePageChange(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={() => handlePageChange(page)}
|
||
style={{ cursor: 'pointer' }}
|
||
>
|
||
{page}
|
||
</CPaginationItem>
|
||
))}
|
||
<CPaginationItem
|
||
aria-label="Next"
|
||
disabled={pageInfo.isLastPage}
|
||
onClick={() => handlePageChange(currentPage + 1)}
|
||
style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }}
|
||
>
|
||
›
|
||
</CPaginationItem>
|
||
<CPaginationItem
|
||
aria-label="Last"
|
||
disabled={pageInfo.isLastPage}
|
||
onClick={() => handlePageChange(pageInfo.totalPage)}
|
||
style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }}
|
||
>
|
||
»
|
||
</CPaginationItem>
|
||
</CPagination>
|
||
</div>
|
||
)}
|
||
</CCardBody>
|
||
</CCard>
|
||
|
||
{/* 추가/수정 모달 */}
|
||
<CModal visible={modalVisible} onClose={() => { setModalVisible(false); setIconPickerVisible(false); }} backdrop="static">
|
||
<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>부모 메뉴</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>
|
||
<CFormInput
|
||
type="number"
|
||
id="menuOrder"
|
||
name="menuOrder"
|
||
min={1}
|
||
value={formData.menuOrder}
|
||
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>아이콘 이름</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"
|
||
value={formData.iconName || ''}
|
||
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>
|
||
<CFormInput
|
||
type="text"
|
||
id="menuUrl"
|
||
name="menuUrl"
|
||
value={formData.menuUrl || ''}
|
||
onChange={handleInputChange}
|
||
placeholder="/admin/example (선택사항)"
|
||
/>
|
||
</div>
|
||
<div className="mb-3">
|
||
<CFormLabel htmlFor="level">접근 가능 최소 회원 레벨</CFormLabel>
|
||
<CFormInput
|
||
type="number"
|
||
id="level"
|
||
name="level"
|
||
value={formData.level}
|
||
onChange={handleInputChange}
|
||
placeholder="메뉴 접근에 필요한 최소 회원 레벨을 입력하세요 (기본 1000)"
|
||
/>
|
||
</div>
|
||
</CForm>
|
||
</CModalBody>
|
||
<CModalFooter>
|
||
<CButton color="secondary" onClick={() => { setModalVisible(false); setIconPickerVisible(false); }}>
|
||
취소
|
||
</CButton>
|
||
<CButton color="primary" onClick={handleSave}>
|
||
{isEditMode ? '수정' : '추가'}
|
||
</CButton>
|
||
</CModalFooter>
|
||
</CModal>
|
||
|
||
{/* 삭제 확인 모달 */}
|
||
<CModal visible={deleteModalVisible} onClose={() => setDeleteModalVisible(false)} backdrop="static">
|
||
<CModalHeader>
|
||
<CModalTitle>메뉴 삭제</CModalTitle>
|
||
</CModalHeader>
|
||
<CModalBody>
|
||
{hasChildMenus ? (
|
||
<>
|
||
<div className="text-danger mb-2">
|
||
<CIcon icon={cilWarning} className="me-2" />
|
||
<strong>삭제할 수 없습니다.</strong>
|
||
</div>
|
||
<p>
|
||
<strong>{selectedMenu?.menuName}</strong> 메뉴에 하위 메뉴가 존재합니다.
|
||
</p>
|
||
<small className="text-muted">
|
||
하위 메뉴를 먼저 모두 삭제한 후 다시 시도해주세요.
|
||
</small>
|
||
</>
|
||
) : (
|
||
<>
|
||
정말로 <strong>{selectedMenu?.menuName}</strong> 메뉴를 삭제하시겠습니까?
|
||
</>
|
||
)}
|
||
</CModalBody>
|
||
<CModalFooter>
|
||
<CButton color="secondary" onClick={() => setDeleteModalVisible(false)}>
|
||
{hasChildMenus ? '닫기' : '취소'}
|
||
</CButton>
|
||
{!hasChildMenus && (
|
||
<CButton color="danger" onClick={handleDelete}>
|
||
삭제
|
||
</CButton>
|
||
)}
|
||
</CModalFooter>
|
||
</CModal>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default AdminMenuManagement;
|