임동욱 임동욱 08-20
fix: 메뉴 저장 실패 사유를 화면에 그대로 보여준다
백엔드가 success:false 봉투로 사유를 주는데("최상위 메뉴가 없습니다" 등) 액션이
전부 "저장하지 못했습니다"로 뭉개고 있었다. 통신 오류만 일반 문구로 남긴다.

menuIdPath는 조상 경로가 되도록 부모 경로 + 부모 id로 만든다. 백엔드가 값을 만들어
주지 않고 저장만 한다.

Co-Authored-By: Claude Opus 5 
@3057371babec6b5560b85727495474fbf512516e
app/(protected)/(basic)/system/banned-words/_actions.ts
--- app/(protected)/(basic)/system/banned-words/_actions.ts
+++ app/(protected)/(basic)/system/banned-words/_actions.ts
@@ -3,6 +3,7 @@
 import { revalidatePath } from 'next/cache';
 import { redirect } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
+import { BackendRequestError } from '@/lib/http/backend-fetch';
 import {
   createBannedWord,
   deleteBannedWord,
@@ -20,6 +21,10 @@
 const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
 const UPLOAD_FAILED_MESSAGE =
   '엑셀 파일을 등록하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+function failureMessage(error: unknown, fallback: string): string {
+  return error instanceof BackendRequestError ? error.message : fallback;
+}
 
 function readString(formData: FormData, key: string): string {
   const value = formData.get(key);
@@ -41,8 +46,8 @@
   let result;
   try {
     result = await createBannedWord(validation.value);
-  } catch {
-    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  } catch (error) {
+    return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
   }
 
   if (result.status === 'duplicate') {
@@ -60,8 +65,8 @@
 
   try {
     await deleteBannedWord(id);
-  } catch {
-    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  } catch (error) {
+    return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
   }
 
   revalidatePath(BANNED_WORDS_PATH);
@@ -84,8 +89,8 @@
   let result: ExcelUploadResult;
   try {
     result = await uploadBannedWordExcel(file);
-  } catch {
-    return { status: 'error', message: UPLOAD_FAILED_MESSAGE };
+  } catch (error) {
+    return { status: 'error', message: failureMessage(error, UPLOAD_FAILED_MESSAGE) };
   }
 
   revalidatePath(BANNED_WORDS_PATH);
app/(protected)/(basic)/system/menus/_actions.ts
--- app/(protected)/(basic)/system/menus/_actions.ts
+++ app/(protected)/(basic)/system/menus/_actions.ts
@@ -2,6 +2,7 @@
 
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
+import { BackendRequestError } from '@/lib/http/backend-fetch';
 import { uploadAttachment } from '@/lib/data/repositories/file-repository';
 import {
   createSystemMenu,
@@ -16,6 +17,12 @@
 
 const SYSTEM_MENUS_PATH = '/system/menus';
 const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+/** 백엔드가 돌려준 사유는 그대로 보여준다 — "최상위 메뉴가 없습니다"처럼 사용자가 알아야 할
+ *  내용이고, 감추면 화면만 보고는 원인을 알 수 없다. */
+function failureMessage(error: unknown, fallback: string): string {
+  return error instanceof BackendRequestError ? error.message : fallback;
+}
 const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
 const UPLOAD_FAILED_MESSAGE = '아이콘을 올리지 못했습니다. 잠시 후 다시 시도해 주세요.';
 
@@ -88,8 +95,8 @@
     } else {
       await createSystemMenu(validation.values);
     }
-  } catch {
-    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  } catch (error) {
+    return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
   }
 
   revalidatePath(SYSTEM_MENUS_PATH);
@@ -103,8 +110,8 @@
 
   try {
     await deleteSystemMenu(menuId);
-  } catch {
-    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  } catch (error) {
+    return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
   }
 
   revalidatePath(SYSTEM_MENUS_PATH);
app/(protected)/(basic)/system/menus/_components/menu-form-modal.tsx
--- app/(protected)/(basic)/system/menus/_components/menu-form-modal.tsx
+++ app/(protected)/(basic)/system/menus/_components/menu-form-modal.tsx
@@ -13,6 +13,7 @@
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import { fileModuleAccept, FILE_MODULE } from '@/lib/domain/file-module';
 import {
+  buildChildIdPath,
   formatParentLabel,
   type SystemMenu,
   type SystemMenuNode,
@@ -108,7 +109,7 @@
       >
         <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="parentIdPath" value={buildChildIdPath(parent)} />
         <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'} />
lib/domain/system-menu.ts
--- lib/domain/system-menu.ts
+++ lib/domain/system-menu.ts
@@ -126,6 +126,20 @@
     .sort((a, b) => a.label.localeCompare(b.label, 'ko-KR'));
 }
 
+/**
+ * 새로 만들 메뉴의 `menuIdPath` — 조상 경로다(부모 경로 + 부모 id).
+ *
+ * 백엔드는 이 값을 받아 저장만 하고 만들어 주지 않는다(등록 SQL이 `IFNULL(#{menuIdPath}, '/')`).
+ * 규격을 정한 곳이 없어 뿌리는 `/`, 그 아래는 `/3`, `/3/18`로 쌓는 형식을 쓴다.
+ */
+export function buildChildIdPath(parent: SystemMenu | null): string {
+  if (!parent) {
+    return '/';
+  }
+  const base = parent.idPath && parent.idPath !== '/' ? parent.idPath : '';
+  return `${base}/${parent.id}`;
+}
+
 /** 수정·추가 팝업의 상위메뉴명 표시 — 기획의 `[3] 고객센터` 형식. */
 export function formatParentLabel(parent: SystemMenu | null): string {
   return parent ? `[${parent.id}] ${parent.name}` : '';
Add a comment
List