From 7a36ef999ecde276c0bac266334e119e43b77bf9 Mon Sep 17 00:00:00 2001 From: artwork21c Date: Tue, 13 Jan 2026 19:28:00 +0900 Subject: [PATCH] =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 7 + src/services/adminMenuService.ts | 40 +- src/views/admin/AdminMenuManagement.tsx | 494 +++++++++++++++++++----- 3 files changed, 450 insertions(+), 91 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bbf2e31 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run build:*)" + ] + } +} diff --git a/src/services/adminMenuService.ts b/src/services/adminMenuService.ts index 6fa1910..6912d69 100644 --- a/src/services/adminMenuService.ts +++ b/src/services/adminMenuService.ts @@ -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 => { - const response = await axios.get('/admin/menu/list'); - return response.data.resultData || []; +// 어드민 메뉴 목록 조회 (페이징) +export const getAdminMenuList = async (pageNum: number = 1): Promise => { + const response = await axios.get(`/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 => { const response = await axios.get(`/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로 어드민 메뉴 조회 diff --git a/src/views/admin/AdminMenuManagement.tsx b/src/views/admin/AdminMenuManagement.tsx index 7d84231..a1e45a3 100644 --- a/src/views/admin/AdminMenuManagement.tsx +++ b/src/views/admin/AdminMenuManagement.tsx @@ -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([]); + const [topLevelMenuList, setTopLevelMenuList] = useState([]); 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(null); const [formData, setFormData] = useState({ parentSeq: 0, - menuOrder: 0, + menuOrder: 1, menuName: '', iconName: '', menuUrl: '', }); + // 페이징 상태 + const [currentPage, setCurrentPage] = useState(1); + const [pageInfo, setPageInfo] = useState>({ + 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(); - - // 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 = () => { - + - 메뉴 번호 - 메뉴 이름 - 부모 번호 - 정렬 순서 - 아이콘 - URL - 액션 + 메뉴 번호 + {!isCompact && ( + 아이콘 + )} + 메뉴 이름 + 부모 번호 + 정렬 순서 + {!isCompact && ( + URL + )} + 액션 - + {loading ? ( - + 로딩 중... - ) : getSortedMenuList().length === 0 ? ( + ) : menuList.length === 0 ? ( - + 등록된 메뉴가 없습니다. ) : ( - getSortedMenuList().map((menu: any) => ( + menuList.map((menu) => { + const depth = getMenuDepth(menu); + return (
{menu.adminMenuSeq}
+ {!isCompact && ( + + {menu.iconName && getIconByName(menu.iconName) ? ( + + ) : ( + - + )} + + )} -
- {menu.depth > 0 && '└ '} +
+ {depth > 0 && '└ '} {menu.menuName}
{menu.parentSeq === 0 ? ( - 최상위 + 최상위 ) : ( menu.parentSeq )} {menu.menuOrder} - -
{menu.iconName || '-'}
-
- -
{menu.menuUrl || '-'}
-
+ {!isCompact && ( + +
+ {menu.menuUrl || '-'} +
+
+ )} { - )) + ); + }) )} + + {/* 페이징 */} + {pageInfo.totalPage > 0 && ( +
+
+ 총 {pageInfo.totalContent}건 중 {(currentPage - 1) * pageInfo.pageSize + 1}- + {Math.min(currentPage * pageInfo.pageSize, pageInfo.totalContent)}건 +
+ + fetchMenuList(1)} + style={{ cursor: pageInfo.isFirstPage ? 'default' : 'pointer' }} + > + « + + fetchMenuList(currentPage - 1)} + style={{ cursor: pageInfo.isFirstPage ? 'default' : 'pointer' }} + > + ‹ + + {Array.from({ length: pageInfo.totalPage }, (_, i) => i + 1).map((page) => ( + fetchMenuList(page)} + style={{ cursor: 'pointer' }} + > + {page} + + ))} + fetchMenuList(currentPage + 1)} + style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }} + > + › + + fetchMenuList(pageInfo.totalPage)} + style={{ cursor: pageInfo.isLastPage ? 'default' : 'pointer' }} + > + » + + +
+ )} {/* 추가/수정 모달 */} - setModalVisible(false)}> + { setModalVisible(false); setIconPickerVisible(false); }}> {isEditMode ? '메뉴 수정' : '메뉴 추가'} @@ -282,38 +536,106 @@ const AdminMenuManagement: React.FC = () => { />
- 부모 번호 - 부모 메뉴 + - 0이면 최상위 메뉴입니다. + value={String(formData.parentSeq)} + onChange={(e) => { + setFormData((prev) => ({ + ...prev, + parentSeq: parseInt(e.target.value) || 0, + })); + }} + > + + {Array.isArray(topLevelMenuList) && topLevelMenuList + .filter((menu) => menu.adminMenuSeq !== formData.adminMenuSeq) + .map((menu) => ( + + ))} + + 최상위 메뉴를 선택하면 하위 메뉴로 등록됩니다.
- 정렬 순서 + 정렬 순서 * { + const value = parseInt(e.target.value) || 1; + setFormData((prev) => ({ + ...prev, + menuOrder: value < 1 ? 1 : value, + })); + }} + placeholder="정렬 순서 (1 이상)" />
- 아이콘 이름 - + 아이콘 이름 + + + {formData.iconName && getIconByName(formData.iconName) ? ( + + ) : ( + - + )} + + setIconPickerVisible(!iconPickerVisible)} + style={{ cursor: 'pointer' }} + /> + {formData.iconName && ( + + + + )} + setIconPickerVisible(!iconPickerVisible)} + > + 선택 + + + {iconPickerVisible && ( +
+
+ {availableIcons.map((item) => ( + handleIconSelect(item.name)} + title={`${item.label} (${item.name})`} + style={{ width: '36px', height: '36px', padding: '4px' }} + > + + + ))} +
+
+ )} + 클릭하여 아이콘을 선택하세요 (선택사항)
메뉴 URL @@ -329,7 +651,7 @@ const AdminMenuManagement: React.FC = () => { - setModalVisible(false)}> + { setModalVisible(false); setIconPickerVisible(false); }}> 취소