임동욱 임동욱 09-16
feat: 가맹몰 상세 목록에 상세가 없는 가맹점도 다시 세운다
백엔드(feature/voc) 목록은 상세 있는 가맹점만 주므로 Repository가 가맹점 목록에 상세 목록을
덧입혀 합치고, 검색·카테고리·노출·등록일 기간·페이지를 프론트에서 처리한다(사용자 확정). 상세
없는 행의 [수정]은 가맹점 목록에서 그 건을 찾아 고정한 채 처음 만드는 화면을 연다. 상세 응답에
업체명이 빠지는 결함은 가맹점 목록의 이름으로 메운다.

Co-Authored-By: Claude Opus 5 
@dacbc0a064b27e2a2970383603b50c7a248a6a9f
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,9 +43,11 @@
 import styles from './merchant-detail-form.module.scss';
 
 interface MerchantDetailFormProps {
-  /** 상세가 있는 가맹점 — 고친다. 없으면 등록이다. */
+  /** 상세가 있는 가맹점 — 고친다. */
   detail?: MerchantDetail;
-  /** 등록 — 아직 상세가 없는 가맹점 선택지. 업체명 자리에서 하나를 고른다. */
+  /** 상세가 아직 없는데 가맹점은 정해진 경우(행의 [수정]) — 그 가맹점에 처음 만든다. */
+  merchant?: MerchantDetailSummary;
+  /** 가맹점도 정해지지 않은 경우([+ 업체 등록]) — 업체명 자리에서 하나를 고른다. */
   candidates?: MerchantDetailSummary[];
   products?: MerchantProduct[];
   categories: CommonCode[];
@@ -53,15 +55,16 @@
 
 /**
  * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 있으면
- * 고치고, 없으면 업체명 자리에서 상세가 없는 가맹점을 골라 처음 만든다 — 백엔드 목록·단건이 상세 있는
- * 가맹점만 다루므로(feature/voc) 등록은 이 선택으로만 들어온다. 첫 저장 뒤에는 그 가맹점 주소로 옮겨
- * 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후 저장은 그 자리에 머문다.
+ * 고치고, `merchant`면 그 가맹점에 처음 만들고, 둘 다 없으면 업체명 자리에서 가맹점을 골라 만든다.
+ * 첫 저장 뒤에는 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후
+ * 저장은 그 자리에 머문다.
  *
  * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다. 기획의 [주소 검색]은 가맹점 관리
  * 화면의 것이라 여기서는 값을 바꾸지 않는다.
  */
 export function MerchantDetailForm({
   detail,
+  merchant: fixedMerchant,
   candidates = [],
   products = [],
   categories,
@@ -76,10 +79,11 @@
   const isEdit = detail !== undefined;
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  const [frcsNo, setFrcsNo] = useState(detail?.id ?? '');
-  // 읽기 전용 칸의 출처 — 수정은 상세, 등록은 고른 가맹점.
+  // 가맹점이 정해져 있으면(상세가 있든 없든) 업체명은 읽기 전용이고, 아니면 여기서 고른다.
+  const pinned: MerchantDetailSummary | null = detail ?? fixedMerchant ?? null;
+  const [frcsNo, setFrcsNo] = useState(pinned?.id ?? '');
   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',
@@ -163,8 +167,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
@@ -2,7 +2,10 @@
 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 {
+  fetchMerchantDetail,
+  fetchMerchantSummary,
+} 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 { MerchantDetailForm } from './_components/merchant-detail-form';
@@ -15,22 +18,35 @@
 }
 
 /**
- * 상세가 있는 가맹점의 상세 화면. 상세가 없으면 백엔드(feature/voc)가 NOT_FOUND를 주므로 404다 —
- * develop 백엔드는 가맹점 행을 그대로 주므로 `hasDetail`로 한 번 더 거른다(수정이 빈 UPDATE가 되지 않게).
+ * 가맹점 하나의 상세 화면. 상세가 있으면 고치고, 없으면(백엔드 단건이 NOT_FOUND — develop은 빈 가맹점
+ * 행을 주므로 `hasDetail`로 한 번 더 거른다) 가맹점 목록에서 찾아 그 가맹점을 고정한 채 처음 만든다.
  */
 export default async function Page({ params }: PageProps) {
   await verifySession();
 
   const { frcsNo } = await params;
+  const categoriesPromise = fetchCommonCodes(CODE_GROUP.merchantType);
   const detail = await fetchMerchantDetail(frcsNo);
+
   if (!detail || !detail.hasDetail) {
-    notFound();
+    const merchant = await fetchMerchantSummary(frcsNo);
+    if (!merchant) {
+      notFound();
+    }
+    return <MerchantDetailForm merchant={merchant} categories={await categoriesPromise} />;
   }
 
-  const [products, categories] = await Promise.all([
+  const [products, categories, merchant] = await Promise.all([
     fetchMerchantProducts(frcsNo),
-    fetchCommonCodes(CODE_GROUP.merchantType),
+    categoriesPromise,
+    // 상세 응답에 업체명이 빠지는 백엔드 결함 — 코드만 왔으면 가맹점 목록에서 이름을 채운다.
+    detail.name === detail.id ? fetchMerchantSummary(frcsNo) : Promise.resolve(null),
   ]);
-
-  return <MerchantDetailForm detail={detail} products={products} categories={categories} />;
+  return (
+    <MerchantDetailForm
+      detail={merchant ? { ...detail, name: merchant.name } : 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을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것.
  *
- * 백엔드(feature/voc)가 상세가 있는 가맹점만 주므로 모든 행이 상세 행이다. [+ 업체 등록]은 상세 화면
- * (등록)으로 가서 상세가 없는 가맹점을 고르고, 행의 [수정]은 그 가맹점의 상세 화면으로 간다.
+ * 가맹점 전체가 서고 상세가 없는 건은 상태·등록일이 `-`다(사용자 확정) — Repository가 가맹점 목록에
+ * 상세를 덧입혀 준다. [+ 업체 등록]은 가맹점을 고르는 등록 화면으로, 행의 [수정]은 그 가맹점의
+ * 상세 화면으로 간다(상세가 없으면 거기서 처음 만든다).
  *
  * 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지
  *   않아 대부분 `-`로 보인다.
@@ -317,7 +318,7 @@
               onValueChange={(value) => setDraft((current) => ({ ...current, visibility: value }))}
             />
           </div>
-          {/* 등록일 기간 — 백엔드가 아직 조건에 쓰지 않는다(보고함). 값은 그대로 보낸다. */}
+          {/* 등록일 기간 — 백엔드가 조건에 쓰지 않아 Repository가 등록일로 거른다. */}
           <div className={styles.period}>
             <FoxInput
               size="md"
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
@@ -16,7 +16,11 @@
   row: MerchantDetailSummary;
 }
 
-/** 기획 BO-MAL-001 관리 열 — [수정] [비활성|노출] [삭제]. 목록 행은 모두 상세가 있는 가맹점이다. */
+/**
+ * 기획 BO-MAL-001 관리 열 — 모든 행에 [수정] [비활성|노출] [삭제](시안대로, 사용자 확정). [수정]은 그
+ * 가맹점의 상세 화면으로 가고(상세가 없으면 거기서 처음 만든다), 상세가 없는 행의 [비활성]·[삭제]는
+ * 백엔드가 NOT_FOUND로 거절해 사유가 토스트로 올라온다.
+ */
 export function MerchantDetailRowActions({ row }: MerchantDetailRowActionsProps) {
   const router = useRouter();
   const { showAlert, hideAlert, showToast } = useFeedback();
lib/data/repositories/merchant-detail-repository.ts
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
@@ -19,7 +19,7 @@
  * POST   /api/v1/mngr/voc/detail              @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404
  * PUT    /api/v1/mngr/voc/detail/{frcsNo}     @RequestBody · 전 컬럼 덮어씀
  * DELETE /api/v1/mngr/voc/{frcsNo}            논리 삭제(DEL_YN)
- * GET    /api/v1/mngr/voc/pagination          가맹점 전체 — 등록 화면의 선택지에만 쓴다
+ * GET    /api/v1/mngr/voc/pagination          가맹점 전체 — 목록 합치기·등록 선택지·상세 없는 단건에 쓴다
  * ```
  *
  * 목록·단건이 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서·우편번호·등록자(`frstRgtrNmStr`)·
@@ -180,44 +180,57 @@
   };
 }
 
+/**
+ * 목록 — 가맹점 전체에 상세를 덧입혀 상세가 없는 가맹점도 세운다(사용자 확정). 백엔드 목록은 상세가
+ * 있는 것만 주므로 가맹점 목록과 상세 목록을 모두 받아 합치고, 검색·기간·페이지는 여기서 처리한다.
+ * 부수 효과로 백엔드가 쓰지 않던 등록일 기간 조건과 응답에 빠진 업체명(결함 1)이 여기서 채워진다.
+ */
 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: {
-      ...(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',
-    canHaveNullData: true,
+  const merged = await fetchMergedSummaries();
+  const keyword = query.keyword.trim().toLowerCase();
+  const filtered = merged.filter((row) => {
+    if (keyword && !row.name.toLowerCase().includes(keyword)) return false;
+    if (query.category && row.categoryCode !== query.category) return false;
+    if (query.visibility && row.isVisible !== (query.visibility === 'Y')) return false;
+    if (query.startDate && (row.registeredAt === null || row.registeredAt < query.startDate)) return false;
+    if (query.endDate && (row.registeredAt === null || row.registeredAt > query.endDate)) return false;
+    return true;
   });
-
-  if (!result.ok) {
-    throw new BackendRequestError(result);
-  }
-
-  const data = result.data;
-  if (data === null || data === undefined) {
-    return { items: [], totalCount: 0 };
-  }
-  if (!isRecord(data) || !Array.isArray(data.list)) {
-    throw new Error('가맹몰 상세 목록 응답의 형식이 올바르지 않습니다.');
-  }
-
+  const start = (query.page - 1) * query.pageSize;
   return {
-    items: data.list.flatMap(toSummary),
-    totalCount:
-      typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
+    items: filtered.slice(start, start + query.pageSize),
+    totalCount: filtered.length,
   };
+}
+
+/** 상세 있는 가맹점을 등록일 내림차순으로 먼저, 나머지 가맹점을 코드 내림차순으로 뒤에 둔다. */
+function compareSummaryOrder(a: MerchantDetailSummary, b: MerchantDetailSummary): number {
+  if (a.hasDetail !== b.hasDetail) return a.hasDetail ? -1 : 1;
+  const dateA = a.registeredAt ?? '';
+  const dateB = b.registeredAt ?? '';
+  if (dateA !== dateB) return dateA < dateB ? 1 : -1;
+  return b.id.localeCompare(a.id, undefined, { numeric: true });
+}
+
+async function fetchMergedSummaries(): Promise<MerchantDetailSummary[]> {
+  const [merchants, details] = await Promise.all([
+    fetchAllPages(`${VOC_PATH}/pagination`, toCandidate),
+    fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary),
+  ]);
+  const byId = new Map(merchants.map((row) => [row.id, row]));
+  for (const detail of details) {
+    // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 섞어 준다 — 상세 있는 행만 덧입힌다.
+    if (!detail.hasDetail) continue;
+    const merchant = byId.get(detail.id);
+    byId.set(detail.id, {
+      ...detail,
+      // 결함 1 — 상세 응답에 업체명이 없으면 가맹점 목록의 이름을 쓴다.
+      name: detail.name === detail.id && merchant ? merchant.name : detail.name,
+    });
+  }
+  return [...byId.values()].sort(compareSummaryOrder);
 }
 
 export async function fetchMerchantDetail(
@@ -283,18 +296,17 @@
   return rows;
 }
 
-/**
- * 등록 화면의 업체 선택지 — 가맹점 전체에서 이미 상세가 있는 것을 뺀다. 백엔드에 "상세 없는 가맹점"
- * 조회가 없어 두 목록을 훑어 차집합을 만든다.
- */
+/** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */
 export async function fetchMerchantDetailCandidates(): Promise<MerchantDetailSummary[]> {
-  const [merchants, registered] = await Promise.all([
-    fetchAllPages(`${VOC_PATH}/pagination`, toCandidate),
-    fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary),
-  ]);
-  // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 목록에 섞어 주므로 상세가 있는 것만 뺀다.
-  const registeredIds = new Set(registered.filter((row) => row.hasDetail).map((row) => row.id));
-  return merchants.filter((row) => !registeredIds.has(row.id));
+  return (await fetchMergedSummaries()).filter((row) => !row.hasDetail);
+}
+
+/** 가맹점 한 건(상세 없이) — 가맹점 단건 API가 없어 목록에서 찾는다. */
+export async function fetchMerchantSummary(
+  frcsNo: string
+): Promise<MerchantDetailSummary | null> {
+  const merchants = await fetchAllPages(`${VOC_PATH}/pagination`, toCandidate);
+  return merchants.find((row) => row.id === frcsNo) ?? null;
 }
 
 function toWritePayload(values: MerchantDetailValues): Record<string, unknown> {
Add a comment
List