임동욱 임동욱 09-16
feat: 가맹몰 상세의 기본 정보를 늘 가맹점 값으로 고정하고 [업체 등록]은 비활성으로 둔다
상세가 없는 가맹점도 목록에 서므로 상세 화면은 언제나 행에서 들어온다 — 업체명 선택 상자와
/new 화면, 그 선택지를 만들던 전 페이지 훑기를 지운다. 업체(가맹점)는 BizPlay 미러라
[+ 업체 등록]은 시안 자리만 지키고 비활성이다(사용자 확정). 표의 대표이미지는 인라인
흐름에 두어 셀 가운데에 선다.

Co-Authored-By: Claude Opus 5 
@b865f0bfded9920a3000452535bf1b5118e3c091
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,32 +43,25 @@
 import styles from './merchant-detail-form.module.scss';
 
 interface MerchantDetailFormProps {
-  /** 상세가 있는 가맹점 — 고친다. */
+  /** 이 화면의 가맹점 — 업체명·연락처·주소의 출처. 상세 유무와 무관하게 늘 정해져 있다. */
+  merchant: MerchantDetailSummary;
+  /** 상세가 이미 있으면 그 값. 없으면 이 화면에서 처음 만든다. */
   detail?: MerchantDetail;
-  /** 상세가 아직 없는데 가맹점은 정해진 경우(행의 [수정]) — 그 가맹점에 처음 만든다. */
-  merchant?: MerchantDetailSummary;
-  /** 가맹점도 정해지지 않은 경우([+ 업체 등록]) — 업체명 자리에서 여기서 하나를 고른다. */
-  candidates?: MerchantDetailSummary[];
-  /** 선택지 중 미리 골라 둘 가맹점(`?frcsNo=`). */
-  initialFrcsNo?: string;
   products?: MerchantProduct[];
   categories: CommonCode[];
 }
 
 /**
- * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이
- * 있으면 상세가 이미 있는 가맹점, `merchant`면 상세가 없는 정해진 가맹점, 둘 다 없으면 업체명
- * 자리에서 가맹점을 고른다. 처음 저장하면 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로
- * 메뉴를 붙일 수 있고, 이후 저장은 그 자리에 머문다.
+ * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). 목록의 행에서
+ * 들어오며 `detail`이 있으면 고치고, 없으면 처음 만든다. 첫 저장 뒤에도 화면 구성은 그대로라
+ * 바로 메뉴를 붙일 수 있고, 저장은 그 자리에 머문다.
  *
- * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이고, 기획의 [주소 검색]은 가맹점 관리
- * 화면의 것이라 여기서는 값을 바꾸지 않는다.
+ * 업체명·연락처·우편번호·주소는 가맹점 원본이라 늘 읽기 전용이다(사용자 확정). 기획의 [주소 검색]은
+ * 가맹점 관리 화면의 것이라 여기서는 값을 바꾸지 않는다.
  */
 export function MerchantDetailForm({
+  merchant,
   detail,
-  merchant: fixedMerchant,
-  candidates = [],
-  initialFrcsNo,
   products = [],
   categories,
 }: MerchantDetailFormProps) {
@@ -82,11 +75,7 @@
   const isEdit = detail !== undefined;
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  // 가맹점이 정해져 있으면(상세가 있든 없든) 업체명은 읽기 전용이고, 아니면 여기서 고른다.
-  const pinned: MerchantDetailSummary | null = detail ?? fixedMerchant ?? null;
-  const [frcsNo, setFrcsNo] = useState(pinned?.id ?? initialFrcsNo ?? '');
-  const merchant: MerchantDetailSummary | null =
-    pinned ?? candidates.find((row) => row.id === frcsNo) ?? null;
+  const frcsNo = merchant.id;
   const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? '');
   const [visibility, setVisibility] = useState<string[]>([
     detail?.isVisible === false ? 'N' : 'Y',
@@ -170,29 +159,7 @@
         <FoxCard size="lg" title="기본 정보" className={styles.section}>
           <div className={styles.grid}>
             <div className={styles.fieldWide}>
-              {pinned ? (
-                <FoxInput size="md" label="업체명" requirement="required" value={pinned.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="상세를 붙일 가맹점이 없습니다. 가맹점 관리에서 먼저 등록해 주세요."
-                    />
-                  )}
-                </>
-              )}
+              <FoxInput size="md" label="업체명" requirement="required" value={merchant.name} readOnly state="view" />
             </div>
             <div className={styles.field}>
               <FoxSelect
@@ -213,7 +180,7 @@
                 size="md"
                 label="업체 연락처"
                 requirement="required"
-                value={formatOptionalValue(formatPhoneNumber(merchant?.phoneNumber ?? null))}
+                value={formatOptionalValue(formatPhoneNumber(merchant.phoneNumber))}
                 readOnly
                 state="view"
               />
@@ -225,10 +192,10 @@
               <FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(detail?.postalCode)} readOnly state="view" />
             </div>
             <div className={styles.fieldWide}>
-              <FoxInput size="md" label="주소" value={formatOptionalValue(merchant?.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(merchant?.addressDetail)} readOnly state="view" />
+              <FoxInput size="md" label="상세주소" value={formatOptionalValue(merchant.addressDetail)} readOnly state="view" />
             </div>
           </div>
 
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
@@ -30,8 +30,12 @@
     fetchCommonCodes(CODE_GROUP.merchantType),
   ]);
 
-  if (!detail.hasDetail) {
-    return <MerchantDetailForm merchant={detail} categories={categories} />;
-  }
-  return <MerchantDetailForm detail={detail} products={products} categories={categories} />;
+  return (
+    <MerchantDetailForm
+      merchant={detail}
+      detail={detail.hasDetail ? detail : undefined}
+      products={products}
+      categories={categories}
+    />
+  );
 }
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', errors: { frcsNo: '업체를 선택해 주세요.' } };
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
   }
 
   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,7 +26,6 @@
 import {
   FILTER_ALL,
   MERCHANT_DETAILS_PATH,
-  MERCHANT_DETAIL_NEW_PATH,
   MERCHANT_DETAIL_PAGE_SIZE_OPTIONS,
   buildMerchantDetailHref,
   clampDateRange,
@@ -56,9 +55,9 @@
 /**
  * 가맹몰 상세 목록 — 기획 BO-MAL-001을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것.
  *
- * 가맹점 전체가 서고 상세가 없는 건은 상태·등록일이 `-`다(사용자 확정). [+ 업체 등록]은 상세
- * 화면에서 가맹점을 고르게 하고, 행의 [수정]은 그 가맹점의 상세 화면으로 간다 — 상세가 없으면
- * 거기서 처음 만든다.
+ * 가맹점 전체가 서고 상세가 없는 건은 상태·등록일이 `-`다(사용자 확정). 행의 [수정]은 그 가맹점의
+ * 상세 화면으로 간다 — 상세가 없으면 거기서 처음 만든다. 업체(가맹점) 자체는 BizPlay 쪽에서 오므로
+ * [+ 업체 등록]은 시안 자리만 지키고 비활성이다(사용자 확정).
  *
  * 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지
  *   않아 대부분 `-`로 보인다.
@@ -372,7 +371,7 @@
             size="md"
             leadingIcon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />}
             label="업체 등록"
-            onAction={() => router.push(MERCHANT_DETAIL_NEW_PATH)}
+            disabled
           />
         </>
       }
app/(protected)/(basic)/system/merchant-details/_components/merchant-thumbnail.module.scss
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-thumbnail.module.scss
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-thumbnail.module.scss
@@ -3,17 +3,19 @@
 // 기획 BO-MAL-001·002의 56×40 썸네일 틀을 1.5배 → 다시 1.2배(약 101×72)로 키웠다(사용자
 // 확정 — 시스템에 썸네일 크기 토큰이 없어 와이어프레임 값이 기준이었다). 토큰 스케일 밖이라
 // 리터럴로 두고 근거를 남긴다(꾸미기아이템 썸네일과 같은 처리).
+// 둘 다 인라인 흐름에 둔다 — 표 셀의 text-align: center가 그대로 먹어 가운데 선다.
 .image,
 .placeholder {
   box-sizing: border-box;
   inline-size: 10.08rem;
   block-size: 7.2rem;
+  vertical-align: middle;
   border: fox.border(1) solid fox.color(border-neutral-subtler);
   border-radius: fox.radius(4);
 }
 
 .image {
-  display: block;
+  display: inline-block;
   object-fit: cover;
 }
 
 
app/(protected)/(basic)/system/merchant-details/new/page.tsx (deleted)
--- app/(protected)/(basic)/system/merchant-details/new/page.tsx
@@ -1,34 +0,0 @@
-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
@@ -147,28 +147,21 @@
   };
 }
 
-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 }> {
+export async function fetchMerchantDetails(
+  query: MerchantDetailQuery
+): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> {
   const accessToken = await getSessionAccessToken();
 
   const result = await backendFetch<unknown>(`${DETAIL_PATH}/pagination`, {
     method: 'GET',
     query: {
-      ...(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,
+      ...(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,
     },
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
@@ -181,7 +174,7 @@
 
   const data = result.data;
   if (data === null || data === undefined) {
-    return { items: [], rowCount: 0, totalCount: 0 };
+    return { items: [], totalCount: 0 };
   }
   if (!isRecord(data) || !Array.isArray(data.list)) {
     throw new Error('가맹몰 상세 목록 응답의 형식이 올바르지 않습니다.');
@@ -189,46 +182,9 @@
 
   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 { items, totalCount } = await fetchSummaryPage(query, query.page, query.pageSize);
-  return { items, totalCount };
-}
-
-/** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */
-export async function fetchMerchantDetailCandidates(): Promise<MerchantDetailSummary[]> {
-  return (await fetchAllSummaries({})).filter((row) => !row.hasDetail);
 }
 
 export async function fetchMerchantDetail(
lib/domain/merchant-detail-form.ts
--- lib/domain/merchant-detail-form.ts
+++ lib/domain/merchant-detail-form.ts
@@ -29,7 +29,6 @@
 
 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,7 +1,6 @@
 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)}`;
Add a comment
List