임동욱 임동욱 08-21
merge: hub 최신화
@36722fac5e1906ffaafb5a51d026c522abfa64d8
app/(protected)/(basic)/boards/_components/board-post-columns.tsx
--- app/(protected)/(basic)/boards/_components/board-post-columns.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-columns.tsx
@@ -11,6 +11,7 @@
   formatAnswerStatusLabel,
   formatBoardCategoryLabel,
   formatFaqTypeLabel,
+  formatContentExcerpt,
   formatInquiryTypeLabel,
   formatOptionalValue,
   formatVisibilityLabel,
@@ -26,6 +27,23 @@
     header: '번호',
     width: 80,
     render: (_row, index) => (page - 1) * pageSize + index + 1,
+  };
+}
+
+// 이슈: 구분 값은 백엔드에 아직 없다(협의 중). 자리는 시안대로 두고 값이 생기면 배지로 돌아간다.
+function categoryColumn(): FoxListColumn<BoardPost> {
+  return {
+    key: 'category',
+    header: '구분',
+    width: 120,
+    render: (row) =>
+      row.categoryCode ? (
+        <FoxBadge type="pastel" color="neutral" size="md">
+          {formatBoardCategoryLabel(row.categoryCode)}
+        </FoxBadge>
+      ) : (
+        EMPTY_FIELD_PLACEHOLDER
+      ),
   };
 }
 
@@ -62,16 +80,7 @@
 function noticeColumns(page: number, pageSize: number): FoxListColumn<BoardPost>[] {
   return [
     numberColumn(page, pageSize),
-    {
-      key: 'category',
-      header: '구분',
-      width: 120,
-      render: (row) => (
-        <FoxBadge type="pastel" color="neutral" size="md">
-          {formatBoardCategoryLabel(row.categoryCode)}
-        </FoxBadge>
-      ),
-    },
+    categoryColumn(),
     { key: 'title', header: '제목', width: 320, emphasis: true },
     {
       key: 'attachment',
@@ -116,21 +125,13 @@
 
   return [
     numberColumn(page, pageSize),
-    {
-      key: 'category',
-      header: '구분',
-      width: 120,
-      render: (row) => (
-        <FoxBadge type="pastel" color="neutral" size="md">
-          {formatBoardCategoryLabel(row.categoryCode)}
-        </FoxBadge>
-      ),
-    },
+    categoryColumn(),
     {
       key: 'type',
       header: '유형',
       width: 120,
-      render: (row) => formatFaqTypeLabel(row.typeCode, typeOptions),
+      // 서버가 조인해 준 이름을 그대로 쓴다. 코드표 조회는 이름이 안 온 행의 보루다.
+      render: (row) => row.typeName ?? formatFaqTypeLabel(row.typeCode, typeOptions),
     },
     { key: 'title', header: '제목', width: 320, emphasis: true },
     {
@@ -189,9 +190,17 @@
       key: 'type',
       header: '질문유형',
       width: 120,
-      render: (row) => formatInquiryTypeLabel(row.typeCode),
+      // 서버 조인 이름 우선 — 코드표(잠정값)는 이름이 안 온 행의 보루다.
+      render: (row) => row.typeName ?? formatInquiryTypeLabel(row.typeCode),
     },
-    { key: 'title', header: '질문제목', width: 280, emphasis: true },
+    // 1:1문의는 제목이 없다(내용만 온다) — 내용 발췌가 이 줄의 대표 칸이다.
+    {
+      key: 'content',
+      header: '질문내용',
+      width: 280,
+      emphasis: true,
+      render: (row) => formatContentExcerpt(row.content),
+    },
     {
       key: 'authorName',
       header: '질문자명',
app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-answer-modal.tsx
@@ -91,21 +91,12 @@
           <FoxInput
             size="md"
             label="질문유형"
-            value={formatInquiryTypeLabel(post.typeCode)}
+            value={post.typeName ?? formatInquiryTypeLabel(post.typeCode)}
             readOnly
           />
         </div>
 
         <p className={styles.sectionTitle}>게시물의 항목 (조회 전용)</p>
-
-        <div className={styles.field}>
-          <FoxInput
-            size="md"
-            label="질문제목"
-            value={formatOptionalValue(post.title)}
-            readOnly
-          />
-        </div>
 
         <div className={styles.field}>
           <div className={styles.pair}>
app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
@@ -5,7 +5,7 @@
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
-import type { BoardPost } from '@/lib/domain/board-post';
+import { formatContentExcerpt, type BoardPost } from '@/lib/domain/board-post';
 import type { CommonCode } from '@/lib/domain/common-code';
 import { deleteBoardPostAction, loadBoardPostAction } from '../../_actions';
 import { InquiryAnswerModal } from './inquiry-answer-modal';
@@ -61,7 +61,7 @@
     showAlert({
       variant: 'danger',
       title: '문의를 삭제하시겠습니까?',
-      message: `"${post.title}" 문의를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      message: `"${formatContentExcerpt(post.content, 30)}" 문의를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
       actions: (
         <FoxButtonGroup size="md">
           <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
app/(protected)/(basic)/contents/_components/content-form-actions.tsx
--- app/(protected)/(basic)/contents/_components/content-form-actions.tsx
+++ app/(protected)/(basic)/contents/_components/content-form-actions.tsx
@@ -3,7 +3,7 @@
 import { useRouter } from 'next/navigation';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
-import styles from './content-form.module.scss';
+import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
 
 interface ContentFormActionsProps {
   submitLabel: string;
@@ -11,7 +11,7 @@
   pending: boolean;
 }
 
-/** 폼 하단의 [목록] [등록/수정]. 시안이 가운데 정렬이다. */
+/** 폼 하단의 [목록] [등록/수정] — 시안 btn-page(5402:12368). 그룹이 하나라 패널이 가운데 세운다. */
 export function ContentFormActions({
   submitLabel,
   listHref,
@@ -20,22 +20,22 @@
   const router = useRouter();
 
   return (
-    <div className={styles.actions}>
-      <FoxButtonGroup size="md">
+    <FoxButtonPanel>
+      <FoxButtonGroup size="lg">
         <FoxButton
           type="default"
-          size="md"
+          size="lg"
           label="목록"
           onAction={() => router.push(listHref)}
         />
         <FoxButton
           type="primary"
-          size="md"
+          size="lg"
           htmlType="submit"
           label={submitLabel}
           loading={pending}
         />
       </FoxButtonGroup>
-    </div>
+    </FoxButtonPanel>
   );
 }
app/(protected)/(basic)/contents/_components/content-form.module.scss
--- app/(protected)/(basic)/contents/_components/content-form.module.scss
+++ app/(protected)/(basic)/contents/_components/content-form.module.scss
@@ -113,12 +113,6 @@
   padding-inline: fox.padding(6);
 }
 
-.actions {
-  display: flex;
-  justify-content: center;
-  padding-block-start: fox.padding(8);
-}
-
 .thumbnail {
   display: flex;
   align-items: flex-start;
app/(protected)/(basic)/contents/_components/content-video-field.tsx
--- app/(protected)/(basic)/contents/_components/content-video-field.tsx
+++ app/(protected)/(basic)/contents/_components/content-video-field.tsx
@@ -5,7 +5,6 @@
 import { FoxHelperText } from '@fox/core/components/fox-helper-text';
 import {
   FILE_MODULE,
-  MAX_STREAM_UPLOAD_BYTES,
   allowedExtensions,
   fileModuleAccept,
   isAllowedFileExtension,
@@ -43,13 +42,6 @@
       );
       return;
     }
-    if (file.size > MAX_STREAM_UPLOAD_BYTES) {
-      setError(
-        `${Math.floor(MAX_STREAM_UPLOAD_BYTES / 1_000_000)}MB 이하만 올릴 수 있습니다.`
-      );
-      return;
-    }
-
     const body = new FormData();
     body.set('file', file);
 
app/(protected)/(basic)/contents/upload/route.ts
--- app/(protected)/(basic)/contents/upload/route.ts
+++ app/(protected)/(basic)/contents/upload/route.ts
@@ -1,9 +1,6 @@
 import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
 import { backendFetchUpload } from '@/lib/http/backend-fetch';
-import {
-  MAX_STREAM_UPLOAD_BYTES,
-  findFileModule,
-} from '@/lib/domain/file-module';
+import { findFileModule } from '@/lib/domain/file-module';
 
 /**
  * 큰 파일 업로드 중계 — 브라우저 → 이 라우트 → 백엔드로 본문을 그대로 흘려보낸다.
@@ -11,6 +8,10 @@
  * Server Action을 쓰지 않는 이유는 그 경로의 multipart가 10MB 부근에서 잘리기 때문이다
  * (`MAX_ATTACHMENT_BYTES` 주석). 브라우저가 백엔드를 직접 부를 수는 없다 — 토큰이 httpOnly
  * 세션 안에만 있어서 여기서 붙여 준다.
+ *
+ * 크기를 여기서 막지 않는다(사용자 확정). 본문을 버퍼에 담지 않고 그대로 흘려보내므로 이
+ * 라우트에는 상한이 필요 없고, 실제 한도는 앞단 프록시와 백엔드가 정한다 — 넘으면 413이
+ * 사유와 함께 화면까지 올라간다.
  */
 
 const UPLOAD_PATH = '/api/v1/common/file/upload';
@@ -33,14 +34,6 @@
     return fail(400, '요청 형식이 올바르지 않습니다.');
   }
 
-  const declaredLength = Number(request.headers.get('content-length') ?? '');
-  if (Number.isFinite(declaredLength) && declaredLength > MAX_STREAM_UPLOAD_BYTES) {
-    return fail(
-      413,
-      `${Math.floor(MAX_STREAM_UPLOAD_BYTES / 1_000_000)}MB 이하만 올릴 수 있습니다.`
-    );
-  }
-
   if (!request.body) {
     return fail(400, '파일이 없습니다.');
   }
@@ -57,7 +50,8 @@
   );
 
   if (!result.ok) {
-    return fail(502, result.message);
+    // 프록시가 자른 413은 크기 문제라 사용자에게 그대로 보인다. 나머지는 중계 실패다.
+    return fail(result.code === 413 ? 413 : 502, result.message);
   }
 
   if (typeof result.data !== 'string' || result.data === '') {
lib/data/repositories/admin-menu-repository.ts
--- lib/data/repositories/admin-menu-repository.ts
+++ lib/data/repositories/admin-menu-repository.ts
@@ -1,161 +1,184 @@
 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}     삭제
+/**
+ * 관리자페이지 메뉴 Repository — 기획 ADM_MNU_001/002_p/003_p.
  *
- * 이슈(백엔드, 보고함):
- * 1. `DELETE`의 `menuId`에 `@PathVariable`이 없어 경로값이 바인딩되지 않는다 → 항상 실패한다.
- * 2. 등록의 menuId 채번이 `MAX(menu_id) WHERE UP_MENU_ID = ?`라 상위별 최대값이다 → 자식이 없는
- *    메뉴에 처음 추가하면 이미 존재하는 번호가 나와 PK 충돌로 실패한다.
- * 3. `GET /{menuId}`는 경로변수 이름이 `userId`로 어긋나 있고 빈 VO로 조회한다 → 쓰지 않는다.
- *    수정 팝업의 초기값은 목록 응답에서 넘겨 채운다.
- * 4. `totalCount`가 현재 페이지 행 수다(다른 목록과 같은 PaginationUtil 결함).
- * 5. 새창여부에 해당하는 컬럼이 없다 — 화면은 기획대로 그리되 저장되지 않는다.
+ * TODO(백엔드): 이 화면의 API가 아직 없다. 아래 mock 저장소를 지우고 다른 Repository처럼
+ * backendFetch + getSessionAccessToken으로 교체하면 호출부는 그대로 둘 수 있다.
+ *
+ * ⚠️ 종전에는 `/api/v1/mngr/menu`(TB_SYS_MENU)를 불렀는데, 그것은 **앱 메뉴**이고
+ * 「시스템관리 > 메뉴관리」가 이미 같은 표를 편집한다. 두 화면이 같은 행을 고치게 되어
+ * 이 화면에서 삭제하면 앱 메뉴가 사라졌다. 전용 API가 나오기 전까지 mock으로 끊어 둔다.
+ *
+ * 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 수정·삭제가 초기값으로 되돌아간다.
  */
 
-const ADMIN_MENU_PATH = '/api/v1/mngr/menu';
+const ALL_USERS = { roleId: 'ROLE_USER', roleName: '모든 사용자' };
+const STUDENT = { roleId: 'ROLE_STUDENT', roleName: '수강생' };
 
-// 기획이 "페이징 없이 전체 노출"이라 한 장으로 받는다.
-const FETCH_LIMIT = 10_000;
+let MENUS: AdminMenu[] = [
+  {
+    id: '14',
+    parentId: null,
+    name: 'FOX PAY',
+    url: '/main',
+    description: '메인',
+    sortOrder: 1,
+    isActive: true,
+    isTopActive: true,
+    ...ALL_USERS,
+  },
+  {
+    id: '15',
+    parentId: '14',
+    name: '서비스 소개',
+    url: '/intro',
+    description: '서비스 소개',
+    sortOrder: 1,
+    isActive: true,
+    isTopActive: false,
+    ...ALL_USERS,
+  },
+  {
+    id: '1',
+    parentId: null,
+    name: '고객센터',
+    url: '/cs',
+    description: '고객센터',
+    sortOrder: 2,
+    isActive: true,
+    isTopActive: true,
+    ...ALL_USERS,
+  },
+  {
+    id: '16',
+    parentId: '1',
+    name: '이용 문의',
+    url: '/cs/inquiry',
+    description: '1:1 문의',
+    sortOrder: 1,
+    isActive: true,
+    isTopActive: false,
+    ...ALL_USERS,
+  },
+  {
+    id: '4',
+    parentId: null,
+    name: '내 강의실',
+    url: '/class',
+    description: '내 강의실',
+    sortOrder: 4,
+    isActive: true,
+    isTopActive: true,
+    ...STUDENT,
+  },
+  {
+    id: '5',
+    parentId: '4',
+    name: '교육 신청 이력',
+    url: '/class/history',
+    description: '교육 신청 이력',
+    sortOrder: 1,
+    isActive: true,
+    isTopActive: false,
+    ...STUDENT,
+  },
+  {
+    id: '9',
+    parentId: '4',
+    name: '내 정보 수정',
+    url: '/class/profile',
+    description: '내 정보 수정',
+    sortOrder: 6,
+    isActive: false,
+    isTopActive: false,
+    ...STUDENT,
+  },
+];
 
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return value !== null && typeof value === 'object';
+/** 기획 ADM_MNU_001 ① — 메뉴 등록순 정렬. */
+export async function fetchAdminMenus(): Promise<AdminMenu[]> {
+  return MENUS.map((menu) => ({ ...menu }));
 }
 
-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),
-  };
+function roleNameOf(roleId: string): string {
+  return (
+    MENUS.find((menu) => menu.roleId === roleId)?.roleName ?? roleId
+  );
 }
 
 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);
+  if (!MENUS.some((menu) => menu.id === parentId)) {
+    throw new Error('상위 메뉴를 찾을 수 없습니다.');
   }
+
+  // 전체에서 가장 큰 번호 다음 — 형제 안에서만 세면 다른 가지와 번호가 겹친다.
+  const nextId = MENUS.reduce(
+    (max, menu) => Math.max(max, Number(menu.id) || 0),
+    0
+  ) + 1;
+
+  MENUS = [
+    ...MENUS,
+    {
+      id: String(nextId),
+      parentId,
+      name: values.name,
+      url: values.url,
+      description: values.description,
+      sortOrder: values.sortOrder,
+      roleId: values.roleId,
+      roleName: roleNameOf(values.roleId),
+      isActive: values.isActive,
+      isTopActive: values.isTopActive,
+    },
+  ];
 }
 
 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);
+  const index = MENUS.findIndex((menu) => menu.id === menuId);
+  if (index < 0) {
+    throw new Error('메뉴를 찾을 수 없습니다.');
   }
+
+  MENUS[index] = {
+    ...MENUS[index],
+    name: values.name,
+    url: values.url,
+    description: values.description,
+    sortOrder: values.sortOrder,
+    roleId: values.roleId,
+    roleName: roleNameOf(values.roleId),
+    isActive: values.isActive,
+    isTopActive: values.isTopActive,
+  };
 }
 
+/** 하위 메뉴까지 함께 지운다 — 부모만 지우면 갈 곳 없는 가지가 남는다. */
 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);
+  if (!MENUS.some((menu) => menu.id === menuId)) {
+    throw new Error('메뉴를 찾을 수 없습니다.');
   }
+
+  const doomed = new Set<string>([menuId]);
+  let grew = true;
+  while (grew) {
+    grew = false;
+    for (const menu of MENUS) {
+      if (menu.parentId && doomed.has(menu.parentId) && !doomed.has(menu.id)) {
+        doomed.add(menu.id);
+        grew = true;
+      }
+    }
+  }
+
+  MENUS = MENUS.filter((menu) => !doomed.has(menu.id));
 }
lib/data/repositories/board-post-repository.ts
--- lib/data/repositories/board-post-repository.ts
+++ lib/data/repositories/board-post-repository.ts
@@ -3,7 +3,6 @@
 import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
 import {
   BOARD_SETTING_IDS,
-  BOARD_TYPE_CODE_PARAM,
   formatBoardCategoryLabel,
   type BoardPost,
   type BoardType,
@@ -20,19 +19,17 @@
  *   PUT    /api/v1/mngr/bbs/{bbsId}
  *   DELETE /api/v1/mngr/bbs/{bbsId}      (논리 삭제 — DEL_YN='Y')
  *
- * 아래는 백엔드 저장소(edupay-backend develop 924db37)의 실제 구현을 읽고 확인한 것이다 —
+ * 아래는 백엔드 저장소(edupay-backend origin/develop f7d601a)의 실제 구현을 읽고 확인한 것이다 —
  * MngrBbsApiController / MngrBbsServiceImpl / MngrBbsMapper.xml / PaginationUtil / CrudLogInterceptor.
  *
- * - **쓰기 요청은 JSON이 아니라 폼 인코딩이다.** 컨트롤러의 `MngrBbsRequestVo`에 `@RequestBody`가
- *   없어 Spring이 요청 파라미터로 바인딩한다. 그래서 `application/x-www-form-urlencoded`로 보낸다.
+ * - **쓰기 중 등록만 폼 인코딩이다.** insert에는 `@RequestBody`가 없고 update에는 있다.
  * - **작성자·수정자·삭제자는 보내지 않는다.** `CrudLogInterceptor`(MyBatis 플러그인)가 인증
  *   주체에서 꺼내 자동 기록한다. 프론트가 보내도 덮어써진다.
- * - **`totalCount`를 신뢰할 수 없다.** count 쿼리가 없어 `PaginationUtil`이 현재 페이지 행 수를
- *   총건수로 반환한다(학생·관리자 목록과 동일한 결함).
- * - **노출기간·앱푸쉬는 저장되지만 조회되지 않는다.** INSERT/UPDATE에는 있으나 조회 SQL의 select
- *   목록에 START_DT·END_DT·PUSH_YN이 없다. 사용자 확인 후 "전송은 하되 조회는 빈 값" 방침이다.
- * - **`reg_dt` 별칭이 두 번 쓰인다**(ANS_DT에 한 번, FRST_REG_DT에 한 번). 같은 이름의 컬럼이 둘이라
- *   작성일 자리에 답변일이 들어올 수 있다 — 보고한 백엔드 결함이며, 값이 이상하면 이 지점을 의심한다.
+ * - **`bbsCd`는 유형 코드다.** 조회가 코드관리 BBS_FAQ_CD와 조인해 이름(`bbsFaqNm`)까지 준다.
+ *   반면 쓰기는 요청 VO에도 컨트롤러 복사에도 bbsCd가 없어 **유형이 저장되지 않는다**(보고함).
+ * - **앱푸쉬는 끝까지 닿지 않는다.** 요청 VO·컨트롤러까지는 pushYn이 오지만 INSERT/UPDATE/SELECT
+ *   SQL 어디에도 PUSH_YN이 없다(보고함). 노출기간(START_DT·END_DT)은 이제 조회까지 온다.
+ * - **구분 값이 없다.** 협의 중이라 화면 입력은 유지하되 조회·전송 모두 비워 둔다(사용자 확정).
  *
  * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용. 캐시: `no-store`(조건이 매 요청 다르고 관리 데이터다).
  */
@@ -99,9 +96,10 @@
     id,
     title: readString(raw, 'bbsNm') ?? '',
     content: readString(raw, 'bbsCn') ?? '',
-    categoryCode: readString(raw, 'bbsCd') ?? '',
-    // 백엔드에 유형 저장 필드가 아직 없다(BOARD_TYPE_CODE_PARAM 주석) — 응답에도 없으므로 null이다.
-    typeCode: readString(raw, BOARD_TYPE_CODE_PARAM),
+    // 이슈: 구분 값은 백엔드에 아직 없다(협의 중). bbsCd는 구분이 아니라 유형이다.
+    categoryCode: null,
+    typeCode: readString(raw, 'bbsCd'),
+    typeName: readString(raw, 'bbsFaqNm'),
     attachmentId: readString(raw, 'atchFileId'),
     isPinned: parseYesNo(raw.hghrkYn),
     isVisible: parseYesNo(raw.rlsYn),
@@ -274,9 +272,9 @@
     pushYn: toYesNo(input.isPushEnabled),
   };
 
-  // 구분(bbsCd)은 아직 요청 VO에 없어 백엔드가 무시한다. 필드가 추가되는 즉시 동작하도록 보낸다.
-  if (input.categoryCode) params.bbsCd = input.categoryCode;
-  if (input.typeCode) params[BOARD_TYPE_CODE_PARAM] = input.typeCode;
+  // 이슈: bbsCd(유형)는 요청 VO·컨트롤러 복사에 없어 백엔드가 무시한다. 추가되는 즉시
+  // 동작하도록 실제 컬럼 이름으로 보낸다. 구분은 백엔드에 자리가 없어 보내지 않는다(협의 중).
+  if (input.typeCode) params.bbsCd = input.typeCode;
   if (input.attachmentId) params.atchFileId = input.attachmentId;
   if (input.startDate) params.startDt = input.startDate;
   if (input.endDate) params.endDt = input.endDate;
lib/data/repositories/content-repository.ts
--- lib/data/repositories/content-repository.ts
+++ lib/data/repositories/content-repository.ts
@@ -24,14 +24,14 @@
  *
  * 이슈: 경로가 `cntnts`가 아니라 **`cntntns`**다(백엔드 오타). 고쳐지면 아래 상수만 바꾼다.
  *
- * ⚠️ 백엔드 결함(보고함, 미수정)
- * 1. `selectPaginationCount`가 `FROM TB_COM_CNTNTS`만 두고 `searchWhere`를 그대로 포함한다.
- *    그 조건은 `c.`·`k.`·`g.` 별칭을 쓰므로 별칭을 모르는 count 쿼리에서 SQL이 깨진다.
- * 2. 학교·학년 조건의 `<if test='searchSchulGradeCd != null != ""'>`가 OGNL에서 항상 참이고,
- *    본문도 값 대신 `#{searchKeyword}`를 비교한다. 파라미터를 보내도 의미가 없어 보내지 않고,
- *    학교·학년 필터는 받아온 뒤 여기서 건다.
- * 3. 목록 SQL이 키워드·학교급·파일을 모두 LEFT JOIN해 한 콘텐츠가 여러 행이 된다. LIMIT이 그
- *    늘어난 행에 걸려 한 페이지에 오는 콘텐츠 수가 `recordCountPerPage`보다 적을 수 있다.
+ * ⚠️ 백엔드 결함(보고함, 미수정) — 그래서 목록은 전량 수령 후 로컬 검색·정렬·페이징이다.
+ * 1. 검색 조건(`c.CNTNTS_TITLE`·`k.CNTNTS_KEYWORD`)이 별칭을 쓰는데 `selectPaginationCount`는
+ *    `FROM TB_COM_CNTNTS`뿐이라 검색어를 실으면 count가 SQL 오류로 죽는다.
+ * 2. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시 뒤집어
+ *    **옛 글부터** 나간다. 새로 등록한 글이 첫 페이지가 아니라 마지막 페이지에 붙는다.
+ * 3. 키워드·학교급·파일 LEFT JOIN으로 한 콘텐츠가 여러 행이 되고 LIMIT이 그 행 기준이라, 페이지
+ *    경계에서 잘린 콘텐츠는 키워드·컷 목록이 반쪽으로 온다.
+ * 4. 학교·학년 파라미터는 `<if>` 본문이 값 대신 `#{searchKeyword}`를 비교해 보내도 의미가 없다.
  */
 
 const CONTENT_BASE_PATH = '/api/v1/mngr/cntntns';
@@ -129,17 +129,14 @@
 
 function buildListParams(
   kind: ContentApiKind,
-  query: ContentQuery,
   pageIndex: number,
   recordCountPerPage: number
 ): Record<string, string | number> {
-  const keyword = query.keyword.trim();
-
   return {
     // 없으면 `CNTNTS_TYPE = NULL`이 되어 한 건도 오지 않는다.
+    // 검색어는 보내지 않는다 — 검색 조건이 c.·k. 별칭을 쓰는데 count 쿼리에는 별칭이 없어
+    // 키워드를 실으면 count가 SQL 오류로 죽는다(파일 상단 ⚠️ 1). 검색은 아래에서 로컬로 건다.
     searchCntntsType: CONTENT_TYPE_CODE[kind],
-    searchCondition: keyword ? '1' : '',
-    searchKeyword: keyword,
     pageIndex,
     recordCountPerPage,
   };
@@ -147,7 +144,6 @@
 
 async function requestList(
   kind: ContentApiKind,
-  query: ContentQuery,
   pageIndex: number,
   recordCountPerPage: number
 ): Promise<{ items: ContentItem[]; reportedTotalCount: number }> {
@@ -155,7 +151,7 @@
 
   const result = await backendFetch<unknown>(`${CONTENT_BASE_PATH}/pagination`, {
     method: 'GET',
-    query: buildListParams(kind, query, pageIndex, recordCountPerPage),
+    query: buildListParams(kind, pageIndex, recordCountPerPage),
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
     canHaveNullData: true,
@@ -188,26 +184,50 @@
   isTotalCountExact: boolean;
 };
 
+// TODO(백엔드): 목록 정렬·LIMIT이 고쳐지면 서버 페이징으로 되돌린다. 그전까지 이 상한을
+// 넘는 콘텐츠는 목록에서 조용히 잘린다.
+const FETCH_LIMIT = 500;
+
+function matchesContentQuery(item: ContentItem, query: ContentQuery): boolean {
+  if (query.school && item.schoolCode !== query.school) {
+    return false;
+  }
+  if (query.grade && !item.grades.includes(query.grade)) {
+    return false;
+  }
+
+  const keyword = query.keyword.trim().toLowerCase();
+  if (!keyword) {
+    return true;
+  }
+  if (query.searchField === 'keyword') {
+    return item.keywords.some((entry) =>
+      entry.keyword.toLowerCase().includes(keyword)
+    );
+  }
+  // title(쇼츠)·name(만화) 모두 같은 컬럼(CNTNTS_NM=CNTNTS_TITLE)이라 한 값을 본다.
+  return item.title.toLowerCase().includes(keyword);
+}
+
 export async function fetchContents(
   kind: ContentApiKind,
   query: ContentQuery
 ): Promise<ContentPage> {
-  const { items, reportedTotalCount } = await requestList(
-    kind,
-    query,
-    query.page,
-    query.pageSize
-  );
+  // 서버 페이징을 쓰지 않는다 — 목록 SQL이 rnum(등록일 내림차순)을 다시 뒤집어 옛 글부터
+  // 내보내고, LIMIT도 조인으로 불어난 행 기준이라 새 글이 마지막 페이지로 밀린다(⚠️ 2·3).
+  const { items } = await requestList(kind, 1, FETCH_LIMIT);
 
-  const confirmedCount = (query.page - 1) * query.pageSize + items.length;
+  const matched = items
+    .filter((item) => matchesContentQuery(item, query))
+    // rnum은 resultMap에 없고 등록일은 일 단위라, 새 글이 먼저 오도록 일련번호로 세운다.
+    .sort((a, b) => Number(b.cntntsSn) - Number(a.cntntsSn));
+
+  const offset = (query.page - 1) * query.pageSize;
 
   return {
-    items,
-    // 백엔드 `PaginationUtil`이 count 없이 페이지 행 수를 총건수로 내려주는 결함이 있어
-    // 하한값으로 보정한다(다른 목록 화면과 같은 처리).
-    totalCount: Math.max(reportedTotalCount, confirmedCount),
-    isTotalCountExact:
-      items.length < query.pageSize || reportedTotalCount > confirmedCount,
+    items: matched.slice(offset, offset + query.pageSize),
+    totalCount: matched.length,
+    isTotalCountExact: true,
   };
 }
 
@@ -299,36 +319,21 @@
 
 
 /**
- * 보낸 것을 서버 콘솔에 그대로 남긴다.
- *
- * 브라우저 네트워크 탭에는 이 요청이 **뜨지 않는다** — 화면은 Server Action을 부르고 백엔드
- * 호출은 서버에서 일어난다(BFF). 그래서 개발 중에는 성공·실패를 가리지 않고 찍고, 운영에서는
- * 실패만 남긴다. JSON은 Swagger의 Request body에 그대로 붙여 넣을 수 있는 모양이다.
+ * 실패한 쓰기의 사유와 보낸 본문을 남긴다. 요청 자체는 테스트 환경에서 backend-fetch가
+ * 전부 찍으므로(중앙 로거) 여기는 실패만 맡는다 — 운영에서도 원인 추적에 이게 유일한 단서다.
  */
-function logWrite(
+function logWriteFailure(
   method: string,
   path: string,
   body: unknown,
-  failure: { code: number; message: string } | null
+  failure: { code: number; message: string }
 ): void {
-  if (failure === null && process.env.NODE_ENV === 'production') {
-    return;
-  }
-
-  const head = failure
-    ? `[contents] ${method} ${path} 실패 (code ${failure.code}: ${failure.message})`
-    : `[contents] ${method} ${path}`;
-
   const lines = [
-    head,
+    `[contents] ${method} ${path} 실패 (code ${failure.code}: ${failure.message})`,
     body === undefined ? null : `body(JSON):\n${JSON.stringify(body, null, 2)}`,
   ].filter(Boolean);
 
-  if (failure) {
-    console.error(lines.join('\n'));
-  } else {
-    console.info(lines.join('\n'));
-  }
+  console.error(lines.join('\n'));
 }
 
 async function sendWrite(
@@ -347,9 +352,8 @@
     canHaveEmptyBody: true,
   });
 
-  logWrite(method, path, body, result.ok ? null : result);
-
   if (!result.ok) {
+    logWriteFailure(method, path, body, result);
     throw new BackendRequestError(result);
   }
 
lib/data/repositories/sidebar-menu-repository.ts
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -50,7 +50,10 @@
           id: 'admins',
           label: '관리자정보관리',
           icon: 'user-square',
-          children: [{ id: 'admin-members', label: '관리자 회원', href: '/admins' }],
+          children: [
+            { id: 'admin-members', label: '관리자 회원', href: '/admins' },
+            { id: 'admin-menus', label: '메뉴관리', href: '/admins/menus' },
+          ],
         },
         {
           id: 'decoration',
lib/domain/board-post-query.ts
--- lib/domain/board-post-query.ts
+++ lib/domain/board-post-query.ts
@@ -39,8 +39,8 @@
     { value: 'category', label: '구분' },
     { value: 'title', label: '제목' },
   ],
+  // 1:1문의는 제목 없이 내용만 쓴다 — 제목 검색은 대상이 없다.
   inquiry: [
-    { value: 'title', label: '제목' },
     { value: 'authorName', label: '질문자명' },
     { value: 'authorPhoneNumber', label: '전화번호' },
     { value: 'content', label: '내용' },
lib/domain/board-post.ts
--- lib/domain/board-post.ts
+++ lib/domain/board-post.ts
@@ -33,24 +33,23 @@
   title: string;
   /** 본문 — 백엔드 `bbsCn`. */
   content: string;
-  /** 구분 코드 — 백엔드 `bbsCd`. */
-  categoryCode: string;
-  /**
-   * 유형 코드(FAQ의 유형 / 1:1문의의 질문유형).
-   * **백엔드에 아직 저장 필드가 없다** — `BOARD_TYPE_CODE_PARAM` 주석 참조. 조회 시 항상 null이다.
-   */
+  /** 구분 코드. 백엔드에 해당 값이 아직 없다(협의 중) — 조회 시 항상 null이다. */
+  categoryCode: string | null;
+  /** 유형 코드(FAQ의 유형 / 1:1문의의 질문유형) — 백엔드 `bbsCd`(BBS_FAQ_CD 코드). */
   typeCode: string | null;
+  /** 유형 이름 — 백엔드 `bbsFaqNm`(코드관리 BBS_FAQ_CD를 서버가 조인해 준다). */
+  typeName: string | null;
   /** 첨부파일 식별자 — 백엔드 `atchFileId`. 파일명·용량 조회 API가 없어 존재 여부만 알 수 있다. */
   attachmentId: string | null;
   /** 상단고정 — 백엔드 `hghrkYn`. */
   isPinned: boolean | null;
   /** 사용여부(노출여부) — 백엔드 `rlsYn`. */
   isVisible: boolean | null;
-  /** 노출 시작일. 백엔드 조회 응답에 없어 현재는 항상 null이다(파일 상단 주석). */
+  /** 노출 시작일(YYYY-MM-DD) — 백엔드 `startDt`. */
   startDate: string | null;
-  /** 노출 종료일. 위와 같다. */
+  /** 노출 종료일 — 백엔드 `endDt`. */
   endDate: string | null;
-  /** 앱푸쉬 발송 여부. 위와 같다. */
+  /** 앱푸쉬 발송 여부. 조회 SQL에 PUSH_YN이 없어 아직 항상 null이다. */
   isPushEnabled: boolean | null;
   /** 작성자 표기 — 백엔드 `regNm`(이름(로그인ID) 형태로 이미 조합돼 온다). */
   authorName: string | null;
@@ -81,18 +80,6 @@
   return value === null || value === '' ? EMPTY_FIELD_PLACEHOLDER : String(value);
 }
 
-/**
- * 「유형」을 실어 보낼 요청 파라미터 이름 — **임시 이름이다.**
- *
- * 시안은 구분과 유형을 별도 항목으로 요구하지만 백엔드에는 `BBS_CD` 하나뿐이고, 등록/수정 요청
- * VO(`MngrBbsRequestVo`)에는 그마저도 없다. 사용자 확인 결과 **"백엔드가 필드를 추가할 예정이니
- * 프론트는 미리 구현"** 이라, 구분은 `bbsCd`로 유형은 이 이름으로 보낸다.
- *
- * 백엔드가 실제 이름을 확정하면 이 상수 한 줄만 고치면 된다. 그전까지 백엔드는 이 파라미터를
- * 무시하므로 **유형은 저장되지 않는다**(화면 입력과 전송은 정상 동작한다).
- */
-export const BOARD_TYPE_CODE_PARAM = 'bbsTypeCd';
-
 type CodeOption = { value: string; label: string };
 
 /**
@@ -121,6 +108,15 @@
 // TODO: 답변완료를 가리키는 코드값. BBS_ANS_CD의 실제 값이 확인되면 교체한다.
 export const ANSWER_STATUS_DONE = 'DONE';
 
+/** 목록 한 칸·확인 문구에 넣을 본문 발췌 — 줄바꿈을 접고 길면 말줄임한다. */
+export function formatContentExcerpt(content: string, maxLength = 60): string {
+  const flat = content.replace(/\s+/g, ' ').trim();
+  if (flat === '') {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+  return flat.length > maxLength ? `${flat.slice(0, maxLength)}…` : flat;
+}
+
 /** 코드 → 라벨. 표에 없는 코드는 지어내지 않고 코드 그대로 노출한다(잘못된 라벨보다 낫다). */
 function formatCode(options: readonly CodeOption[], code: string | null): string {
   if (code === null || code === '') {
lib/domain/file-module.ts
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
@@ -50,7 +50,9 @@
   vodContent: {
     id: 'MODULE_VOD_CONTENT',
     description: '동영상 콘텐츠 저장소',
-    extensions: ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv'],
+    // 이슈: 백엔드 전역 화이트리스트(Globals.fileUpload.Extensions)에 영상은 mp4뿐이다.
+    // mov·avi 등은 목록에 추가되면 여기도 되돌린다.
+    extensions: ['mp4'],
   },
   /** 꾸미기 아이템 썸네일. */
   userItem: {
@@ -124,6 +126,3 @@
  *   없다. 더 큰 파일은 미들웨어에서 제외한 스트리밍 라우트(`/contents/upload`)로 보낸다.
  */
 export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
-
-/** 스트리밍 업로드 상한 — 백엔드 `multipartResolver`의 `maxUploadSize`(100,000,000)와 같은 값. */
-export const MAX_STREAM_UPLOAD_BYTES = 100_000_000;
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -137,6 +137,49 @@
   };
 }
 
+/**
+ * 테스트 환경에서는 백엔드로 나가는 **모든** 요청을 서버 콘솔에 남긴다 — 브라우저 네트워크
+ * 탭에는 이 호출이 뜨지 않아(BFF) 여기가 유일하게 보이는 자리다. 운영에서는 찍지 않는다.
+ * JSON 본문은 Swagger Request body에 그대로 붙여 넣을 수 있는 모양으로 찍는다.
+ */
+function logRequest(
+  method: string,
+  url: string,
+  payload?: { body?: unknown; form?: Record<string, string | number | undefined>; multipart?: FormData; note?: string }
+): void {
+  if (process.env.NODE_ENV === 'production') {
+    return;
+  }
+
+  const lines = [`[backend-fetch] ${method} ${url}`];
+
+  if (payload?.body !== undefined) {
+    lines.push(`body(JSON):\n${JSON.stringify(payload.body, null, 2)}`);
+  }
+  if (payload?.form) {
+    const params = new URLSearchParams();
+    for (const [key, value] of Object.entries(payload.form)) {
+      if (value !== undefined) {
+        params.set(key, String(value));
+      }
+    }
+    lines.push(`form: ${params.toString()}`);
+  }
+  if (payload?.multipart) {
+    const parts = [...payload.multipart.entries()].map(([key, value]) =>
+      value instanceof File
+        ? `${key}=${value.name}(${Math.ceil(value.size / 1024)}KB)`
+        : `${key}=${String(value)}`
+    );
+    lines.push(`multipart: ${parts.join(', ')}`);
+  }
+  if (payload?.note) {
+    lines.push(payload.note);
+  }
+
+  console.info(lines.join('\n'));
+}
+
 function resolveUrl(
   path: string,
   query: Record<string, string | number> | undefined
@@ -185,9 +228,12 @@
   path: string,
   init: BackendStreamRequestInit
 ): Promise<BackendResult<Response>> {
+  const url = resolveUrl(path, init.query);
+  logRequest('GET', url, { note: '(파일 응답 스트림)' });
+
   let response: Response;
   try {
-    response = await fetch(resolveUrl(path, init.query), {
+    response = await fetch(url, {
       method: 'GET',
       headers: {
         ...(init.accessToken
@@ -262,10 +308,17 @@
   init: BackendRequestInit
 ): Promise<BackendResult<T>> {
   const { body, contentType } = buildRequestBody(init);
+  const url = resolveUrl(path, init.query);
+
+  logRequest(init.method, url, {
+    body: init.body,
+    form: init.form,
+    multipart: init.multipart,
+  });
 
   let response: Response;
   try {
-    response = await fetch(resolveUrl(path, init.query), {
+    response = await fetch(url, {
       method: init.method,
       headers: {
         ...(contentType ? { 'Content-Type': contentType } : {}),
@@ -298,6 +351,15 @@
       };
     }
 
+    // 이슈: dev API 프록시(client_max_body_size 미설정, 기본 1MiB)가 앱보다 먼저 큰 본문을
+    // 봉투 없이 413으로 거절한다(2026-08-20 실측). 인프라 상향 전까지 사유를 살려 내보낸다.
+    if (response.status === 413) {
+      return {
+        ok: false,
+        code: 413,
+        message: '파일이 서버가 받을 수 있는 크기를 넘었습니다.',
+      };
+    }
     return communicationError(`예상치 못한 HTTP 상태: ${response.status}`);
   }
 
@@ -359,9 +421,12 @@
     timeoutMs?: number;
   }
 ): Promise<BackendResult<T>> {
+  const url = resolveUrl(path, undefined);
+  logRequest('POST', url, { note: '(multipart 스트림 본문 — 내용 생략)' });
+
   let response: Response;
   try {
-    response = await fetch(resolveUrl(path, undefined), {
+    response = await fetch(url, {
       method: 'POST',
       headers: {
         'Content-Type': init.contentType,
@@ -392,6 +457,13 @@
             : COMMUNICATION_ERROR_MESSAGE,
       };
     }
+    if (response.status === 413) {
+      return {
+        ok: false,
+        code: 413,
+        message: '파일이 서버가 받을 수 있는 크기를 넘었습니다.',
+      };
+    }
     return communicationError(
       `업로드 응답의 예상치 못한 HTTP 상태: ${response.status}`
     );
Add a comment
List