임동욱 임동욱 09-14
feat: 가맹몰 상세 등록 직후 같은 화면에 남아 바로 메뉴를 추가하게 한다
/api/v1/mngr/voc/product 계약을 origin/develop 22f67cb로 재확인했다 — 등록은 가맹점만
있으면 받지만 목록·단건·수정·삭제는 상세와 INNER JOIN이라 상세 저장이 먼저여야 한다.
등록 저장 뒤 목록으로 나가면 [수정]을 다시 눌러야 메뉴를 붙일 수 있었으므로, 등록만
같은 화면(수정 모드)에 남긴다. 목록 SQL에 ORDER BY가 없어 노출 순서 오름차순으로
정렬한다.

Co-Authored-By: Claude Opus 5 
@83801e9dff362ffc18c2e50f1c15ab8754e37ec8
app/(protected)/(basic)/system/merchant-details/_actions.ts
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
@@ -257,7 +257,10 @@
 
   revalidatePath(MERCHANT_DETAILS_PATH);
   revalidatePath(merchantDetailPath(frcsNo));
-  redirect(MERCHANT_DETAILS_PATH);
+  // 등록 직후엔 같은 화면(수정 모드)에 남긴다 — 메뉴는 상세 행이 있어야 등록·조회되므로
+  // (백엔드 목록 SQL이 상세와 INNER JOIN) 여기서 바로 [+ 메뉴 추가]로 이어지게 한다.
+  // 수정은 다른 화면처럼 목록으로 돌아간다.
+  redirect(isEdit ? MERCHANT_DETAILS_PATH : merchantDetailPath(frcsNo));
 }
 
 export async function deleteMerchantDetailAction(
app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
@@ -47,7 +47,9 @@
         />
       </div>
       {!canAdd && (
-        <p className={styles.hint}>상세를 먼저 저장하면 메뉴를 추가할 수 있습니다.</p>
+        <p className={styles.hint}>
+          아래 [등록]으로 상세를 먼저 저장하면 이 화면에서 바로 메뉴를 추가할 수 있습니다.
+        </p>
       )}
 
       <div className={styles.table}>
lib/data/repositories/merchant-product-repository.ts
--- lib/data/repositories/merchant-product-repository.ts
+++ lib/data/repositories/merchant-product-repository.ts
@@ -10,15 +10,19 @@
 import type { MerchantProductValues } from '@/lib/domain/merchant-product-form';
 
 /**
- * 가맹몰 메뉴 Repository — edupay-backend origin/develop 15d409a 기준.
+ * 가맹몰 메뉴 Repository — edupay-backend origin/develop 22f67cb 기준(2026-09-14 재확인).
  *
  * ```
- * GET    /api/v1/mngr/voc/product/list/{frcsNo}   상세가 있는 가맹점의 메뉴 전체(페이징 없음)
- * GET    /api/v1/mngr/voc/product/{frcsProductSn}
- * POST   /api/v1/mngr/voc/product                  @RequestBody · 가맹점 상세가 있어야 한다
- * PUT    /api/v1/mngr/voc/product/{frcsProductSn}  @RequestBody
- * DELETE /api/v1/mngr/voc/product/{frcsProductSn}  논리 삭제
+ * GET    /api/v1/mngr/voc/product/list/{frcsNo}   메뉴 전체(페이징 없음) — 상세와 INNER JOIN
+ * GET    /api/v1/mngr/voc/product/{frcsProductSn}  없으면 NOT_FOUND 봉투
+ * POST   /api/v1/mngr/voc/product                  @RequestBody · 가맹점만 있으면 받는다
+ * PUT    /api/v1/mngr/voc/product/{frcsProductSn}  @RequestBody · 전 컬럼 덮어씀
+ * DELETE /api/v1/mngr/voc/product/{frcsProductSn}  논리 삭제(DEL_YN)
  * ```
+ *
+ * 등록은 상세 행이 없어도 통과하지만 목록·단건·수정·삭제는 상세와 INNER JOIN이라 상세를 저장하기
+ * 전에 넣은 메뉴는 보이지도 고쳐지지도 않는다 — 그래서 화면은 상세를 먼저 저장하게 한다.
+ * 목록 SQL에 ORDER BY가 없어 노출 순서(낮을수록 상단)는 여기서 정렬한다.
  */
 
 const PRODUCT_PATH = '/api/v1/mngr/voc/product';
@@ -82,7 +86,16 @@
   if (!result.ok) {
     throw new BackendRequestError(result);
   }
-  return Array.isArray(result.data) ? result.data.flatMap(toProduct) : [];
+  const products = Array.isArray(result.data) ? result.data.flatMap(toProduct) : [];
+  return products.sort(compareProductOrder);
+}
+
+/** 노출 순서 오름차순, 순서가 없는 메뉴는 뒤로, 같으면 등록된 순(번호)대로. */
+function compareProductOrder(a: MerchantProduct, b: MerchantProduct): number {
+  const orderA = a.sortOrder ?? Number.POSITIVE_INFINITY;
+  const orderB = b.sortOrder ?? Number.POSITIVE_INFINITY;
+  if (orderA !== orderB) return orderA - orderB;
+  return a.id.localeCompare(b.id, undefined, { numeric: true });
 }
 
 export async function fetchMerchantProduct(
Add a comment
List