feat: 관리자정보관리 > 메뉴관리 (ADM_MNU_001~003_p)
기획 3화면을 앞선 목록 화면들과 같은 구성으로 만들었다 — 시안이 없어 @fox의 FoxListContainer·
FoxModal·FoxInput·FoxSelect 조합을 그대로 쓴다.
목록: 메뉴번호·활성여부·메뉴명·메뉴순번·권한·관리. 메뉴명은 `upMenuId`로 트리를 세워 들여쓰고
자식이 있으면 캐럿으로 접고 편다(기획 ①의 아코디언). 검색·페이징은 기획대로 두지 않는다.
관리 열은 수정·하위추가·삭제 세 개이고, 최상위 신규 등록은 기획대로 제공하지 않는다(툴바 없음).
팝업은 등록·수정이 항목·순서가 같아 한 파일이 두 모드를 맡는다. 상위메뉴명은 `[번호] 이름`으로
읽기 전용이고, 최상위 메뉴 수정처럼 상위가 없으면 칸 자체가 빠진다.
브라우저 확인: 트리 6행이 부모-자식 순으로 서고, FOX PAY를 접으면 하위가 통째로 사라져 2행이
된다(재귀). 수정 팝업은 상위 `[4] 내 강의실`·메뉴명·순번·역할·활성 값을 모두 채운다.
## 백엔드 이슈 (그대로 연동, 사용자 확정)
- `DELETE /{menuId}`에 `@PathVariable`이 없어 경로값이 안 들어온다 → 삭제가 항상 실패한다.
- 등록 menuId 채번이 `MAX(menu_id) WHERE UP_MENU_ID = ?`라 상위별 최대값이다 → 자식이 없는
메뉴에 처음 추가하면 이미 있는 번호가 나와 PK 충돌로 실패한다.
- `GET /{menuId}`는 경로변수 이름이 `userId`로 어긋나고 빈 VO로 조회한다 → 쓰지 않고 수정 팝업의
초기값은 목록 응답에서 넘긴다.
- 새창여부에 해당하는 컬럼이 없다 — 기획대로 그리되 저장되지 않는다.
- 역할 목록 API가 없어 메뉴에 쓰인 역할을 모아 선택지로 쓴다.
API는 `/api/v1/mngr/menu/**`다. `mngr/bbs`는 게시판 설정이라 메뉴와 무관하다.
Co-Authored-By: Claude Opus 5
@a2b5b27e0e40cb1dff876126dfcc1a9fb0c8e564
app/(protected)/(basic)/admins/menus/_actions.ts (added)
+++ app/(protected)/(basic)/admins/menus/_actions.ts
... | ... | @@ -0,0 +1,130 @@ |
| 1 | +'use server'; | |
| 2 | + | |
| 3 | +import { revalidatePath } from 'next/cache'; | |
| 4 | +import { verifySession } from '@/lib/auth/dal'; | |
| 5 | +import { | |
| 6 | + createAdminMenu, | |
| 7 | + deleteAdminMenu, | |
| 8 | + fetchAdminMenus, | |
| 9 | + updateAdminMenu, | |
| 10 | +} from '@/lib/data/repositories/admin-menu-repository'; | |
| 11 | +import { ADMIN_MENU_ROOT_PATH } from '@/lib/domain/admin-menu'; | |
| 12 | +import { | |
| 13 | + validateAdminMenu, | |
| 14 | + type AdminMenuFormState, | |
| 15 | + type AdminMenuValues, | |
| 16 | +} from '@/lib/domain/admin-menu-form'; | |
| 17 | +import { BackendRequestError } from '@/lib/http/backend-fetch'; | |
| 18 | + | |
| 19 | +const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; | |
| 20 | +const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 21 | +const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; | |
| 22 | + | |
| 23 | +function readString(formData: FormData, key: string): string { | |
| 24 | + const value = formData.get(key); | |
| 25 | + return typeof value === 'string' ? value : ''; | |
| 26 | +} | |
| 27 | + | |
| 28 | +function readNumber(formData: FormData, key: string): number { | |
| 29 | + const trimmed = readString(formData, key).trim(); | |
| 30 | + return trimmed === '' ? NaN : Number(trimmed); | |
| 31 | +} | |
| 32 | + | |
| 33 | +function readValues(formData: FormData): AdminMenuValues { | |
| 34 | + return { | |
| 35 | + name: readString(formData, 'name'), | |
| 36 | + sortOrder: readNumber(formData, 'sortOrder'), | |
| 37 | + roleId: readString(formData, 'roleId'), | |
| 38 | + isTopActive: readString(formData, 'isTopActive') === 'true', | |
| 39 | + url: readString(formData, 'url'), | |
| 40 | + opensNewWindow: readString(formData, 'opensNewWindow') === 'true', | |
| 41 | + description: readString(formData, 'description'), | |
| 42 | + isActive: readString(formData, 'isActive') === 'true', | |
| 43 | + }; | |
| 44 | +} | |
| 45 | + | |
| 46 | +async function allowedRoleIds(): Promise<string[]> { | |
| 47 | + const menus = await fetchAdminMenus(); | |
| 48 | + return [...new Set(menus.map((menu) => menu.roleId).filter(Boolean))]; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function describeFailure(error: unknown, fallback: string): string { | |
| 52 | + return error instanceof BackendRequestError ? error.message : fallback; | |
| 53 | +} | |
| 54 | + | |
| 55 | +export async function createAdminMenuAction( | |
| 56 | + _prevState: AdminMenuFormState, | |
| 57 | + formData: FormData | |
| 58 | +): Promise<AdminMenuFormState> { | |
| 59 | + await verifySession(); | |
| 60 | + | |
| 61 | + const parentId = readString(formData, 'parentId').trim(); | |
| 62 | + if (!parentId) { | |
| 63 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 64 | + } | |
| 65 | + | |
| 66 | + const validation = validateAdminMenu( | |
| 67 | + readValues(formData), | |
| 68 | + await allowedRoleIds() | |
| 69 | + ); | |
| 70 | + if (!validation.ok) { | |
| 71 | + return { status: 'error', errors: validation.errors }; | |
| 72 | + } | |
| 73 | + | |
| 74 | + try { | |
| 75 | + await createAdminMenu(parentId, validation.values); | |
| 76 | + } catch (error) { | |
| 77 | + return { status: 'error', message: describeFailure(error, SAVE_FAILED_MESSAGE) }; | |
| 78 | + } | |
| 79 | + | |
| 80 | + revalidatePath(ADMIN_MENU_ROOT_PATH); | |
| 81 | + return { status: 'success' }; | |
| 82 | +} | |
| 83 | + | |
| 84 | +export async function updateAdminMenuAction( | |
| 85 | + _prevState: AdminMenuFormState, | |
| 86 | + formData: FormData | |
| 87 | +): Promise<AdminMenuFormState> { | |
| 88 | + await verifySession(); | |
| 89 | + | |
| 90 | + const menuId = readString(formData, 'menuId').trim(); | |
| 91 | + if (!menuId) { | |
| 92 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 93 | + } | |
| 94 | + | |
| 95 | + const validation = validateAdminMenu( | |
| 96 | + readValues(formData), | |
| 97 | + await allowedRoleIds() | |
| 98 | + ); | |
| 99 | + if (!validation.ok) { | |
| 100 | + return { status: 'error', errors: validation.errors }; | |
| 101 | + } | |
| 102 | + | |
| 103 | + try { | |
| 104 | + await updateAdminMenu(menuId, validation.values); | |
| 105 | + } catch (error) { | |
| 106 | + return { status: 'error', message: describeFailure(error, SAVE_FAILED_MESSAGE) }; | |
| 107 | + } | |
| 108 | + | |
| 109 | + revalidatePath(ADMIN_MENU_ROOT_PATH); | |
| 110 | + return { status: 'success' }; | |
| 111 | +} | |
| 112 | + | |
| 113 | +export async function deleteAdminMenuAction( | |
| 114 | + menuId: string | |
| 115 | +): Promise<AdminMenuFormState> { | |
| 116 | + await verifySession(); | |
| 117 | + | |
| 118 | + if (!menuId) { | |
| 119 | + return { status: 'error', message: INVALID_REQUEST_MESSAGE }; | |
| 120 | + } | |
| 121 | + | |
| 122 | + try { | |
| 123 | + await deleteAdminMenu(menuId); | |
| 124 | + } catch (error) { | |
| 125 | + return { status: 'error', message: describeFailure(error, DELETE_FAILED_MESSAGE) }; | |
| 126 | + } | |
| 127 | + | |
| 128 | + revalidatePath(ADMIN_MENU_ROOT_PATH); | |
| 129 | + return { status: 'success' }; | |
| 130 | +} |
app/(protected)/(basic)/admins/menus/_components/admin-menu-list.module.scss (added)
+++ app/(protected)/(basic)/admins/menus/_components/admin-menu-list.module.scss
... | ... | @@ -0,0 +1,39 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.name { | |
| 4 | + display: flex; | |
| 5 | + align-items: center; | |
| 6 | + gap: fox.gap(2); | |
| 7 | +} | |
| 8 | + | |
| 9 | +.toggle { | |
| 10 | + display: flex; | |
| 11 | + flex-shrink: 0; | |
| 12 | + align-items: center; | |
| 13 | + justify-content: center; | |
| 14 | + inline-size: fox.icon(4); | |
| 15 | + block-size: fox.icon(4); | |
| 16 | + border: 0; | |
| 17 | + background: none; | |
| 18 | + color: fox.color(font-neutral-subtle); | |
| 19 | + cursor: pointer; | |
| 20 | +} | |
| 21 | + | |
| 22 | +.togglePlaceholder { | |
| 23 | + flex-shrink: 0; | |
| 24 | + inline-size: fox.icon(4); | |
| 25 | +} | |
| 26 | + | |
| 27 | +.state { | |
| 28 | + display: inline-flex; | |
| 29 | + inline-size: fox.icon(5); | |
| 30 | + block-size: fox.icon(5); | |
| 31 | +} | |
| 32 | + | |
| 33 | +.active { | |
| 34 | + color: fox.color(font-system-success); | |
| 35 | +} | |
| 36 | + | |
| 37 | +.inactive { | |
| 38 | + color: fox.color(font-system-danger); | |
| 39 | +} |
app/(protected)/(basic)/admins/menus/_components/admin-menu-list.tsx (added)
+++ app/(protected)/(basic)/admins/menus/_components/admin-menu-list.tsx
... | ... | @@ -0,0 +1,237 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState, useTransition } from 'react'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; | |
| 6 | +import { FoxIconButton } from '@fox/core/components/fox-icon-button'; | |
| 7 | +import { | |
| 8 | + FoxListContainer, | |
| 9 | + type FoxListColumn, | |
| 10 | +} from '@fox/core/components/fox-list-container'; | |
| 11 | +import { | |
| 12 | + FoxCaretDownIcon, | |
| 13 | + FoxCaretRightIcon, | |
| 14 | + FoxCheckIcon, | |
| 15 | + FoxPencilSimpleIcon, | |
| 16 | + FoxPlusIcon, | |
| 17 | + FoxProhibitIcon, | |
| 18 | + FoxTrashIcon, | |
| 19 | +} from '@fox/core/icons'; | |
| 20 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 21 | +import { | |
| 22 | + flattenAdminMenuTree, | |
| 23 | + formatActiveLabel, | |
| 24 | + type AdminMenu, | |
| 25 | + type AdminMenuNode, | |
| 26 | +} from '@/lib/domain/admin-menu'; | |
| 27 | +import { deleteAdminMenuAction } from '../_actions'; | |
| 28 | +import { AdminMenuModal, type AdminMenuRole } from './admin-menu-modal'; | |
| 29 | +import styles from './admin-menu-list.module.scss'; | |
| 30 | + | |
| 31 | +interface AdminMenuListProps { | |
| 32 | + roots: AdminMenuNode[]; | |
| 33 | + totalCount: number; | |
| 34 | + roles: AdminMenuRole[]; | |
| 35 | +} | |
| 36 | + | |
| 37 | +type ModalState = | |
| 38 | + | { mode: 'create'; parent: AdminMenu; nextSortOrder: number } | |
| 39 | + | { mode: 'edit'; menu: AdminMenuNode; parent: AdminMenu | null }; | |
| 40 | + | |
| 41 | +function findParent( | |
| 42 | + roots: AdminMenuNode[], | |
| 43 | + parentId: string | null | |
| 44 | +): AdminMenu | null { | |
| 45 | + if (!parentId) return null; | |
| 46 | + | |
| 47 | + const stack = [...roots]; | |
| 48 | + while (stack.length > 0) { | |
| 49 | + const node = stack.pop(); | |
| 50 | + if (!node) continue; | |
| 51 | + if (node.id === parentId) return node; | |
| 52 | + stack.push(...node.children); | |
| 53 | + } | |
| 54 | + return null; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function AdminMenuList({ roots, totalCount, roles }: AdminMenuListProps) { | |
| 58 | + const { showAlert, hideAlert, showToast } = useFeedback(); | |
| 59 | + const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>( | |
| 60 | + new Set() | |
| 61 | + ); | |
| 62 | + const [modal, setModal] = useState<ModalState | null>(null); | |
| 63 | + const [isDeleting, startDeleting] = useTransition(); | |
| 64 | + | |
| 65 | + const rows = flattenAdminMenuTree(roots, collapsedIds); | |
| 66 | + | |
| 67 | + function toggle(id: string) { | |
| 68 | + setCollapsedIds((current) => { | |
| 69 | + const next = new Set(current); | |
| 70 | + if (next.has(id)) { | |
| 71 | + next.delete(id); | |
| 72 | + } else { | |
| 73 | + next.add(id); | |
| 74 | + } | |
| 75 | + return next; | |
| 76 | + }); | |
| 77 | + } | |
| 78 | + | |
| 79 | + function confirmDelete(menu: AdminMenuNode) { | |
| 80 | + showAlert({ | |
| 81 | + variant: 'danger', | |
| 82 | + title: '메뉴를 삭제하시겠습니까?', | |
| 83 | + message: `${menu.name} 메뉴를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, | |
| 84 | + actions: ( | |
| 85 | + <FoxButtonGroup size="md"> | |
| 86 | + <FoxButton type="default" size="md" label="취소" onAction={hideAlert} /> | |
| 87 | + <FoxButton | |
| 88 | + type="error" | |
| 89 | + size="md" | |
| 90 | + label="삭제" | |
| 91 | + onAction={() => { | |
| 92 | + hideAlert(); | |
| 93 | + startDeleting(async () => { | |
| 94 | + const result = await deleteAdminMenuAction(menu.id); | |
| 95 | + showToast( | |
| 96 | + result.status === 'error' | |
| 97 | + ? { | |
| 98 | + variant: 'danger', | |
| 99 | + message: result.message ?? '삭제하지 못했습니다.', | |
| 100 | + } | |
| 101 | + : { variant: 'success', message: '메뉴를 삭제했습니다.' } | |
| 102 | + ); | |
| 103 | + }); | |
| 104 | + }} | |
| 105 | + /> | |
| 106 | + </FoxButtonGroup> | |
| 107 | + ), | |
| 108 | + }); | |
| 109 | + } | |
| 110 | + | |
| 111 | + const columns: FoxListColumn<AdminMenuNode>[] = [ | |
| 112 | + { key: 'id', header: '메뉴번호', width: 120 }, | |
| 113 | + { | |
| 114 | + key: 'isActive', | |
| 115 | + header: '활성여부', | |
| 116 | + width: 120, | |
| 117 | + render: (row) => ( | |
| 118 | + <span | |
| 119 | + className={`${styles.state} ${row.isActive ? styles.active : styles.inactive}`} | |
| 120 | + title={formatActiveLabel(row.isActive)} | |
| 121 | + > | |
| 122 | + {row.isActive ? <FoxCheckIcon /> : <FoxProhibitIcon />} | |
| 123 | + </span> | |
| 124 | + ), | |
| 125 | + }, | |
| 126 | + { | |
| 127 | + key: 'name', | |
| 128 | + header: '메뉴명', | |
| 129 | + align: 'start', | |
| 130 | + render: (row) => ( | |
| 131 | + <span | |
| 132 | + className={styles.name} | |
| 133 | + style={{ paddingInlineStart: `${row.depth * 1.6}rem` }} | |
| 134 | + > | |
| 135 | + {row.children.length > 0 ? ( | |
| 136 | + <button | |
| 137 | + type="button" | |
| 138 | + className={styles.toggle} | |
| 139 | + aria-expanded={!collapsedIds.has(row.id)} | |
| 140 | + aria-label={`${row.name} 하위 메뉴 ${collapsedIds.has(row.id) ? '펼치기' : '접기'}`} | |
| 141 | + onClick={() => toggle(row.id)} | |
| 142 | + > | |
| 143 | + {collapsedIds.has(row.id) ? ( | |
| 144 | + <FoxCaretRightIcon /> | |
| 145 | + ) : ( | |
| 146 | + <FoxCaretDownIcon /> | |
| 147 | + )} | |
| 148 | + </button> | |
| 149 | + ) : ( | |
| 150 | + <span className={styles.togglePlaceholder} aria-hidden="true" /> | |
| 151 | + )} | |
| 152 | + {row.name} | |
| 153 | + </span> | |
| 154 | + ), | |
| 155 | + }, | |
| 156 | + { key: 'sortOrder', header: '메뉴순번', width: 120 }, | |
| 157 | + { | |
| 158 | + key: 'roleName', | |
| 159 | + header: '권한', | |
| 160 | + width: 160, | |
| 161 | + render: (row) => row.roleName || row.roleId || '-', | |
| 162 | + }, | |
| 163 | + { | |
| 164 | + key: 'actions', | |
| 165 | + header: '관리', | |
| 166 | + width: 160, | |
| 167 | + render: (row) => ( | |
| 168 | + <FoxButtonGroup size="sm" align="center"> | |
| 169 | + <FoxIconButton | |
| 170 | + type="default" | |
| 171 | + size="sm" | |
| 172 | + icon={<FoxPencilSimpleIcon />} | |
| 173 | + label="수정" | |
| 174 | + onAction={() => | |
| 175 | + setModal({ | |
| 176 | + mode: 'edit', | |
| 177 | + menu: row, | |
| 178 | + parent: findParent(roots, row.parentId), | |
| 179 | + }) | |
| 180 | + } | |
| 181 | + /> | |
| 182 | + <FoxIconButton | |
| 183 | + type="default" | |
| 184 | + size="sm" | |
| 185 | + icon={<FoxPlusIcon />} | |
| 186 | + label="하위 메뉴 추가" | |
| 187 | + onAction={() => | |
| 188 | + setModal({ | |
| 189 | + mode: 'create', | |
| 190 | + parent: row, | |
| 191 | + nextSortOrder: row.children.length + 1, | |
| 192 | + }) | |
| 193 | + } | |
| 194 | + /> | |
| 195 | + <FoxIconButton | |
| 196 | + type="default" | |
| 197 | + size="sm" | |
| 198 | + icon={<FoxTrashIcon />} | |
| 199 | + label={isDeleting ? '삭제 중' : '삭제'} | |
| 200 | + disabled={isDeleting} | |
| 201 | + onAction={() => confirmDelete(row)} | |
| 202 | + /> | |
| 203 | + </FoxButtonGroup> | |
| 204 | + ), | |
| 205 | + }, | |
| 206 | + ]; | |
| 207 | + | |
| 208 | + return ( | |
| 209 | + <> | |
| 210 | + <FoxListContainer<AdminMenuNode> | |
| 211 | + title="메뉴관리" | |
| 212 | + breadcrumb={[ | |
| 213 | + { label: '홈', href: '/' }, | |
| 214 | + { label: '관리자정보관리' }, | |
| 215 | + { label: '메뉴관리' }, | |
| 216 | + ]} | |
| 217 | + caption="메뉴 목록" | |
| 218 | + columns={columns} | |
| 219 | + rows={rows} | |
| 220 | + rowKey={(row) => row.id} | |
| 221 | + totalCount={totalCount} | |
| 222 | + emptyMessage="등록된 메뉴가 없습니다." | |
| 223 | + searchHidden | |
| 224 | + /> | |
| 225 | + | |
| 226 | + {modal && ( | |
| 227 | + <AdminMenuModal | |
| 228 | + parent={modal.parent} | |
| 229 | + menu={modal.mode === 'edit' ? modal.menu : undefined} | |
| 230 | + roles={roles} | |
| 231 | + nextSortOrder={modal.mode === 'create' ? modal.nextSortOrder : 1} | |
| 232 | + onClose={() => setModal(null)} | |
| 233 | + /> | |
| 234 | + )} | |
| 235 | + </> | |
| 236 | + ); | |
| 237 | +} |
app/(protected)/(basic)/admins/menus/_components/admin-menu-modal.module.scss (added)
+++ app/(protected)/(basic)/admins/menus/_components/admin-menu-modal.module.scss
... | ... | @@ -0,0 +1,9 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +.fields { | |
| 4 | + display: flex; | |
| 5 | + flex-direction: column; | |
| 6 | + gap: fox.gap(6); | |
| 7 | + inline-size: 100%; | |
| 8 | + min-inline-size: 0; | |
| 9 | +} |
app/(protected)/(basic)/admins/menus/_components/admin-menu-modal.tsx (added)
+++ app/(protected)/(basic)/admins/menus/_components/admin-menu-modal.tsx
... | ... | @@ -0,0 +1,226 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useActionState, useEffect, useRef, useState } from 'react'; | |
| 4 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 5 | +import { FoxInput } from '@fox/core/components/fox-input'; | |
| 6 | +import { FoxModal } from '@fox/core/components/fox-modal'; | |
| 7 | +import { FoxSelect } from '@fox/core/components/fox-select'; | |
| 8 | +import { submitFormAction } from '@/app/_hooks/submit-form-action'; | |
| 9 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 10 | +import { | |
| 11 | + ACTIVE_OPTIONS, | |
| 12 | + NEW_WINDOW_OPTIONS, | |
| 13 | + type AdminMenu, | |
| 14 | + type AdminMenuNode, | |
| 15 | +} from '@/lib/domain/admin-menu'; | |
| 16 | +import { | |
| 17 | + ADMIN_MENU_DESCRIPTION_MESSAGES, | |
| 18 | + ADMIN_MENU_NAME_MESSAGES, | |
| 19 | + ADMIN_MENU_URL_MESSAGES, | |
| 20 | + DESCRIPTION_VALIDATORS, | |
| 21 | + INITIAL_ADMIN_MENU_FORM_STATE, | |
| 22 | + NAME_VALIDATORS, | |
| 23 | + URL_VALIDATORS, | |
| 24 | +} from '@/lib/domain/admin-menu-form'; | |
| 25 | +import { createAdminMenuAction, updateAdminMenuAction } from '../_actions'; | |
| 26 | +import styles from './admin-menu-modal.module.scss'; | |
| 27 | + | |
| 28 | +export type AdminMenuRole = { id: string; name: string }; | |
| 29 | + | |
| 30 | +interface AdminMenuModalProps { | |
| 31 | + /** 등록이면 상위가 될 메뉴, 수정이면 대상의 상위. 최상위 메뉴 수정 시에는 없다. */ | |
| 32 | + parent: AdminMenu | null; | |
| 33 | + /** 없으면 등록 팝업이다. */ | |
| 34 | + menu?: AdminMenuNode; | |
| 35 | + roles: AdminMenuRole[]; | |
| 36 | + nextSortOrder: number; | |
| 37 | + onClose: () => void; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export function AdminMenuModal({ | |
| 41 | + parent, | |
| 42 | + menu, | |
| 43 | + roles, | |
| 44 | + nextSortOrder, | |
| 45 | + onClose, | |
| 46 | +}: AdminMenuModalProps) { | |
| 47 | + const isEdit = menu !== undefined; | |
| 48 | + const { showToast } = useFeedback(); | |
| 49 | + const formRef = useRef<HTMLFormElement>(null); | |
| 50 | + const [state, formAction, isPending] = useActionState( | |
| 51 | + isEdit ? updateAdminMenuAction : createAdminMenuAction, | |
| 52 | + INITIAL_ADMIN_MENU_FORM_STATE | |
| 53 | + ); | |
| 54 | + | |
| 55 | + const [roleId, setRoleId] = useState(menu?.roleId ?? roles[0]?.id ?? ''); | |
| 56 | + const [isTopActive, setIsTopActive] = useState(menu?.isTopActive ?? false); | |
| 57 | + const [opensNewWindow, setOpensNewWindow] = useState(true); | |
| 58 | + const [isActive, setIsActive] = useState(menu?.isActive ?? true); | |
| 59 | + | |
| 60 | + useEffect(() => { | |
| 61 | + if (state.status === 'success') { | |
| 62 | + showToast({ | |
| 63 | + variant: 'success', | |
| 64 | + message: isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.', | |
| 65 | + }); | |
| 66 | + onClose(); | |
| 67 | + } | |
| 68 | + }, [state, showToast, onClose, isEdit]); | |
| 69 | + | |
| 70 | + const errors = state.status === 'error' ? (state.errors ?? {}) : {}; | |
| 71 | + | |
| 72 | + return ( | |
| 73 | + <FoxModal | |
| 74 | + open | |
| 75 | + size="sm" | |
| 76 | + title={isEdit ? '수정' : '신규 등록'} | |
| 77 | + onClose={onClose} | |
| 78 | + actions={ | |
| 79 | + <> | |
| 80 | + <FoxButton type="default" size="md" label="취소" onAction={onClose} /> | |
| 81 | + <FoxButton | |
| 82 | + type="primary" | |
| 83 | + size="md" | |
| 84 | + label={isEdit ? '수정' : '저장'} | |
| 85 | + loading={isPending} | |
| 86 | + onAction={() => formRef.current?.requestSubmit()} | |
| 87 | + /> | |
| 88 | + </> | |
| 89 | + } | |
| 90 | + > | |
| 91 | + <form | |
| 92 | + ref={formRef} | |
| 93 | + onSubmit={(event) => submitFormAction(event, formAction)} | |
| 94 | + className={styles.fields} | |
| 95 | + > | |
| 96 | + {isEdit ? ( | |
| 97 | + <input type="hidden" name="menuId" value={menu.id} /> | |
| 98 | + ) : ( | |
| 99 | + <input type="hidden" name="parentId" value={parent?.id ?? ''} /> | |
| 100 | + )} | |
| 101 | + | |
| 102 | + {parent && ( | |
| 103 | + <FoxInput | |
| 104 | + size="md" | |
| 105 | + label="상위메뉴명" | |
| 106 | + requirement="required" | |
| 107 | + value={`[${parent.id}] ${parent.name}`} | |
| 108 | + readOnly | |
| 109 | + /> | |
| 110 | + )} | |
| 111 | + | |
| 112 | + <FoxInput | |
| 113 | + size="md" | |
| 114 | + name="name" | |
| 115 | + label="메뉴명" | |
| 116 | + requirement="required" | |
| 117 | + placeholder="메뉴명을 입력하세요." | |
| 118 | + defaultValue={menu?.name ?? ''} | |
| 119 | + invalid={Boolean(errors.name)} | |
| 120 | + message={errors.name} | |
| 121 | + validators={NAME_VALIDATORS} | |
| 122 | + validationMessages={ADMIN_MENU_NAME_MESSAGES} | |
| 123 | + /> | |
| 124 | + | |
| 125 | + <FoxInput | |
| 126 | + size="md" | |
| 127 | + type="number" | |
| 128 | + name="sortOrder" | |
| 129 | + label="메뉴순번" | |
| 130 | + requirement="required" | |
| 131 | + min={1} | |
| 132 | + step={1} | |
| 133 | + defaultValue={menu?.sortOrder ?? nextSortOrder} | |
| 134 | + invalid={Boolean(errors.sortOrder)} | |
| 135 | + message={errors.sortOrder} | |
| 136 | + /> | |
| 137 | + | |
| 138 | + <div> | |
| 139 | + <FoxSelect | |
| 140 | + size="md" | |
| 141 | + label="메뉴권한" | |
| 142 | + requirement="required" | |
| 143 | + options={roles.map((role) => ({ value: role.id, label: role.name }))} | |
| 144 | + placeholder="메뉴권한을 선택하세요." | |
| 145 | + value={roleId} | |
| 146 | + onValueChange={setRoleId} | |
| 147 | + error={Boolean(errors.roleId)} | |
| 148 | + hint={errors.roleId} | |
| 149 | + /> | |
| 150 | + <input type="hidden" name="roleId" value={roleId} /> | |
| 151 | + </div> | |
| 152 | + | |
| 153 | + <div> | |
| 154 | + <FoxSelect | |
| 155 | + size="md" | |
| 156 | + label="최상위권한활성" | |
| 157 | + requirement="required" | |
| 158 | + options={ACTIVE_OPTIONS.map((option) => ({ ...option }))} | |
| 159 | + value={String(isTopActive)} | |
| 160 | + onValueChange={(value) => setIsTopActive(value === 'true')} | |
| 161 | + /> | |
| 162 | + <input type="hidden" name="isTopActive" value={String(isTopActive)} /> | |
| 163 | + </div> | |
| 164 | + | |
| 165 | + <FoxInput | |
| 166 | + size="md" | |
| 167 | + name="url" | |
| 168 | + label="메뉴링크" | |
| 169 | + requirement="required" | |
| 170 | + placeholder="메뉴링크를 입력하세요." | |
| 171 | + defaultValue={menu?.url ?? ''} | |
| 172 | + invalid={Boolean(errors.url)} | |
| 173 | + message={errors.url} | |
| 174 | + validators={URL_VALIDATORS} | |
| 175 | + validationMessages={ADMIN_MENU_URL_MESSAGES} | |
| 176 | + /> | |
| 177 | + | |
| 178 | + {/* 이슈: 백엔드에 대응 컬럼이 없어 저장되지 않는다(기획대로 화면에만 둔다). */} | |
| 179 | + <div> | |
| 180 | + <FoxSelect | |
| 181 | + size="md" | |
| 182 | + label="새창여부" | |
| 183 | + requirement="required" | |
| 184 | + options={NEW_WINDOW_OPTIONS.map((option) => ({ ...option }))} | |
| 185 | + value={String(opensNewWindow)} | |
| 186 | + onValueChange={(value) => setOpensNewWindow(value === 'true')} | |
| 187 | + /> | |
| 188 | + <input | |
| 189 | + type="hidden" | |
| 190 | + name="opensNewWindow" | |
| 191 | + value={String(opensNewWindow)} | |
| 192 | + /> | |
| 193 | + </div> | |
| 194 | + | |
| 195 | + <FoxInput | |
| 196 | + size="md" | |
| 197 | + name="description" | |
| 198 | + label="메뉴설명" | |
| 199 | + requirement="required" | |
| 200 | + placeholder="메뉴설명을 입력하세요." | |
| 201 | + defaultValue={menu?.description ?? ''} | |
| 202 | + invalid={Boolean(errors.description)} | |
| 203 | + message={errors.description} | |
| 204 | + validators={DESCRIPTION_VALIDATORS} | |
| 205 | + validationMessages={ADMIN_MENU_DESCRIPTION_MESSAGES} | |
| 206 | + /> | |
| 207 | + | |
| 208 | + <div> | |
| 209 | + <FoxSelect | |
| 210 | + size="md" | |
| 211 | + label="활성여부" | |
| 212 | + requirement="required" | |
| 213 | + options={ACTIVE_OPTIONS.map((option) => ({ ...option }))} | |
| 214 | + value={String(isActive)} | |
| 215 | + onValueChange={(value) => setIsActive(value === 'true')} | |
| 216 | + /> | |
| 217 | + <input type="hidden" name="isActive" value={String(isActive)} /> | |
| 218 | + </div> | |
| 219 | + | |
| 220 | + {state.status === 'error' && state.message && ( | |
| 221 | + <p role="alert">{state.message}</p> | |
| 222 | + )} | |
| 223 | + </form> | |
| 224 | + </FoxModal> | |
| 225 | + ); | |
| 226 | +} |
app/(protected)/(basic)/admins/menus/page.tsx (added)
+++ app/(protected)/(basic)/admins/menus/page.tsx
... | ... | @@ -0,0 +1,32 @@ |
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { verifySession } from '@/lib/auth/dal'; | |
| 3 | +import { fetchAdminMenus } from '@/lib/data/repositories/admin-menu-repository'; | |
| 4 | +import { buildAdminMenuTree } from '@/lib/domain/admin-menu'; | |
| 5 | +import { AdminMenuList } from './_components/admin-menu-list'; | |
| 6 | + | |
| 7 | +export const metadata: Metadata = { | |
| 8 | + title: '메뉴관리', | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export const dynamic = 'force-dynamic'; | |
| 12 | + | |
| 13 | +export default async function Page() { | |
| 14 | + await verifySession(); | |
| 15 | + | |
| 16 | + const menus = await fetchAdminMenus(); | |
| 17 | + | |
| 18 | + // 이슈: 역할 목록 조회 API가 없어 메뉴에 쓰인 역할을 모아 선택지로 쓴다. | |
| 19 | + const roles = [...new Map( | |
| 20 | + menus | |
| 21 | + .filter((menu) => menu.roleId) | |
| 22 | + .map((menu) => [menu.roleId, { id: menu.roleId, name: menu.roleName || menu.roleId }]) | |
| 23 | + ).values()]; | |
| 24 | + | |
| 25 | + return ( | |
| 26 | + <AdminMenuList | |
| 27 | + roots={buildAdminMenuTree(menus)} | |
| 28 | + totalCount={menus.length} | |
| 29 | + roles={roles} | |
| 30 | + /> | |
| 31 | + ); | |
| 32 | +} |
lib/data/repositories/admin-menu-repository.ts (added)
+++ lib/data/repositories/admin-menu-repository.ts
... | ... | @@ -0,0 +1,161 @@ |
| 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 | +import type { AdminMenu } from '@/lib/domain/admin-menu'; | |
| 6 | +import { toYesNo } from '@/lib/domain/admin-menu'; | |
| 7 | +import type { AdminMenuValues } from '@/lib/domain/admin-menu-form'; | |
| 8 | + | |
| 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} 삭제 | |
| 14 | + * | |
| 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. 새창여부에 해당하는 컬럼이 없다 — 화면은 기획대로 그리되 저장되지 않는다. | |
| 23 | + */ | |
| 24 | + | |
| 25 | +const ADMIN_MENU_PATH = '/api/v1/mngr/menu'; | |
| 26 | + | |
| 27 | +// 기획이 "페이징 없이 전체 노출"이라 한 장으로 받는다. | |
| 28 | +const FETCH_LIMIT = 10_000; | |
| 29 | + | |
| 30 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 31 | + return value !== null && typeof value === 'object'; | |
| 32 | +} | |
| 33 | + | |
| 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 | + }; | |
| 102 | +} | |
| 103 | + | |
| 104 | +export async function createAdminMenu( | |
| 105 | + parentId: string, | |
| 106 | + values: AdminMenuValues | |
| 107 | +): 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); | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +export async function updateAdminMenu( | |
| 124 | + menuId: string, | |
| 125 | + values: AdminMenuValues | |
| 126 | +): 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); | |
| 142 | + } | |
| 143 | +} | |
| 144 | + | |
| 145 | +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); | |
| 160 | + } | |
| 161 | +} |
lib/domain/admin-menu-form.ts (added)
+++ lib/domain/admin-menu-form.ts
... | ... | @@ -0,0 +1,112 @@ |
| 1 | +import { | |
| 2 | + foxValidationMessage, | |
| 3 | + foxValidators, | |
| 4 | + type FoxValidationMessages, | |
| 5 | +} from '@fox/core/validation'; | |
| 6 | + | |
| 7 | +export type AdminMenuValues = { | |
| 8 | + name: string; | |
| 9 | + sortOrder: number; | |
| 10 | + roleId: string; | |
| 11 | + isTopActive: boolean; | |
| 12 | + url: string; | |
| 13 | + /** 이슈: 백엔드에 대응 컬럼이 없어 저장되지 않는다. 기획대로 화면에만 둔다. */ | |
| 14 | + opensNewWindow: boolean; | |
| 15 | + description: string; | |
| 16 | + isActive: boolean; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +export type AdminMenuFormErrors = Partial<Record<keyof AdminMenuValues, string>>; | |
| 20 | + | |
| 21 | +export type AdminMenuFormState = | |
| 22 | + | { status: 'idle' } | |
| 23 | + | { status: 'error'; message?: string; errors?: AdminMenuFormErrors } | |
| 24 | + | { status: 'success' }; | |
| 25 | + | |
| 26 | +export const INITIAL_ADMIN_MENU_FORM_STATE: AdminMenuFormState = { | |
| 27 | + status: 'idle', | |
| 28 | +}; | |
| 29 | + | |
| 30 | +const NAME_MAX_LENGTH = 100; | |
| 31 | +const URL_MAX_LENGTH = 300; | |
| 32 | +const DESCRIPTION_MAX_LENGTH = 500; | |
| 33 | + | |
| 34 | +const NAME_VALIDATORS = [ | |
| 35 | + foxValidators.required, | |
| 36 | + foxValidators.maxLength(NAME_MAX_LENGTH), | |
| 37 | +]; | |
| 38 | +const URL_VALIDATORS = [ | |
| 39 | + foxValidators.required, | |
| 40 | + foxValidators.maxLength(URL_MAX_LENGTH), | |
| 41 | +]; | |
| 42 | +const DESCRIPTION_VALIDATORS = [ | |
| 43 | + foxValidators.required, | |
| 44 | + foxValidators.maxLength(DESCRIPTION_MAX_LENGTH), | |
| 45 | +]; | |
| 46 | + | |
| 47 | +export const ADMIN_MENU_NAME_MESSAGES: FoxValidationMessages = { | |
| 48 | + required: '메뉴명을 입력해 주세요.', | |
| 49 | + maxlength: `메뉴명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`, | |
| 50 | +}; | |
| 51 | +export const ADMIN_MENU_URL_MESSAGES: FoxValidationMessages = { | |
| 52 | + required: '메뉴링크를 입력해 주세요.', | |
| 53 | + maxlength: `메뉴링크는 ${URL_MAX_LENGTH}자 이내로 입력해 주세요.`, | |
| 54 | +}; | |
| 55 | +export const ADMIN_MENU_DESCRIPTION_MESSAGES: FoxValidationMessages = { | |
| 56 | + required: '메뉴설명을 입력해 주세요.', | |
| 57 | + maxlength: `메뉴설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`, | |
| 58 | +}; | |
| 59 | + | |
| 60 | +export { NAME_VALIDATORS, URL_VALIDATORS, DESCRIPTION_VALIDATORS }; | |
| 61 | + | |
| 62 | +function check( | |
| 63 | + value: string, | |
| 64 | + validators: typeof NAME_VALIDATORS, | |
| 65 | + messages: FoxValidationMessages | |
| 66 | +): string | undefined { | |
| 67 | + return foxValidationMessage( | |
| 68 | + foxValidators.compose(validators)(value.trim()), | |
| 69 | + messages | |
| 70 | + ); | |
| 71 | +} | |
| 72 | + | |
| 73 | +export function validateAdminMenu( | |
| 74 | + values: AdminMenuValues, | |
| 75 | + allowedRoleIds: string[] | |
| 76 | +): | |
| 77 | + | { ok: true; values: AdminMenuValues } | |
| 78 | + | { ok: false; errors: AdminMenuFormErrors } { | |
| 79 | + const errors: AdminMenuFormErrors = {}; | |
| 80 | + | |
| 81 | + const name = values.name.trim(); | |
| 82 | + const url = values.url.trim(); | |
| 83 | + const description = values.description.trim(); | |
| 84 | + | |
| 85 | + errors.name = check(name, NAME_VALIDATORS, ADMIN_MENU_NAME_MESSAGES); | |
| 86 | + errors.url = check(url, URL_VALIDATORS, ADMIN_MENU_URL_MESSAGES); | |
| 87 | + errors.description = check( | |
| 88 | + description, | |
| 89 | + DESCRIPTION_VALIDATORS, | |
| 90 | + ADMIN_MENU_DESCRIPTION_MESSAGES | |
| 91 | + ); | |
| 92 | + | |
| 93 | + if (!Number.isInteger(values.sortOrder) || values.sortOrder < 1) { | |
| 94 | + errors.sortOrder = '메뉴순번은 1 이상의 숫자로 입력해 주세요.'; | |
| 95 | + } | |
| 96 | + | |
| 97 | + if (!allowedRoleIds.includes(values.roleId)) { | |
| 98 | + errors.roleId = '메뉴권한을 선택해 주세요.'; | |
| 99 | + } | |
| 100 | + | |
| 101 | + for (const key of Object.keys(errors) as (keyof AdminMenuFormErrors)[]) { | |
| 102 | + if (errors[key] === undefined) { | |
| 103 | + delete errors[key]; | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + if (Object.keys(errors).length > 0) { | |
| 108 | + return { ok: false, errors }; | |
| 109 | + } | |
| 110 | + | |
| 111 | + return { ok: true, values: { ...values, name, url, description } }; | |
| 112 | +} |
lib/domain/admin-menu.ts (added)
+++ lib/domain/admin-menu.ts
... | ... | @@ -0,0 +1,91 @@ |
| 1 | +export type AdminMenu = { | |
| 2 | + id: string; | |
| 3 | + parentId: string | null; | |
| 4 | + name: string; | |
| 5 | + url: string; | |
| 6 | + description: string; | |
| 7 | + sortOrder: number; | |
| 8 | + roleId: string; | |
| 9 | + roleName: string; | |
| 10 | + isActive: boolean; | |
| 11 | + isTopActive: boolean; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export type AdminMenuNode = AdminMenu & { | |
| 15 | + depth: number; | |
| 16 | + children: AdminMenuNode[]; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +export const ADMIN_MENU_ROOT_PATH = '/admins/menus'; | |
| 20 | + | |
| 21 | +export function toYesNo(value: boolean): string { | |
| 22 | + return value ? 'Y' : 'N'; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export const ACTIVE_OPTIONS = [ | |
| 26 | + { value: 'true', label: '활성' }, | |
| 27 | + { value: 'false', label: '비활성' }, | |
| 28 | +] as const; | |
| 29 | + | |
| 30 | +export const NEW_WINDOW_OPTIONS = [ | |
| 31 | + { value: 'true', label: '예' }, | |
| 32 | + { value: 'false', label: '아니오' }, | |
| 33 | +] as const; | |
| 34 | + | |
| 35 | +export function formatActiveLabel(isActive: boolean): string { | |
| 36 | + return isActive ? '활성' : '비활성'; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** | |
| 40 | + * 백엔드가 준 순서(메뉴 등록순)를 유지한 채 `parentId`로만 중첩한다. | |
| 41 | + * 부모가 목록에 없는 행은 최상위로 올려 화면에서 사라지지 않게 한다. | |
| 42 | + */ | |
| 43 | +export function buildAdminMenuTree(menus: AdminMenu[]): AdminMenuNode[] { | |
| 44 | + const nodes = new Map<string, AdminMenuNode>(); | |
| 45 | + for (const menu of menus) { | |
| 46 | + nodes.set(menu.id, { ...menu, depth: 0, children: [] }); | |
| 47 | + } | |
| 48 | + | |
| 49 | + const roots: AdminMenuNode[] = []; | |
| 50 | + for (const menu of menus) { | |
| 51 | + const node = nodes.get(menu.id); | |
| 52 | + if (!node) continue; | |
| 53 | + | |
| 54 | + const parent = menu.parentId ? nodes.get(menu.parentId) : undefined; | |
| 55 | + if (parent && parent !== node) { | |
| 56 | + parent.children.push(node); | |
| 57 | + } else { | |
| 58 | + roots.push(node); | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + const assignDepth = (list: AdminMenuNode[], depth: number) => { | |
| 63 | + for (const node of list) { | |
| 64 | + node.depth = depth; | |
| 65 | + assignDepth(node.children, depth + 1); | |
| 66 | + } | |
| 67 | + }; | |
| 68 | + assignDepth(roots, 0); | |
| 69 | + | |
| 70 | + return roots; | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** 트리를 화면이 그릴 순서(부모 바로 뒤에 자식)로 편다. 접힌 가지는 건너뛴다. */ | |
| 74 | +export function flattenAdminMenuTree( | |
| 75 | + roots: AdminMenuNode[], | |
| 76 | + collapsedIds: ReadonlySet<string> | |
| 77 | +): AdminMenuNode[] { | |
| 78 | + const rows: AdminMenuNode[] = []; | |
| 79 | + | |
| 80 | + const walk = (nodes: AdminMenuNode[]) => { | |
| 81 | + for (const node of nodes) { | |
| 82 | + rows.push(node); | |
| 83 | + if (!collapsedIds.has(node.id)) { | |
| 84 | + walk(node.children); | |
| 85 | + } | |
| 86 | + } | |
| 87 | + }; | |
| 88 | + walk(roots); | |
| 89 | + | |
| 90 | + return rows; | |
| 91 | +} |
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?