fix: 관리자 메뉴관리를 앱 메뉴 API에서 떼어 mock으로
기획 ADM_MNU_001/002_p/003_p. 화면은 이미 세 시안대로 있었지만 두 가지가 어긋나 있었다. 1. 저장소가 /api/v1/mngr/menu(TB_SYS_MENU)를 불렀다. 그건 앱 메뉴이고 「시스템관리 > 메뉴관리」가 이미 같은 표를 편집한다 — 이 화면에서 삭제하면 앱 메뉴가 사라졌다. 전용 API가 없으므로(사용자 확정) 같은 인터페이스의 mock으로 끊는다. 2. 사이드바에 없어 들어갈 길이 없었다. 관리자정보관리 아래에 넣는다. mock 삭제는 하위 가지까지 함께 지우고, 새 번호는 전역 최댓값+1로 매긴다 — 형제 안에서만 세는 백엔드 채번 결함을 답습하지 않는다. Co-Authored-By: Claude Opus 5
@bf7d4b6cc52a7e217b2cd2715ba80daec33c1261
--- lib/data/repositories/admin-menu-repository.ts
+++ lib/data/repositories/admin-menu-repository.ts
... | ... | @@ -1,161 +1,184 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 |
-import { cache } from 'react';
|
|
| 3 |
-import { getSessionAccessToken } from '@/lib/auth/dal';
|
|
| 4 |
-import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
|
| 5 | 2 |
import type { AdminMenu } from '@/lib/domain/admin-menu';
|
| 6 |
-import { toYesNo } from '@/lib/domain/admin-menu';
|
|
| 7 | 3 |
import type { AdminMenuValues } from '@/lib/domain/admin-menu-form';
|
| 8 | 4 |
|
| 9 |
-/* |
|
| 10 |
- * GET /api/v1/mngr/menu/pagination 목록 (TB_SYS_MENU) |
|
| 11 |
- * POST /api/v1/mngr/menu 등록 — 어노테이션 없음 → form |
|
| 12 |
- * PUT /api/v1/mngr/menu/{menuId} 수정 — @RequestBody(JSON)
|
|
| 13 |
- * DELETE /api/v1/mngr/menu/{menuId} 삭제
|
|
| 5 |
+/** |
|
| 6 |
+ * 관리자페이지 메뉴 Repository — 기획 ADM_MNU_001/002_p/003_p. |
|
| 14 | 7 |
* |
| 15 |
- * 이슈(백엔드, 보고함): |
|
| 16 |
- * 1. `DELETE`의 `menuId`에 `@PathVariable`이 없어 경로값이 바인딩되지 않는다 → 항상 실패한다. |
|
| 17 |
- * 2. 등록의 menuId 채번이 `MAX(menu_id) WHERE UP_MENU_ID = ?`라 상위별 최대값이다 → 자식이 없는 |
|
| 18 |
- * 메뉴에 처음 추가하면 이미 존재하는 번호가 나와 PK 충돌로 실패한다. |
|
| 19 |
- * 3. `GET /{menuId}`는 경로변수 이름이 `userId`로 어긋나 있고 빈 VO로 조회한다 → 쓰지 않는다.
|
|
| 20 |
- * 수정 팝업의 초기값은 목록 응답에서 넘겨 채운다. |
|
| 21 |
- * 4. `totalCount`가 현재 페이지 행 수다(다른 목록과 같은 PaginationUtil 결함). |
|
| 22 |
- * 5. 새창여부에 해당하는 컬럼이 없다 — 화면은 기획대로 그리되 저장되지 않는다. |
|
| 8 |
+ * TODO(백엔드): 이 화면의 API가 아직 없다. 아래 mock 저장소를 지우고 다른 Repository처럼 |
|
| 9 |
+ * backendFetch + getSessionAccessToken으로 교체하면 호출부는 그대로 둘 수 있다. |
|
| 10 |
+ * |
|
| 11 |
+ * ⚠️ 종전에는 `/api/v1/mngr/menu`(TB_SYS_MENU)를 불렀는데, 그것은 **앱 메뉴**이고 |
|
| 12 |
+ * 「시스템관리 > 메뉴관리」가 이미 같은 표를 편집한다. 두 화면이 같은 행을 고치게 되어 |
|
| 13 |
+ * 이 화면에서 삭제하면 앱 메뉴가 사라졌다. 전용 API가 나오기 전까지 mock으로 끊어 둔다. |
|
| 14 |
+ * |
|
| 15 |
+ * 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 수정·삭제가 초기값으로 되돌아간다. |
|
| 23 | 16 |
*/ |
| 24 | 17 |
|
| 25 |
-const ADMIN_MENU_PATH = '/api/v1/mngr/menu'; |
|
| 18 |
+const ALL_USERS = { roleId: 'ROLE_USER', roleName: '모든 사용자' };
|
|
| 19 |
+const STUDENT = { roleId: 'ROLE_STUDENT', roleName: '수강생' };
|
|
| 26 | 20 |
|
| 27 |
-// 기획이 "페이징 없이 전체 노출"이라 한 장으로 받는다. |
|
| 28 |
-const FETCH_LIMIT = 10_000; |
|
| 21 |
+let MENUS: AdminMenu[] = [ |
|
| 22 |
+ {
|
|
| 23 |
+ id: '14', |
|
| 24 |
+ parentId: null, |
|
| 25 |
+ name: 'FOX PAY', |
|
| 26 |
+ url: '/main', |
|
| 27 |
+ description: '메인', |
|
| 28 |
+ sortOrder: 1, |
|
| 29 |
+ isActive: true, |
|
| 30 |
+ isTopActive: true, |
|
| 31 |
+ ...ALL_USERS, |
|
| 32 |
+ }, |
|
| 33 |
+ {
|
|
| 34 |
+ id: '15', |
|
| 35 |
+ parentId: '14', |
|
| 36 |
+ name: '서비스 소개', |
|
| 37 |
+ url: '/intro', |
|
| 38 |
+ description: '서비스 소개', |
|
| 39 |
+ sortOrder: 1, |
|
| 40 |
+ isActive: true, |
|
| 41 |
+ isTopActive: false, |
|
| 42 |
+ ...ALL_USERS, |
|
| 43 |
+ }, |
|
| 44 |
+ {
|
|
| 45 |
+ id: '1', |
|
| 46 |
+ parentId: null, |
|
| 47 |
+ name: '고객센터', |
|
| 48 |
+ url: '/cs', |
|
| 49 |
+ description: '고객센터', |
|
| 50 |
+ sortOrder: 2, |
|
| 51 |
+ isActive: true, |
|
| 52 |
+ isTopActive: true, |
|
| 53 |
+ ...ALL_USERS, |
|
| 54 |
+ }, |
|
| 55 |
+ {
|
|
| 56 |
+ id: '16', |
|
| 57 |
+ parentId: '1', |
|
| 58 |
+ name: '이용 문의', |
|
| 59 |
+ url: '/cs/inquiry', |
|
| 60 |
+ description: '1:1 문의', |
|
| 61 |
+ sortOrder: 1, |
|
| 62 |
+ isActive: true, |
|
| 63 |
+ isTopActive: false, |
|
| 64 |
+ ...ALL_USERS, |
|
| 65 |
+ }, |
|
| 66 |
+ {
|
|
| 67 |
+ id: '4', |
|
| 68 |
+ parentId: null, |
|
| 69 |
+ name: '내 강의실', |
|
| 70 |
+ url: '/class', |
|
| 71 |
+ description: '내 강의실', |
|
| 72 |
+ sortOrder: 4, |
|
| 73 |
+ isActive: true, |
|
| 74 |
+ isTopActive: true, |
|
| 75 |
+ ...STUDENT, |
|
| 76 |
+ }, |
|
| 77 |
+ {
|
|
| 78 |
+ id: '5', |
|
| 79 |
+ parentId: '4', |
|
| 80 |
+ name: '교육 신청 이력', |
|
| 81 |
+ url: '/class/history', |
|
| 82 |
+ description: '교육 신청 이력', |
|
| 83 |
+ sortOrder: 1, |
|
| 84 |
+ isActive: true, |
|
| 85 |
+ isTopActive: false, |
|
| 86 |
+ ...STUDENT, |
|
| 87 |
+ }, |
|
| 88 |
+ {
|
|
| 89 |
+ id: '9', |
|
| 90 |
+ parentId: '4', |
|
| 91 |
+ name: '내 정보 수정', |
|
| 92 |
+ url: '/class/profile', |
|
| 93 |
+ description: '내 정보 수정', |
|
| 94 |
+ sortOrder: 6, |
|
| 95 |
+ isActive: false, |
|
| 96 |
+ isTopActive: false, |
|
| 97 |
+ ...STUDENT, |
|
| 98 |
+ }, |
|
| 99 |
+]; |
|
| 29 | 100 |
|
| 30 |
-function isRecord(value: unknown): value is Record<string, unknown> {
|
|
| 31 |
- return value !== null && typeof value === 'object'; |
|
| 101 |
+/** 기획 ADM_MNU_001 ① — 메뉴 등록순 정렬. */ |
|
| 102 |
+export async function fetchAdminMenus(): Promise<AdminMenu[]> {
|
|
| 103 |
+ return MENUS.map((menu) => ({ ...menu }));
|
|
| 32 | 104 |
} |
| 33 | 105 |
|
| 34 |
-function readString(source: Record<string, unknown>, key: string): string {
|
|
| 35 |
- const value = source[key]; |
|
| 36 |
- return typeof value === 'string' ? value : ''; |
|
| 37 |
-} |
|
| 38 |
- |
|
| 39 |
-function readNumber(source: Record<string, unknown>, key: string): number {
|
|
| 40 |
- const value = source[key]; |
|
| 41 |
- return typeof value === 'number' ? value : 0; |
|
| 42 |
-} |
|
| 43 |
- |
|
| 44 |
-function toAdminMenu(raw: unknown): AdminMenu {
|
|
| 45 |
- if (!isRecord(raw)) {
|
|
| 46 |
- throw new Error('메뉴 응답 항목의 형식이 올바르지 않습니다.');
|
|
| 47 |
- } |
|
| 48 |
- |
|
| 49 |
- const id = readString(raw, 'menuId'); |
|
| 50 |
- if (!id) {
|
|
| 51 |
- throw new Error('메뉴 응답에 menuId가 없습니다.');
|
|
| 52 |
- } |
|
| 53 |
- |
|
| 54 |
- const parentId = readString(raw, 'upMenuId'); |
|
| 55 |
- |
|
| 56 |
- return {
|
|
| 57 |
- id, |
|
| 58 |
- parentId: parentId || null, |
|
| 59 |
- name: readString(raw, 'menuName'), |
|
| 60 |
- url: readString(raw, 'menuUrl'), |
|
| 61 |
- description: readString(raw, 'menuExplan'), |
|
| 62 |
- sortOrder: readNumber(raw, 'sortOrdr'), |
|
| 63 |
- roleId: readString(raw, 'roleId'), |
|
| 64 |
- roleName: readString(raw, 'roleNm'), |
|
| 65 |
- isActive: readString(raw, 'useYn') !== 'N', |
|
| 66 |
- isTopActive: readString(raw, 'topYn') === 'Y', |
|
| 67 |
- }; |
|
| 68 |
-} |
|
| 69 |
- |
|
| 70 |
-export const fetchAdminMenus = cache(async (): Promise<AdminMenu[]> => {
|
|
| 71 |
- const accessToken = await getSessionAccessToken(); |
|
| 72 |
- |
|
| 73 |
- const result = await backendFetch<unknown>(`${ADMIN_MENU_PATH}/pagination`, {
|
|
| 74 |
- method: 'GET', |
|
| 75 |
- query: { pageIndex: 1, recordCountPerPage: FETCH_LIMIT },
|
|
| 76 |
- accessToken: accessToken ?? undefined, |
|
| 77 |
- cache: 'no-store', |
|
| 78 |
- }); |
|
| 79 |
- |
|
| 80 |
- if (!result.ok) {
|
|
| 81 |
- throw new BackendRequestError(result); |
|
| 82 |
- } |
|
| 83 |
- |
|
| 84 |
- const data = result.data; |
|
| 85 |
- if (!isRecord(data) || !Array.isArray(data.list)) {
|
|
| 86 |
- throw new Error('메뉴 목록 응답의 형식이 올바르지 않습니다.');
|
|
| 87 |
- } |
|
| 88 |
- |
|
| 89 |
- return data.list.map(toAdminMenu); |
|
| 90 |
-}); |
|
| 91 |
- |
|
| 92 |
-function buildPayload(values: AdminMenuValues) {
|
|
| 93 |
- return {
|
|
| 94 |
- menuName: values.name, |
|
| 95 |
- menuUrl: values.url, |
|
| 96 |
- menuExplan: values.description, |
|
| 97 |
- sortOrdr: values.sortOrder, |
|
| 98 |
- roleId: values.roleId, |
|
| 99 |
- useYn: toYesNo(values.isActive), |
|
| 100 |
- topYn: toYesNo(values.isTopActive), |
|
| 101 |
- }; |
|
| 106 |
+function roleNameOf(roleId: string): string {
|
|
| 107 |
+ return ( |
|
| 108 |
+ MENUS.find((menu) => menu.roleId === roleId)?.roleName ?? roleId |
|
| 109 |
+ ); |
|
| 102 | 110 |
} |
| 103 | 111 |
|
| 104 | 112 |
export async function createAdminMenu( |
| 105 | 113 |
parentId: string, |
| 106 | 114 |
values: AdminMenuValues |
| 107 | 115 |
): Promise<void> {
|
| 108 |
- const accessToken = await getSessionAccessToken(); |
|
| 109 |
- |
|
| 110 |
- const result = await backendFetch<null>(ADMIN_MENU_PATH, {
|
|
| 111 |
- method: 'POST', |
|
| 112 |
- form: { ...buildPayload(values), upMenuId: parentId },
|
|
| 113 |
- accessToken: accessToken ?? undefined, |
|
| 114 |
- cache: 'no-store', |
|
| 115 |
- canHaveNullData: true, |
|
| 116 |
- }); |
|
| 117 |
- |
|
| 118 |
- if (!result.ok) {
|
|
| 119 |
- throw new BackendRequestError(result); |
|
| 116 |
+ if (!MENUS.some((menu) => menu.id === parentId)) {
|
|
| 117 |
+ throw new Error('상위 메뉴를 찾을 수 없습니다.');
|
|
| 120 | 118 |
} |
| 119 |
+ |
|
| 120 |
+ // 전체에서 가장 큰 번호 다음 — 형제 안에서만 세면 다른 가지와 번호가 겹친다. |
|
| 121 |
+ const nextId = MENUS.reduce( |
|
| 122 |
+ (max, menu) => Math.max(max, Number(menu.id) || 0), |
|
| 123 |
+ 0 |
|
| 124 |
+ ) + 1; |
|
| 125 |
+ |
|
| 126 |
+ MENUS = [ |
|
| 127 |
+ ...MENUS, |
|
| 128 |
+ {
|
|
| 129 |
+ id: String(nextId), |
|
| 130 |
+ parentId, |
|
| 131 |
+ name: values.name, |
|
| 132 |
+ url: values.url, |
|
| 133 |
+ description: values.description, |
|
| 134 |
+ sortOrder: values.sortOrder, |
|
| 135 |
+ roleId: values.roleId, |
|
| 136 |
+ roleName: roleNameOf(values.roleId), |
|
| 137 |
+ isActive: values.isActive, |
|
| 138 |
+ isTopActive: values.isTopActive, |
|
| 139 |
+ }, |
|
| 140 |
+ ]; |
|
| 121 | 141 |
} |
| 122 | 142 |
|
| 123 | 143 |
export async function updateAdminMenu( |
| 124 | 144 |
menuId: string, |
| 125 | 145 |
values: AdminMenuValues |
| 126 | 146 |
): Promise<void> {
|
| 127 |
- const accessToken = await getSessionAccessToken(); |
|
| 128 |
- |
|
| 129 |
- const result = await backendFetch<null>( |
|
| 130 |
- `${ADMIN_MENU_PATH}/${encodeURIComponent(menuId)}`,
|
|
| 131 |
- {
|
|
| 132 |
- method: 'PUT', |
|
| 133 |
- body: buildPayload(values), |
|
| 134 |
- accessToken: accessToken ?? undefined, |
|
| 135 |
- cache: 'no-store', |
|
| 136 |
- canHaveNullData: true, |
|
| 137 |
- } |
|
| 138 |
- ); |
|
| 139 |
- |
|
| 140 |
- if (!result.ok) {
|
|
| 141 |
- throw new BackendRequestError(result); |
|
| 147 |
+ const index = MENUS.findIndex((menu) => menu.id === menuId); |
|
| 148 |
+ if (index < 0) {
|
|
| 149 |
+ throw new Error('메뉴를 찾을 수 없습니다.');
|
|
| 142 | 150 |
} |
| 151 |
+ |
|
| 152 |
+ MENUS[index] = {
|
|
| 153 |
+ ...MENUS[index], |
|
| 154 |
+ name: values.name, |
|
| 155 |
+ url: values.url, |
|
| 156 |
+ description: values.description, |
|
| 157 |
+ sortOrder: values.sortOrder, |
|
| 158 |
+ roleId: values.roleId, |
|
| 159 |
+ roleName: roleNameOf(values.roleId), |
|
| 160 |
+ isActive: values.isActive, |
|
| 161 |
+ isTopActive: values.isTopActive, |
|
| 162 |
+ }; |
|
| 143 | 163 |
} |
| 144 | 164 |
|
| 165 |
+/** 하위 메뉴까지 함께 지운다 — 부모만 지우면 갈 곳 없는 가지가 남는다. */ |
|
| 145 | 166 |
export async function deleteAdminMenu(menuId: string): Promise<void> {
|
| 146 |
- const accessToken = await getSessionAccessToken(); |
|
| 147 |
- |
|
| 148 |
- const result = await backendFetch<null>( |
|
| 149 |
- `${ADMIN_MENU_PATH}/${encodeURIComponent(menuId)}`,
|
|
| 150 |
- {
|
|
| 151 |
- method: 'DELETE', |
|
| 152 |
- accessToken: accessToken ?? undefined, |
|
| 153 |
- cache: 'no-store', |
|
| 154 |
- canHaveNullData: true, |
|
| 155 |
- } |
|
| 156 |
- ); |
|
| 157 |
- |
|
| 158 |
- if (!result.ok) {
|
|
| 159 |
- throw new BackendRequestError(result); |
|
| 167 |
+ if (!MENUS.some((menu) => menu.id === menuId)) {
|
|
| 168 |
+ throw new Error('메뉴를 찾을 수 없습니다.');
|
|
| 160 | 169 |
} |
| 170 |
+ |
|
| 171 |
+ const doomed = new Set<string>([menuId]); |
|
| 172 |
+ let grew = true; |
|
| 173 |
+ while (grew) {
|
|
| 174 |
+ grew = false; |
|
| 175 |
+ for (const menu of MENUS) {
|
|
| 176 |
+ if (menu.parentId && doomed.has(menu.parentId) && !doomed.has(menu.id)) {
|
|
| 177 |
+ doomed.add(menu.id); |
|
| 178 |
+ grew = true; |
|
| 179 |
+ } |
|
| 180 |
+ } |
|
| 181 |
+ } |
|
| 182 |
+ |
|
| 183 |
+ MENUS = MENUS.filter((menu) => !doomed.has(menu.id)); |
|
| 161 | 184 |
} |
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
... | ... | @@ -50,7 +50,10 @@ |
| 50 | 50 |
id: 'admins', |
| 51 | 51 |
label: '관리자정보관리', |
| 52 | 52 |
icon: 'user-square', |
| 53 |
- children: [{ id: 'admin-members', label: '관리자 회원', href: '/admins' }],
|
|
| 53 |
+ children: [ |
|
| 54 |
+ { id: 'admin-members', label: '관리자 회원', href: '/admins' },
|
|
| 55 |
+ { id: 'admin-menus', label: '메뉴관리', href: '/admins/menus' },
|
|
| 56 |
+ ], |
|
| 54 | 57 |
}, |
| 55 | 58 |
{
|
| 56 | 59 |
id: 'decoration', |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?