임동욱 임동욱 08-19
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 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  createAdminMenu,
+  deleteAdminMenu,
+  fetchAdminMenus,
+  updateAdminMenu,
+} from '@/lib/data/repositories/admin-menu-repository';
+import { ADMIN_MENU_ROOT_PATH } from '@/lib/domain/admin-menu';
+import {
+  validateAdminMenu,
+  type AdminMenuFormState,
+  type AdminMenuValues,
+} from '@/lib/domain/admin-menu-form';
+import { BackendRequestError } from '@/lib/http/backend-fetch';
+
+const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
+const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+function readNumber(formData: FormData, key: string): number {
+  const trimmed = readString(formData, key).trim();
+  return trimmed === '' ? NaN : Number(trimmed);
+}
+
+function readValues(formData: FormData): AdminMenuValues {
+  return {
+    name: readString(formData, 'name'),
+    sortOrder: readNumber(formData, 'sortOrder'),
+    roleId: readString(formData, 'roleId'),
+    isTopActive: readString(formData, 'isTopActive') === 'true',
+    url: readString(formData, 'url'),
+    opensNewWindow: readString(formData, 'opensNewWindow') === 'true',
+    description: readString(formData, 'description'),
+    isActive: readString(formData, 'isActive') === 'true',
+  };
+}
+
+async function allowedRoleIds(): Promise<string[]> {
+  const menus = await fetchAdminMenus();
+  return [...new Set(menus.map((menu) => menu.roleId).filter(Boolean))];
+}
+
+function describeFailure(error: unknown, fallback: string): string {
+  return error instanceof BackendRequestError ? error.message : fallback;
+}
+
+export async function createAdminMenuAction(
+  _prevState: AdminMenuFormState,
+  formData: FormData
+): Promise<AdminMenuFormState> {
+  await verifySession();
+
+  const parentId = readString(formData, 'parentId').trim();
+  if (!parentId) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  const validation = validateAdminMenu(
+    readValues(formData),
+    await allowedRoleIds()
+  );
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await createAdminMenu(parentId, validation.values);
+  } catch (error) {
+    return { status: 'error', message: describeFailure(error, SAVE_FAILED_MESSAGE) };
+  }
+
+  revalidatePath(ADMIN_MENU_ROOT_PATH);
+  return { status: 'success' };
+}
+
+export async function updateAdminMenuAction(
+  _prevState: AdminMenuFormState,
+  formData: FormData
+): Promise<AdminMenuFormState> {
+  await verifySession();
+
+  const menuId = readString(formData, 'menuId').trim();
+  if (!menuId) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  const validation = validateAdminMenu(
+    readValues(formData),
+    await allowedRoleIds()
+  );
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    await updateAdminMenu(menuId, validation.values);
+  } catch (error) {
+    return { status: 'error', message: describeFailure(error, SAVE_FAILED_MESSAGE) };
+  }
+
+  revalidatePath(ADMIN_MENU_ROOT_PATH);
+  return { status: 'success' };
+}
+
+export async function deleteAdminMenuAction(
+  menuId: string
+): Promise<AdminMenuFormState> {
+  await verifySession();
+
+  if (!menuId) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    await deleteAdminMenu(menuId);
+  } catch (error) {
+    return { status: 'error', message: describeFailure(error, DELETE_FAILED_MESSAGE) };
+  }
+
+  revalidatePath(ADMIN_MENU_ROOT_PATH);
+  return { status: 'success' };
+}
 
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 @@
+@use "@fox/styles/abstracts" as fox;
+
+.name {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(2);
+}
+
+.toggle {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  inline-size: fox.icon(4);
+  block-size: fox.icon(4);
+  border: 0;
+  background: none;
+  color: fox.color(font-neutral-subtle);
+  cursor: pointer;
+}
+
+.togglePlaceholder {
+  flex-shrink: 0;
+  inline-size: fox.icon(4);
+}
+
+.state {
+  display: inline-flex;
+  inline-size: fox.icon(5);
+  block-size: fox.icon(5);
+}
+
+.active {
+  color: fox.color(font-system-success);
+}
+
+.inactive {
+  color: fox.color(font-system-danger);
+}
 
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 @@
+'use client';
+
+import { useState, useTransition } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import {
+  FoxCaretDownIcon,
+  FoxCaretRightIcon,
+  FoxCheckIcon,
+  FoxPencilSimpleIcon,
+  FoxPlusIcon,
+  FoxProhibitIcon,
+  FoxTrashIcon,
+} from '@fox/core/icons';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  flattenAdminMenuTree,
+  formatActiveLabel,
+  type AdminMenu,
+  type AdminMenuNode,
+} from '@/lib/domain/admin-menu';
+import { deleteAdminMenuAction } from '../_actions';
+import { AdminMenuModal, type AdminMenuRole } from './admin-menu-modal';
+import styles from './admin-menu-list.module.scss';
+
+interface AdminMenuListProps {
+  roots: AdminMenuNode[];
+  totalCount: number;
+  roles: AdminMenuRole[];
+}
+
+type ModalState =
+  | { mode: 'create'; parent: AdminMenu; nextSortOrder: number }
+  | { mode: 'edit'; menu: AdminMenuNode; parent: AdminMenu | null };
+
+function findParent(
+  roots: AdminMenuNode[],
+  parentId: string | null
+): AdminMenu | null {
+  if (!parentId) return null;
+
+  const stack = [...roots];
+  while (stack.length > 0) {
+    const node = stack.pop();
+    if (!node) continue;
+    if (node.id === parentId) return node;
+    stack.push(...node.children);
+  }
+  return null;
+}
+
+export function AdminMenuList({ roots, totalCount, roles }: AdminMenuListProps) {
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>(
+    new Set()
+  );
+  const [modal, setModal] = useState<ModalState | null>(null);
+  const [isDeleting, startDeleting] = useTransition();
+
+  const rows = flattenAdminMenuTree(roots, collapsedIds);
+
+  function toggle(id: string) {
+    setCollapsedIds((current) => {
+      const next = new Set(current);
+      if (next.has(id)) {
+        next.delete(id);
+      } else {
+        next.add(id);
+      }
+      return next;
+    });
+  }
+
+  function confirmDelete(menu: AdminMenuNode) {
+    showAlert({
+      variant: 'danger',
+      title: '메뉴를 삭제하시겠습니까?',
+      message: `${menu.name} 메뉴를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton
+            type="error"
+            size="md"
+            label="삭제"
+            onAction={() => {
+              hideAlert();
+              startDeleting(async () => {
+                const result = await deleteAdminMenuAction(menu.id);
+                showToast(
+                  result.status === 'error'
+                    ? {
+                        variant: 'danger',
+                        message: result.message ?? '삭제하지 못했습니다.',
+                      }
+                    : { variant: 'success', message: '메뉴를 삭제했습니다.' }
+                );
+              });
+            }}
+          />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  const columns: FoxListColumn<AdminMenuNode>[] = [
+    { key: 'id', header: '메뉴번호', width: 120 },
+    {
+      key: 'isActive',
+      header: '활성여부',
+      width: 120,
+      render: (row) => (
+        <span
+          className={`${styles.state} ${row.isActive ? styles.active : styles.inactive}`}
+          title={formatActiveLabel(row.isActive)}
+        >
+          {row.isActive ? <FoxCheckIcon /> : <FoxProhibitIcon />}
+        </span>
+      ),
+    },
+    {
+      key: 'name',
+      header: '메뉴명',
+      align: 'start',
+      render: (row) => (
+        <span
+          className={styles.name}
+          style={{ paddingInlineStart: `${row.depth * 1.6}rem` }}
+        >
+          {row.children.length > 0 ? (
+            <button
+              type="button"
+              className={styles.toggle}
+              aria-expanded={!collapsedIds.has(row.id)}
+              aria-label={`${row.name} 하위 메뉴 ${collapsedIds.has(row.id) ? '펼치기' : '접기'}`}
+              onClick={() => toggle(row.id)}
+            >
+              {collapsedIds.has(row.id) ? (
+                <FoxCaretRightIcon />
+              ) : (
+                <FoxCaretDownIcon />
+              )}
+            </button>
+          ) : (
+            <span className={styles.togglePlaceholder} aria-hidden="true" />
+          )}
+          {row.name}
+        </span>
+      ),
+    },
+    { key: 'sortOrder', header: '메뉴순번', width: 120 },
+    {
+      key: 'roleName',
+      header: '권한',
+      width: 160,
+      render: (row) => row.roleName || row.roleId || '-',
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 160,
+      render: (row) => (
+        <FoxButtonGroup size="sm" align="center">
+          <FoxIconButton
+            type="default"
+            size="sm"
+            icon={<FoxPencilSimpleIcon />}
+            label="수정"
+            onAction={() =>
+              setModal({
+                mode: 'edit',
+                menu: row,
+                parent: findParent(roots, row.parentId),
+              })
+            }
+          />
+          <FoxIconButton
+            type="default"
+            size="sm"
+            icon={<FoxPlusIcon />}
+            label="하위 메뉴 추가"
+            onAction={() =>
+              setModal({
+                mode: 'create',
+                parent: row,
+                nextSortOrder: row.children.length + 1,
+              })
+            }
+          />
+          <FoxIconButton
+            type="default"
+            size="sm"
+            icon={<FoxTrashIcon />}
+            label={isDeleting ? '삭제 중' : '삭제'}
+            disabled={isDeleting}
+            onAction={() => confirmDelete(row)}
+          />
+        </FoxButtonGroup>
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<AdminMenuNode>
+        title="메뉴관리"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '관리자정보관리' },
+          { label: '메뉴관리' },
+        ]}
+        caption="메뉴 목록"
+        columns={columns}
+        rows={rows}
+        rowKey={(row) => row.id}
+        totalCount={totalCount}
+        emptyMessage="등록된 메뉴가 없습니다."
+        searchHidden
+      />
+
+      {modal && (
+        <AdminMenuModal
+          parent={modal.parent}
+          menu={modal.mode === 'edit' ? modal.menu : undefined}
+          roles={roles}
+          nextSortOrder={modal.mode === 'create' ? modal.nextSortOrder : 1}
+          onClose={() => setModal(null)}
+        />
+      )}
+    </>
+  );
+}
 
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 @@
+@use "@fox/styles/abstracts" as fox;
+
+.fields {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(6);
+  inline-size: 100%;
+  min-inline-size: 0;
+}
 
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 @@
+'use client';
+
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  ACTIVE_OPTIONS,
+  NEW_WINDOW_OPTIONS,
+  type AdminMenu,
+  type AdminMenuNode,
+} from '@/lib/domain/admin-menu';
+import {
+  ADMIN_MENU_DESCRIPTION_MESSAGES,
+  ADMIN_MENU_NAME_MESSAGES,
+  ADMIN_MENU_URL_MESSAGES,
+  DESCRIPTION_VALIDATORS,
+  INITIAL_ADMIN_MENU_FORM_STATE,
+  NAME_VALIDATORS,
+  URL_VALIDATORS,
+} from '@/lib/domain/admin-menu-form';
+import { createAdminMenuAction, updateAdminMenuAction } from '../_actions';
+import styles from './admin-menu-modal.module.scss';
+
+export type AdminMenuRole = { id: string; name: string };
+
+interface AdminMenuModalProps {
+  /** 등록이면 상위가 될 메뉴, 수정이면 대상의 상위. 최상위 메뉴 수정 시에는 없다. */
+  parent: AdminMenu | null;
+  /** 없으면 등록 팝업이다. */
+  menu?: AdminMenuNode;
+  roles: AdminMenuRole[];
+  nextSortOrder: number;
+  onClose: () => void;
+}
+
+export function AdminMenuModal({
+  parent,
+  menu,
+  roles,
+  nextSortOrder,
+  onClose,
+}: AdminMenuModalProps) {
+  const isEdit = menu !== undefined;
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    isEdit ? updateAdminMenuAction : createAdminMenuAction,
+    INITIAL_ADMIN_MENU_FORM_STATE
+  );
+
+  const [roleId, setRoleId] = useState(menu?.roleId ?? roles[0]?.id ?? '');
+  const [isTopActive, setIsTopActive] = useState(menu?.isTopActive ?? false);
+  const [opensNewWindow, setOpensNewWindow] = useState(true);
+  const [isActive, setIsActive] = useState(menu?.isActive ?? true);
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({
+        variant: 'success',
+        message: isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.',
+      });
+      onClose();
+    }
+  }, [state, showToast, onClose, isEdit]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <FoxModal
+      open
+      size="sm"
+      title={isEdit ? '수정' : '신규 등록'}
+      onClose={onClose}
+      actions={
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isEdit ? '수정' : '저장'}
+            loading={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </>
+      }
+    >
+      <form
+        ref={formRef}
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.fields}
+      >
+        {isEdit ? (
+          <input type="hidden" name="menuId" value={menu.id} />
+        ) : (
+          <input type="hidden" name="parentId" value={parent?.id ?? ''} />
+        )}
+
+        {parent && (
+          <FoxInput
+            size="md"
+            label="상위메뉴명"
+            requirement="required"
+            value={`[${parent.id}] ${parent.name}`}
+            readOnly
+          />
+        )}
+
+        <FoxInput
+          size="md"
+          name="name"
+          label="메뉴명"
+          requirement="required"
+          placeholder="메뉴명을 입력하세요."
+          defaultValue={menu?.name ?? ''}
+          invalid={Boolean(errors.name)}
+          message={errors.name}
+          validators={NAME_VALIDATORS}
+          validationMessages={ADMIN_MENU_NAME_MESSAGES}
+        />
+
+        <FoxInput
+          size="md"
+          type="number"
+          name="sortOrder"
+          label="메뉴순번"
+          requirement="required"
+          min={1}
+          step={1}
+          defaultValue={menu?.sortOrder ?? nextSortOrder}
+          invalid={Boolean(errors.sortOrder)}
+          message={errors.sortOrder}
+        />
+
+        <div>
+          <FoxSelect
+            size="md"
+            label="메뉴권한"
+            requirement="required"
+            options={roles.map((role) => ({ value: role.id, label: role.name }))}
+            placeholder="메뉴권한을 선택하세요."
+            value={roleId}
+            onValueChange={setRoleId}
+            error={Boolean(errors.roleId)}
+            hint={errors.roleId}
+          />
+          <input type="hidden" name="roleId" value={roleId} />
+        </div>
+
+        <div>
+          <FoxSelect
+            size="md"
+            label="최상위권한활성"
+            requirement="required"
+            options={ACTIVE_OPTIONS.map((option) => ({ ...option }))}
+            value={String(isTopActive)}
+            onValueChange={(value) => setIsTopActive(value === 'true')}
+          />
+          <input type="hidden" name="isTopActive" value={String(isTopActive)} />
+        </div>
+
+        <FoxInput
+          size="md"
+          name="url"
+          label="메뉴링크"
+          requirement="required"
+          placeholder="메뉴링크를 입력하세요."
+          defaultValue={menu?.url ?? ''}
+          invalid={Boolean(errors.url)}
+          message={errors.url}
+          validators={URL_VALIDATORS}
+          validationMessages={ADMIN_MENU_URL_MESSAGES}
+        />
+
+        {/* 이슈: 백엔드에 대응 컬럼이 없어 저장되지 않는다(기획대로 화면에만 둔다). */}
+        <div>
+          <FoxSelect
+            size="md"
+            label="새창여부"
+            requirement="required"
+            options={NEW_WINDOW_OPTIONS.map((option) => ({ ...option }))}
+            value={String(opensNewWindow)}
+            onValueChange={(value) => setOpensNewWindow(value === 'true')}
+          />
+          <input
+            type="hidden"
+            name="opensNewWindow"
+            value={String(opensNewWindow)}
+          />
+        </div>
+
+        <FoxInput
+          size="md"
+          name="description"
+          label="메뉴설명"
+          requirement="required"
+          placeholder="메뉴설명을 입력하세요."
+          defaultValue={menu?.description ?? ''}
+          invalid={Boolean(errors.description)}
+          message={errors.description}
+          validators={DESCRIPTION_VALIDATORS}
+          validationMessages={ADMIN_MENU_DESCRIPTION_MESSAGES}
+        />
+
+        <div>
+          <FoxSelect
+            size="md"
+            label="활성여부"
+            requirement="required"
+            options={ACTIVE_OPTIONS.map((option) => ({ ...option }))}
+            value={String(isActive)}
+            onValueChange={(value) => setIsActive(value === 'true')}
+          />
+          <input type="hidden" name="isActive" value={String(isActive)} />
+        </div>
+
+        {state.status === 'error' && state.message && (
+          <p role="alert">{state.message}</p>
+        )}
+      </form>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/admins/menus/page.tsx (added)
+++ app/(protected)/(basic)/admins/menus/page.tsx
@@ -0,0 +1,32 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchAdminMenus } from '@/lib/data/repositories/admin-menu-repository';
+import { buildAdminMenuTree } from '@/lib/domain/admin-menu';
+import { AdminMenuList } from './_components/admin-menu-list';
+
+export const metadata: Metadata = {
+  title: '메뉴관리',
+};
+
+export const dynamic = 'force-dynamic';
+
+export default async function Page() {
+  await verifySession();
+
+  const menus = await fetchAdminMenus();
+
+  // 이슈: 역할 목록 조회 API가 없어 메뉴에 쓰인 역할을 모아 선택지로 쓴다.
+  const roles = [...new Map(
+    menus
+      .filter((menu) => menu.roleId)
+      .map((menu) => [menu.roleId, { id: menu.roleId, name: menu.roleName || menu.roleId }])
+  ).values()];
+
+  return (
+    <AdminMenuList
+      roots={buildAdminMenuTree(menus)}
+      totalCount={menus.length}
+      roles={roles}
+    />
+  );
+}
 
lib/data/repositories/admin-menu-repository.ts (added)
+++ lib/data/repositories/admin-menu-repository.ts
@@ -0,0 +1,161 @@
+import 'server-only';
+import { cache } from 'react';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import type { AdminMenu } from '@/lib/domain/admin-menu';
+import { toYesNo } from '@/lib/domain/admin-menu';
+import type { AdminMenuValues } from '@/lib/domain/admin-menu-form';
+
+/*
+ * GET    /api/v1/mngr/menu/pagination   목록 (TB_SYS_MENU)
+ * POST   /api/v1/mngr/menu              등록 — 어노테이션 없음 → form
+ * PUT    /api/v1/mngr/menu/{menuId}     수정 — @RequestBody(JSON)
+ * DELETE /api/v1/mngr/menu/{menuId}     삭제
+ *
+ * 이슈(백엔드, 보고함):
+ * 1. `DELETE`의 `menuId`에 `@PathVariable`이 없어 경로값이 바인딩되지 않는다 → 항상 실패한다.
+ * 2. 등록의 menuId 채번이 `MAX(menu_id) WHERE UP_MENU_ID = ?`라 상위별 최대값이다 → 자식이 없는
+ *    메뉴에 처음 추가하면 이미 존재하는 번호가 나와 PK 충돌로 실패한다.
+ * 3. `GET /{menuId}`는 경로변수 이름이 `userId`로 어긋나 있고 빈 VO로 조회한다 → 쓰지 않는다.
+ *    수정 팝업의 초기값은 목록 응답에서 넘겨 채운다.
+ * 4. `totalCount`가 현재 페이지 행 수다(다른 목록과 같은 PaginationUtil 결함).
+ * 5. 새창여부에 해당하는 컬럼이 없다 — 화면은 기획대로 그리되 저장되지 않는다.
+ */
+
+const ADMIN_MENU_PATH = '/api/v1/mngr/menu';
+
+// 기획이 "페이징 없이 전체 노출"이라 한 장으로 받는다.
+const FETCH_LIMIT = 10_000;
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string {
+  const value = source[key];
+  return typeof value === 'string' ? value : '';
+}
+
+function readNumber(source: Record<string, unknown>, key: string): number {
+  const value = source[key];
+  return typeof value === 'number' ? value : 0;
+}
+
+function toAdminMenu(raw: unknown): AdminMenu {
+  if (!isRecord(raw)) {
+    throw new Error('메뉴 응답 항목의 형식이 올바르지 않습니다.');
+  }
+
+  const id = readString(raw, 'menuId');
+  if (!id) {
+    throw new Error('메뉴 응답에 menuId가 없습니다.');
+  }
+
+  const parentId = readString(raw, 'upMenuId');
+
+  return {
+    id,
+    parentId: parentId || null,
+    name: readString(raw, 'menuName'),
+    url: readString(raw, 'menuUrl'),
+    description: readString(raw, 'menuExplan'),
+    sortOrder: readNumber(raw, 'sortOrdr'),
+    roleId: readString(raw, 'roleId'),
+    roleName: readString(raw, 'roleNm'),
+    isActive: readString(raw, 'useYn') !== 'N',
+    isTopActive: readString(raw, 'topYn') === 'Y',
+  };
+}
+
+export const fetchAdminMenus = cache(async (): Promise<AdminMenu[]> => {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${ADMIN_MENU_PATH}/pagination`, {
+    method: 'GET',
+    query: { pageIndex: 1, recordCountPerPage: FETCH_LIMIT },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  const data = result.data;
+  if (!isRecord(data) || !Array.isArray(data.list)) {
+    throw new Error('메뉴 목록 응답의 형식이 올바르지 않습니다.');
+  }
+
+  return data.list.map(toAdminMenu);
+});
+
+function buildPayload(values: AdminMenuValues) {
+  return {
+    menuName: values.name,
+    menuUrl: values.url,
+    menuExplan: values.description,
+    sortOrdr: values.sortOrder,
+    roleId: values.roleId,
+    useYn: toYesNo(values.isActive),
+    topYn: toYesNo(values.isTopActive),
+  };
+}
+
+export async function createAdminMenu(
+  parentId: string,
+  values: AdminMenuValues
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<null>(ADMIN_MENU_PATH, {
+    method: 'POST',
+    form: { ...buildPayload(values), upMenuId: parentId },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export async function updateAdminMenu(
+  menuId: string,
+  values: AdminMenuValues
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<null>(
+    `${ADMIN_MENU_PATH}/${encodeURIComponent(menuId)}`,
+    {
+      method: 'PUT',
+      body: buildPayload(values),
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export async function deleteAdminMenu(menuId: string): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<null>(
+    `${ADMIN_MENU_PATH}/${encodeURIComponent(menuId)}`,
+    {
+      method: 'DELETE',
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
 
lib/domain/admin-menu-form.ts (added)
+++ lib/domain/admin-menu-form.ts
@@ -0,0 +1,112 @@
+import {
+  foxValidationMessage,
+  foxValidators,
+  type FoxValidationMessages,
+} from '@fox/core/validation';
+
+export type AdminMenuValues = {
+  name: string;
+  sortOrder: number;
+  roleId: string;
+  isTopActive: boolean;
+  url: string;
+  /** 이슈: 백엔드에 대응 컬럼이 없어 저장되지 않는다. 기획대로 화면에만 둔다. */
+  opensNewWindow: boolean;
+  description: string;
+  isActive: boolean;
+};
+
+export type AdminMenuFormErrors = Partial<Record<keyof AdminMenuValues, string>>;
+
+export type AdminMenuFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: AdminMenuFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_ADMIN_MENU_FORM_STATE: AdminMenuFormState = {
+  status: 'idle',
+};
+
+const NAME_MAX_LENGTH = 100;
+const URL_MAX_LENGTH = 300;
+const DESCRIPTION_MAX_LENGTH = 500;
+
+const NAME_VALIDATORS = [
+  foxValidators.required,
+  foxValidators.maxLength(NAME_MAX_LENGTH),
+];
+const URL_VALIDATORS = [
+  foxValidators.required,
+  foxValidators.maxLength(URL_MAX_LENGTH),
+];
+const DESCRIPTION_VALIDATORS = [
+  foxValidators.required,
+  foxValidators.maxLength(DESCRIPTION_MAX_LENGTH),
+];
+
+export const ADMIN_MENU_NAME_MESSAGES: FoxValidationMessages = {
+  required: '메뉴명을 입력해 주세요.',
+  maxlength: `메뉴명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`,
+};
+export const ADMIN_MENU_URL_MESSAGES: FoxValidationMessages = {
+  required: '메뉴링크를 입력해 주세요.',
+  maxlength: `메뉴링크는 ${URL_MAX_LENGTH}자 이내로 입력해 주세요.`,
+};
+export const ADMIN_MENU_DESCRIPTION_MESSAGES: FoxValidationMessages = {
+  required: '메뉴설명을 입력해 주세요.',
+  maxlength: `메뉴설명은 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`,
+};
+
+export { NAME_VALIDATORS, URL_VALIDATORS, DESCRIPTION_VALIDATORS };
+
+function check(
+  value: string,
+  validators: typeof NAME_VALIDATORS,
+  messages: FoxValidationMessages
+): string | undefined {
+  return foxValidationMessage(
+    foxValidators.compose(validators)(value.trim()),
+    messages
+  );
+}
+
+export function validateAdminMenu(
+  values: AdminMenuValues,
+  allowedRoleIds: string[]
+):
+  | { ok: true; values: AdminMenuValues }
+  | { ok: false; errors: AdminMenuFormErrors } {
+  const errors: AdminMenuFormErrors = {};
+
+  const name = values.name.trim();
+  const url = values.url.trim();
+  const description = values.description.trim();
+
+  errors.name = check(name, NAME_VALIDATORS, ADMIN_MENU_NAME_MESSAGES);
+  errors.url = check(url, URL_VALIDATORS, ADMIN_MENU_URL_MESSAGES);
+  errors.description = check(
+    description,
+    DESCRIPTION_VALIDATORS,
+    ADMIN_MENU_DESCRIPTION_MESSAGES
+  );
+
+  if (!Number.isInteger(values.sortOrder) || values.sortOrder < 1) {
+    errors.sortOrder = '메뉴순번은 1 이상의 숫자로 입력해 주세요.';
+  }
+
+  if (!allowedRoleIds.includes(values.roleId)) {
+    errors.roleId = '메뉴권한을 선택해 주세요.';
+  }
+
+  for (const key of Object.keys(errors) as (keyof AdminMenuFormErrors)[]) {
+    if (errors[key] === undefined) {
+      delete errors[key];
+    }
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return { ok: true, values: { ...values, name, url, description } };
+}
 
lib/domain/admin-menu.ts (added)
+++ lib/domain/admin-menu.ts
@@ -0,0 +1,91 @@
+export type AdminMenu = {
+  id: string;
+  parentId: string | null;
+  name: string;
+  url: string;
+  description: string;
+  sortOrder: number;
+  roleId: string;
+  roleName: string;
+  isActive: boolean;
+  isTopActive: boolean;
+};
+
+export type AdminMenuNode = AdminMenu & {
+  depth: number;
+  children: AdminMenuNode[];
+};
+
+export const ADMIN_MENU_ROOT_PATH = '/admins/menus';
+
+export function toYesNo(value: boolean): string {
+  return value ? 'Y' : 'N';
+}
+
+export const ACTIVE_OPTIONS = [
+  { value: 'true', label: '활성' },
+  { value: 'false', label: '비활성' },
+] as const;
+
+export const NEW_WINDOW_OPTIONS = [
+  { value: 'true', label: '예' },
+  { value: 'false', label: '아니오' },
+] as const;
+
+export function formatActiveLabel(isActive: boolean): string {
+  return isActive ? '활성' : '비활성';
+}
+
+/**
+ * 백엔드가 준 순서(메뉴 등록순)를 유지한 채 `parentId`로만 중첩한다.
+ * 부모가 목록에 없는 행은 최상위로 올려 화면에서 사라지지 않게 한다.
+ */
+export function buildAdminMenuTree(menus: AdminMenu[]): AdminMenuNode[] {
+  const nodes = new Map<string, AdminMenuNode>();
+  for (const menu of menus) {
+    nodes.set(menu.id, { ...menu, depth: 0, children: [] });
+  }
+
+  const roots: AdminMenuNode[] = [];
+  for (const menu of menus) {
+    const node = nodes.get(menu.id);
+    if (!node) continue;
+
+    const parent = menu.parentId ? nodes.get(menu.parentId) : undefined;
+    if (parent && parent !== node) {
+      parent.children.push(node);
+    } else {
+      roots.push(node);
+    }
+  }
+
+  const assignDepth = (list: AdminMenuNode[], depth: number) => {
+    for (const node of list) {
+      node.depth = depth;
+      assignDepth(node.children, depth + 1);
+    }
+  };
+  assignDepth(roots, 0);
+
+  return roots;
+}
+
+/** 트리를 화면이 그릴 순서(부모 바로 뒤에 자식)로 편다. 접힌 가지는 건너뛴다. */
+export function flattenAdminMenuTree(
+  roots: AdminMenuNode[],
+  collapsedIds: ReadonlySet<string>
+): AdminMenuNode[] {
+  const rows: AdminMenuNode[] = [];
+
+  const walk = (nodes: AdminMenuNode[]) => {
+    for (const node of nodes) {
+      rows.push(node);
+      if (!collapsedIds.has(node.id)) {
+        walk(node.children);
+      }
+    }
+  };
+  walk(roots);
+
+  return rows;
+}
Add a comment
List