임동욱 임동욱 08-20
feat: 시스템관리 메뉴관리·금칙어관리 — 백엔드 연동
기획 SYS_MNU_001/002_p/003_p, SYS_WRD_001/002. 디자인 시안은 메뉴관리 목록
5432:16942, 메뉴 수정 5431:12392.

메뉴관리는 트리다. 기획대로 페이징 없이 전부 받아 upMenuId로 세우고, 최상위 신규
등록은 두지 않았다(추가는 각 행의 + 로 하위에만). 아이콘은 MODULE_MENU_ICON에
올려 받은 파일 아이디를 저장하고, 지울 때는 빈 문자열을 보낸다 — UPDATE가 null만
건너뛰어 이래야 컬럼이 비워진다.

금칙어는 목록·등록(별도 화면)·삭제·엑셀 일괄등록까지 연결했다. 중복은 백엔드가
판정하고 화면은 기획 문구로 바꿔 보여준다.

백엔드 결함 세 가지를 확인해 우회했다(별도 보고).
- menu/benWord DELETE에 @PathVariable이 없어 경로와 쿼리에 같은 값을 함께 보낸다.
- benWord 목록 정렬이 이중 역순이라 오래된 것이 먼저 온다 — 한 번에 받아 최신순으로
  다시 세운다.
- TB_SYS_MENU에 새창여부 컬럼이 없다. 사용자 확정에 따라 칸은 두고 값은 보내지 않는다.

Co-Authored-By: Claude Opus 5 
@fa3bd2d7a7ecdbe838411cfd23f3cd126f4a3e98
 
app/(protected)/(basic)/system/banned-words/_actions.ts (added)
+++ app/(protected)/(basic)/system/banned-words/_actions.ts
@@ -0,0 +1,93 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { redirect } from 'next/navigation';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  createBannedWord,
+  deleteBannedWord,
+  uploadBannedWordExcel,
+} from '@/lib/data/repositories/banned-word-repository';
+import {
+  DUPLICATE_WORD_MESSAGE,
+  validateBannedWord,
+  type BannedWordFormState,
+  type ExcelUploadResult,
+} from '@/lib/domain/banned-word-form';
+import { BANNED_WORDS_PATH } from '@/lib/domain/banned-word-query';
+
+const SAVE_FAILED_MESSAGE = '등록하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const UPLOAD_FAILED_MESSAGE =
+  '엑셀 파일을 등록하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+/** 기획 SYS_WRD_002 ② — 등록이 끝나면 목록으로 돌아간다. */
+export async function createBannedWordAction(
+  _prevState: BannedWordFormState,
+  formData: FormData
+): Promise<BannedWordFormState> {
+  await verifySession();
+
+  const validation = validateBannedWord(readString(formData, 'word'));
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  let result;
+  try {
+    result = await createBannedWord(validation.value);
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  if (result.status === 'duplicate') {
+    return { status: 'error', errors: { word: DUPLICATE_WORD_MESSAGE } };
+  }
+
+  revalidatePath(BANNED_WORDS_PATH);
+  redirect(BANNED_WORDS_PATH);
+}
+
+export async function deleteBannedWordAction(
+  id: number
+): Promise<{ status: 'success' } | { status: 'error'; message: string }> {
+  await verifySession();
+
+  try {
+    await deleteBannedWord(id);
+  } catch {
+    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(BANNED_WORDS_PATH);
+  return { status: 'success' };
+}
+
+export async function uploadBannedWordExcelAction(
+  formData: FormData
+): Promise<
+  | { status: 'success'; result: ExcelUploadResult }
+  | { status: 'error'; message: string }
+> {
+  await verifySession();
+
+  const file = formData.get('file');
+  if (!(file instanceof File) || file.size === 0) {
+    return { status: 'error', message: '엑셀 파일을 첨부해 주세요.' };
+  }
+
+  let result: ExcelUploadResult;
+  try {
+    result = await uploadBannedWordExcel(file);
+  } catch {
+    return { status: 'error', message: UPLOAD_FAILED_MESSAGE };
+  }
+
+  revalidatePath(BANNED_WORDS_PATH);
+  return { status: 'success', result };
+}
 
app/(protected)/(basic)/system/banned-words/_components/banned-word-excel-modal.tsx (added)
+++ app/(protected)/(basic)/system/banned-words/_components/banned-word-excel-modal.tsx
@@ -0,0 +1,110 @@
+'use client';
+
+import { useRef, useState, useTransition } from 'react';
+import { FoxAlert } from '@fox/core/components/fox-alert';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxLinkButton } from '@fox/core/components/fox-link-button';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import { BANNED_WORD_EXCEL_TEMPLATE_PATH } from '@/lib/domain/banned-word-query';
+import { uploadBannedWordExcelAction } from '../_actions';
+
+interface BannedWordExcelModalProps {
+  onClose: () => void;
+}
+
+export function BannedWordExcelModal({ onClose }: BannedWordExcelModalProps) {
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [errors, setErrors] = useState<string[]>([]);
+  const [isPending, startUpload] = useTransition();
+
+  function submit() {
+    const form = formRef.current;
+    if (!form) {
+      return;
+    }
+
+    setErrors([]);
+    startUpload(async () => {
+      const result = await uploadBannedWordExcelAction(new FormData(form));
+
+      if (result.status === 'error') {
+        showToast({ variant: 'danger', message: result.message });
+        return;
+      }
+
+      if (result.result.errors.length > 0) {
+        setErrors(result.result.errors);
+        return;
+      }
+
+      showToast({
+        variant: 'success',
+        message: `금칙어 ${result.result.successCount}건을 등록했습니다.`,
+      });
+      onClose();
+    });
+  }
+
+  return (
+    <FoxModal
+      open
+      size="sm"
+      title="엑셀등록"
+      onClose={onClose}
+      actions={
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '등록 중...' : '등록'}
+            disabled={isPending}
+            onAction={submit}
+          />
+        </>
+      }
+    >
+      <form ref={formRef}>
+        <FoxAlert
+          type="information"
+          message="양식을 내려받아 금칙어명을 채운 뒤 올려 주세요. 한 줄이라도 형식이 맞지 않으면 전체가 등록되지 않습니다."
+        />
+
+        <FoxLinkButton
+          type="secondary"
+          size="sm"
+          href={BANNED_WORD_EXCEL_TEMPLATE_PATH}
+          label="업로드 양식 내려받기"
+        />
+
+        <FoxFileUpload
+          mode="default"
+          name="file"
+          label="엑셀 파일"
+          requirement="required"
+          selectLabel="파일선택"
+          placeholder="파일을 선택해 주세요."
+          accept=".xlsx,.xls"
+        />
+
+        {errors.length > 0 && (
+          <FoxAlert
+            type="danger"
+            urgent
+            title="엑셀 검증에 실패해 등록되지 않았습니다."
+            message={
+              <ul>
+                {errors.map((error) => (
+                  <li key={error}>{error}</li>
+                ))}
+              </ul>
+            }
+          />
+        )}
+      </form>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/system/banned-words/_components/banned-word-list.tsx (added)
+++ app/(protected)/(basic)/system/banned-words/_components/banned-word-list.tsx
@@ -0,0 +1,183 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+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 { FoxSelectText } from '@fox/core/components/fox-select-text';
+import {
+  FoxPlusIcon,
+  FoxTrashIcon,
+  FoxUploadSimpleIcon,
+} from '@fox/core/icons';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import { formatOptionalValue, type BannedWord } from '@/lib/domain/banned-word';
+import {
+  BANNED_WORD_NEW_PATH,
+  BANNED_WORD_PAGE_SIZE_OPTIONS,
+  buildBannedWordHref,
+  type BannedWordPageSize,
+  type BannedWordQuery,
+} from '@/lib/domain/banned-word-query';
+import { deleteBannedWordAction } from '../_actions';
+import { BannedWordExcelModal } from './banned-word-excel-modal';
+
+interface BannedWordListProps {
+  items: BannedWord[];
+  query: BannedWordQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+export function BannedWordList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: BannedWordListProps) {
+  const router = useRouter();
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isExcelOpen, setIsExcelOpen] = useState(false);
+  const [isDeleting, startDeleting] = useTransition();
+
+  function go(patch: Partial<BannedWordQuery>) {
+    router.replace(buildBannedWordHref(query, { ...patch, page: 1 }));
+  }
+
+  function runDelete(word: BannedWord) {
+    hideAlert();
+    startDeleting(async () => {
+      const result = await deleteBannedWordAction(word.id);
+      if (result.status === 'error') {
+        showToast({ variant: 'danger', message: result.message });
+        return;
+      }
+      showToast({ variant: 'success', message: '금칙어를 삭제했습니다.' });
+    });
+  }
+
+  function confirmDelete(word: BannedWord) {
+    showAlert({
+      variant: 'danger',
+      title: '금칙어를 삭제하시겠습니까?',
+      message: `"${word.word}"를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label="삭제"
+            onAction={() => runDelete(word)}
+          />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  const columns: FoxListColumn<BannedWord>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 100,
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    { key: 'word', header: '금지 키워드', width: 400, emphasis: true },
+    {
+      key: 'createdAt',
+      header: '등록일',
+      width: 200,
+      render: (row) => formatOptionalValue(row.createdAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 100,
+      render: (row) => (
+        <FoxIconButton
+          type="default"
+          size="sm"
+          icon={<FoxTrashIcon />}
+          label={isDeleting ? '삭제 중' : '삭제'}
+          disabled={isDeleting}
+          onAction={() => confirmDelete(row)}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<BannedWord>
+        title="금칙어관리"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '시스템관리' },
+          { label: '금칙어관리' },
+        ]}
+        caption="금칙어 목록"
+        columns={columns}
+        rows={items}
+        rowKey={(row) => String(row.id)}
+        totalCount={totalCount}
+        emptyMessage="검색결과가 없습니다."
+        sorts={
+          <FoxSelectText
+            size="sm"
+            ariaLabel="페이지 크기"
+            options={BANNED_WORD_PAGE_SIZE_OPTIONS.map((size) => ({
+              value: String(size),
+              label: `${size}개씩 보기`,
+            }))}
+            value={String(query.pageSize)}
+            onValueChange={(value) =>
+              go({ pageSize: Number(value) as BannedWordPageSize })
+            }
+          />
+        }
+        searchFields={[]}
+        keyword={query.keyword}
+        searchPlaceholder="금칙어를 입력하세요."
+        toolbarActions={
+          <>
+            <FoxButton
+              type="secondary"
+              size="md"
+              leadingIcon={<FoxUploadSimpleIcon />}
+              label="엑셀등록"
+              onAction={() => setIsExcelOpen(true)}
+            />
+            <FoxButton
+              type="primary"
+              size="md"
+              leadingIcon={<FoxPlusIcon />}
+              label="등록"
+              onAction={() => router.push(BANNED_WORD_NEW_PATH)}
+            />
+          </>
+        }
+        page={currentPage}
+        totalPages={totalPages}
+        buildHref={(patch) =>
+          buildBannedWordHref(query, { page: Number(patch.page ?? 1) })
+        }
+        onQueryChange={(patch) => {
+          if (patch.keyword !== undefined) {
+            go({ keyword: String(patch.keyword ?? '') });
+          }
+        }}
+      />
+
+      {isExcelOpen && (
+        <BannedWordExcelModal onClose={() => setIsExcelOpen(false)} />
+      )}
+    </>
+  );
+}
 
app/(protected)/(basic)/system/banned-words/excel-template/route.ts (added)
+++ app/(protected)/(basic)/system/banned-words/excel-template/route.ts
@@ -0,0 +1,36 @@
+import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
+import { backendFetchStream } from '@/lib/http/backend-fetch';
+
+const TEMPLATE_PATH = '/api/v1/mngr/benWord/excel/template';
+const DOWNLOAD_FAILED_MESSAGE =
+  '양식을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const XLSX_CONTENT_TYPE =
+  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+export async function GET() {
+  await verifySession();
+
+  const accessToken = await getSessionAccessToken();
+  const result = await backendFetchStream(TEMPLATE_PATH, {
+    accessToken: accessToken ?? undefined,
+  });
+
+  if (!result.ok) {
+    return new Response(DOWNLOAD_FAILED_MESSAGE, {
+      status: 502,
+      headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+    });
+  }
+
+  const upstream = result.data;
+
+  return new Response(upstream.body, {
+    status: 200,
+    headers: {
+      'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE,
+      'Content-Disposition':
+        upstream.headers.get('content-disposition') ??
+        'attachment; filename="banned-words-template.xlsx"',
+    },
+  });
+}
 
app/(protected)/(basic)/system/banned-words/new/_components/banned-word-form.module.scss (added)
+++ app/(protected)/(basic)/system/banned-words/new/_components/banned-word-form.module.scss
@@ -0,0 +1,14 @@
+@use "@fox/styles/abstracts" as fox;
+
+.page {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(6);
+}
+
+.actions {
+  display: flex;
+  justify-content: center;
+  gap: fox.gap(3);
+  padding-block-start: fox.spacing(top-md);
+}
 
app/(protected)/(basic)/system/banned-words/new/_components/banned-word-form.tsx (added)
+++ app/(protected)/(basic)/system/banned-words/new/_components/banned-word-form.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useActionState, useRef } from 'react';
+import { FoxAlert } from '@fox/core/components/fox-alert';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxPageHeader } from '@fox/core/components/fox-page-header';
+import { FoxTable } from '@fox/core/components/fox-table';
+import { FoxTableCell } from '@fox/core/components/fox-table-cell';
+import { FoxTableRow } from '@fox/core/components/fox-table-row';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import {
+  INITIAL_BANNED_WORD_FORM_STATE,
+  WORD_MAX_LENGTH,
+} from '@/lib/domain/banned-word-form';
+import { BANNED_WORDS_PATH } from '@/lib/domain/banned-word-query';
+import { createBannedWordAction } from '../../_actions';
+import styles from './banned-word-form.module.scss';
+
+export function BannedWordForm() {
+  const router = useRouter();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    createBannedWordAction,
+    INITIAL_BANNED_WORD_FORM_STATE
+  );
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  return (
+    <div className={styles.page}>
+      <FoxPageHeader
+        title="금칙어 등록"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '시스템관리' },
+          { label: '금칙어관리', href: BANNED_WORDS_PATH },
+          { label: '금칙어 등록' },
+        ]}
+      />
+
+      <FoxAlert
+        type="information"
+        message="키워드를 등록한 이후부터 해당 키워드를 사용하는 경우 바로 적용됩니다."
+      />
+
+      <form
+        ref={formRef}
+        onSubmit={(event) => submitFormAction(event, formAction)}
+      >
+        <FoxTable type="form" caption="금칙어 등록">
+          <FoxTableRow>
+            <FoxTableCell head width={200}>
+              키워드 *
+            </FoxTableCell>
+            <FoxTableCell>
+              <FoxInput
+                size="md"
+                name="word"
+                aria-label="키워드"
+                placeholder="키워드를 입력하세요."
+                maxLength={WORD_MAX_LENGTH}
+                invalid={Boolean(errors.word)}
+                message={errors.word}
+              />
+            </FoxTableCell>
+          </FoxTableRow>
+        </FoxTable>
+
+        <div className={styles.actions}>
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '등록 중...' : '등록'}
+            disabled={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+          <FoxButton
+            type="default"
+            size="md"
+            label="목록"
+            onAction={() => router.push(BANNED_WORDS_PATH)}
+          />
+        </div>
+      </form>
+    </div>
+  );
+}
 
app/(protected)/(basic)/system/banned-words/new/page.tsx (added)
+++ app/(protected)/(basic)/system/banned-words/new/page.tsx
@@ -0,0 +1,10 @@
+import { verifySession } from '@/lib/auth/dal';
+import { BannedWordForm } from './_components/banned-word-form';
+
+export const dynamic = 'force-dynamic';
+
+export default async function Page() {
+  await verifySession();
+
+  return <BannedWordForm />;
+}
 
app/(protected)/(basic)/system/banned-words/page.tsx (added)
+++ app/(protected)/(basic)/system/banned-words/page.tsx
@@ -0,0 +1,30 @@
+import { verifySession } from '@/lib/auth/dal';
+import { fetchBannedWords } from '@/lib/data/repositories/banned-word-repository';
+import { parseBannedWordQuery } from '@/lib/domain/banned-word-query';
+import { BannedWordList } from './_components/banned-word-list';
+
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseBannedWordQuery(await searchParams);
+  const { items, totalCount } = await fetchBannedWords(query);
+
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <BannedWordList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
app/(protected)/(basic)/system/menus/_actions.ts (added)
+++ app/(protected)/(basic)/system/menus/_actions.ts
@@ -0,0 +1,112 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import { uploadAttachment } from '@/lib/data/repositories/file-repository';
+import {
+  createSystemMenu,
+  deleteSystemMenu,
+  updateSystemMenu,
+} from '@/lib/data/repositories/system-menu-repository';
+import { FILE_MODULE } from '@/lib/domain/file-module';
+import {
+  validateSystemMenu,
+  type SystemMenuFormState,
+} from '@/lib/domain/system-menu-form';
+
+const SYSTEM_MENUS_PATH = '/system/menus';
+const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const UPLOAD_FAILED_MESSAGE = '아이콘을 올리지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+function parseFormNumber(formData: FormData, key: string): number {
+  const trimmed = readString(formData, key).trim();
+  return trimmed === '' ? NaN : Number(trimmed);
+}
+
+/**
+ * 아이콘 칸의 결과 파일 아이디. 새 파일을 고르면 올려서 그 아이디를, 아니면 기존 아이디를
+ * 쓴다. 비운 경우 빈 문자열을 보내 컬럼을 지운다.
+ */
+async function resolveIconId(
+  formData: FormData,
+  fileKey: string,
+  currentKey: string
+): Promise<string> {
+  const picked = formData.get(fileKey);
+
+  if (picked instanceof File && picked.size > 0) {
+    return uploadAttachment(picked, FILE_MODULE.menuIcon.id);
+  }
+
+  return readString(formData, currentKey);
+}
+
+async function readValues(formData: FormData) {
+  return {
+    id: readString(formData, 'id'),
+    parentId: readString(formData, 'parentId'),
+    parentIdPath: readString(formData, 'parentIdPath'),
+    name: readString(formData, 'name'),
+    sortOrder: parseFormNumber(formData, 'sortOrder'),
+    roleId: readString(formData, 'roleId'),
+    isTopActive: readString(formData, 'isTopActive') === 'Y',
+    url: readString(formData, 'url'),
+    description: readString(formData, 'description'),
+    largeIconId: await resolveIconId(formData, 'largeIcon', 'largeIconId'),
+    smallIconId: await resolveIconId(formData, 'smallIcon', 'smallIconId'),
+    isActive: readString(formData, 'isActive') === 'Y',
+  };
+}
+
+export async function saveSystemMenuAction(
+  _prevState: SystemMenuFormState,
+  formData: FormData
+): Promise<SystemMenuFormState> {
+  await verifySession();
+
+  let values;
+  try {
+    values = await readValues(formData);
+  } catch {
+    return { status: 'error', message: UPLOAD_FAILED_MESSAGE };
+  }
+
+  const validation = validateSystemMenu(values);
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    if (validation.values.id) {
+      await updateSystemMenu(validation.values);
+    } else {
+      await createSystemMenu(validation.values);
+    }
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(SYSTEM_MENUS_PATH);
+  return { status: 'success' };
+}
+
+export async function deleteSystemMenuAction(
+  menuId: string
+): Promise<{ status: 'success' } | { status: 'error'; message: string }> {
+  await verifySession();
+
+  try {
+    await deleteSystemMenu(menuId);
+  } catch {
+    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(SYSTEM_MENUS_PATH);
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/system/menus/_components/menu-form-modal.tsx (added)
+++ app/(protected)/(basic)/system/menus/_components/menu-form-modal.tsx
@@ -0,0 +1,285 @@
+'use client';
+
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+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 { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import { fileModuleAccept, FILE_MODULE } from '@/lib/domain/file-module';
+import {
+  formatParentLabel,
+  type SystemMenu,
+  type SystemMenuNode,
+} from '@/lib/domain/system-menu';
+import {
+  DESCRIPTION_MAX_LENGTH,
+  INITIAL_SYSTEM_MENU_FORM_STATE,
+  NAME_MAX_LENGTH,
+  URL_MAX_LENGTH,
+} from '@/lib/domain/system-menu-form';
+import { saveSystemMenuAction } from '../_actions';
+import styles from './menu-form.module.scss';
+
+interface MenuFormModalProps {
+  mode: 'create' | 'edit';
+  /** 수정 대상. 추가일 때는 비어 있다. */
+  menu?: SystemMenuNode;
+  /** 추가일 때의 상위 메뉴. 수정일 때는 대상의 상위다. */
+  parent: SystemMenu | null;
+  roleOptions: { value: string; label: string }[];
+  onClose: () => void;
+}
+
+const ICON_ACCEPT = fileModuleAccept(FILE_MODULE.menuIcon);
+const ICON_HINT =
+  '이미지 파일만 업로드 가능 (jpg, png, svg 등). 앱 화면에서 메뉴 표시 시 사용됩니다.';
+
+export function MenuFormModal({
+  mode,
+  menu,
+  parent,
+  roleOptions,
+  onClose,
+}: MenuFormModalProps) {
+  const isCreate = mode === 'create';
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    saveSystemMenuAction,
+    INITIAL_SYSTEM_MENU_FORM_STATE
+  );
+
+  const [roleId, setRoleId] = useState(menu?.roleId ?? roleOptions[0]?.value ?? '');
+  const [isTopActive, setIsTopActive] = useState(menu?.isTopActive ?? false);
+  const [isActive, setIsActive] = useState(menu?.isActive ?? true);
+  // 새창여부는 저장할 컬럼이 없어 화면에만 둔다(사용자 확정).
+  const [opensNewWindow, setOpensNewWindow] = useState(false);
+  const [largeIconId, setLargeIconId] = useState(menu?.largeIconId ?? '');
+  const [smallIconId, setSmallIconId] = useState(menu?.smallIconId ?? '');
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({
+        variant: 'success',
+        message: isCreate ? '메뉴를 등록했습니다.' : '메뉴를 수정했습니다.',
+      });
+      onClose();
+    }
+  }, [state, showToast, onClose, isCreate]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+  const parentLabel = formatParentLabel(parent);
+
+  function iconFiles(id: string, name: string | null, url: string | null) {
+    return id
+      ? [{ id, name: name ?? '등록된 아이콘', previewUrl: url ?? undefined }]
+      : [];
+  }
+
+  return (
+    <FoxModal
+      open
+      size="md"
+      title={isCreate ? '신규 등록' : '메뉴 수정'}
+      onClose={onClose}
+      actions={
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '저장 중...' : isCreate ? '저장' : '수정'}
+            disabled={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </>
+      }
+    >
+      <form
+        ref={formRef}
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.fields}
+      >
+        <input type="hidden" name="id" value={menu?.id ?? ''} />
+        <input type="hidden" name="parentId" value={parent?.id ?? ''} />
+        <input type="hidden" name="parentIdPath" value={parent?.idPath ?? ''} />
+        <input type="hidden" name="roleId" value={roleId} />
+        <input type="hidden" name="isTopActive" value={isTopActive ? 'Y' : 'N'} />
+        <input type="hidden" name="isActive" value={isActive ? 'Y' : 'N'} />
+        <input type="hidden" name="largeIconId" value={largeIconId} />
+        <input type="hidden" name="smallIconId" value={smallIconId} />
+
+        {/* 기획 ① 상위 메뉴가 없으면(최상위 등록) 이 칸을 감춘다. */}
+        {parentLabel && (
+          <div className={styles.field}>
+            <FoxInput
+              size="md"
+              label="상위메뉴명"
+              requirement="required"
+              value={parentLabel}
+              readOnly
+              state="view"
+            />
+          </div>
+        )}
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="name"
+            label="메뉴명"
+            requirement="required"
+            defaultValue={menu?.name ?? ''}
+            placeholder="메뉴명을 입력하세요."
+            maxLength={NAME_MAX_LENGTH}
+            invalid={Boolean(errors.name)}
+            message={errors.name}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <div className={styles.narrow}>
+            <FoxInput
+              size="md"
+              name="sortOrder"
+              type="number"
+              inputMode="numeric"
+              min={1}
+              label="메뉴순번"
+              requirement="required"
+              defaultValue={String(menu?.sortOrder ?? 1)}
+              invalid={Boolean(errors.sortOrder)}
+              message={errors.sortOrder}
+            />
+          </div>
+        </div>
+
+        <div className={styles.field}>
+          <FoxSelect
+            size="md"
+            label="메뉴권한"
+            requirement="required"
+            options={roleOptions}
+            value={roleId}
+            onValueChange={setRoleId}
+            error={Boolean(errors.roleId)}
+            hint={errors.roleId}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span" requirement="required">
+            최상위권한활성
+          </FoxFormLabel>
+          <FoxToggleSwitch
+            size="md"
+            label={isTopActive ? '활성' : '비활성'}
+            checked={isTopActive}
+            onChange={setIsTopActive}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="url"
+            label="메뉴링크"
+            requirement="required"
+            defaultValue={menu?.url ?? ''}
+            placeholder="메뉴링크를 입력하세요."
+            maxLength={URL_MAX_LENGTH}
+            invalid={Boolean(errors.url)}
+            message={errors.url}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span" requirement="required">
+            새창여부
+          </FoxFormLabel>
+          <FoxToggleSwitch
+            size="md"
+            label={opensNewWindow ? '예' : '아니오'}
+            checked={opensNewWindow}
+            onChange={setOpensNewWindow}
+          />
+          {/* TODO(백엔드): TB_SYS_MENU에 새창여부 컬럼이 없어 저장되지 않는다. */}
+          <FoxHelperText
+            type="information"
+            message="저장되지 않는 항목입니다. 백엔드에 컬럼이 추가되면 연결됩니다."
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="description"
+            label="메뉴설명"
+            requirement="required"
+            defaultValue={menu?.description ?? ''}
+            placeholder="메뉴설명을 입력하세요."
+            maxLength={DESCRIPTION_MAX_LENGTH}
+            invalid={Boolean(errors.description)}
+            message={errors.description}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxFileUpload
+            mode="image"
+            name="largeIcon"
+            label="큰 아이콘"
+            selectLabel="파일선택"
+            accept={ICON_ACCEPT}
+            defaultFiles={iconFiles(
+              largeIconId,
+              menu?.largeIconName ?? null,
+              menu?.largeIconUrl ?? null
+            )}
+            onRemove={() => setLargeIconId('')}
+          />
+          <span className={styles.iconHint}>{ICON_HINT}</span>
+        </div>
+
+        <div className={styles.field}>
+          <FoxFileUpload
+            mode="image"
+            name="smallIcon"
+            label="작은 아이콘"
+            selectLabel="파일선택"
+            accept={ICON_ACCEPT}
+            defaultFiles={iconFiles(
+              smallIconId,
+              menu?.smallIconName ?? null,
+              menu?.smallIconUrl ?? null
+            )}
+            onRemove={() => setSmallIconId('')}
+          />
+          <span className={styles.iconHint}>{ICON_HINT}</span>
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span" requirement="required">
+            활성여부
+          </FoxFormLabel>
+          <FoxToggleSwitch
+            size="md"
+            label={isActive ? '사용' : '미사용'}
+            checked={isActive}
+            onChange={setIsActive}
+          />
+        </div>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+      </form>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/system/menus/_components/menu-form.module.scss (added)
+++ app/(protected)/(basic)/system/menus/_components/menu-form.module.scss
@@ -0,0 +1,25 @@
+@use "@fox/styles/abstracts" as fox;
+
+.fields {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+}
+
+.field {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(3);
+  padding-block: fox.padding(6);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+}
+
+.narrow {
+  inline-size: 20rem;
+}
+
+.iconHint {
+  color: fox.color(font-neutral-subtler);
+  font-size: fox.font-size(label-sm);
+}
 
app/(protected)/(basic)/system/menus/_components/menu-row-actions.tsx (added)
+++ app/(protected)/(basic)/system/menus/_components/menu-row-actions.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { 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 {
+  FoxPencilSimpleIcon,
+  FoxPlusIcon,
+  FoxTrashIcon,
+} from '@fox/core/icons';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { SystemMenuNode } from '@/lib/domain/system-menu';
+import { deleteSystemMenuAction } from '../_actions';
+import styles from './menu-tree.module.scss';
+
+interface MenuRowActionsProps {
+  node: SystemMenuNode;
+  onEdit: (node: SystemMenuNode) => void;
+  onAddChild: (node: SystemMenuNode) => void;
+}
+
+export function MenuRowActions({ node, onEdit, onAddChild }: MenuRowActionsProps) {
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isDeleting, startDeleting] = useTransition();
+
+  function runDelete() {
+    hideAlert();
+    startDeleting(async () => {
+      const result = await deleteSystemMenuAction(node.id);
+      if (result.status === 'error') {
+        showToast({ variant: 'danger', message: result.message });
+        return;
+      }
+      showToast({ variant: 'success', message: '메뉴를 삭제했습니다.' });
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '메뉴를 삭제하시겠습니까?',
+      message:
+        node.children.length > 0
+          ? `"${node.name}" 아래에 하위 메뉴 ${node.children.length}개가 있습니다. 삭제 후에는 되돌릴 수 없습니다.`
+          : `"${node.name}" 메뉴를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  return (
+    <div className={styles.actions}>
+      <FoxIconButton
+        type="default"
+        size="sm"
+        icon={<FoxPencilSimpleIcon />}
+        label="수정"
+        onAction={() => onEdit(node)}
+      />
+      <FoxIconButton
+        type="default"
+        size="sm"
+        icon={<FoxPlusIcon />}
+        label="하위 메뉴 추가"
+        onAction={() => onAddChild(node)}
+      />
+      <FoxIconButton
+        type="default"
+        size="sm"
+        icon={<FoxTrashIcon />}
+        label={isDeleting ? '삭제 중' : '삭제'}
+        disabled={isDeleting}
+        onAction={confirmDelete}
+      />
+    </div>
+  );
+}
 
app/(protected)/(basic)/system/menus/_components/menu-tree-list.tsx (added)
+++ app/(protected)/(basic)/system/menus/_components/menu-tree-list.tsx
@@ -0,0 +1,182 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
+import {
+  FoxCaretDownIcon,
+  FoxCaretRightIcon,
+  FoxFileIcon,
+  FoxFolderIcon,
+} from '@fox/core/icons';
+import {
+  buildMenuTree,
+  collectNodeIds,
+  collectRoleOptions,
+  flattenOpenNodes,
+  formatActiveLabel,
+  formatOptionalValue,
+  type SystemMenu,
+  type SystemMenuNode,
+} from '@/lib/domain/system-menu';
+import { MenuFormModal } from './menu-form-modal';
+import { MenuRowActions } from './menu-row-actions';
+import styles from './menu-tree.module.scss';
+
+interface MenuTreeListProps {
+  items: SystemMenu[];
+}
+
+type FormTarget =
+  | { mode: 'edit'; node: SystemMenuNode }
+  | { mode: 'create'; parent: SystemMenuNode };
+
+/** 깊이만큼 이름을 밀어 넣는다. 표 칸이라 여백은 인라인으로 준다. */
+const INDENT_REM = 2;
+
+export function MenuTreeList({ items }: MenuTreeListProps) {
+  const tree = useMemo(() => buildMenuTree(items), [items]);
+  const roleOptions = useMemo(() => collectRoleOptions(items), [items]);
+  const byId = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
+
+  const [closedIds, setClosedIds] = useState<Set<string>>(new Set());
+  const [target, setTarget] = useState<FormTarget | null>(null);
+
+  const isOpen = (id: string) => !closedIds.has(id);
+
+  function toggle(id: string) {
+    setClosedIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) {
+        next.delete(id);
+      } else {
+        next.add(id);
+      }
+      return next;
+    });
+  }
+
+  const allBranchIds = useMemo(() => collectNodeIds(tree), [tree]);
+  const isAllOpen = closedIds.size === 0;
+
+  function toggleAll() {
+    setClosedIds(isAllOpen ? new Set(allBranchIds) : new Set());
+  }
+
+  const rows = flattenOpenNodes(tree, isOpen);
+
+  const columns: FoxListColumn<SystemMenuNode>[] = [
+    { key: 'id', header: '번호', width: 100 },
+    {
+      key: 'toggle',
+      header: (
+        <button
+          type="button"
+          className={styles.toggle}
+          aria-label={isAllOpen ? '모두 접기' : '모두 펼치기'}
+          aria-expanded={isAllOpen}
+          onClick={toggleAll}
+        >
+          {isAllOpen ? <FoxCaretDownIcon /> : <FoxCaretRightIcon />}
+        </button>
+      ),
+      width: 60,
+      render: (row) =>
+        row.children.length > 0 ? (
+          <button
+            type="button"
+            className={styles.toggle}
+            aria-label={`${row.name} ${isOpen(row.id) ? '접기' : '펼치기'}`}
+            aria-expanded={isOpen(row.id)}
+            onClick={() => toggle(row.id)}
+          >
+            {isOpen(row.id) ? <FoxCaretDownIcon /> : <FoxCaretRightIcon />}
+          </button>
+        ) : null,
+    },
+    {
+      key: 'name',
+      header: '메뉴명',
+      width: 420,
+      emphasis: true,
+      render: (row) => (
+        <span
+          className={styles.name}
+          style={{ paddingInlineStart: `${row.depth * INDENT_REM}rem` }}
+        >
+          <span className={styles.icon}>
+            {row.children.length > 0 ? <FoxFolderIcon /> : <FoxFileIcon />}
+          </span>
+          {row.name}
+        </span>
+      ),
+    },
+    { key: 'sortOrder', header: '메뉴순번', width: 120, align: 'center' },
+    {
+      key: 'isActive',
+      header: '활성여부',
+      width: 120,
+      render: (row) => (
+        <FoxStatusIndicator
+          type={row.isActive ? 'success' : 'danger'}
+          label={formatActiveLabel(row.isActive)}
+        />
+      ),
+    },
+    {
+      key: 'roleName',
+      header: '권한',
+      width: 160,
+      render: (row) => formatOptionalValue(row.roleName),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 140,
+      render: (row) => (
+        <MenuRowActions
+          node={row}
+          onEdit={(node) => setTarget({ mode: 'edit', node })}
+          onAddChild={(node) => setTarget({ mode: 'create', parent: node })}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<SystemMenuNode>
+        title="메뉴관리"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '시스템관리' },
+          { label: '메뉴관리' },
+        ]}
+        caption="메뉴 목록"
+        columns={columns}
+        rows={rows}
+        rowKey={(row) => row.id}
+        totalCount={items.length}
+        emptyMessage="등록된 메뉴가 없습니다."
+        searchHidden
+      />
+
+      {target && (
+        <MenuFormModal
+          mode={target.mode}
+          menu={target.mode === 'edit' ? target.node : undefined}
+          parent={
+            target.mode === 'edit'
+              ? (target.node.parentId ? (byId.get(target.node.parentId) ?? null) : null)
+              : target.parent
+          }
+          roleOptions={roleOptions}
+          onClose={() => setTarget(null)}
+        />
+      )}
+    </>
+  );
+}
 
app/(protected)/(basic)/system/menus/_components/menu-tree.module.scss (added)
+++ app/(protected)/(basic)/system/menus/_components/menu-tree.module.scss
@@ -0,0 +1,55 @@
+@use "@fox/styles/abstracts" as fox;
+
+// 트리 한 줄 — 깊이만큼 들여쓰고 캐럿·아이콘·이름이 차례로 선다.
+.name {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(2);
+}
+
+.toggle {
+  display: inline-flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  inline-size: fox.icon(4);
+  block-size: fox.icon(4);
+  border: none;
+  background: none;
+  color: fox.color(icon-neutral-default);
+  cursor: pointer;
+
+  > svg {
+    inline-size: fox.icon(3);
+    block-size: fox.icon(3);
+  }
+
+  &:focus-visible {
+    outline: fox.border(2) solid fox.color(border-theme-primary);
+    outline-offset: fox.border(1);
+  }
+}
+
+// 자식이 없는 줄도 자리를 잡아 이름 시작 위치가 흔들리지 않게 한다.
+.togglePlaceholder {
+  display: inline-block;
+  flex-shrink: 0;
+  inline-size: fox.icon(4);
+}
+
+.icon {
+  display: inline-flex;
+  flex-shrink: 0;
+  color: fox.color(icon-neutral-subtle);
+
+  > svg {
+    inline-size: fox.icon(3);
+    block-size: fox.icon(3);
+  }
+}
+
+.actions {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(1);
+}
 
app/(protected)/(basic)/system/menus/page.tsx (added)
+++ app/(protected)/(basic)/system/menus/page.tsx
@@ -0,0 +1,13 @@
+import { verifySession } from '@/lib/auth/dal';
+import { fetchSystemMenus } from '@/lib/data/repositories/system-menu-repository';
+import { MenuTreeList } from './_components/menu-tree-list';
+
+export const dynamic = 'force-dynamic';
+
+export default async function Page() {
+  await verifySession();
+
+  const items = await fetchSystemMenus();
+
+  return <MenuTreeList items={items} />;
+}
 
lib/data/repositories/banned-word-repository.ts (added)
+++ lib/data/repositories/banned-word-repository.ts
@@ -0,0 +1,192 @@
+import 'server-only';
+import { cache } from 'react';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import type { BannedWord } from '@/lib/domain/banned-word';
+import type { BannedWordQuery } from '@/lib/domain/banned-word-query';
+import type { ExcelUploadResult } from '@/lib/domain/banned-word-form';
+
+/**
+ * 금칙어 Repository — `TB_SYS_BEN_WORD`.
+ *
+ * ```
+ * GET    /api/v1/mngr/benWord/pagination      searchCondition=1 + searchKeyword로 부분검색
+ * POST   /api/v1/mngr/benWord                 어노테이션 없음 → form. 중복이면 실패 응답
+ * DELETE /api/v1/mngr/benWord/{benWordSn}     soft delete(DEL_YN)
+ * POST   /api/v1/mngr/benWord/excel           multipart, 파트 이름 `file`
+ * ```
+ *
+ * ⚠️ 백엔드 결함(보고함, 미수정)
+ * 1. 목록 정렬이 기획과 반대다. `ROW_NUMBER() OVER (ORDER BY frst_reg_dt DESC)`로 매긴 rnum을
+ *    다시 `ORDER BY RNUM DESC`로 뒤집어 **오래된 것이 먼저** 온다. 기획은 최신순이다. 그래서
+ *    페이징을 백엔드에 맡기지 않고 한 번에 받아 여기서 정렬·절단한다(rnum 1 = 최신).
+ * 2. `DELETE`의 인자에 `@PathVariable`이 없다. 경로만 보내면 benWordSn이 null이라 아무것도
+ *    지워지지 않는다. 경로와 쿼리에 같은 값을 함께 실어 보낸다 — 어노테이션이 붙어도 그대로
+ *    동작한다.
+ */
+
+const BEN_WORD_PATH = '/api/v1/mngr/benWord';
+
+// TODO(백엔드): 정렬이 고쳐지면 서버 페이징으로 되돌린다. 그전까지는 이 상한을 넘는 금칙어가
+// 생기면 목록이 조용히 잘린다.
+const FETCH_LIMIT = 1000;
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string | null {
+  const value = source[key];
+  return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+function toBannedWord(raw: unknown): BannedWord[] {
+  if (!isRecord(raw)) {
+    return [];
+  }
+
+  const id = Number(raw.benWordSn);
+  if (!Number.isInteger(id)) {
+    return [];
+  }
+
+  return [
+    {
+      id,
+      word: readString(raw, 'benWordNm') ?? '',
+      createdAt: readString(raw, 'frstRegDtStr'),
+    },
+  ];
+}
+
+/** rnum 1이 최신이라 오름차순이 곧 최신순이다. */
+function byNewestFirst(raw: unknown): number {
+  return isRecord(raw) ? Number(raw.rnum ?? 0) : 0;
+}
+
+const fetchAllBannedWords = cache(
+  async (keyword: string): Promise<BannedWord[]> => {
+    const accessToken = await getSessionAccessToken();
+
+    const result = await backendFetch<unknown>(`${BEN_WORD_PATH}/pagination`, {
+      method: 'GET',
+      query: {
+        pageIndex: 1,
+        recordCountPerPage: FETCH_LIMIT,
+        ...(keyword ? { searchCondition: '1', searchKeyword: keyword } : {}),
+      },
+      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]
+      .sort((a, b) => byNewestFirst(a) - byNewestFirst(b))
+      .flatMap(toBannedWord);
+  }
+);
+
+export async function fetchBannedWords(
+  query: BannedWordQuery
+): Promise<{ items: BannedWord[]; totalCount: number }> {
+  const all = await fetchAllBannedWords(query.keyword);
+  const start = (query.page - 1) * query.pageSize;
+
+  return {
+    items: all.slice(start, start + query.pageSize),
+    totalCount: all.length,
+  };
+}
+
+export type CreateBannedWordResult =
+  | { status: 'success' }
+  | { status: 'duplicate' };
+
+export async function createBannedWord(
+  word: string
+): Promise<CreateBannedWordResult> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(BEN_WORD_PATH, {
+    method: 'POST',
+    form: { benWordNm: word },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    // 중복은 정상 흐름이라 예외로 올리지 않는다 — 백엔드가 "이미 등록된 단어 입니다"로 답한다.
+    if (result.message.includes('이미 등록된')) {
+      return { status: 'duplicate' };
+    }
+    throw new BackendRequestError(result);
+  }
+
+  return { status: 'success' };
+}
+
+export async function deleteBannedWord(id: number): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${BEN_WORD_PATH}/${id}`, {
+    method: 'DELETE',
+    query: { benWordSn: id },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export async function uploadBannedWordExcel(
+  file: File
+): Promise<ExcelUploadResult> {
+  const accessToken = await getSessionAccessToken();
+
+  const multipart = new FormData();
+  multipart.set('file', file);
+
+  const result = await backendFetch<unknown>(`${BEN_WORD_PATH}/excel`, {
+    method: 'POST',
+    multipart,
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+
+  const data = result.ok ? result.data : null;
+  const errors =
+    isRecord(data) && Array.isArray(data.errors)
+      ? data.errors.filter((entry): entry is string => typeof entry === 'string')
+      : [];
+
+  if (!result.ok) {
+    // 검증 실패는 fail(300) + data.errors로 온다. 통신 오류와 구분해 화면에 그대로 보여준다.
+    if (errors.length > 0) {
+      return { successCount: 0, errors };
+    }
+    throw new BackendRequestError(result);
+  }
+
+  return {
+    successCount:
+      isRecord(data) && Number.isInteger(Number(data.successCount))
+        ? Number(data.successCount)
+        : 0,
+    errors,
+  };
+}
lib/data/repositories/sidebar-menu-repository.ts
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -93,7 +93,11 @@
           id: 'system',
           label: '시스템관리',
           icon: 'wrench',
-          children: [{ id: 'codes', label: '코드관리', href: '/system/codes' }],
+          children: [
+            { id: 'codes', label: '코드관리', href: '/system/codes' },
+            { id: 'menus', label: '메뉴관리', href: '/system/menus' },
+            { id: 'banned-words', label: '금칙어관리', href: '/system/banned-words' },
+          ],
         },
       ],
     },
 
lib/data/repositories/system-menu-repository.ts (added)
+++ lib/data/repositories/system-menu-repository.ts
@@ -0,0 +1,171 @@
+import 'server-only';
+import { cache } from 'react';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import { buildFileImageUrl } from '@/lib/data/repositories/file-repository';
+import type { SystemMenu } from '@/lib/domain/system-menu';
+import type { SystemMenuValues } from '@/lib/domain/system-menu-form';
+
+/**
+ * 시스템 메뉴 Repository — `TB_SYS_MENU`.
+ *
+ * ```
+ * GET    /api/v1/mngr/menu/pagination   목록(페이징). 트리라 한 번에 다 받는다
+ * POST   /api/v1/mngr/menu              어노테이션 없음 → form. upMenuId의 부모가 있어야 한다
+ * PUT    /api/v1/mngr/menu/{menuId}     @RequestBody(JSON)
+ * DELETE /api/v1/mngr/menu/{menuId}     soft delete(DEL_YN)
+ * ```
+ *
+ * ⚠️ 백엔드 결함(보고함, 미수정)
+ * 1. `DELETE`의 인자에 `@PathVariable`이 없어 경로 값이 바인딩되지 않는다. 경로와 쿼리에 같은
+ *    값을 함께 실어 보낸다 — 어노테이션이 붙어도 그대로 동작한다.
+ * 2. `GET /{menuId}`는 경로가 `{menuId}`인데 인자가 `@PathVariable String userId`이고 본문도 빈
+ *    VO로 조회한다. 목록에 모든 필드가 있어 이 API는 쓰지 않는다.
+ * 3. 새창여부에 해당하는 컬럼이 없다. 화면에는 시안대로 두되 값을 보내지 않는다(사용자 확정).
+ */
+
+const MENU_PATH = '/api/v1/mngr/menu';
+
+// TODO(백엔드): 전체 조회 모드가 없어 큰 페이지 하나로 받는다. 메뉴가 이보다 많아지면 잘린다.
+const FETCH_LIMIT = 1000;
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string | null {
+  const value = source[key];
+  return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+function parseYesNo(value: unknown): boolean {
+  return value === 'Y';
+}
+
+function toSystemMenu(raw: unknown): SystemMenu[] {
+  if (!isRecord(raw)) {
+    return [];
+  }
+
+  const id = readString(raw, 'menuId');
+  if (!id) {
+    return [];
+  }
+
+  return [
+    {
+      id,
+      parentId: readString(raw, 'upMenuId'),
+      name: readString(raw, 'menuName') ?? '',
+      idPath: readString(raw, 'menuIdPath'),
+      url: readString(raw, 'menuUrl'),
+      roleId: readString(raw, 'roleId'),
+      roleName: readString(raw, 'roleNm'),
+      sortOrder: Number.isInteger(Number(raw.sortOrdr)) ? Number(raw.sortOrdr) : 0,
+      isActive: parseYesNo(raw.useYn),
+      isTopActive: parseYesNo(raw.topYn),
+      isDefault: parseYesNo(raw.defaultYn),
+      description: readString(raw, 'menuExplan'),
+      largeIconId: readString(raw, 'atchFileId'),
+      largeIconName: readString(raw, 'fileNm'),
+      // 미리보기 주소는 서버에서 만든다 — 주소 조립기가 server-only라 화면이 부를 수 없다.
+      largeIconUrl: buildFileImageUrl(readString(raw, 'atchFileId')),
+      smallIconId: readString(raw, 'smallAtchFileId'),
+      smallIconName: readString(raw, 'smallFileNm'),
+      smallIconUrl: buildFileImageUrl(readString(raw, 'smallAtchFileId')),
+    },
+  ];
+}
+
+export const fetchSystemMenus = cache(async (): Promise<SystemMenu[]> => {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${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.flatMap(toSystemMenu);
+});
+
+/** 새창여부는 보내지 않는다 — 저장할 컬럼이 없다(파일 상단 ⚠️ 3). */
+function toWriteParams(values: SystemMenuValues) {
+  return {
+    menuName: values.name,
+    sortOrdr: values.sortOrder,
+    roleId: values.roleId,
+    topYn: values.isTopActive ? 'Y' : 'N',
+    menuUrl: values.url,
+    menuExplan: values.description,
+    // 빈 문자열을 그대로 보낸다 — UPDATE가 null만 건너뛰므로 이래야 아이콘을 지울 수 있다.
+    atchFileId: values.largeIconId,
+    smallAtchFileId: values.smallIconId,
+    useYn: values.isActive ? 'Y' : 'N',
+  };
+}
+
+export async function createSystemMenu(values: SystemMenuValues): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(MENU_PATH, {
+    method: 'POST',
+    form: {
+      ...toWriteParams(values),
+      upMenuId: values.parentId,
+      menuIdPath: values.parentIdPath || undefined,
+    },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export async function updateSystemMenu(values: SystemMenuValues): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${MENU_PATH}/${values.id}`, {
+    method: 'PUT',
+    body: toWriteParams(values),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export async function deleteSystemMenu(menuId: string): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${MENU_PATH}/${menuId}`, {
+    method: 'DELETE',
+    query: { menuId },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
 
lib/domain/banned-word-form.ts (added)
+++ lib/domain/banned-word-form.ts
@@ -0,0 +1,41 @@
+export const WORD_MAX_LENGTH = 100;
+
+export type BannedWordFormErrors = Partial<Record<'word', string>>;
+
+export type BannedWordFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: BannedWordFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_BANNED_WORD_FORM_STATE: BannedWordFormState = {
+  status: 'idle',
+};
+
+/** 기획 SYS_WRD_002 ② — 중복은 백엔드가 판정하고 이 문구로 알린다. */
+export const DUPLICATE_WORD_MESSAGE = '중복된 키워드입니다.';
+export const REQUIRED_MESSAGE = '필수입력사항을 입력하세요.';
+
+type ValidationResult =
+  | { ok: true; value: string }
+  | { ok: false; errors: BannedWordFormErrors };
+
+export function validateBannedWord(raw: string): ValidationResult {
+  const value = raw.trim();
+
+  if (!value) {
+    return { ok: false, errors: { word: REQUIRED_MESSAGE } };
+  }
+  if (value.length > WORD_MAX_LENGTH) {
+    return {
+      ok: false,
+      errors: { word: `키워드는 ${WORD_MAX_LENGTH}자 이내로 입력해 주세요.` },
+    };
+  }
+
+  return { ok: true, value };
+}
+
+export type ExcelUploadResult = {
+  successCount: number;
+  errors: string[];
+};
 
lib/domain/banned-word-query.ts (added)
+++ lib/domain/banned-word-query.ts
@@ -0,0 +1,66 @@
+export const BANNED_WORDS_PATH = '/system/banned-words';
+export const BANNED_WORD_NEW_PATH = `${BANNED_WORDS_PATH}/new`;
+/** 엑셀 업로드 양식 다운로드 — 파일 응답이라 라우트 핸들러가 받는다. */
+export const BANNED_WORD_EXCEL_TEMPLATE_PATH = `${BANNED_WORDS_PATH}/excel-template`;
+
+export const BANNED_WORD_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type BannedWordPageSize =
+  (typeof BANNED_WORD_PAGE_SIZE_OPTIONS)[number];
+
+export const DEFAULT_BANNED_WORD_PAGE_SIZE: BannedWordPageSize = 10;
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+export type BannedWordQuery = {
+  keyword: string;
+  page: number;
+  pageSize: BannedWordPageSize;
+};
+
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string | undefined {
+  const value = params[key];
+  return Array.isArray(value) ? value[0] : value;
+}
+
+function isBannedWordPageSize(value: number): value is BannedWordPageSize {
+  return (BANNED_WORD_PAGE_SIZE_OPTIONS as readonly number[]).includes(value);
+}
+
+export function parseBannedWordQuery(
+  searchParams: RawSearchParams
+): BannedWordQuery {
+  const keywordRaw = readParam(searchParams, 'keyword');
+  const pageRaw = Number(readParam(searchParams, 'page'));
+  const pageSizeRaw = Number(readParam(searchParams, 'pageSize'));
+
+  return {
+    keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH),
+    page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE,
+    pageSize: isBannedWordPageSize(pageSizeRaw)
+      ? pageSizeRaw
+      : DEFAULT_BANNED_WORD_PAGE_SIZE,
+  };
+}
+
+export function buildBannedWordHref(
+  query: BannedWordQuery,
+  overrides: Partial<BannedWordQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+
+  if (merged.keyword) {
+    params.set('keyword', merged.keyword);
+  }
+  if (merged.pageSize !== DEFAULT_BANNED_WORD_PAGE_SIZE) {
+    params.set('pageSize', String(merged.pageSize));
+  }
+  if (merged.page !== DEFAULT_PAGE) {
+    params.set('page', String(merged.page));
+  }
+
+  const queryString = params.toString();
+  return queryString ? `${BANNED_WORDS_PATH}?${queryString}` : BANNED_WORDS_PATH;
+}
 
lib/domain/banned-word.ts (added)
+++ lib/domain/banned-word.ts
@@ -0,0 +1,15 @@
+export interface BannedWord {
+  id: number;
+  word: string;
+  createdAt: string | null;
+}
+
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+export function formatOptionalValue(
+  value: string | number | null | undefined
+): string {
+  return value === null || value === undefined || value === ''
+    ? EMPTY_FIELD_PLACEHOLDER
+    : String(value);
+}
 
lib/domain/system-menu-form.ts (added)
+++ lib/domain/system-menu-form.ts
@@ -0,0 +1,106 @@
+export const NAME_MAX_LENGTH = 100;
+export const URL_MAX_LENGTH = 300;
+export const DESCRIPTION_MAX_LENGTH = 300;
+export const SORT_ORDER_MAX = 9999;
+
+export interface SystemMenuValues {
+  id: string;
+  parentId: string;
+  parentIdPath: string;
+  name: string;
+  sortOrder: number;
+  roleId: string;
+  isTopActive: boolean;
+  url: string;
+  description: string;
+  largeIconId: string;
+  smallIconId: string;
+  isActive: boolean;
+}
+
+export type SystemMenuFormErrors = Partial<
+  Record<
+    'name' | 'sortOrder' | 'roleId' | 'url' | 'description' | 'largeIconId' | 'smallIconId',
+    string
+  >
+>;
+
+export type SystemMenuFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: SystemMenuFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_SYSTEM_MENU_FORM_STATE: SystemMenuFormState = {
+  status: 'idle',
+};
+
+/** 기획 SYS_MNU_002_p ④ — 미입력 시 이 문구를 띄운다. */
+export const REQUIRED_MESSAGE = '필수입력 사항을 입력하세요.';
+
+type ValidationResult =
+  | { ok: true; values: SystemMenuValues }
+  | { ok: false; errors: SystemMenuFormErrors };
+
+function validateText(
+  raw: string,
+  max: number,
+  label: string
+): { value: string; error?: string } {
+  const value = raw.trim();
+
+  if (!value) {
+    return { value, error: REQUIRED_MESSAGE };
+  }
+  if (value.length > max) {
+    return { value, error: `${label}은 ${max}자 이내로 입력해 주세요.` };
+  }
+  return { value };
+}
+
+export function validateSystemMenu(values: SystemMenuValues): ValidationResult {
+  const errors: SystemMenuFormErrors = {};
+
+  const name = validateText(values.name, NAME_MAX_LENGTH, '메뉴명');
+  if (name.error) {
+    errors.name = name.error;
+  }
+
+  const url = validateText(values.url, URL_MAX_LENGTH, '메뉴링크');
+  if (url.error) {
+    errors.url = url.error;
+  }
+
+  const description = validateText(
+    values.description,
+    DESCRIPTION_MAX_LENGTH,
+    '메뉴설명'
+  );
+  if (description.error) {
+    errors.description = description.error;
+  }
+
+  if (!values.roleId.trim()) {
+    errors.roleId = REQUIRED_MESSAGE;
+  }
+
+  if (!Number.isInteger(values.sortOrder) || values.sortOrder < 1) {
+    errors.sortOrder = '메뉴순번은 1 이상의 숫자로 입력해 주세요.';
+  } else if (values.sortOrder > SORT_ORDER_MAX) {
+    errors.sortOrder = `메뉴순번은 ${SORT_ORDER_MAX} 이하로 입력해 주세요.`;
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return {
+    ok: true,
+    values: {
+      ...values,
+      name: name.value,
+      url: url.value,
+      description: description.value,
+      roleId: values.roleId.trim(),
+    },
+  };
+}
 
lib/domain/system-menu.ts (added)
+++ lib/domain/system-menu.ts
@@ -0,0 +1,132 @@
+export interface SystemMenu {
+  id: string;
+  parentId: string | null;
+  name: string;
+  idPath: string | null;
+  url: string | null;
+  roleId: string | null;
+  roleName: string | null;
+  sortOrder: number;
+  isActive: boolean;
+  isTopActive: boolean;
+  isDefault: boolean;
+  description: string | null;
+  largeIconId: string | null;
+  largeIconName: string | null;
+  largeIconUrl: string | null;
+  smallIconId: string | null;
+  smallIconName: string | null;
+  smallIconUrl: string | null;
+}
+
+export interface SystemMenuNode extends SystemMenu {
+  depth: number;
+  children: SystemMenuNode[];
+}
+
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+export function formatOptionalValue(
+  value: string | number | null | undefined
+): string {
+  return value === null || value === undefined || value === ''
+    ? EMPTY_FIELD_PLACEHOLDER
+    : String(value);
+}
+
+export function formatActiveLabel(isActive: boolean): string {
+  return isActive ? '활성' : '비활성';
+}
+
+/**
+ * 평면 목록을 트리로 세운다. 부모가 목록에 없는 항목(권한 때문에 잘려 온 가지 등)은 뿌리로
+ * 올려 화면에서 사라지지 않게 한다.
+ */
+export function buildMenuTree(items: SystemMenu[]): SystemMenuNode[] {
+  const byId = new Map<string, SystemMenuNode>();
+  for (const item of items) {
+    byId.set(item.id, { ...item, depth: 0, children: [] });
+  }
+
+  const roots: SystemMenuNode[] = [];
+  for (const node of byId.values()) {
+    const parent = node.parentId ? byId.get(node.parentId) : undefined;
+    if (parent && parent !== node) {
+      parent.children.push(node);
+    } else {
+      roots.push(node);
+    }
+  }
+
+  function order(nodes: SystemMenuNode[], depth: number): SystemMenuNode[] {
+    nodes.sort((a, b) => a.sortOrder - b.sortOrder || a.id.localeCompare(b.id));
+    for (const node of nodes) {
+      node.depth = depth;
+      order(node.children, depth + 1);
+    }
+    return nodes;
+  }
+
+  return order(roots, 0);
+}
+
+/** 펼쳐진 가지만 위에서부터 한 줄씩 늘어놓는다. */
+export function flattenOpenNodes(
+  nodes: SystemMenuNode[],
+  isOpen: (id: string) => boolean
+): SystemMenuNode[] {
+  const rows: SystemMenuNode[] = [];
+
+  function walk(list: SystemMenuNode[]) {
+    for (const node of list) {
+      rows.push(node);
+      if (node.children.length > 0 && isOpen(node.id)) {
+        walk(node.children);
+      }
+    }
+  }
+
+  walk(nodes);
+  return rows;
+}
+
+export function collectNodeIds(nodes: SystemMenuNode[]): string[] {
+  const ids: string[] = [];
+
+  function walk(list: SystemMenuNode[]) {
+    for (const node of list) {
+      if (node.children.length > 0) {
+        ids.push(node.id);
+        walk(node.children);
+      }
+    }
+  }
+
+  walk(nodes);
+  return ids;
+}
+
+/**
+ * 메뉴권한 셀렉트의 선택지 — 역할 목록 API가 없어 목록 응답에 실제로 쓰인 역할만 모은다
+ * (사용자 확정 사항).
+ */
+export function collectRoleOptions(
+  items: SystemMenu[]
+): { value: string; label: string }[] {
+  const byId = new Map<string, string>();
+
+  for (const item of items) {
+    if (item.roleId && !byId.has(item.roleId)) {
+      byId.set(item.roleId, item.roleName ?? item.roleId);
+    }
+  }
+
+  return [...byId.entries()]
+    .map(([value, label]) => ({ value, label }))
+    .sort((a, b) => a.label.localeCompare(b.label, 'ko-KR'));
+}
+
+/** 수정·추가 팝업의 상위메뉴명 표시 — 기획의 `[3] 고객센터` 형식. */
+export function formatParentLabel(parent: SystemMenu | null): string {
+  return parent ? `[${parent.id}] ${parent.name}` : '';
+}
Add a comment
List