임동욱 임동욱 09-14
feat: 가맹몰 상세 등록 화면을 분리하고 목록은 상세가 있는 가맹점만 보여 준다
[+ 업체 등록]은 /system/merchant-details/new로 간다 — 업체명 자리에서 상세가 없는 가맹점을
고르면 연락처·주소가 채워지고 나머지는 같은 폼이다(사용자 확정). 행의 [수정]은 언제나 수정
화면이 되도록 목록에는 상세가 등록된 가맹점만 세운다 — 백엔드 목록에 그 조건이 없어
Repository가 전 페이지를 받아 거르고 화면 쪽 페이지로 자른다(TODO 백엔드). 상세가 없는
가맹점의 수정 주소로 들어오면 등록 화면으로 보내 미리 골라 둔다.

Co-Authored-By: Claude Opus 5 
@276b625d8ae7452f8017af2080194533d6c2f97c
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
@@ -21,6 +21,7 @@
   formatMerchantDetailStatusLabel,
   formatOptionalValue,
   type MerchantDetail,
+  type MerchantDetailSummary,
 } from '@/lib/domain/merchant-detail';
 import {
   CLOSED_DAYS_MAX_LENGTH,
@@ -42,19 +43,30 @@
 import styles from './merchant-detail-form.module.scss';
 
 interface MerchantDetailFormProps {
-  detail: MerchantDetail;
-  products: MerchantProduct[];
+  /** 수정 — 상세가 있는 가맹점. 없으면 등록 화면이다. */
+  detail?: MerchantDetail;
+  /** 등록 — 아직 상세가 없는 가맹점 선택지. 업체명 자리에서 하나를 고른다. */
+  candidates?: MerchantDetailSummary[];
+  /** 등록 — 미리 골라 둘 가맹점(`?frcsNo=`). */
+  initialFrcsNo?: string;
+  products?: MerchantProduct[];
   categories: CommonCode[];
 }
 
 /**
- * 가맹몰 상세 등록·수정 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다. 상세는
- * 가맹점당 하나라 `frcsNo`만으로 등록인지 수정인지 갈린다(`detail.hasDetail`).
+ * 가맹몰 상세 등록·수정 — 기획 BO-MAL-002(등록)·003(수정). `detail`이 있으면 수정, 없으면 등록이다.
  *
- * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다(사용자 확정). 기획의 [주소 검색]은
- * 가맹점 관리 화면의 것이고 여기서는 값을 바꾸지 않는다.
+ * 상세는 기존 가맹점에만 붙으므로 등록 화면은 업체명 자리에서 상세가 없는 가맹점을 고른다
+ * (사용자 확정). 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이고, 기획의 [주소 검색]은
+ * 가맹점 관리 화면의 것이라 여기서는 값을 바꾸지 않는다.
  */
-export function MerchantDetailForm({ detail, products, categories }: MerchantDetailFormProps) {
+export function MerchantDetailForm({
+  detail,
+  candidates = [],
+  initialFrcsNo,
+  products = [],
+  categories,
+}: MerchantDetailFormProps) {
   const router = useRouter();
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [isBusy, startAction] = useBlockingAction();
@@ -62,16 +74,21 @@
     saveMerchantDetailAction,
     INITIAL_MERCHANT_DETAIL_FORM_STATE
   );
-  const isEdit = detail.hasDetail;
+  const isEdit = detail !== undefined;
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  const [categoryCode, setCategoryCode] = useState(detail.categoryCode ?? '');
+  const [frcsNo, setFrcsNo] = useState(detail?.id ?? initialFrcsNo ?? '');
+  // 읽기 전용 칸의 출처 — 수정은 상세, 등록은 고른 가맹점.
+  const merchant: MerchantDetailSummary | null =
+    detail ?? candidates.find((row) => row.id === frcsNo) ?? null;
+  const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? '');
   const [visibility, setVisibility] = useState<string[]>([
-    detail.isVisible === false ? 'N' : 'Y',
+    detail?.isVisible === false ? 'N' : 'Y',
   ]);
   const [voucherUse, setVoucherUse] = useState<string[]>(['Y']);
 
   function toggleVisibility() {
+    if (!detail) return;
     const next = detail.isVisible === false;
     startAction(async () => {
       const result = await updateMerchantDetailVisibilityAction(detail.id, next);
@@ -84,6 +101,7 @@
   }
 
   function runDelete() {
+    if (!detail) return;
     hideAlert();
     startAction(async () => {
       const result = await deleteMerchantDetailAction(detail.id);
@@ -97,6 +115,7 @@
   }
 
   function confirmDelete() {
+    if (!detail) return;
     showAlert({
       variant: 'danger',
       title: '가맹몰 상세를 삭제하시겠습니까?',
@@ -132,13 +151,35 @@
         onSubmit={(event) => submitFormAction(event, formAction)}
         className={styles.form}
       >
-        <input type="hidden" name="frcsNo" value={detail.id} />
+        <input type="hidden" name="frcsNo" value={frcsNo} />
         <input type="hidden" name="mode" value={isEdit ? 'edit' : 'create'} />
 
         <FoxCard size="lg" title="기본 정보" className={styles.section}>
           <div className={styles.grid}>
             <div className={styles.fieldWide}>
-              <FoxInput size="md" label="업체명" requirement="required" value={detail.name} readOnly state="view" />
+              {detail ? (
+                <FoxInput size="md" label="업체명" requirement="required" value={detail.name} readOnly state="view" />
+              ) : (
+                <>
+                  <FoxSelect
+                    size="md"
+                    label="업체명"
+                    requirement="required"
+                    placeholder="상세를 등록할 가맹점 선택"
+                    options={candidates.map((row) => ({ value: row.id, label: row.name }))}
+                    value={frcsNo}
+                    onValueChange={setFrcsNo}
+                    error={Boolean(errors.frcsNo)}
+                  />
+                  {errors.frcsNo && <FoxHelperText type="danger" message={errors.frcsNo} />}
+                  {candidates.length === 0 && (
+                    <FoxHelperText
+                      type="information"
+                      message="상세를 붙일 가맹점이 없습니다. 가맹점 관리에서 먼저 등록해 주세요."
+                    />
+                  )}
+                </>
+              )}
             </div>
             <div className={styles.field}>
               <FoxSelect
@@ -159,7 +200,7 @@
                 size="md"
                 label="업체 연락처"
                 requirement="required"
-                value={formatOptionalValue(formatPhoneNumber(detail.phoneNumber))}
+                value={formatOptionalValue(formatPhoneNumber(merchant?.phoneNumber ?? null))}
                 readOnly
                 state="view"
               />
@@ -168,13 +209,13 @@
 
           <div className={styles.grid}>
             <div className={styles.fieldNarrow}>
-              <FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(detail.postalCode)} readOnly state="view" />
+              <FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(detail?.postalCode)} readOnly state="view" />
             </div>
             <div className={styles.fieldWide}>
-              <FoxInput size="md" label="주소" value={formatOptionalValue(detail.address)} readOnly state="view" />
+              <FoxInput size="md" label="주소" value={formatOptionalValue(merchant?.address)} readOnly state="view" />
             </div>
             <div className={styles.field}>
-              <FoxInput size="md" label="상세주소" value={formatOptionalValue(detail.addressDetail)} readOnly state="view" />
+              <FoxInput size="md" label="상세주소" value={formatOptionalValue(merchant?.addressDetail)} readOnly state="view" />
             </div>
           </div>
 
@@ -185,7 +226,7 @@
                 name="description"
                 label="한줄 소개"
                 placeholder="앱 목록·상세에 노출되는 한 줄 설명"
-                defaultValue={detail.description ?? ''}
+                defaultValue={detail?.description ?? ''}
                 maxLength={DESCRIPTION_MAX_LENGTH}
                 invalid={Boolean(errors.description)}
                 message={errors.description}
@@ -197,7 +238,7 @@
                 name="openTime"
                 type="time"
                 label="영업 시작"
-                defaultValue={detail.openTime ?? ''}
+                defaultValue={detail?.openTime ?? ''}
                 invalid={Boolean(errors.openTime)}
                 message={errors.openTime}
               />
@@ -208,7 +249,7 @@
                 name="closeTime"
                 type="time"
                 label="영업 종료"
-                defaultValue={detail.closeTime ?? ''}
+                defaultValue={detail?.closeTime ?? ''}
                 invalid={Boolean(errors.closeTime)}
                 message={errors.closeTime}
               />
@@ -219,7 +260,7 @@
                 name="closedDays"
                 label="휴무일"
                 placeholder="예) 매주 월요일"
-                defaultValue={detail.closedDays ?? ''}
+                defaultValue={detail?.closedDays ?? ''}
                 maxLength={CLOSED_DAYS_MAX_LENGTH}
                 invalid={Boolean(errors.closedDays)}
                 message={errors.closedDays}
@@ -254,7 +295,7 @@
                 max={SORT_ORDER_MAX}
                 label="노출 순서"
                 placeholder="숫자 (낮을수록 상단)"
-                defaultValue={detail.sortOrder === null ? '' : String(detail.sortOrder)}
+                defaultValue={detail?.sortOrder == null ? '' : String(detail.sortOrder)}
                 invalid={Boolean(errors.sortOrder)}
                 message={errors.sortOrder}
               />
@@ -269,10 +310,10 @@
           className={styles.section}
         >
           <MerchantImageField
-            defaultThumbnailUrl={detail.thumbnailUrl}
-            retainedThumbnailId={detail.thumbnailFileId}
-            defaultImageUrls={detail.imageUrls}
-            retainedImageFileId={detail.imageFileId}
+            defaultThumbnailUrl={detail?.thumbnailUrl ?? null}
+            retainedThumbnailId={detail?.thumbnailFileId ?? null}
+            defaultImageUrls={detail?.imageUrls ?? []}
+            retainedImageFileId={detail?.imageFileId ?? null}
             thumbnailError={errors.thumbnail}
             imagesError={errors.images}
           />
@@ -284,10 +325,10 @@
           description="앱 상세 화면의 메뉴 목록에 노출된다"
           className={styles.section}
         >
-          <MerchantProductTable frcsNo={detail.id} products={products} canAdd={isEdit} />
+          <MerchantProductTable frcsNo={frcsNo} products={products} canAdd={isEdit} />
         </FoxCard>
 
-        {isEdit && (
+        {detail && (
           <FoxCard size="lg" title="등록 정보" className={styles.section}>
             <FoxDescriptionList
               layout="inline"
@@ -313,7 +354,7 @@
 
         <FoxButtonPanel>
           <FoxButtonGroup size="lg">
-            {isEdit && (
+            {detail && (
               <>
                 <FoxButton type="default" size="lg" label="삭제" disabled={isBusy} onAction={confirmDelete} />
                 <FoxButton
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,19 +1,21 @@
 import type { Metadata } from 'next';
-import { notFound } from 'next/navigation';
+import { notFound, redirect } 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: '가맹몰 상세 등록·수정' };
+export const metadata: Metadata = { title: '가맹몰 상세 수정' };
 export const dynamic = 'force-dynamic';
 
 interface PageProps {
   params: Promise<{ frcsNo: string }>;
 }
 
+/** 수정 화면 — 상세가 있는 가맹점만. 아직 없는 가맹점이면 등록 화면으로 보내 거기서 고르게 한다. */
 export default async function Page({ params }: PageProps) {
   await verifySession();
 
@@ -22,10 +24,12 @@
   if (!detail) {
     notFound();
   }
+  if (!detail.hasDetail) {
+    redirect(merchantDetailNewPath(frcsNo));
+  }
 
-  // 상세가 없으면 백엔드가 메뉴 조회를 빈 목록으로 답한다 — 따로 가릴 것 없다.
   const [products, categories] = await Promise.all([
-    detail.hasDetail ? fetchMerchantProducts(frcsNo) : Promise.resolve([]),
+    fetchMerchantProducts(frcsNo),
     fetchCommonCodes(CODE_GROUP.merchantType),
   ]);
 
app/(protected)/(basic)/system/merchant-details/_actions.ts
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
@@ -222,7 +222,7 @@
   const frcsNo = readString(formData, 'frcsNo');
   const isEdit = readString(formData, 'mode') === 'edit';
   if (!frcsNo) {
-    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+    return { status: 'error', errors: { frcsNo: '업체를 선택해 주세요.' } };
   }
 
   let failed: MerchantDetailFormState | null = null;
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
@@ -26,6 +26,7 @@
 import {
   FILTER_ALL,
   MERCHANT_DETAILS_PATH,
+  MERCHANT_DETAIL_NEW_PATH,
   MERCHANT_DETAIL_PAGE_SIZE_OPTIONS,
   buildMerchantDetailHref,
   clampDateRange,
@@ -55,9 +56,8 @@
 /**
  * 가맹몰 상세 목록 — 기획 BO-MAL-001을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것.
  *
- * 가맹점 전체가 오고 상세가 붙은 건만 상태·등록일이 있다. 버튼 구성은 시안 그대로다 —
- * [+ 업체 등록]과 행의 [수정][비활성][삭제]를 모두 둔다(사용자 확정, 동작보다 시안 우선).
- * 상세는 기존 가맹점에만 붙으므로 등록은 행의 [수정]으로 들어간다.
+ * 상세가 등록된 가맹점만 선다(사용자 확정) — Repository가 가맹점 전체에서 걸러 준다. [+ 업체 등록]은
+ * 등록 화면으로 가서 상세가 없는 가맹점을 고르고, 행의 [수정]은 수정 화면으로 간다.
  *
  * 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지
  *   않아 대부분 `-`로 보인다.
@@ -366,19 +366,12 @@
             disabled={isBusy || selectedIds.size === 0}
             onAction={confirmBulkDelete}
           />
-          {/* 시안 BO-MAL-001의 [+ 업체 등록]. 상세는 기존 가맹점에만 붙어 여기서 업체를 만들 수
-              없다 — 자리는 시안대로 두고 누르면 가는 곳을 안내한다(사용자 확정). */}
           <FoxButton
             type="primary"
             size="md"
             leadingIcon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />}
             label="업체 등록"
-            onAction={() =>
-              showToast({
-                variant: 'info',
-                message: '업체(가맹점)는 가맹점 관리에서 등록합니다. 여기서는 등록된 가맹점의 상세를 관리합니다.',
-              })
-            }
+            onAction={() => router.push(MERCHANT_DETAIL_NEW_PATH)}
           />
         </>
       }
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,9 +17,8 @@
 }
 
 /**
- * 기획 BO-MAL-001 관리 열 — 모든 행에 [수정] [비활성|노출] [삭제]. 상세가 없는 가맹점도 시안
- * 그대로 세 버튼을 둔다(사용자 확정 — 동작보다 시안 우선). [수정]은 등록 화면으로 이어지고,
- * 나머지는 백엔드가 NOT_FOUND로 거절해 사유가 토스트로 올라온다.
+ * 기획 BO-MAL-001 관리 열 — [수정] [비활성|노출] [삭제]. 목록에는 상세가 있는 가맹점만 서므로
+ * [수정]은 수정 화면으로 간다.
  */
 export function MerchantDetailRowActions({ row }: MerchantDetailRowActionsProps) {
   const router = useRouter();
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
@@ -48,7 +48,7 @@
       </div>
       {!canAdd && (
         <p className={styles.hint}>
-          아래 [등록]으로 상세를 먼저 저장하면 이 화면에서 바로 메뉴를 추가할 수 있습니다.
+          아래 [등록]으로 상세를 먼저 저장하면 이어지는 수정 화면에서 메뉴를 추가할 수 있습니다.
         </p>
       )}
 
 
app/(protected)/(basic)/system/merchant-details/new/page.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/new/page.tsx
@@ -0,0 +1,34 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
+import { fetchMerchantDetailCandidates } from '@/lib/data/repositories/merchant-detail-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
+import { MerchantDetailForm } from '../[frcsNo]/_components/merchant-detail-form';
+
+export const metadata: Metadata = { title: '가맹몰 상세 등록' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+/** 등록 화면 — 상세가 없는 가맹점 중 하나를 골라 상세를 붙인다. `?frcsNo=`로 미리 고를 수 있다. */
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const params = await searchParams;
+  const requested = Array.isArray(params.frcsNo) ? params.frcsNo[0] : params.frcsNo;
+  const [candidates, categories] = await Promise.all([
+    fetchMerchantDetailCandidates(),
+    fetchCommonCodes(CODE_GROUP.merchantType),
+  ]);
+  const initialFrcsNo = candidates.some((row) => row.id === requested) ? requested : undefined;
+
+  return (
+    <MerchantDetailForm
+      candidates={candidates}
+      initialFrcsNo={initialFrcsNo}
+      categories={categories}
+    />
+  );
+}
lib/data/repositories/merchant-detail-repository.ts
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
@@ -15,6 +15,7 @@
  *
  * ```
  * GET    /api/v1/mngr/voc/detail/pagination   TB_COM_VOUCHER ⟕ TB_COM_VOUCHER_DTL — 가맹점 전체
+ *                                             (화면은 상세 있는 건만 목록에, 없는 건은 등록 선택지에)
  * GET    /api/v1/mngr/voc/detail/{frcsNo}
  * POST   /api/v1/mngr/voc/detail              @RequestBody · 가맹점이 있어야 붙는다
  * PUT    /api/v1/mngr/voc/detail/{frcsNo}     @RequestBody
@@ -146,21 +147,28 @@
   };
 }
 
-export async function fetchMerchantDetails(
-  query: MerchantDetailQuery
-): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> {
+type SummaryFilters = Pick<
+  MerchantDetailQuery,
+  'keyword' | 'category' | 'visibility' | 'startDate' | 'endDate'
+>;
+
+async function fetchSummaryPage(
+  filters: Partial<SummaryFilters>,
+  pageIndex: number,
+  pageSize: number
+): Promise<{ items: MerchantDetailSummary[]; rowCount: number; totalCount: number }> {
   const accessToken = await getSessionAccessToken();
 
   const result = await backendFetch<unknown>(`${DETAIL_PATH}/pagination`, {
     method: 'GET',
     query: {
-      ...(query.keyword ? { searchFrcsNm: query.keyword } : {}),
-      ...(query.category ? { searchFrcsTypeCd: query.category } : {}),
-      ...(query.visibility ? { searchUseYn: query.visibility } : {}),
-      ...(query.startDate ? { searchStartDt: query.startDate } : {}),
-      ...(query.endDate ? { searchEndDt: query.endDate } : {}),
-      pageIndex: query.page,
-      recordCountPerPage: query.pageSize,
+      ...(filters.keyword ? { searchFrcsNm: filters.keyword } : {}),
+      ...(filters.category ? { searchFrcsTypeCd: filters.category } : {}),
+      ...(filters.visibility ? { searchUseYn: filters.visibility } : {}),
+      ...(filters.startDate ? { searchStartDt: filters.startDate } : {}),
+      ...(filters.endDate ? { searchEndDt: filters.endDate } : {}),
+      pageIndex,
+      recordCountPerPage: pageSize,
     },
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
@@ -173,7 +181,7 @@
 
   const data = result.data;
   if (data === null || data === undefined) {
-    return { items: [], totalCount: 0 };
+    return { items: [], rowCount: 0, totalCount: 0 };
   }
   if (!isRecord(data) || !Array.isArray(data.list)) {
     throw new Error('가맹몰 상세 목록 응답의 형식이 올바르지 않습니다.');
@@ -181,11 +189,52 @@
 
   return {
     items: data.list.flatMap(toSummary),
+    // 매핑에서 걸러진 행이 있어도 페이지가 끝났는지는 원본 행 수로 판단한다.
+    rowCount: data.list.length,
     totalCount:
       typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
   };
 }
 
+// 백엔드 목록에는 "상세가 있는 건만" 조건이 없다(보고함). 그래서 전 페이지를 받아 여기서 거른다 —
+// 가맹점은 많아야 수백 건이라 한두 번의 요청이다. 상한을 넘으면 그 뒤는 잘린다.
+// TODO(백엔드): 상세 유무 조건이 생기면 이 훑기를 없애고 페이지 하나만 받는다.
+const SCAN_PAGE_SIZE = 200;
+const MAX_SCAN_PAGES = 25;
+
+async function fetchAllSummaries(
+  filters: Partial<SummaryFilters>
+): Promise<MerchantDetailSummary[]> {
+  const items: MerchantDetailSummary[] = [];
+  let seen = 0;
+  for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) {
+    const page = await fetchSummaryPage(filters, pageIndex, SCAN_PAGE_SIZE);
+    items.push(...page.items);
+    seen += page.rowCount;
+    if (page.rowCount < SCAN_PAGE_SIZE || seen >= page.totalCount) {
+      break;
+    }
+  }
+  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,
+  };
+}
+
+/** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */
+export async function fetchMerchantDetailCandidates(): Promise<MerchantDetailSummary[]> {
+  return (await fetchAllSummaries({})).filter((row) => !row.hasDetail);
+}
+
 export async function fetchMerchantDetail(
   frcsNo: string
 ): Promise<MerchantDetail | null> {
lib/domain/merchant-detail-form.ts
--- lib/domain/merchant-detail-form.ts
+++ lib/domain/merchant-detail-form.ts
@@ -29,6 +29,7 @@
 
 export type MerchantDetailFormErrors = Partial<
   Record<
+    | 'frcsNo'
     | 'categoryCode'
     | 'description'
     | 'openTime'
lib/domain/merchant-detail-query.ts
--- lib/domain/merchant-detail-query.ts
+++ lib/domain/merchant-detail-query.ts
@@ -1,11 +1,19 @@
 import { MERCHANT_DETAIL_VISIBILITY_OPTIONS } from '@/lib/domain/merchant-detail';
 
 export const MERCHANT_DETAILS_PATH = '/system/merchant-details';
+export const MERCHANT_DETAIL_NEW_PATH = `${MERCHANT_DETAILS_PATH}/new`;
 
 export function merchantDetailPath(frcsNo: string): string {
   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,7 +7,10 @@
  * 가져오고, 상세 항목만 등록·수정한다(사용자 확정).
  */
 
-/** 목록 한 행. 가맹점 전체가 오며 상세 미등록 건은 `hasDetail`이 false다. */
+/**
+ * 가맹점 한 건 + 상세 유무. 백엔드 목록은 가맹점 전체를 주지만 화면의 목록에는 상세가 있는
+ * 건만 서고(사용자 확정), 상세가 없는 건은 등록 화면의 업체 선택지가 된다.
+ */
 export type MerchantDetailSummary = {
   /** 백엔드 `frcsNo` — 가맹점 코드이자 상세의 키. */
   id: string;
@@ -25,7 +28,7 @@
   isVisible: boolean | null;
   /** 상세 등록일(`d.FRST_REG_DT`). 없으면 상세가 아직 없는 가맹점이다. */
   registeredAt: string | null;
-  /** 상세 행이 있는지 — 일괄 비활성·삭제 대상인지, 메뉴를 추가할 수 있는지를 가른다. */
+  /** 상세 행이 있는지 — 목록에 서는지, 등록 화면의 선택지가 되는지를 가른다. */
   hasDetail: boolean;
 };
 
Add a comment
List