임동욱 임동욱 09-16
feat: 가맹몰 상세 목록에 상세가 없는 가맹점도 다시 보이게 한다
목록은 백엔드 페이지를 그대로 쓰고(전 페이지 훑기는 등록 화면의 가맹점 선택지에만 남긴다),
상세가 없는 행의 [수정]은 그 가맹점을 고정한 상세 화면으로 가서 거기서 처음 만든다
(사용자 확정). 등록 화면으로 되돌리던 리다이렉트와 그 경로 도우미는 지운다.

Co-Authored-By: Claude Opus 5 
@a60a1e96a9c2b5a8e27be6711153dfa4da168f39
app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
@@ -43,11 +43,13 @@
 import styles from './merchant-detail-form.module.scss';
 
 interface MerchantDetailFormProps {
-  /** 수정 — 상세가 있는 가맹점. 없으면 등록 화면이다. */
+  /** 상세가 있는 가맹점 — 고친다. */
   detail?: MerchantDetail;
-  /** 등록 — 아직 상세가 없는 가맹점 선택지. 업체명 자리에서 하나를 고른다. */
+  /** 상세가 아직 없는데 가맹점은 정해진 경우(행의 [수정]) — 그 가맹점에 처음 만든다. */
+  merchant?: MerchantDetailSummary;
+  /** 가맹점도 정해지지 않은 경우([+ 업체 등록]) — 업체명 자리에서 여기서 하나를 고른다. */
   candidates?: MerchantDetailSummary[];
-  /** 등록 — 미리 골라 둘 가맹점(`?frcsNo=`). */
+  /** 선택지 중 미리 골라 둘 가맹점(`?frcsNo=`). */
   initialFrcsNo?: string;
   products?: MerchantProduct[];
   categories: CommonCode[];
@@ -55,15 +57,16 @@
 
 /**
  * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이
- * 있으면 상세가 이미 있는 가맹점이고, 없으면 업체명 자리에서 상세가 없는 가맹점을 골라 처음 만든다.
- * 처음 저장하면 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고,
- * 이후 저장은 그 자리에 머문다.
+ * 있으면 상세가 이미 있는 가맹점, `merchant`면 상세가 없는 정해진 가맹점, 둘 다 없으면 업체명
+ * 자리에서 가맹점을 고른다. 처음 저장하면 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로
+ * 메뉴를 붙일 수 있고, 이후 저장은 그 자리에 머문다.
  *
  * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이고, 기획의 [주소 검색]은 가맹점 관리
  * 화면의 것이라 여기서는 값을 바꾸지 않는다.
  */
 export function MerchantDetailForm({
   detail,
+  merchant: fixedMerchant,
   candidates = [],
   initialFrcsNo,
   products = [],
@@ -79,10 +82,11 @@
   const isEdit = detail !== undefined;
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  const [frcsNo, setFrcsNo] = useState(detail?.id ?? initialFrcsNo ?? '');
-  // 읽기 전용 칸의 출처 — 수정은 상세, 등록은 고른 가맹점.
+  // 가맹점이 정해져 있으면(상세가 있든 없든) 업체명은 읽기 전용이고, 아니면 여기서 고른다.
+  const pinned: MerchantDetailSummary | null = detail ?? fixedMerchant ?? null;
+  const [frcsNo, setFrcsNo] = useState(pinned?.id ?? initialFrcsNo ?? '');
   const merchant: MerchantDetailSummary | null =
-    detail ?? candidates.find((row) => row.id === frcsNo) ?? null;
+    pinned ?? candidates.find((row) => row.id === frcsNo) ?? null;
   const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? '');
   const [visibility, setVisibility] = useState<string[]>([
     detail?.isVisible === false ? 'N' : 'Y',
@@ -166,8 +170,8 @@
         <FoxCard size="lg" title="기본 정보" className={styles.section}>
           <div className={styles.grid}>
             <div className={styles.fieldWide}>
-              {detail ? (
-                <FoxInput size="md" label="업체명" requirement="required" value={detail.name} readOnly state="view" />
+              {pinned ? (
+                <FoxInput size="md" label="업체명" requirement="required" value={pinned.name} readOnly state="view" />
               ) : (
                 <>
                   <FoxSelect
app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
@@ -1,11 +1,10 @@
 import type { Metadata } from 'next';
-import { notFound, redirect } from 'next/navigation';
+import { notFound } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
 import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository';
 import { fetchMerchantProducts } from '@/lib/data/repositories/merchant-product-repository';
 import { CODE_GROUP } from '@/lib/domain/common-code';
-import { merchantDetailNewPath } from '@/lib/domain/merchant-detail-query';
 import { MerchantDetailForm } from './_components/merchant-detail-form';
 
 export const metadata: Metadata = { title: '가맹몰 상세 정보' };
@@ -15,7 +14,7 @@
   params: Promise<{ frcsNo: string }>;
 }
 
-/** 수정 화면 — 상세가 있는 가맹점만. 아직 없는 가맹점이면 등록 화면으로 보내 거기서 고르게 한다. */
+/** 가맹점 하나의 상세 화면 — 상세가 아직 없으면 그 가맹점을 고정한 채 처음 만드는 화면이 된다. */
 export default async function Page({ params }: PageProps) {
   await verifySession();
 
@@ -24,14 +23,15 @@
   if (!detail) {
     notFound();
   }
-  if (!detail.hasDetail) {
-    redirect(merchantDetailNewPath(frcsNo));
-  }
 
+  // 상세가 없으면 백엔드 메뉴 목록도 비어 온다(상세와 INNER JOIN) — 부르지 않는다.
   const [products, categories] = await Promise.all([
-    fetchMerchantProducts(frcsNo),
+    detail.hasDetail ? fetchMerchantProducts(frcsNo) : Promise.resolve([]),
     fetchCommonCodes(CODE_GROUP.merchantType),
   ]);
 
+  if (!detail.hasDetail) {
+    return <MerchantDetailForm merchant={detail} categories={categories} />;
+  }
   return <MerchantDetailForm detail={detail} products={products} categories={categories} />;
 }
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
@@ -56,8 +56,9 @@
 /**
  * 가맹몰 상세 목록 — 기획 BO-MAL-001을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것.
  *
- * 상세가 등록된 가맹점만 선다(사용자 확정) — Repository가 가맹점 전체에서 걸러 준다. [+ 업체 등록]은
- * 등록 화면으로 가서 상세가 없는 가맹점을 고르고, 행의 [수정]은 수정 화면으로 간다.
+ * 가맹점 전체가 서고 상세가 없는 건은 상태·등록일이 `-`다(사용자 확정). [+ 업체 등록]은 상세
+ * 화면에서 가맹점을 고르게 하고, 행의 [수정]은 그 가맹점의 상세 화면으로 간다 — 상세가 없으면
+ * 거기서 처음 만든다.
  *
  * 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지
  *   않아 대부분 `-`로 보인다.
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
@@ -17,8 +17,9 @@
 }
 
 /**
- * 기획 BO-MAL-001 관리 열 — [수정] [비활성|노출] [삭제]. 목록에는 상세가 있는 가맹점만 서므로
- * [수정]은 수정 화면으로 간다.
+ * 기획 BO-MAL-001 관리 열 — 모든 행에 [수정] [비활성|노출] [삭제](시안대로, 사용자 확정).
+ * [수정]은 그 가맹점의 상세 화면으로 가고(상세가 없으면 거기서 처음 만든다), 상세가 없는 행의
+ * [비활성]·[삭제]는 백엔드가 NOT_FOUND로 거절해 사유가 토스트로 올라온다.
  */
 export function MerchantDetailRowActions({ row }: MerchantDetailRowActionsProps) {
   const router = useRouter();
lib/data/repositories/merchant-detail-repository.ts
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
@@ -15,7 +15,7 @@
  *
  * ```
  * GET    /api/v1/mngr/voc/detail/pagination   TB_COM_VOUCHER ⟕ TB_COM_VOUCHER_DTL — 가맹점 전체
- *                                             (화면은 상세 있는 건만 목록에, 없는 건은 등록 선택지에)
+ *                                             (상세 없는 건은 hasDetail=false로 목록에도 선다)
  * GET    /api/v1/mngr/voc/detail/{frcsNo}
  * POST   /api/v1/mngr/voc/detail              @RequestBody · 가맹점이 있어야 붙는다
  * PUT    /api/v1/mngr/voc/detail/{frcsNo}     @RequestBody
@@ -196,7 +196,7 @@
   };
 }
 
-// 백엔드 목록에는 "상세가 있는 건만" 조건이 없다(보고함). 그래서 전 페이지를 받아 여기서 거른다 —
+// 등록 화면의 선택지("상세가 없는 가맹점")를 백엔드가 걸러 주지 않아 전 페이지를 받아 거른다 —
 // 가맹점은 많아야 수백 건이라 한두 번의 요청이다. 상한을 넘으면 그 뒤는 잘린다.
 // TODO(백엔드): 상세 유무 조건이 생기면 이 훑기를 없애고 페이지 하나만 받는다.
 const SCAN_PAGE_SIZE = 200;
@@ -218,16 +218,12 @@
   return items;
 }
 
-/** 목록 — 상세가 있는 가맹점만, 화면 쪽 페이지로 잘라 준다(사용자 확정). */
+/** 목록 — 가맹점 전체가 오며 상세가 없는 건도 그대로 선다(사용자 확정). */
 export async function fetchMerchantDetails(
   query: MerchantDetailQuery
 ): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> {
-  const registered = (await fetchAllSummaries(query)).filter((row) => row.hasDetail);
-  const start = (query.page - 1) * query.pageSize;
-  return {
-    items: registered.slice(start, start + query.pageSize),
-    totalCount: registered.length,
-  };
+  const { items, totalCount } = await fetchSummaryPage(query, query.page, query.pageSize);
+  return { items, totalCount };
 }
 
 /** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */
lib/domain/merchant-detail-query.ts
--- lib/domain/merchant-detail-query.ts
+++ lib/domain/merchant-detail-query.ts
@@ -7,13 +7,6 @@
   return `${MERCHANT_DETAILS_PATH}/${encodeURIComponent(frcsNo)}`;
 }
 
-/** 등록 화면. `frcsNo`를 주면 그 가맹점을 미리 골라 둔다. */
-export function merchantDetailNewPath(frcsNo?: string): string {
-  return frcsNo
-    ? `${MERCHANT_DETAIL_NEW_PATH}?frcsNo=${encodeURIComponent(frcsNo)}`
-    : MERCHANT_DETAIL_NEW_PATH;
-}
-
 export function merchantProductNewPath(frcsNo: string): string {
   return `${merchantDetailPath(frcsNo)}/products/new`;
 }
lib/domain/merchant-detail.ts
--- lib/domain/merchant-detail.ts
+++ lib/domain/merchant-detail.ts
@@ -7,10 +7,7 @@
  * 가져오고, 상세 항목만 등록·수정한다(사용자 확정).
  */
 
-/**
- * 가맹점 한 건 + 상세 유무. 백엔드 목록은 가맹점 전체를 주지만 화면의 목록에는 상세가 있는
- * 건만 서고(사용자 확정), 상세가 없는 건은 등록 화면의 업체 선택지가 된다.
- */
+/** 목록 한 행 — 가맹점 전체가 오며 상세가 없는 건은 `hasDetail`이 false다(그대로 목록에 선다). */
 export type MerchantDetailSummary = {
   /** 백엔드 `frcsNo` — 가맹점 코드이자 상세의 키. */
   id: string;
@@ -28,7 +25,7 @@
   isVisible: boolean | null;
   /** 상세 등록일(`d.FRST_REG_DT`). 없으면 상세가 아직 없는 가맹점이다. */
   registeredAt: string | null;
-  /** 상세 행이 있는지 — 목록에 서는지, 등록 화면의 선택지가 되는지를 가른다. */
+  /** 상세 행이 있는지 — 일괄 비활성·삭제 대상인지, 메뉴를 붙일 수 있는지를 가른다. */
   hasDetail: boolean;
 };
 
Add a comment
List