어드민 메뉴 관리 기능 개선

This commit is contained in:
2026-01-13 19:28:00 +09:00
parent 9d31cbbd10
commit 7a36ef999e
3 changed files with 450 additions and 91 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run build:*)"
]
}
}

View File

@@ -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로 어드민 메뉴 조회

View File

@@ -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}>