임동욱 임동욱 09-11
feat: 시스템관리 > 가맹몰 상세 관리 화면을 만든다
기획 BO-MAL-001~004(지역상생몰 > 업체관리)를 「가맹몰 상세 관리」로 옮겼다. 백엔드
(15d409a)의 상세는 기존 가맹점(frcsNo)에 1:1로 붙는 구조라 업체를 새로 만들지 않는다 —
목록에 가맹점 전체를 보이고 행에서 [상세 등록]으로 들어가며, 업체명·연락처·주소는 가맹점
원본을 읽기 전용으로 보여 준다(사용자 확정).

- 목록: 업체명·카테고리·노출상태·등록일 검색, 체크박스 일괄 비활성·삭제, 행 관리
- 등록·수정(/[frcsNo]): 기본 정보 + 이미지(대표 1 + 추가 11) + 메뉴 표 + 등록 정보
- 메뉴 등록·수정(/[frcsNo]/products): 이미지·메뉴명·가격·설명·배지·노출·순서
- @fox FoxSortableList 신설 — 네이티브 DnD + 방향키, 상태 없이 onReorder만 (사용자 확정)
- 배지는 코드를 쉼표로 이어 frcsBadge에 싣는다(BEST,MAIN,NEW,SALE — 사용자 확정)
- 파일 저장소 모듈은 바우처용이 없어 잠정 MODULE_VOUCHER (사용자 확정)

백엔드가 아직 주지 않는 값(노출 상태·카테고리·수정 프리필·기간 검색·메뉴 건수·바우처 사용)은
화면을 시안대로 두고 그대로 보낸다(사용자 확정). 목록의 노출 토글만은 예외다 — PUT이 모든
컬럼을 덮어써 단건 조회가 비어 있는 동안 값 없이 보내면 상세가 지워지므로, 조회 결과에
대표이미지가 없으면 거부하고 수정 화면으로 안내한다.

추가 이미지 묶음은 fileSn이 1..n으로 채번되어 순서·삭제·추가 어느 것이든 통째로 다시 올린다.
저장된 장은 백엔드에서 다시 읽어 합친다. 묶음의 장 수를 묻는 API가 없어 HEAD로 더듬는다.

Co-Authored-By: Claude Opus 5 
@e545cdcfc2d432620b1663e5ab0653b1aba48dc3
 
@fox/core/components/fox-sortable-list/fox-sortable-list.tsx (added)
+++ @fox/core/components/fox-sortable-list/fox-sortable-list.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import {
+  useId,
+  useState,
+  type DragEvent,
+  type KeyboardEvent,
+  type ReactNode,
+} from "react";
+import { cx } from "../../utils";
+
+export type FoxSortableListDirection = "horizontal" | "vertical";
+
+export interface FoxSortableListProps<T> {
+  items: readonly T[];
+  /** 항목의 안정적인 키. 인덱스를 쓰면 옮긴 뒤 상태가 어긋난다. */
+  getKey: (item: T) => string;
+  /** 항목 본문. 핸들은 목록이 그리므로 여기서 다시 그리지 않는다. */
+  renderItem: (item: T, index: number) => ReactNode;
+  /** 순서가 바뀔 때마다 새 배열을 준다. 목록은 상태를 갖지 않는다. */
+  onReorder: (next: T[]) => void;
+  direction?: FoxSortableListDirection;
+  /** 목록 전체를 읽어 줄 이름. 핸들의 안내문에도 쓰인다. */
+  ariaLabel: string;
+  disabled?: boolean;
+  className?: string;
+}
+
+/**
+ * @fox 정렬 목록 — 항목의 **순서만** 바꾼다. 내용은 `renderItem`이 그린다.
+ *
+ * 마우스는 핸들을 끌어 놓고(네이티브 HTML5 드래그 — 의존성 없음), 키보드는 핸들에 초점을
+ * 두고 방향키로 옮긴다. 둘 다 `onReorder`로 새 배열을 돌려주며 목록은 상태를 갖지 않는다.
+ *
+ * 놓는 위치는 "끌고 있는 항목을 지금 가리키는 항목 자리로" 한 가지다 — 앞뒤 절반을 나누면
+ * 타일 크기가 제각각인 곳에서 예측이 안 된다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxSortableList<T>({
+  items,
+  getKey,
+  renderItem,
+  onReorder,
+  direction = "horizontal",
+  ariaLabel,
+  disabled = false,
+  className,
+}: FoxSortableListProps<T>) {
+  const [draggingKey, setDraggingKey] = useState<string | null>(null);
+  const [overKey, setOverKey] = useState<string | null>(null);
+  const hintId = useId();
+
+  function move(from: number, to: number) {
+    if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {
+      return;
+    }
+    const next = [...items];
+    const [picked] = next.splice(from, 1);
+    next.splice(to, 0, picked);
+    onReorder(next);
+  }
+
+  function indexOfKey(key: string) {
+    return items.findIndex((item) => getKey(item) === key);
+  }
+
+  function handleDragStart(event: DragEvent<HTMLElement>, key: string) {
+    // Firefox는 데이터가 없으면 드래그를 시작하지 않는다.
+    event.dataTransfer.setData("text/plain", key);
+    event.dataTransfer.effectAllowed = "move";
+    setDraggingKey(key);
+  }
+
+  function handleDragOver(event: DragEvent<HTMLElement>, key: string) {
+    if (draggingKey === null) {
+      return;
+    }
+    event.preventDefault();
+    event.dataTransfer.dropEffect = "move";
+    if (overKey !== key) {
+      setOverKey(key);
+    }
+  }
+
+  function handleDrop(event: DragEvent<HTMLElement>, key: string) {
+    event.preventDefault();
+    if (draggingKey !== null) {
+      move(indexOfKey(draggingKey), indexOfKey(key));
+    }
+    setDraggingKey(null);
+    setOverKey(null);
+  }
+
+  function handleDragEnd() {
+    setDraggingKey(null);
+    setOverKey(null);
+  }
+
+  function handleHandleKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
+    const backward = direction === "horizontal" ? "ArrowLeft" : "ArrowUp";
+    const forward = direction === "horizontal" ? "ArrowRight" : "ArrowDown";
+
+    if (event.key === backward) {
+      event.preventDefault();
+      move(index, index - 1);
+    } else if (event.key === forward) {
+      event.preventDefault();
+      move(index, index + 1);
+    } else if (event.key === "Home") {
+      event.preventDefault();
+      move(index, 0);
+    } else if (event.key === "End") {
+      event.preventDefault();
+      move(index, items.length - 1);
+    }
+  }
+
+  const keyHint =
+    direction === "horizontal"
+      ? "왼쪽·오른쪽 방향키로 순서를 옮깁니다."
+      : "위·아래 방향키로 순서를 옮깁니다.";
+
+  return (
+    <ul
+      className={cx(
+        "fox-sortable-list",
+        `fox-sortable-list--${direction}`,
+        disabled && "fox-sortable-list--disabled",
+        className,
+      )}
+      aria-label={ariaLabel}
+    >
+      <span id={hintId} hidden>
+        {keyHint}
+      </span>
+      {items.map((item, index) => {
+        const key = getKey(item);
+        const isDragging = draggingKey === key;
+        const isOver = overKey === key && draggingKey !== null && !isDragging;
+
+        return (
+          <li
+            key={key}
+            className={cx(
+              "fox-sortable-list__item",
+              isDragging && "fox-sortable-list__item--dragging",
+              isOver && "fox-sortable-list__item--over",
+            )}
+            onDragOver={disabled ? undefined : (event) => handleDragOver(event, key)}
+            onDrop={disabled ? undefined : (event) => handleDrop(event, key)}
+          >
+            {!disabled && (
+              <button
+                type="button"
+                className="fox-sortable-list__handle"
+                aria-label={`${index + 1}번째 항목 순서 변경`}
+                aria-describedby={hintId}
+                draggable
+                onDragStart={(event) => handleDragStart(event, key)}
+                onDragEnd={handleDragEnd}
+                onKeyDown={(event) => handleHandleKeyDown(event, index)}
+              >
+                <i className="fox-ico fox-ico-DotsSixVertical" aria-hidden="true" />
+              </button>
+            )}
+            <div className="fox-sortable-list__body">{renderItem(item, index)}</div>
+          </li>
+        );
+      })}
+    </ul>
+  );
+}
 
@fox/core/components/fox-sortable-list/index.ts (added)
+++ @fox/core/components/fox-sortable-list/index.ts
@@ -0,0 +1,5 @@
+export {
+  FoxSortableList,
+  type FoxSortableListDirection,
+  type FoxSortableListProps,
+} from "./fox-sortable-list";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -70,3 +70,4 @@
 export * from "./fox-list-toolbar";
 export * from "./fox-list-container";
 export * from "./fox-description-list";
+export * from "./fox-sortable-list";
@fox/dev-test/component-groups.ts
--- @fox/dev-test/component-groups.ts
+++ @fox/dev-test/component-groups.ts
@@ -75,7 +75,7 @@
   {
     label: "Layout",
     subgroups: [
-      { label: "Table & List", ids: ["fox-table", "fox-bullet-list"] },
+      { label: "Table & List", ids: ["fox-table", "fox-bullet-list", "fox-sortable-list"] },
       { label: "Tab", ids: ["fox-tab"] },
       { label: "Badge", ids: ["fox-badge", "fox-badge-dot", "fox-badge-group"] },
       { label: "Disclosure", ids: ["fox-accordion", "fox-disclosure"] },
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -64,6 +64,7 @@
 import { FoxAccordion } from "../core/components/fox-accordion";
 import { FoxCard } from "../core/components/fox-card";
 import { FoxDisclosure } from "../core/components/fox-disclosure";
+import { FoxSortableList } from "../core/components/fox-sortable-list";
 import styles from "./dev-test.module.scss";
 
 export interface ComponentExample {
@@ -139,6 +140,35 @@
 }
 
 // 카운터가 실제로 올라가는 걸 보려면 예제가 값을 들고 있어야 한다.
+function SortableListLiveDemo({ direction }: { direction: "horizontal" | "vertical" }) {
+  const [items, setItems] = useState(["첫째", "둘째", "셋째", "넷째"]);
+  return (
+    <FoxSortableList
+      items={items}
+      getKey={(item) => item}
+      direction={direction}
+      ariaLabel="정렬 예제"
+      onReorder={setItems}
+      renderItem={(item) => (
+        <div
+          style={{
+            display: "flex",
+            alignItems: "center",
+            justifyContent: "center",
+            inlineSize: "8rem",
+            blockSize: "8rem",
+            border: "var(--fox-border-1) solid var(--fox-color-border-neutral-subtle)",
+            borderRadius: "var(--fox-radius-3)",
+            background: "var(--fox-color-surface-neutral-gray)",
+          }}
+        >
+          {item}
+        </div>
+      )}
+    />
+  );
+}
+
 function TextAreaLiveDemo() {
   const [text, setText] = useState("입력하면 아래 숫자가 따라 올라갑니다.");
   return (
@@ -4283,4 +4313,20 @@
       },
     ],
   },
+  {
+    id: "fox-sortable-list",
+    name: "FoxSortableList",
+    description:
+      "항목 순서를 끌어서 바꾸는 목록입니다. 시안에 정렬 목록 컴포넌트가 없어 기획 문구(추가 이미지는 드래그로 순서를 바꾼다)만으로 만들었습니다 — 핸들 하나와 끌고 있는 항목·놓을 자리 표시가 전부이고 본문은 호출부가 그립니다. 핸들에 초점을 두고 방향키(가로는 ←→, 세로는 ↑↓)·Home·End로도 옮길 수 있어 마우스 없이도 됩니다. 목록은 상태를 갖지 않고 onReorder로 새 배열만 돌려줍니다.",
+    variants: [
+      {
+        label: "가로 — 핸들을 끌거나 초점을 두고 ←→",
+        node: <SortableListLiveDemo direction="horizontal" />,
+      },
+      {
+        label: "세로 — ↑↓",
+        node: <SortableListLiveDemo direction="vertical" />,
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-sortable-list.scss (added)
+++ @fox/styles/_fox-sortable-list.scss
@@ -0,0 +1,90 @@
+// FoxSortableList — 항목 순서를 끌어서(또는 방향키로) 바꾸는 목록.
+//
+// 시안에 정렬 목록 컴포넌트가 없어 기획 문구("추가 이미지는 드래그로 순서를 바꾼다",
+// BO-MAL-002 ⑤)만으로 만들었다(사용자 확정). 모양은 최소한으로 둔다 — 핸들 하나와
+// 끌고 있는 항목·놓을 자리 표시뿐이고, 항목 본문은 호출부가 그린다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <ul class="fox-sortable-list fox-sortable-list--horizontal" aria-label="…">
+//     <li class="fox-sortable-list__item [--dragging|--over]">
+//       <button class="fox-sortable-list__handle" type="button" draggable aria-label="…">
+//         <i class="fox-ico fox-ico-DotsSixVertical"></i>
+//       </button>
+//       <div class="fox-sortable-list__body">…항목 본문…</div>
+//     </li>
+//   </ul>
+//
+// 토큰이 아닌 값:
+//   - 끌고 있는 항목의 opacity: 상태 표현이라 색 토큰이 없다. 관례값 0.4.
+//   - 버튼 초기화(appearance/cursor/색 상속): 구조 규칙.
+
+@use "abstracts" as fox;
+
+.fox-sortable-list {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  gap: fox.gap(4);
+  padding: 0;
+  margin: 0;
+  list-style: none;
+
+  &--vertical {
+    flex-direction: column;
+    flex-wrap: nowrap;
+    align-items: stretch;
+  }
+
+  &__item {
+    position: relative;
+    display: flex;
+    align-items: flex-start;
+    gap: fox.gap(2);
+    border-radius: fox.radius(3);
+    // 놓을 자리 테두리가 나타날 때 항목이 밀리지 않도록 투명 테두리를 미리 둔다.
+    outline: fox.border(2) solid transparent;
+    outline-offset: fox.number(2);
+
+    &--dragging {
+      opacity: 0.4;
+    }
+
+    &--over {
+      outline-color: fox.color(border-theme-primary);
+    }
+  }
+
+  &__handle {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    padding: fox.padding(1);
+    margin: 0;
+    border: 0;
+    border-radius: fox.radius(2);
+    background: transparent;
+    color: fox.color(icon-neutral-subtle);
+    font-size: fox.icon(3);
+    cursor: grab;
+    appearance: none;
+
+    &:hover {
+      background: fox.color(action-default-hover);
+      color: fox.color(icon-neutral-default);
+    }
+
+    &:active {
+      cursor: grabbing;
+    }
+  }
+
+  &__body {
+    min-inline-size: 0;
+    flex: 1 1 auto;
+  }
+
+  &--disabled &__item {
+    outline: 0;
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -74,4 +74,5 @@
 @use "fox-list-toolbar";
 @use "fox-list-container";
 @use "fox-disclosure";
+@use "fox-sortable-list";
 @use "fox-description-list";
 
app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.module.scss (added)
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.module.scss
@@ -0,0 +1,50 @@
+@use "@fox/styles/abstracts" as fox;
+
+.form {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(6);
+}
+
+.section {
+  inline-size: 100%;
+}
+
+// 기획 BO-MAL-002의 한 줄 — 칸 폭은 시안 실측(400 / 280 / 160 / 520 / 680)을 따르되 고정하지
+// 않고 기준 폭으로 두어 창이 좁으면 접는다. 토큰 스케일 밖의 값이라 리터럴로 둔다.
+.grid {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  gap: fox.gap(4);
+  inline-size: 100%;
+
+  & + & {
+    margin-block-start: fox.gap(5);
+  }
+}
+
+.field {
+  display: flex;
+  flex: 1 1 28rem;
+  min-inline-size: 0;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
+
+.fieldWide {
+  display: flex;
+  flex: 2 1 40rem;
+  min-inline-size: 0;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
+
+.fieldNarrow {
+  display: flex;
+  flex: 0 1 18rem;
+  min-inline-size: 0;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
 
app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
@@ -0,0 +1,340 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useActionState, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
+import { FoxCard } from '@fox/core/components/fox-card';
+import { FoxDescriptionList } from '@fox/core/components/fox-description-list';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxPageHeader } from '@fox/core/components/fox-page-header';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCode } from '@/lib/domain/common-code';
+import {
+  MERCHANT_DETAIL_VISIBILITY_OPTIONS,
+  MERCHANT_VOUCHER_USE_OPTIONS,
+  formatMerchantDetailStatusLabel,
+  formatOptionalValue,
+  type MerchantDetail,
+} from '@/lib/domain/merchant-detail';
+import {
+  CLOSED_DAYS_MAX_LENGTH,
+  DESCRIPTION_MAX_LENGTH,
+  INITIAL_MERCHANT_DETAIL_FORM_STATE,
+  SORT_ORDER_MAX,
+} from '@/lib/domain/merchant-detail-form';
+import { MERCHANT_DETAILS_PATH } from '@/lib/domain/merchant-detail-query';
+import type { MerchantProduct } from '@/lib/domain/merchant-product';
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
+import {
+  deleteMerchantDetailAction,
+  saveMerchantDetailAction,
+  updateMerchantDetailVisibilityAction,
+} from '../../_actions';
+import { MerchantChoiceChips } from '../../_components/merchant-choice-chips';
+import { MerchantImageField } from '../../_components/merchant-image-field';
+import { MerchantProductTable } from '../../_components/merchant-product-table';
+import styles from './merchant-detail-form.module.scss';
+
+interface MerchantDetailFormProps {
+  detail: MerchantDetail;
+  products: MerchantProduct[];
+  categories: CommonCode[];
+}
+
+/**
+ * 가맹몰 상세 등록·수정 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다. 상세는
+ * 가맹점당 하나라 `frcsNo`만으로 등록인지 수정인지 갈린다(`detail.hasDetail`).
+ *
+ * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다(사용자 확정). 기획의 [주소 검색]은
+ * 가맹점 관리 화면의 것이고 여기서는 값을 바꾸지 않는다.
+ */
+export function MerchantDetailForm({ detail, products, categories }: MerchantDetailFormProps) {
+  const router = useRouter();
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isBusy, startAction] = useBlockingAction();
+  const [state, formAction, isPending] = useActionState(
+    saveMerchantDetailAction,
+    INITIAL_MERCHANT_DETAIL_FORM_STATE
+  );
+  const isEdit = detail.hasDetail;
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  const [categoryCode, setCategoryCode] = useState(detail.categoryCode ?? '');
+  const [visibility, setVisibility] = useState<string[]>([
+    detail.isVisible === false ? 'N' : 'Y',
+  ]);
+  const [voucherUse, setVoucherUse] = useState<string[]>(['Y']);
+
+  function toggleVisibility() {
+    const next = detail.isVisible === false;
+    startAction(async () => {
+      const result = await updateMerchantDetailVisibilityAction(detail.id, next);
+      showToast(
+        result.status === 'error'
+          ? { variant: 'danger', message: result.message }
+          : { variant: 'success', message: next ? '노출로 바꿨습니다.' : '비활성으로 바꿨습니다.' }
+      );
+    });
+  }
+
+  function runDelete() {
+    hideAlert();
+    startAction(async () => {
+      const result = await deleteMerchantDetailAction(detail.id);
+      if (result.status === 'error') {
+        showToast({ variant: 'danger', message: result.message });
+        return;
+      }
+      showToast({ variant: 'success', message: '삭제했습니다.' });
+      router.push(MERCHANT_DETAILS_PATH);
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '가맹몰 상세를 삭제하시겠습니까?',
+      message: `"${detail.name}"의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  return (
+    <>
+      <FoxPageHeader
+        title={`가맹몰 상세 ${isEdit ? '수정' : '등록'}`}
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '시스템관리' },
+          { label: '가맹몰 상세 관리', href: MERCHANT_DETAILS_PATH },
+          { label: isEdit ? '수정' : '등록' },
+        ]}
+      />
+
+      <form
+        id="merchant-detail-form"
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.form}
+      >
+        <input type="hidden" name="frcsNo" value={detail.id} />
+        <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" />
+            </div>
+            <div className={styles.field}>
+              <FoxSelect
+                size="md"
+                label="카테고리"
+                requirement="required"
+                placeholder="선택"
+                options={categories.map((code) => ({ value: code.code, label: code.label }))}
+                value={categoryCode}
+                onValueChange={setCategoryCode}
+                error={Boolean(errors.categoryCode)}
+              />
+              <input type="hidden" name="categoryCode" value={categoryCode} />
+              {errors.categoryCode && <FoxHelperText type="danger" message={errors.categoryCode} />}
+            </div>
+            <div className={styles.field}>
+              <FoxInput
+                size="md"
+                label="업체 연락처"
+                requirement="required"
+                value={formatOptionalValue(formatPhoneNumber(detail.phoneNumber))}
+                readOnly
+                state="view"
+              />
+            </div>
+          </div>
+
+          <div className={styles.grid}>
+            <div className={styles.fieldNarrow}>
+              <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" />
+            </div>
+            <div className={styles.field}>
+              <FoxInput size="md" label="상세주소" value={formatOptionalValue(detail.addressDetail)} readOnly state="view" />
+            </div>
+          </div>
+
+          <div className={styles.grid}>
+            <div className={styles.fieldWide}>
+              <FoxInput
+                size="md"
+                name="description"
+                label="한줄 소개"
+                placeholder="앱 목록·상세에 노출되는 한 줄 설명"
+                defaultValue={detail.description ?? ''}
+                maxLength={DESCRIPTION_MAX_LENGTH}
+                invalid={Boolean(errors.description)}
+                message={errors.description}
+              />
+            </div>
+            <div className={styles.fieldNarrow}>
+              <FoxInput
+                size="md"
+                name="openTime"
+                type="time"
+                label="영업 시작"
+                defaultValue={detail.openTime ?? ''}
+                invalid={Boolean(errors.openTime)}
+                message={errors.openTime}
+              />
+            </div>
+            <div className={styles.fieldNarrow}>
+              <FoxInput
+                size="md"
+                name="closeTime"
+                type="time"
+                label="영업 종료"
+                defaultValue={detail.closeTime ?? ''}
+                invalid={Boolean(errors.closeTime)}
+                message={errors.closeTime}
+              />
+            </div>
+            <div className={styles.field}>
+              <FoxInput
+                size="md"
+                name="closedDays"
+                label="휴무일"
+                placeholder="예) 매주 월요일"
+                defaultValue={detail.closedDays ?? ''}
+                maxLength={CLOSED_DAYS_MAX_LENGTH}
+                invalid={Boolean(errors.closedDays)}
+                message={errors.closedDays}
+              />
+            </div>
+          </div>
+
+          <div className={styles.grid}>
+            <MerchantChoiceChips
+              label="노출 상태"
+              name="isVisible"
+              required
+              options={MERCHANT_DETAIL_VISIBILITY_OPTIONS}
+              values={visibility}
+              onChange={setVisibility}
+            />
+            <MerchantChoiceChips
+              label="바우처 사용"
+              name="isVoucherUsable"
+              required
+              options={MERCHANT_VOUCHER_USE_OPTIONS}
+              values={voucherUse}
+              onChange={setVoucherUse}
+            />
+            <div className={styles.fieldNarrow}>
+              <FoxInput
+                size="md"
+                name="sortOrder"
+                type="number"
+                inputMode="numeric"
+                min={0}
+                max={SORT_ORDER_MAX}
+                label="노출 순서"
+                placeholder="숫자 (낮을수록 상단)"
+                defaultValue={detail.sortOrder === null ? '' : String(detail.sortOrder)}
+                invalid={Boolean(errors.sortOrder)}
+                message={errors.sortOrder}
+              />
+            </div>
+          </div>
+        </FoxCard>
+
+        <FoxCard
+          size="lg"
+          title="이미지 등록"
+          description="대표 1장 필수 · 추가 최대 11장 (앱 슬라이더 최대 12장)"
+          className={styles.section}
+        >
+          <MerchantImageField
+            defaultThumbnailUrl={detail.thumbnailUrl}
+            retainedThumbnailId={detail.thumbnailFileId}
+            defaultImageUrls={detail.imageUrls}
+            retainedImageFileId={detail.imageFileId}
+            thumbnailError={errors.thumbnail}
+            imagesError={errors.images}
+          />
+        </FoxCard>
+
+        <FoxCard
+          size="lg"
+          title="메뉴 정보"
+          description="앱 상세 화면의 메뉴 목록에 노출된다"
+          className={styles.section}
+        >
+          <MerchantProductTable frcsNo={detail.id} products={products} canAdd={isEdit} />
+        </FoxCard>
+
+        {isEdit && (
+          <FoxCard size="lg" title="등록 정보" className={styles.section}>
+            <FoxDescriptionList
+              items={[
+                { key: 'registeredAt', term: '등록일시', description: formatOptionalValue(detail.registeredAt) },
+                { key: 'registeredBy', term: '등록자', description: formatOptionalValue(detail.registeredBy) },
+                {
+                  key: 'modified',
+                  term: '최종 수정',
+                  description: [detail.modifiedAt, detail.modifiedBy]
+                    .filter((value): value is string => Boolean(value))
+                    .join(' / ') || '-',
+                },
+                { key: 'status', term: '현재 상태', description: formatMerchantDetailStatusLabel(detail) },
+              ]}
+            />
+          </FoxCard>
+        )}
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+
+        <FoxButtonPanel>
+          <FoxButtonGroup size="lg">
+            {isEdit && (
+              <>
+                <FoxButton type="default" size="lg" label="삭제" disabled={isBusy} onAction={confirmDelete} />
+                <FoxButton
+                  type="default"
+                  size="lg"
+                  label={detail.isVisible === false ? '노출' : '비활성'}
+                  disabled={isBusy}
+                  onAction={toggleVisibility}
+                />
+              </>
+            )}
+            <FoxButton
+              type="default"
+              size="lg"
+              label="취소"
+              onAction={() => router.push(MERCHANT_DETAILS_PATH)}
+            />
+            <FoxButton
+              type="primary"
+              size="lg"
+              htmlType="submit"
+              label={isEdit ? '저장' : '등록'}
+              loading={isPending}
+            />
+          </FoxButtonGroup>
+        </FoxButtonPanel>
+      </form>
+    </>
+  );
+}
 
app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
@@ -0,0 +1,33 @@
+import type { Metadata } from 'next';
+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 { MerchantDetailForm } from './_components/merchant-detail-form';
+
+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();
+
+  const { frcsNo } = await params;
+  const detail = await fetchMerchantDetail(frcsNo);
+  if (!detail) {
+    notFound();
+  }
+
+  // 상세가 없으면 백엔드가 메뉴 조회를 빈 목록으로 답한다 — 따로 가릴 것 없다.
+  const [products, categories] = await Promise.all([
+    detail.hasDetail ? fetchMerchantProducts(frcsNo) : Promise.resolve([]),
+    fetchCommonCodes(CODE_GROUP.merchantType),
+  ]);
+
+  return <MerchantDetailForm detail={detail} products={products} categories={categories} />;
+}
 
app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/[productSn]/page.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/[productSn]/page.tsx
@@ -0,0 +1,32 @@
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository';
+import {
+  fetchMerchantProduct,
+  fetchMerchantProducts,
+} from '@/lib/data/repositories/merchant-product-repository';
+import { MerchantProductForm } from '../_components/merchant-product-form';
+
+export const metadata: Metadata = { title: '가맹몰 메뉴 수정' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  params: Promise<{ frcsNo: string; productSn: string }>;
+}
+
+export default async function Page({ params }: PageProps) {
+  await verifySession();
+
+  const { frcsNo, productSn } = await params;
+  const [detail, product, products] = await Promise.all([
+    fetchMerchantDetail(frcsNo),
+    fetchMerchantProduct(productSn),
+    fetchMerchantProducts(frcsNo),
+  ]);
+  if (!detail || !product || product.frcsNo !== frcsNo) {
+    notFound();
+  }
+
+  return <MerchantProductForm detail={detail} product={product} productCount={products.length} />;
+}
 
app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.module.scss (added)
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.module.scss
@@ -0,0 +1,69 @@
+@use "@fox/styles/abstracts" as fox;
+
+.form {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(6);
+}
+
+.section {
+  inline-size: 100%;
+}
+
+// 기획 BO-MAL-004 — 왼쪽 이미지(240×180) · 오른쪽 입력. 창이 좁으면 아래로 접는다.
+.layout {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  gap: fox.gap(6);
+  inline-size: 100%;
+}
+
+.image {
+  display: flex;
+  flex: 0 0 auto;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
+
+.caption {
+  font-size: fox.font-size(label-sm);
+  color: fox.color(font-neutral-subtle);
+}
+
+.fields {
+  display: flex;
+  flex: 1 1 48rem;
+  min-inline-size: 0;
+  flex-direction: column;
+  gap: fox.gap(4);
+}
+
+.grid {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  gap: fox.gap(4);
+  inline-size: 100%;
+
+  & + & {
+    margin-block-start: fox.gap(5);
+  }
+}
+
+.field {
+  display: flex;
+  flex: 1 1 24rem;
+  min-inline-size: 0;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
+
+.fieldWide {
+  display: flex;
+  flex: 2 1 40rem;
+  min-inline-size: 0;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
 
app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
@@ -0,0 +1,263 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useActionState, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
+import { FoxCard } from '@fox/core/components/fox-card';
+import { FoxDescriptionList } from '@fox/core/components/fox-description-list';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxPageHeader } from '@fox/core/components/fox-page-header';
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
+import { formatOptionalValue, type MerchantDetail } from '@/lib/domain/merchant-detail';
+import {
+  MERCHANT_DETAILS_PATH,
+  merchantDetailPath,
+} from '@/lib/domain/merchant-detail-query';
+import {
+  MAX_PRODUCT_BADGES,
+  MERCHANT_PRODUCT_BADGE_OPTIONS,
+  MERCHANT_PRODUCT_VISIBILITY_OPTIONS,
+  type MerchantProduct,
+} from '@/lib/domain/merchant-product';
+import {
+  INITIAL_MERCHANT_PRODUCT_FORM_STATE,
+  PRODUCT_DESCRIPTION_MAX_LENGTH,
+  PRODUCT_NAME_MAX_LENGTH,
+  PRODUCT_PRICE_MAX,
+  PRODUCT_SORT_ORDER_MAX,
+} from '@/lib/domain/merchant-product-form';
+import { deleteMerchantProductAction, saveMerchantProductAction } from '../../../_actions';
+import { MerchantChoiceChips } from '../../../_components/merchant-choice-chips';
+import styles from './merchant-product-form.module.scss';
+
+interface MerchantProductFormProps {
+  detail: MerchantDetail;
+  product?: MerchantProduct;
+  productCount: number;
+}
+
+/**
+ * 가맹몰 메뉴 등록·수정 — 기획 BO-MAL-004. 대상 업체는 읽기 전용이고 이 화면에서 바꾸지 않는다.
+ * 저장하면 업체 상세 화면으로 돌아간다.
+ */
+export function MerchantProductForm({ detail, product, productCount }: MerchantProductFormProps) {
+  const router = useRouter();
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isBusy, startAction] = useBlockingAction();
+  const [state, formAction, isPending] = useActionState(
+    saveMerchantProductAction,
+    INITIAL_MERCHANT_PRODUCT_FORM_STATE
+  );
+  const isEdit = product !== undefined;
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+  const detailHref = merchantDetailPath(detail.id);
+
+  const [badges, setBadges] = useState<string[]>(product?.badges ?? []);
+  const [visibility, setVisibility] = useState<string[]>([
+    product?.isVisible === false ? 'N' : 'Y',
+  ]);
+
+  function runDelete() {
+    if (!product) return;
+    hideAlert();
+    startAction(async () => {
+      const result = await deleteMerchantProductAction(detail.id, product.id);
+      if (result.status === 'error') {
+        showToast({ variant: 'danger', message: result.message });
+        return;
+      }
+      showToast({ variant: 'success', message: '메뉴를 삭제했습니다.' });
+      router.push(detailHref);
+    });
+  }
+
+  function confirmDelete() {
+    if (!product) return;
+    showAlert({
+      variant: 'danger',
+      title: '메뉴를 삭제하시겠습니까?',
+      message: `"${product.name}" 메뉴를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  return (
+    <>
+      <FoxPageHeader
+        title={`메뉴 ${isEdit ? '수정' : '등록'}`}
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '시스템관리' },
+          { label: '가맹몰 상세 관리', href: MERCHANT_DETAILS_PATH },
+          { label: detail.name, href: detailHref },
+          { label: isEdit ? '메뉴 수정' : '메뉴 등록' },
+        ]}
+      />
+
+      <form onSubmit={(event) => submitFormAction(event, formAction)} className={styles.form}>
+        <input type="hidden" name="frcsNo" value={detail.id} />
+        <input type="hidden" name="productSn" value={product?.id ?? ''} />
+
+        <FoxCard
+          size="lg"
+          title="대상 업체"
+          className={styles.section}
+          actions={
+            <FoxButton
+              type="default"
+              size="sm"
+              label="업체 정보로 이동"
+              onAction={() => router.push(detailHref)}
+            />
+          }
+        >
+          <FoxDescriptionList
+            items={[
+              { key: 'name', term: '업체명', description: detail.name },
+              {
+                key: 'category',
+                term: '카테고리',
+                description: formatOptionalValue(detail.categoryCode),
+              },
+              { key: 'count', term: '등록 메뉴', description: `${productCount}건` },
+            ]}
+          />
+        </FoxCard>
+
+        <FoxCard size="lg" title="메뉴 정보" className={styles.section}>
+          <div className={styles.layout}>
+            <div className={styles.image}>
+              <FoxFileUpload
+                mode="image"
+                name="imageFile"
+                accept={fileModuleAccept(FILE_MODULE.merchantImage)}
+                thumbnailPicker
+                selectLabel="메뉴 이미지 등록"
+                description="JPG · PNG / 5MB 이하"
+                thumbnailWidth={240}
+                thumbnailHeight={180}
+                defaultFiles={
+                  product?.imageUrl
+                    ? [{ id: 'image', name: '메뉴 이미지', previewUrl: product.imageUrl }]
+                    : undefined
+                }
+              />
+              <input type="hidden" name="imageFileId" value={product?.imageFileId ?? ''} />
+              <span className={styles.caption}>메뉴 이미지 (선택)</span>
+              {errors.image && <FoxHelperText type="danger" message={errors.image} />}
+            </div>
+
+            <div className={styles.fields}>
+              <div className={styles.grid}>
+                <div className={styles.fieldWide}>
+                  <FoxInput
+                    size="md"
+                    name="name"
+                    label="메뉴명"
+                    requirement="required"
+                    placeholder="예) 패밀리 3~4인 세트"
+                    defaultValue={product?.name ?? ''}
+                    maxLength={PRODUCT_NAME_MAX_LENGTH}
+                    invalid={Boolean(errors.name)}
+                    message={errors.name}
+                  />
+                </div>
+                <div className={styles.field}>
+                  <FoxInput
+                    size="md"
+                    name="price"
+                    type="number"
+                    inputMode="numeric"
+                    min={0}
+                    max={PRODUCT_PRICE_MAX}
+                    label="가격"
+                    requirement="required"
+                    placeholder="숫자만 입력 (원)"
+                    defaultValue={product?.price === null || product === undefined ? '' : String(product.price)}
+                    invalid={Boolean(errors.price)}
+                    message={errors.price}
+                  />
+                </div>
+              </div>
+
+              <FoxTextArea
+                name="description"
+                label="설명"
+                rows={3}
+                placeholder="앱 메뉴 목록에 2~3줄로 노출된다."
+                defaultValue={product?.description ?? ''}
+                maxLength={PRODUCT_DESCRIPTION_MAX_LENGTH}
+                counter
+                invalid={Boolean(errors.description)}
+                message={errors.description}
+              />
+            </div>
+          </div>
+
+          <div className={styles.grid}>
+            <MerchantChoiceChips
+              label="배지"
+              name="badges"
+              optionalText="복수 선택"
+              options={MERCHANT_PRODUCT_BADGE_OPTIONS}
+              values={badges}
+              onChange={setBadges}
+              maxSelected={MAX_PRODUCT_BADGES}
+              error={errors.badges}
+            />
+            <MerchantChoiceChips
+              label="노출 여부"
+              name="isVisible"
+              required
+              options={MERCHANT_PRODUCT_VISIBILITY_OPTIONS}
+              values={visibility}
+              onChange={setVisibility}
+            />
+            <div className={styles.field}>
+              <FoxInput
+                size="md"
+                name="sortOrder"
+                type="number"
+                inputMode="numeric"
+                min={0}
+                max={PRODUCT_SORT_ORDER_MAX}
+                label="노출 순서"
+                placeholder="숫자 (낮을수록 상단)"
+                defaultValue={product?.sortOrder === null || product === undefined ? '' : String(product.sortOrder)}
+                invalid={Boolean(errors.sortOrder)}
+                message={errors.sortOrder}
+              />
+            </div>
+          </div>
+        </FoxCard>
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+
+        <FoxButtonPanel>
+          <FoxButtonGroup size="lg">
+            {isEdit && (
+              <FoxButton type="default" size="lg" label="삭제" disabled={isBusy} onAction={confirmDelete} />
+            )}
+            <FoxButton type="default" size="lg" label="취소" onAction={() => router.push(detailHref)} />
+            <FoxButton type="primary" size="lg" htmlType="submit" label="저장" loading={isPending} />
+          </FoxButtonGroup>
+        </FoxButtonPanel>
+      </form>
+    </>
+  );
+}
 
app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/new/page.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/new/page.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository';
+import { fetchMerchantProducts } from '@/lib/data/repositories/merchant-product-repository';
+import { MerchantProductForm } from '../_components/merchant-product-form';
+
+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();
+
+  const { frcsNo } = await params;
+  const detail = await fetchMerchantDetail(frcsNo);
+  if (!detail || !detail.hasDetail) {
+    notFound();
+  }
+
+  const products = await fetchMerchantProducts(frcsNo);
+  return <MerchantProductForm detail={detail} productCount={products.length} />;
+}
 
app/(protected)/(basic)/system/merchant-details/_actions.ts (added)
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
@@ -0,0 +1,493 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { redirect, unstable_rethrow } from 'next/navigation';
+import { verifySession } from '@/lib/auth/dal';
+import { BackendRequestError } from '@/lib/http/backend-fetch';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
+import {
+  buildFileImageUrl,
+  uploadAttachment,
+  uploadAttachments,
+} from '@/lib/data/repositories/file-repository';
+import {
+  createMerchantDetail,
+  deleteMerchantDetail,
+  fetchMerchantDetail,
+  updateMerchantDetail,
+} from '@/lib/data/repositories/merchant-detail-repository';
+import {
+  createMerchantProduct,
+  deleteMerchantProduct,
+  fetchMerchantProduct,
+  updateMerchantProduct,
+} from '@/lib/data/repositories/merchant-product-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
+import {
+  FILE_MODULE,
+  MAX_ATTACHMENT_BYTES,
+  allowedExtensions,
+  isAllowedFileExtension,
+} from '@/lib/domain/file-module';
+import { MAX_EXTRA_IMAGES, MAX_IMAGE_BYTES } from '@/lib/domain/merchant-detail';
+import {
+  validateMerchantDetail,
+  type MerchantDetailFormState,
+  type MerchantDetailValues,
+} from '@/lib/domain/merchant-detail-form';
+import {
+  MERCHANT_DETAILS_PATH,
+  merchantDetailPath,
+} from '@/lib/domain/merchant-detail-query';
+import {
+  MERCHANT_PRODUCT_BADGE_OPTIONS,
+  type MerchantProductBadge,
+} from '@/lib/domain/merchant-product';
+import {
+  validateMerchantProduct,
+  type MerchantProductFormState,
+} from '@/lib/domain/merchant-product-form';
+
+/**
+ * 가맹몰 상세 Server Action — 상세(가맹점당 1건)와 메뉴(여러 건) 둘 다 여기서 다룬다.
+ *
+ * 이슈: 목록의 [비활성]/[노출]은 백엔드가 상세 전체를 돌려줘야 안전하다. `PUT`이 모든 컬럼을
+ *   무조건 덮어써서, 단건 조회가 비어 있는 지금 값 없이 보내면 소개·영업시간·이미지가 지워진다.
+ *   그래서 조회 결과에 대표이미지가 없으면 목록 토글을 거부하고 수정 화면으로 안내한다.
+ *   단건 SELECT가 보강되면(보고함) 그대로 동작한다.
+ */
+
+const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
+const DETAIL_NOT_LOADED_MESSAGE =
+  '백엔드가 상세 값을 돌려주지 않아 목록에서는 바꿀 수 없습니다. 수정 화면에서 바꿔 주세요.';
+const IMAGE_OVERSIZE_MESSAGE = `이미지는 장당 ${Math.floor(MAX_IMAGE_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`;
+const IMAGES_TOO_MANY_MESSAGE = `추가 이미지는 최대 ${MAX_EXTRA_IMAGES}장까지입니다.`;
+const REQUEST_OVERSIZE_MESSAGE = `한 번에 올리는 이미지의 합이 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB를 넘습니다. 나눠서 저장해 주세요.`;
+
+class InputError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = 'InputError';
+  }
+}
+
+export type MerchantDetailActionResult =
+  | { status: 'success' }
+  | { status: 'error'; message: string };
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+function readOptionalInteger(formData: FormData, key: string): number | null {
+  const raw = readString(formData, key).trim();
+  if (raw === '') return null;
+  const parsed = Number(raw);
+  return Number.isFinite(parsed) ? parsed : Number.NaN;
+}
+
+function readFiles(formData: FormData, key: string): Array<File | null> {
+  return formData
+    .getAll(key)
+    .map((entry) => (entry instanceof File && entry.size > 0 ? entry : null));
+}
+
+function assertImage(file: File): void {
+  if (file.size > MAX_IMAGE_BYTES) {
+    throw new InputError(IMAGE_OVERSIZE_MESSAGE);
+  }
+  if (!isAllowedFileExtension(FILE_MODULE.merchantImage, file.name)) {
+    throw new InputError(
+      `이미지는 ${allowedExtensions(FILE_MODULE.merchantImage).join(', ')} 파일만 올릴 수 있습니다.`
+    );
+  }
+}
+
+function failureMessage(error: unknown, fallback: string): string {
+  const showable = error instanceof InputError || error instanceof BackendRequestError;
+  return showable && error.message ? error.message : fallback;
+}
+
+// ── 상세 ──────────────────────────────────────────────────────────────────────
+
+async function resolveThumbnailId(formData: FormData): Promise<string> {
+  const file = readFiles(formData, 'thumbnailFile')[0] ?? null;
+  if (!file) {
+    return readString(formData, 'thumbnailFileId');
+  }
+  assertImage(file);
+  return uploadAttachment(file, FILE_MODULE.merchantImage.id);
+}
+
+/** 저장된 이미지 한 장을 다시 파일로 — 묶음을 새 순서로 다시 올릴 때 쓴다. */
+async function fetchSavedImage(fileId: string, fileSn: number): Promise<File> {
+  const url = buildFileImageUrl(fileId, fileSn);
+  if (!url) {
+    throw new InputError(INVALID_REQUEST_MESSAGE);
+  }
+  const response = await fetch(url, { cache: 'no-store' });
+  if (!response.ok) {
+    throw new InputError('저장된 이미지를 다시 읽지 못했습니다. 잠시 후 다시 시도해 주세요.');
+  }
+  const blob = await response.blob();
+  const extension = blob.type.split('/')[1] || 'jpg';
+  return new File([blob], `image-${fileSn}.${extension}`, { type: blob.type });
+}
+
+/**
+ * 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번하므로 순서·삭제·추가
+ * 어느 것이든 바뀌면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽어 합친다.
+ *
+ * 폼: 칸마다 `imageSlot`(`saved:<fileSn>` | `new`)과 `imageFile`(같은 순서, 안 고른 칸은 빈 파일).
+ */
+async function resolveImageBundleId(formData: FormData): Promise<string> {
+  const retainedId = readString(formData, 'imageFileId');
+  const slots = formData
+    .getAll('imageSlot')
+    .map((entry) => (typeof entry === 'string' ? entry : ''));
+  const files = readFiles(formData, 'imageFile');
+
+  if (slots.length > MAX_EXTRA_IMAGES) {
+    throw new InputError(IMAGES_TOO_MANY_MESSAGE);
+  }
+
+  const savedSns = slots
+    .map((slot, index) =>
+      files[index] === null && slot.startsWith('saved:') ? Number(slot.slice(6)) : null
+    )
+    .filter((sn): sn is number => sn !== null);
+  const hasNewFile = files.some((file) => file !== null);
+  const untouched =
+    !hasNewFile &&
+    savedSns.length === slots.length &&
+    savedSns.every((sn, index) => sn === index + 1);
+
+  if (slots.length === 0) {
+    return '';
+  }
+  if (untouched) {
+    return retainedId;
+  }
+
+  let newBytes = 0;
+  const bundle: File[] = [];
+  for (const [index, slot] of slots.entries()) {
+    const picked = files[index];
+    if (picked) {
+      assertImage(picked);
+      newBytes += picked.size;
+      bundle.push(picked);
+    } else if (slot.startsWith('saved:') && retainedId) {
+      bundle.push(await fetchSavedImage(retainedId, Number(slot.slice(6))));
+    }
+  }
+  if (newBytes > MAX_ATTACHMENT_BYTES) {
+    throw new InputError(REQUEST_OVERSIZE_MESSAGE);
+  }
+  if (bundle.length === 0) {
+    return '';
+  }
+  return uploadAttachments(bundle, FILE_MODULE.merchantImage.id);
+}
+
+function readDetailValues(
+  formData: FormData,
+  thumbnailFileId: string,
+  imageFileId: string
+): MerchantDetailValues {
+  return {
+    frcsNo: readString(formData, 'frcsNo'),
+    categoryCode: readString(formData, 'categoryCode'),
+    description: readString(formData, 'description'),
+    openTime: readString(formData, 'openTime'),
+    closeTime: readString(formData, 'closeTime'),
+    closedDays: readString(formData, 'closedDays'),
+    isVisible: readString(formData, 'isVisible') !== 'N',
+    isVoucherUsable: readString(formData, 'isVoucherUsable') !== 'N',
+    sortOrder: readOptionalInteger(formData, 'sortOrder'),
+    thumbnailFileId,
+    imageFileId,
+  };
+}
+
+export async function saveMerchantDetailAction(
+  _prevState: MerchantDetailFormState,
+  formData: FormData
+): Promise<MerchantDetailFormState> {
+  await verifySession();
+
+  const frcsNo = readString(formData, 'frcsNo');
+  const isEdit = readString(formData, 'mode') === 'edit';
+  if (!frcsNo) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  let failed: MerchantDetailFormState | null = null;
+  try {
+    const [thumbnailFileId, imageFileId, categories] = await Promise.all([
+      resolveThumbnailId(formData),
+      resolveImageBundleId(formData),
+      fetchCommonCodes(CODE_GROUP.merchantType),
+    ]);
+
+    const validation = validateMerchantDetail(
+      readDetailValues(formData, thumbnailFileId, imageFileId),
+      categories
+    );
+    if (!validation.ok) {
+      return { status: 'error', errors: validation.errors };
+    }
+
+    if (isEdit) {
+      await updateMerchantDetail(validation.values);
+    } else {
+      await createMerchantDetail(validation.values);
+    }
+  } catch (error) {
+    unstable_rethrow(error);
+    console.error('[merchant-details] 저장 실패', error);
+    failed = { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
+  }
+  if (failed) {
+    return failed;
+  }
+
+  revalidatePath(MERCHANT_DETAILS_PATH);
+  revalidatePath(merchantDetailPath(frcsNo));
+  redirect(MERCHANT_DETAILS_PATH);
+}
+
+export async function deleteMerchantDetailAction(
+  frcsNo: string
+): Promise<MerchantDetailActionResult> {
+  await verifySession();
+  if (!frcsNo) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    await deleteMerchantDetail(frcsNo);
+  } catch (error) {
+    unstable_rethrow(error);
+    console.error('[merchant-details] 삭제 실패', error);
+    return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
+  }
+
+  revalidatePath(MERCHANT_DETAILS_PATH);
+  return { status: 'success' };
+}
+
+export async function deleteMerchantDetailsAction(
+  frcsNos: string[]
+): Promise<MerchantDetailActionResult> {
+  await verifySession();
+  const targets = frcsNos.filter((value) => typeof value === 'string' && value !== '');
+  if (targets.length === 0) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    for (const frcsNo of targets) {
+      await deleteMerchantDetail(frcsNo);
+    }
+  } catch (error) {
+    unstable_rethrow(error);
+    console.error('[merchant-details] 일괄 삭제 실패', error);
+    return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
+  }
+
+  revalidatePath(MERCHANT_DETAILS_PATH);
+  return { status: 'success' };
+}
+
+async function toggleDetailVisibility(frcsNo: string, isVisible: boolean): Promise<void> {
+  const detail = await fetchMerchantDetail(frcsNo);
+  // 대표이미지는 필수라 상세가 있으면 반드시 온다 — 없으면 백엔드가 상세를 돌려주지 않은 것이다.
+  if (!detail || !detail.hasDetail || !detail.thumbnailFileId || !detail.categoryCode) {
+    throw new InputError(DETAIL_NOT_LOADED_MESSAGE);
+  }
+  await updateMerchantDetail({
+    frcsNo,
+    categoryCode: detail.categoryCode,
+    description: detail.description ?? '',
+    openTime: detail.openTime ?? '',
+    closeTime: detail.closeTime ?? '',
+    closedDays: detail.closedDays ?? '',
+    isVisible,
+    isVoucherUsable: true,
+    sortOrder: detail.sortOrder,
+    thumbnailFileId: detail.thumbnailFileId,
+    imageFileId: detail.imageFileId ?? '',
+  });
+}
+
+export async function updateMerchantDetailVisibilityAction(
+  frcsNo: string,
+  isVisible: boolean
+): Promise<MerchantDetailActionResult> {
+  await verifySession();
+  if (!frcsNo) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    await toggleDetailVisibility(frcsNo, isVisible);
+  } catch (error) {
+    unstable_rethrow(error);
+    console.error('[merchant-details] 노출 변경 실패', error);
+    return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
+  }
+
+  revalidatePath(MERCHANT_DETAILS_PATH);
+  revalidatePath(merchantDetailPath(frcsNo));
+  return { status: 'success' };
+}
+
+export async function deactivateMerchantDetailsAction(
+  frcsNos: string[]
+): Promise<MerchantDetailActionResult> {
+  await verifySession();
+  const targets = frcsNos.filter((value) => typeof value === 'string' && value !== '');
+  if (targets.length === 0) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    for (const frcsNo of targets) {
+      await toggleDetailVisibility(frcsNo, false);
+    }
+  } catch (error) {
+    unstable_rethrow(error);
+    console.error('[merchant-details] 일괄 비활성 실패', error);
+    return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
+  }
+
+  revalidatePath(MERCHANT_DETAILS_PATH);
+  return { status: 'success' };
+}
+
+// ── 메뉴 ──────────────────────────────────────────────────────────────────────
+
+function readBadges(formData: FormData): MerchantProductBadge[] {
+  return formData
+    .getAll('badges')
+    .map((entry) => (typeof entry === 'string' ? entry : ''))
+    .filter((value): value is MerchantProductBadge =>
+      MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === value)
+    );
+}
+
+async function resolveProductImageId(formData: FormData): Promise<string> {
+  const file = readFiles(formData, 'imageFile')[0] ?? null;
+  if (!file) {
+    return readString(formData, 'imageFileId');
+  }
+  assertImage(file);
+  return uploadAttachment(file, FILE_MODULE.merchantImage.id);
+}
+
+export async function saveMerchantProductAction(
+  _prevState: MerchantProductFormState,
+  formData: FormData
+): Promise<MerchantProductFormState> {
+  await verifySession();
+
+  const frcsNo = readString(formData, 'frcsNo');
+  const productSn = readString(formData, 'productSn');
+  if (!frcsNo) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  let failed: MerchantProductFormState | null = null;
+  try {
+    const imageFileId = await resolveProductImageId(formData);
+    const validation = validateMerchantProduct({
+      frcsNo,
+      name: readString(formData, 'name'),
+      price: readOptionalInteger(formData, 'price'),
+      description: readString(formData, 'description'),
+      badges: readBadges(formData),
+      isVisible: readString(formData, 'isVisible') !== 'N',
+      sortOrder: readOptionalInteger(formData, 'sortOrder'),
+      imageFileId,
+    });
+    if (!validation.ok) {
+      return { status: 'error', errors: validation.errors };
+    }
+
+    if (productSn) {
+      await updateMerchantProduct(productSn, validation.values);
+    } else {
+      await createMerchantProduct(validation.values);
+    }
+  } catch (error) {
+    unstable_rethrow(error);
+    console.error('[merchant-products] 저장 실패', error);
+    failed = { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
+  }
+  if (failed) {
+    return failed;
+  }
+
+  revalidatePath(merchantDetailPath(frcsNo));
+  redirect(merchantDetailPath(frcsNo));
+}
+
+export async function deleteMerchantProductAction(
+  frcsNo: string,
+  productSn: string
+): Promise<MerchantDetailActionResult> {
+  await verifySession();
+  if (!frcsNo || !productSn) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    await deleteMerchantProduct(productSn);
+  } catch (error) {
+    unstable_rethrow(error);
+    console.error('[merchant-products] 삭제 실패', error);
+    return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) };
+  }
+
+  revalidatePath(merchantDetailPath(frcsNo));
+  return { status: 'success' };
+}
+
+export async function updateMerchantProductVisibilityAction(
+  frcsNo: string,
+  productSn: string,
+  isVisible: boolean
+): Promise<MerchantDetailActionResult> {
+  await verifySession();
+  if (!frcsNo || !productSn) {
+    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+  }
+
+  try {
+    const product = await fetchMerchantProduct(productSn);
+    if (!product) {
+      return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+    }
+    await updateMerchantProduct(productSn, {
+      frcsNo: product.frcsNo,
+      name: product.name,
+      price: product.price,
+      description: product.description ?? '',
+      badges: product.badges,
+      isVisible,
+      sortOrder: product.sortOrder,
+      imageFileId: product.imageFileId ?? '',
+    });
+  } catch (error) {
+    unstable_rethrow(error);
+    console.error('[merchant-products] 노출 변경 실패', error);
+    return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
+  }
+
+  revalidatePath(merchantDetailPath(frcsNo));
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-choice-chips.module.scss (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-choice-chips.module.scss
@@ -0,0 +1,7 @@
+@use "@fox/styles/abstracts" as fox;
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-choice-chips.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-choice-chips.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { FoxChip } from '@fox/core/components/fox-chip';
+import { FoxChipArea } from '@fox/core/components/fox-chip-area';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import styles from './merchant-choice-chips.module.scss';
+
+interface ChoiceOption {
+  value: string;
+  label: string;
+}
+
+interface MerchantChoiceChipsProps {
+  label: string;
+  /** 폼에 실릴 이름. 단일 선택은 값 하나, 복수 선택은 고른 순서대로 같은 이름이 반복된다. */
+  name: string;
+  options: readonly ChoiceOption[];
+  values: readonly string[];
+  onChange: (values: string[]) => void;
+  /** 넘기면 복수 선택이 되고 이 개수까지만 고를 수 있다. 없으면 단일 선택이다. */
+  maxSelected?: number;
+  required?: boolean;
+  optionalText?: string;
+  error?: string;
+}
+
+/**
+ * 기획 BO-MAL-002 ② · BO-MAL-004 ③ — 칩으로 고르는 선택지. 노출 상태·바우처 사용은 단일,
+ * 배지는 복수(잠정 최대 2)다.
+ *
+ * `FoxChip`의 check형은 `aria-pressed` 버튼이라 폼에 실리지 않는다 — 고른 값은 숨은 입력이
+ * 나른다. 복수 선택은 고른 순서를 지켜 싣는다(배지는 그 순서로 앱에 노출된다).
+ */
+export function MerchantChoiceChips({
+  label,
+  name,
+  options,
+  values,
+  onChange,
+  maxSelected,
+  required,
+  optionalText,
+  error,
+}: MerchantChoiceChipsProps) {
+  const isMultiple = maxSelected !== undefined;
+
+  function toggle(value: string, checked: boolean) {
+    if (!isMultiple) {
+      if (checked) onChange([value]);
+      return;
+    }
+    if (!checked) {
+      onChange(values.filter((item) => item !== value));
+      return;
+    }
+    if (values.includes(value) || values.length >= maxSelected) {
+      return;
+    }
+    onChange([...values, value]);
+  }
+
+  return (
+    <div className={styles.field}>
+      <FoxFormLabel
+        as="span"
+        requirement={required ? 'required' : optionalText ? 'optional' : undefined}
+        optionalText={optionalText}
+      >
+        {label}
+      </FoxFormLabel>
+      <FoxChipArea size="md" label={label}>
+        {options.map((option) => {
+          const checked = values.includes(option.value);
+          const isFull = isMultiple && !checked && values.length >= maxSelected;
+          return (
+            <FoxChip
+              key={option.value}
+              type="check"
+              size="md"
+              label={option.label}
+              checked={checked}
+              disabled={isFull}
+              onCheckedChange={(next) => toggle(option.value, next)}
+            />
+          );
+        })}
+      </FoxChipArea>
+      {values.map((value) => (
+        <input key={value} type="hidden" name={name} value={value} />
+      ))}
+      {error && <FoxHelperText type="danger" message={error} />}
+    </div>
+  );
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.module.scss (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.module.scss
@@ -0,0 +1,28 @@
+@use "@fox/styles/abstracts" as fox;
+
+.filter {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: fox.gap(3);
+}
+
+.select {
+  inline-size: 16rem;
+}
+
+.period {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(2);
+}
+
+// 목록 표의 대표이미지 — 기획 BO-MAL-001의 56×40 썸네일. 토큰 스케일이 96px에서 끝나
+// 딱 맞는 값이 없어 리터럴로 두고 근거를 남긴다(꾸미기아이템 썸네일과 같은 처리).
+.thumbnail {
+  inline-size: 5.6rem;
+  block-size: 4rem;
+  border: fox.border(1) solid fox.color(border-neutral-subtler);
+  border-radius: fox.radius(2);
+  object-fit: cover;
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
@@ -0,0 +1,362 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxCheckbox } from '@fox/core/components/fox-checkbox';
+import { FoxInput } from '@fox/core/components/fox-input';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import { formatCommonCode, type CommonCode } from '@/lib/domain/common-code';
+import {
+  EMPTY_FIELD_PLACEHOLDER,
+  MERCHANT_DETAIL_VISIBILITY_OPTIONS,
+  formatMerchantDetailStatusLabel,
+  formatOptionalValue,
+  type MerchantDetailSummary,
+} from '@/lib/domain/merchant-detail';
+import {
+  FILTER_ALL,
+  MERCHANT_DETAIL_PAGE_SIZE_OPTIONS,
+  buildMerchantDetailHref,
+  type MerchantDetailPageSize,
+  type MerchantDetailQuery,
+} from '@/lib/domain/merchant-detail-query';
+import { formatPhoneNumber } from '@/lib/domain/phone-number';
+import {
+  deactivateMerchantDetailsAction,
+  deleteMerchantDetailsAction,
+} from '../_actions';
+import { MerchantDetailRowActions } from './merchant-detail-row-actions';
+import styles from './merchant-detail-list.module.scss';
+
+interface MerchantDetailListProps {
+  items: MerchantDetailSummary[];
+  categories: CommonCode[];
+  query: MerchantDetailQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+/**
+ * 가맹몰 상세 목록 — 기획 BO-MAL-001을 「시스템관리 > 가맹몰 상세 관리」로 옮긴 것.
+ *
+ * 가맹점 전체가 오고 상세가 붙은 건만 상태·등록일이 있다. 기획의 [+ 업체 등록]은 없다 —
+ * 상세는 기존 가맹점에만 붙어 행의 [상세 등록]으로 들어간다(사용자 확정).
+ *
+ * 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지
+ *   않아 대부분 `-`로 보인다.
+ */
+export function MerchantDetailList({
+  items,
+  categories,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: MerchantDetailListProps) {
+  const router = useRouter();
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isBusy, startAction] = useBlockingAction();
+  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
+  const [draft, setDraft] = useState({
+    category: query.category,
+    visibility: query.visibility,
+    startDate: query.startDate,
+    endDate: query.endDate,
+  });
+
+  const selectableIds = items.filter((row) => row.hasDetail).map((row) => row.id);
+  const isAllSelected =
+    selectableIds.length > 0 && selectableIds.every((id) => selectedIds.has(id));
+
+  function go(patch: Partial<MerchantDetailQuery>) {
+    router.replace(buildMerchantDetailHref(query, { ...patch, page: 1 }));
+  }
+
+  function toggleAll(checked: boolean) {
+    setSelectedIds(checked ? new Set(selectableIds) : new Set());
+  }
+
+  function toggleOne(id: string, checked: boolean) {
+    setSelectedIds((current) => {
+      const next = new Set(current);
+      if (checked) next.add(id);
+      else next.delete(id);
+      return next;
+    });
+  }
+
+  function runBulk(
+    run: (ids: string[]) => Promise<{ status: 'success' } | { status: 'error'; message: string }>,
+    successMessage: string
+  ) {
+    hideAlert();
+    const ids = [...selectedIds];
+    startAction(async () => {
+      const result = await run(ids);
+      showToast(
+        result.status === 'error'
+          ? { variant: 'danger', message: result.message }
+          : { variant: 'success', message: successMessage }
+      );
+      if (result.status === 'success') setSelectedIds(new Set());
+    });
+  }
+
+  function confirmBulkDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '선택한 상세를 삭제하시겠습니까?',
+      message: `${selectedIds.size}건의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label="삭제"
+            onAction={() => runBulk(deleteMerchantDetailsAction, '삭제했습니다.')}
+          />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  const columns: FoxListColumn<MerchantDetailSummary>[] = [
+    {
+      key: 'select',
+      header: (
+        <FoxCheckbox
+          size="md"
+          label="전체 선택"
+          hideLabel
+          checked={isAllSelected}
+          disabled={selectableIds.length === 0}
+          onChange={toggleAll}
+        />
+      ),
+      width: 56,
+      align: 'center',
+      render: (row) =>
+        row.hasDetail ? (
+          <FoxCheckbox
+            size="md"
+            label={`${row.name} 선택`}
+            hideLabel
+            checked={selectedIds.has(row.id)}
+            onChange={(checked) => toggleOne(row.id, checked)}
+          />
+        ) : null,
+    },
+    {
+      key: 'no',
+      header: 'No',
+      width: 72,
+      align: 'center',
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    {
+      key: 'thumbnail',
+      header: '대표이미지',
+      width: 110,
+      align: 'center',
+      render: (row) =>
+        row.thumbnailUrl ? (
+          // 백엔드의 공개 이미지 GET이라 브라우저가 직접 부른다. `next/image`는 백엔드 호스트를
+          // next.config에 등록해야 해 여기서는 <img>로 둔다(꾸미기아이템과 같은 판단).
+          // eslint-disable-next-line @next/next/no-img-element
+          <img className={styles.thumbnail} src={row.thumbnailUrl} alt={`${row.name} 대표이미지`} />
+        ) : (
+          EMPTY_FIELD_PLACEHOLDER
+        ),
+    },
+    { key: 'name', header: '업체명', width: 220, emphasis: true },
+    {
+      key: 'category',
+      header: '카테고리',
+      width: 140,
+      render: (row) =>
+        row.categoryCode ? formatCommonCode(categories, row.categoryCode) : EMPTY_FIELD_PLACEHOLDER,
+    },
+    {
+      key: 'phoneNumber',
+      header: '연락처',
+      width: 150,
+      render: (row) => formatOptionalValue(formatPhoneNumber(row.phoneNumber)),
+    },
+    {
+      key: 'address',
+      header: '주소',
+      width: 280,
+      render: (row) => formatOptionalValue(row.address),
+    },
+    {
+      key: 'productCount',
+      header: '메뉴',
+      width: 72,
+      align: 'center',
+      // 목록 응답에 메뉴 건수가 없다(보고함).
+      render: () => EMPTY_FIELD_PLACEHOLDER,
+    },
+    {
+      key: 'status',
+      header: '상태',
+      width: 110,
+      align: 'center',
+      render: (row) =>
+        row.hasDetail && row.isVisible !== null ? (
+          <FoxStatusIndicator
+            type={row.isVisible ? 'success' : 'danger'}
+            label={formatMerchantDetailStatusLabel(row)}
+          />
+        ) : (
+          formatMerchantDetailStatusLabel(row)
+        ),
+    },
+    {
+      key: 'registeredAt',
+      header: '등록일',
+      width: 120,
+      align: 'center',
+      render: (row) => formatOptionalValue(row.registeredAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 220,
+      align: 'center',
+      render: (row) => <MerchantDetailRowActions row={row} />,
+    },
+  ];
+
+  return (
+    <FoxListContainer<MerchantDetailSummary>
+      title="가맹몰 상세 관리"
+      breadcrumb={[
+        { label: '홈', href: '/' },
+        { label: '시스템관리' },
+        { label: '가맹몰 상세 관리' },
+      ]}
+      caption="가맹몰 상세 목록"
+      columns={columns}
+      rows={items}
+      rowKey={(row) => row.id}
+      totalCount={totalCount}
+      emptyMessage="검색 결과가 없습니다."
+      sorts={
+        <FoxSelectText
+          size="sm"
+          ariaLabel="페이지 크기"
+          options={MERCHANT_DETAIL_PAGE_SIZE_OPTIONS.map((size) => ({
+            value: String(size),
+            label: `${size}개씩`,
+          }))}
+          value={String(query.pageSize)}
+          onValueChange={(value) =>
+            go({ pageSize: Number(value) as MerchantDetailPageSize })
+          }
+        />
+      }
+      filter={
+        <div className={styles.filter}>
+          <div className={styles.select}>
+            <FoxSelect
+              size="md"
+              ariaLabel="카테고리"
+              options={[
+                { value: FILTER_ALL, label: '카테고리: 전체' },
+                ...categories.map((code) => ({ value: code.code, label: code.label })),
+              ]}
+              value={draft.category}
+              onValueChange={(value) => {
+                setDraft((current) => ({ ...current, category: value }));
+                go({ category: value });
+              }}
+            />
+          </div>
+          <div className={styles.select}>
+            <FoxSelect
+              size="md"
+              ariaLabel="노출 상태"
+              options={[
+                { value: FILTER_ALL, label: '노출 상태: 전체' },
+                ...MERCHANT_DETAIL_VISIBILITY_OPTIONS.map((option) => ({
+                  value: option.value,
+                  label: option.label,
+                })),
+              ]}
+              value={draft.visibility}
+              onValueChange={(value) => {
+                setDraft((current) => ({ ...current, visibility: value }));
+                go({ visibility: value });
+              }}
+            />
+          </div>
+          {/* 등록일 기간 — 백엔드가 아직 조건에 쓰지 않는다(보고함). 값은 그대로 보낸다. */}
+          <div className={styles.period}>
+            <FoxInput
+              size="md"
+              type="date"
+              aria-label="등록일 시작"
+              value={draft.startDate}
+              onChange={(value) => setDraft((current) => ({ ...current, startDate: value }))}
+            />
+            <span aria-hidden="true">~</span>
+            <FoxInput
+              size="md"
+              type="date"
+              aria-label="등록일 종료"
+              value={draft.endDate}
+              onChange={(value) => setDraft((current) => ({ ...current, endDate: value }))}
+            />
+            <FoxButton
+              type="default"
+              size="md"
+              label="기간 적용"
+              onAction={() => go({ startDate: draft.startDate, endDate: draft.endDate })}
+            />
+          </div>
+        </div>
+      }
+      searchFields={[]}
+      keyword={query.keyword}
+      searchPlaceholder="업체명을 입력하세요."
+      toolbarActions={
+        <>
+          <FoxButton
+            type="default"
+            size="md"
+            label="선택 비활성"
+            disabled={isBusy || selectedIds.size === 0}
+            onAction={() => runBulk(deactivateMerchantDetailsAction, '비활성으로 바꿨습니다.')}
+          />
+          <FoxButton
+            type="default"
+            size="md"
+            label="선택 삭제"
+            disabled={isBusy || selectedIds.size === 0}
+            onAction={confirmBulkDelete}
+          />
+        </>
+      }
+      page={currentPage}
+      totalPages={totalPages}
+      buildHref={(patch) =>
+        buildMerchantDetailHref(query, {
+          ...(patch.keyword !== undefined ? { keyword: String(patch.keyword) } : {}),
+          ...(patch.page !== undefined ? { page: patch.page } : {}),
+        })
+      }
+    />
+  );
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { MerchantDetailSummary } from '@/lib/domain/merchant-detail';
+import { merchantDetailPath } from '@/lib/domain/merchant-detail-query';
+import {
+  deleteMerchantDetailAction,
+  updateMerchantDetailVisibilityAction,
+} from '../_actions';
+
+interface MerchantDetailRowActionsProps {
+  row: MerchantDetailSummary;
+}
+
+/**
+ * 기획 BO-MAL-001 관리 열 — [수정] [비활성|노출] [삭제]. 상세가 없는 가맹점은 [상세 등록]
+ * 하나뿐이다(상세는 기존 가맹점에만 붙는다 — 사용자 확정).
+ */
+export function MerchantDetailRowActions({ row }: MerchantDetailRowActionsProps) {
+  const router = useRouter();
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isBusy, startAction] = useBlockingAction();
+
+  function toggleVisibility() {
+    const next = row.isVisible === false;
+    startAction(async () => {
+      const result = await updateMerchantDetailVisibilityAction(row.id, next);
+      showToast(
+        result.status === 'error'
+          ? { variant: 'danger', message: result.message }
+          : { variant: 'success', message: next ? '노출로 바꿨습니다.' : '비활성으로 바꿨습니다.' }
+      );
+    });
+  }
+
+  function runDelete() {
+    hideAlert();
+    startAction(async () => {
+      const result = await deleteMerchantDetailAction(row.id);
+      showToast(
+        result.status === 'error'
+          ? { variant: 'danger', message: result.message }
+          : { variant: 'success', message: '삭제했습니다.' }
+      );
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '가맹몰 상세를 삭제하시겠습니까?',
+      message: `"${row.name}"의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  if (!row.hasDetail) {
+    return (
+      <FoxButtonGroup size="sm" align="center">
+        <FoxButton
+          type="secondary"
+          size="sm"
+          label="상세 등록"
+          onAction={() => router.push(merchantDetailPath(row.id))}
+        />
+      </FoxButtonGroup>
+    );
+  }
+
+  return (
+    <FoxButtonGroup size="sm" align="center">
+      <FoxButton
+        type="default"
+        size="sm"
+        label="수정"
+        onAction={() => router.push(merchantDetailPath(row.id))}
+      />
+      <FoxButton
+        type="default"
+        size="sm"
+        label={row.isVisible === false ? '노출' : '비활성'}
+        disabled={isBusy}
+        onAction={toggleVisibility}
+      />
+      <FoxButton
+        type="default"
+        size="sm"
+        label="삭제"
+        disabled={isBusy}
+        onAction={confirmDelete}
+      />
+    </FoxButtonGroup>
+  );
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-image-field.module.scss (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-image-field.module.scss
@@ -0,0 +1,67 @@
+@use "@fox/styles/abstracts" as fox;
+
+.root {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+}
+
+.tiles {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  gap: fox.gap(4);
+}
+
+.thumbnail,
+.slot {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(2);
+}
+
+.slotFooter {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: fox.gap(2);
+}
+
+.caption {
+  font-size: fox.font-size(label-sm);
+  color: fox.color(font-neutral-default);
+}
+
+.required {
+  color: fox.color(font-system-danger);
+}
+
+// 「+ 추가」 타일 — 기획 BO-MAL-002의 160×160 점선 상자. 크기는 옆 이미지 칸과 같아야 줄이
+// 맞는다(토큰 스케일이 96px에서 끝나 리터럴로 둔다, 꾸미기아이템 썸네일과 같은 처리).
+.addTile {
+  display: flex;
+  inline-size: 16rem;
+  block-size: 16rem;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: fox.gap(2);
+  padding: 0;
+  margin: 0;
+  border: fox.border(1) dashed fox.color(border-neutral-subtle);
+  border-radius: fox.radius(3);
+  background: fox.color(surface-neutral-gray);
+  color: fox.color(font-neutral-default);
+  font-size: fox.font-size(label-md);
+  cursor: pointer;
+  appearance: none;
+
+  &:hover {
+    background: fox.color(action-default-hover);
+  }
+}
+
+.addHint {
+  font-size: fox.font-size(label-xsm);
+  color: fox.color(font-neutral-subtle);
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-image-field.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-image-field.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { useState } from 'react';
+import { FoxFileUpload } from '@fox/core/components/fox-file-upload';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxSortableList } from '@fox/core/components/fox-sortable-list';
+import { FoxTextButton } from '@fox/core/components/fox-text-button';
+import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
+import {
+  MAX_EXTRA_IMAGES,
+  type MerchantImageSlot,
+} from '@/lib/domain/merchant-detail';
+import styles from './merchant-image-field.module.scss';
+
+interface MerchantImageFieldProps {
+  defaultThumbnailUrl: string | null;
+  retainedThumbnailId: string | null;
+  defaultImageUrls: readonly string[];
+  retainedImageFileId: string | null;
+  thumbnailError?: string;
+  imagesError?: string;
+}
+
+const ACCEPT = fileModuleAccept(FILE_MODULE.merchantImage);
+const IMAGE_HINT = 'JPG · PNG / 5MB 이하';
+
+/**
+ * 기획 BO-MAL-002 ⑤ — 대표 1장 필수 + 추가 최대 11장, 추가 이미지는 드래그로 순서를 바꾼다.
+ *
+ * 추가 이미지는 칸마다 `imageSlot`(`saved:<fileSn>` | `new`)과 `imageFile`을 같은 순서로
+ * 싣는다. 액션은 이 둘을 자리로 짝지어 묶음을 다시 만든다 — 저장된 장은 백엔드에서 다시 읽고
+ * 새 장은 그대로 올린다. 그래서 순서·삭제·추가 어느 것이든 폼 한 번으로 끝난다.
+ *
+ * 비제어 `FoxFileUpload`를 칸마다 두고 React key로 마운트를 관리한다(4컷만화와 같은 방식).
+ */
+export function MerchantImageField({
+  defaultThumbnailUrl,
+  retainedThumbnailId,
+  defaultImageUrls,
+  retainedImageFileId,
+  thumbnailError,
+  imagesError,
+}: MerchantImageFieldProps) {
+  const [slots, setSlots] = useState<MerchantImageSlot[]>(() =>
+    defaultImageUrls.map((url, index) => ({
+      key: `saved-${index + 1}`,
+      previewUrl: url,
+      fileSn: index + 1,
+    }))
+  );
+  const [nextKey, setNextKey] = useState(0);
+
+  function addSlot() {
+    if (slots.length >= MAX_EXTRA_IMAGES) return;
+    setSlots((current) => [
+      ...current,
+      { key: `new-${nextKey}`, previewUrl: null, fileSn: null },
+    ]);
+    setNextKey((current) => current + 1);
+  }
+
+  function removeSlot(key: string) {
+    setSlots((current) => current.filter((slot) => slot.key !== key));
+  }
+
+  return (
+    <div className={styles.root}>
+      <div className={styles.tiles}>
+        <div className={styles.thumbnail}>
+          <FoxFileUpload
+            mode="image"
+            name="thumbnailFile"
+            accept={ACCEPT}
+            thumbnailPicker
+            selectLabel="대표이미지 등록"
+            description={IMAGE_HINT}
+            thumbnailWidth={220}
+            thumbnailHeight={160}
+            defaultFiles={
+              defaultThumbnailUrl
+                ? [{ id: 'thumbnail', name: '대표이미지', previewUrl: defaultThumbnailUrl }]
+                : undefined
+            }
+          />
+          <input type="hidden" name="thumbnailFileId" value={retainedThumbnailId ?? ''} />
+          <span className={styles.caption}>
+            대표이미지 <span className={styles.required}>*</span>
+          </span>
+          {thumbnailError && <FoxHelperText type="danger" message={thumbnailError} />}
+        </div>
+
+        <input type="hidden" name="imageFileId" value={retainedImageFileId ?? ''} />
+        <FoxSortableList
+          items={slots}
+          getKey={(slot) => slot.key}
+          onReorder={setSlots}
+          ariaLabel="추가 이미지 순서"
+          renderItem={(slot, index) => (
+            <div className={styles.slot}>
+              <FoxFileUpload
+                mode="image"
+                name="imageFile"
+                accept={ACCEPT}
+                thumbnailPicker
+                selectLabel={`추가 이미지 ${index + 1} 선택`}
+                thumbnailWidth={160}
+                thumbnailHeight={160}
+                defaultFiles={
+                  slot.previewUrl
+                    ? [{ id: slot.key, name: `추가 이미지 ${index + 1}`, previewUrl: slot.previewUrl }]
+                    : undefined
+                }
+              />
+              <input
+                type="hidden"
+                name="imageSlot"
+                value={slot.fileSn === null ? 'new' : `saved:${slot.fileSn}`}
+              />
+              <span className={styles.slotFooter}>
+                <span className={styles.caption}>추가 이미지 {index + 1}</span>
+                <FoxTextButton
+                  size="md"
+                  type="secondary"
+                  leadingIcon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
+                  label="삭제"
+                  onAction={() => removeSlot(slot.key)}
+                />
+              </span>
+            </div>
+          )}
+        />
+
+        {slots.length < MAX_EXTRA_IMAGES && (
+          <button type="button" className={styles.addTile} onClick={addSlot}>
+            <i className="fox-ico fox-ico-Plus" aria-hidden="true" />
+            <span>추가</span>
+            <span className={styles.addHint}>
+              {slots.length}/{MAX_EXTRA_IMAGES}
+            </span>
+          </button>
+        )}
+      </div>
+
+      {imagesError && <FoxHelperText type="danger" message={imagesError} />}
+    </div>
+  );
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-product-row-actions.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-row-actions.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import { merchantProductPath } from '@/lib/domain/merchant-detail-query';
+import type { MerchantProduct } from '@/lib/domain/merchant-product';
+import {
+  deleteMerchantProductAction,
+  updateMerchantProductVisibilityAction,
+} from '../_actions';
+
+interface MerchantProductRowActionsProps {
+  product: MerchantProduct;
+}
+
+/** 기획 BO-MAL-002 ⑥ — 메뉴 행의 [수정] [숨김|노출] [삭제]. 수정은 메뉴 화면으로 간다. */
+export function MerchantProductRowActions({ product }: MerchantProductRowActionsProps) {
+  const router = useRouter();
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isBusy, startAction] = useBlockingAction();
+
+  function toggleVisibility() {
+    const next = !product.isVisible;
+    startAction(async () => {
+      const result = await updateMerchantProductVisibilityAction(
+        product.frcsNo,
+        product.id,
+        next
+      );
+      showToast(
+        result.status === 'error'
+          ? { variant: 'danger', message: result.message }
+          : { variant: 'success', message: next ? '노출로 바꿨습니다.' : '숨겼습니다.' }
+      );
+    });
+  }
+
+  function runDelete() {
+    hideAlert();
+    startAction(async () => {
+      const result = await deleteMerchantProductAction(product.frcsNo, product.id);
+      showToast(
+        result.status === 'error'
+          ? { variant: 'danger', message: result.message }
+          : { variant: 'success', message: '메뉴를 삭제했습니다.' }
+      );
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '메뉴를 삭제하시겠습니까?',
+      message: `"${product.name}" 메뉴를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  return (
+    <FoxButtonGroup size="sm" align="center">
+      <FoxButton
+        type="default"
+        size="sm"
+        label="수정"
+        onAction={() => router.push(merchantProductPath(product.frcsNo, product.id))}
+      />
+      <FoxButton
+        type="default"
+        size="sm"
+        label={product.isVisible ? '숨김' : '노출'}
+        disabled={isBusy}
+        onAction={toggleVisibility}
+      />
+      <FoxButton
+        type="default"
+        size="sm"
+        label="삭제"
+        disabled={isBusy}
+        onAction={confirmDelete}
+      />
+    </FoxButtonGroup>
+  );
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.module.scss (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.module.scss
@@ -0,0 +1,53 @@
+@use "@fox/styles/abstracts" as fox;
+
+.root {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(4);
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: fox.gap(3);
+}
+
+.count {
+  font-size: fox.font-size(label-md);
+  color: fox.color(font-neutral-default);
+}
+
+.hint {
+  margin: 0;
+  font-size: fox.font-size(label-sm);
+  color: fox.color(font-neutral-subtle);
+}
+
+.table {
+  inline-size: 100%;
+  overflow-x: auto;
+}
+
+// 기획 BO-MAL-002 메뉴 표의 56×40 썸네일(목록 대표이미지와 같은 크기·같은 근거).
+.thumbnail {
+  inline-size: 5.6rem;
+  block-size: 4rem;
+  border: fox.border(1) solid fox.color(border-neutral-subtler);
+  border-radius: fox.radius(2);
+  object-fit: cover;
+}
+
+.description {
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.badges {
+  display: inline-flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: fox.gap(2);
+}
 
app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { FoxBadge } from '@fox/core/components/fox-badge';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
+import { FoxTable } from '@fox/core/components/fox-table';
+import { FoxTableCell } from '@fox/core/components/fox-table-cell';
+import { FoxTableRow } from '@fox/core/components/fox-table-row';
+import { EMPTY_FIELD_PLACEHOLDER } from '@/lib/domain/merchant-detail';
+import { merchantProductNewPath } from '@/lib/domain/merchant-detail-query';
+import {
+  formatProductBadgeLabel,
+  formatProductPrice,
+  formatProductVisibilityLabel,
+  type MerchantProduct,
+} from '@/lib/domain/merchant-product';
+import { MerchantProductRowActions } from './merchant-product-row-actions';
+import styles from './merchant-product-table.module.scss';
+
+interface MerchantProductTableProps {
+  frcsNo: string;
+  products: MerchantProduct[];
+  /** 상세가 아직 저장되지 않은 가맹점 — 백엔드가 메뉴 등록을 거부하므로 추가를 막는다. */
+  canAdd: boolean;
+}
+
+/**
+ * 기획 BO-MAL-002 ⑥ — 메뉴는 목록으로만 보이고 등록·수정은 별도 화면에서 한다.
+ * 목록 화면 뼈대(FoxListContainer)는 머리말·검색까지 딸려 와 카드 안에는 표 조각만 놓는다.
+ */
+export function MerchantProductTable({ frcsNo, products, canAdd }: MerchantProductTableProps) {
+  const router = useRouter();
+
+  return (
+    <div className={styles.root}>
+      <div className={styles.toolbar}>
+        <span className={styles.count}>등록 메뉴 {products.length}건</span>
+        <FoxButton
+          type="primary"
+          size="md"
+          leadingIcon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />}
+          label="메뉴 추가"
+          disabled={!canAdd}
+          onAction={() => router.push(merchantProductNewPath(frcsNo))}
+        />
+      </div>
+      {!canAdd && (
+        <p className={styles.hint}>상세를 먼저 저장하면 메뉴를 추가할 수 있습니다.</p>
+      )}
+
+      <div className={styles.table}>
+        <FoxTable type="list" caption="메뉴 목록">
+          <FoxTableRow head>
+            <FoxTableCell head width={64} align="center">순서</FoxTableCell>
+            <FoxTableCell head width={96} align="center">이미지</FoxTableCell>
+            <FoxTableCell head width={240}>메뉴명</FoxTableCell>
+            <FoxTableCell head>설명</FoxTableCell>
+            <FoxTableCell head width={140} align="center">가격</FoxTableCell>
+            <FoxTableCell head width={180} align="center">배지</FoxTableCell>
+            <FoxTableCell head width={96} align="center">노출</FoxTableCell>
+            <FoxTableCell head width={220} align="center">관리</FoxTableCell>
+          </FoxTableRow>
+
+          {products.length === 0 ? (
+            <FoxTableRow>
+              <FoxTableCell colSpan={8} align="center">
+                등록된 메뉴가 없습니다.
+              </FoxTableCell>
+            </FoxTableRow>
+          ) : (
+            products.map((product, index) => (
+              <FoxTableRow key={product.id}>
+                <FoxTableCell align="center" label="순서">
+                  {product.sortOrder ?? index + 1}
+                </FoxTableCell>
+                <FoxTableCell align="center" label="이미지">
+                  {product.imageUrl ? (
+                    // 백엔드의 공개 이미지 GET — 목록 대표이미지와 같은 이유로 <img>를 쓴다.
+                    // eslint-disable-next-line @next/next/no-img-element
+                    <img className={styles.thumbnail} src={product.imageUrl} alt={`${product.name} 이미지`} />
+                  ) : (
+                    EMPTY_FIELD_PLACEHOLDER
+                  )}
+                </FoxTableCell>
+                <FoxTableCell emphasis label="메뉴명">{product.name}</FoxTableCell>
+                <FoxTableCell label="설명">
+                  <span className={styles.description}>
+                    {product.description ?? EMPTY_FIELD_PLACEHOLDER}
+                  </span>
+                </FoxTableCell>
+                <FoxTableCell align="center" label="가격">
+                  {formatProductPrice(product.price)}
+                </FoxTableCell>
+                <FoxTableCell align="center" label="배지">
+                  {product.badges.length === 0 ? (
+                    EMPTY_FIELD_PLACEHOLDER
+                  ) : (
+                    <span className={styles.badges}>
+                      {product.badges.map((badge) => (
+                        <FoxBadge key={badge} type="pastel" color="neutral" size="sm">
+                          {formatProductBadgeLabel(badge)}
+                        </FoxBadge>
+                      ))}
+                    </span>
+                  )}
+                </FoxTableCell>
+                <FoxTableCell align="center" label="노출">
+                  <FoxStatusIndicator
+                    type={product.isVisible ? 'success' : 'default'}
+                    label={formatProductVisibilityLabel(product.isVisible)}
+                  />
+                </FoxTableCell>
+                <FoxTableCell align="center" label="관리">
+                  <MerchantProductRowActions product={product} />
+                </FoxTableCell>
+              </FoxTableRow>
+            ))
+          )}
+        </FoxTable>
+      </div>
+    </div>
+  );
+}
 
app/(protected)/(basic)/system/merchant-details/page.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/page.tsx
@@ -0,0 +1,38 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
+import { fetchMerchantDetails } from '@/lib/data/repositories/merchant-detail-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
+import { parseMerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
+import { MerchantDetailList } from './_components/merchant-detail-list';
+
+export const metadata: Metadata = { title: '가맹몰 상세 관리' };
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseMerchantDetailQuery(await searchParams);
+  const [{ items, totalCount }, categories] = await Promise.all([
+    fetchMerchantDetails(query),
+    fetchCommonCodes(CODE_GROUP.merchantType),
+  ]);
+
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <MerchantDetailList
+      items={items}
+      categories={categories}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
lib/data/repositories/merchant-detail-repository.ts (added)
+++ lib/data/repositories/merchant-detail-repository.ts
@@ -0,0 +1,264 @@
+import 'server-only';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import { buildFileImageUrl } from '@/lib/data/repositories/file-repository';
+import {
+  MAX_EXTRA_IMAGES,
+  type MerchantDetail,
+  type MerchantDetailSummary,
+} from '@/lib/domain/merchant-detail';
+import type { MerchantDetailValues } from '@/lib/domain/merchant-detail-form';
+import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
+
+/**
+ * 가맹몰 상세 Repository — edupay-backend origin/develop 15d409a·22f67cb 기준.
+ *
+ * ```
+ * 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
+ * DELETE /api/v1/mngr/voc/{frcsNo}            논리 삭제(DEL_YN)
+ * ```
+ *
+ * ⚠️ 백엔드 결함(보고함, 미수정) — 화면은 시안대로 두고 값을 그대로 보낸다(사용자 확정):
+ * 1. `MngrVocDetailVo`에 `useYn`이 없어 노출 상태가 등록·수정에서 유실되고 조회에도 안 온다.
+ * 2. 단건 SELECT에 소개·카테고리·영업시간·휴무일·추가이미지·순서가 없어 수정 프리필이 빈다.
+ * 3. 목록에 카테고리 코드·이름이 없다.
+ * 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다.
+ * 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다.
+ */
+
+const DETAIL_PATH = '/api/v1/mngr/voc/detail';
+const VOC_PATH = '/api/v1/mngr/voc';
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string | null {
+  const value = source[key];
+  if (typeof value === 'string' && value !== '') return value;
+  if (typeof value === 'number') return String(value);
+  return null;
+}
+
+function readNumber(source: Record<string, unknown>, key: string): number | null {
+  const value = source[key];
+  if (typeof value === 'number' && Number.isFinite(value)) return value;
+  if (typeof value === 'string' && value.trim() !== '') {
+    const parsed = Number(value);
+    return Number.isFinite(parsed) ? parsed : null;
+  }
+  return null;
+}
+
+function readYesNo(source: Record<string, unknown>, key: string): boolean | null {
+  const value = readString(source, key);
+  return value === null ? null : value.toUpperCase() === 'Y';
+}
+
+/**
+ * 등록일은 `d.FRST_REG_DT`가 별칭 없이 오므로 `frstRegDt`(Date → ISO 문자열 또는 epoch)로
+ * 받는다. 다른 화면의 `frstRegDtStr`가 있으면 그것을 우선한다.
+ */
+function readDate(source: Record<string, unknown>, key: string): string | null {
+  const formatted = readString(source, `${key}Str`);
+  if (formatted) return formatted.slice(0, 10);
+
+  const raw = source[key];
+  const date =
+    typeof raw === 'number'
+      ? new Date(raw)
+      : typeof raw === 'string' && raw !== ''
+        ? new Date(raw)
+        : null;
+  if (date === null || Number.isNaN(date.getTime())) return null;
+  return date.toISOString().slice(0, 10);
+}
+
+function toSummary(raw: unknown): MerchantDetailSummary[] {
+  if (!isRecord(raw)) return [];
+  const id = readString(raw, 'frcsNo');
+  const name = readString(raw, 'frcsNm');
+  if (!id || !name) return [];
+
+  const registeredAt = readDate(raw, 'frstRegDt');
+  return [
+    {
+      id,
+      name,
+      phoneNumber: readString(raw, 'rprsvTelno'),
+      address: readString(raw, 'frcsPostAddr'),
+      addressDetail: readString(raw, 'frcsDaddr'),
+      thumbnailFileId: readString(raw, 'thumbAtchFileId'),
+      thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')),
+      categoryCode: readString(raw, 'frcsTypeCd'),
+      isVisible: readYesNo(raw, 'useYn'),
+      registeredAt,
+      // 상세 행은 LEFT JOIN이라 없으면 상세 컬럼이 전부 null이다 — 등록일이 그 표지다.
+      hasDetail: registeredAt !== null || readString(raw, 'thumbAtchFileId') !== null,
+    },
+  ];
+}
+
+/**
+ * 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로
+ * 없는 번호가 나올 때까지 HEAD로 더듬는다 — 없는 번호는 백엔드가 500으로 답한다.
+ */
+async function probeImageUrls(fileId: string | null): Promise<string[]> {
+  if (!fileId) return [];
+  const urls: string[] = [];
+  for (let fileSn = 1; fileSn <= MAX_EXTRA_IMAGES; fileSn += 1) {
+    const url = buildFileImageUrl(fileId, fileSn);
+    if (!url) break;
+    try {
+      const response = await fetch(url, { method: 'HEAD', cache: 'no-store' });
+      if (!response.ok) break;
+    } catch {
+      break;
+    }
+    urls.push(url);
+  }
+  return urls;
+}
+
+async function toDetail(raw: unknown): Promise<MerchantDetail | null> {
+  const [summary] = toSummary(raw);
+  if (!summary || !isRecord(raw)) return null;
+
+  const imageFileId = readString(raw, 'atchFileId');
+  return {
+    ...summary,
+    imageUrls: await probeImageUrls(imageFileId),
+    postalCode: readString(raw, 'frcsPostZip'),
+    description: readString(raw, 'frcsCn'),
+    openTime: readString(raw, 'startHour'),
+    closeTime: readString(raw, 'endHour'),
+    closedDays: readString(raw, 'offDayCn'),
+    imageFileId,
+    sortOrder: readNumber(raw, 'sortOrdr'),
+    registeredBy: readString(raw, 'frstRgtrNm'),
+    modifiedAt: readDate(raw, 'lastMdfcnDt'),
+    modifiedBy: readString(raw, 'lastMdfrNm'),
+  };
+}
+
+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,
+  });
+
+  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('가맹몰 상세 목록 응답의 형식이 올바르지 않습니다.');
+  }
+
+  return {
+    items: data.list.flatMap(toSummary),
+    totalCount:
+      typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
+  };
+}
+
+export async function fetchMerchantDetail(
+  frcsNo: string
+): Promise<MerchantDetail | null> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(
+    `${DETAIL_PATH}/${encodeURIComponent(frcsNo)}`,
+    {
+      method: 'GET',
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+  return toDetail(result.data);
+}
+
+function toWritePayload(values: MerchantDetailValues): Record<string, unknown> {
+  return {
+    frcsNo: values.frcsNo,
+    frcsTypeCd: values.categoryCode,
+    frcsCn: values.description,
+    startHour: values.openTime,
+    endHour: values.closeTime,
+    offDayCn: values.closedDays,
+    thumbAtchFileId: values.thumbnailFileId,
+    atchFileId: values.imageFileId || null,
+    sortOrdr: values.sortOrder,
+    useYn: values.isVisible ? 'Y' : 'N',
+    // 요청 VO에 없어 버려진다(⚠️ 5). 자리가 생기면 그대로 저장된다.
+    voucherUseYn: values.isVoucherUsable ? 'Y' : 'N',
+  };
+}
+
+async function sendWrite(
+  path: string,
+  method: 'POST' | 'PUT' | 'DELETE',
+  body?: unknown
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(path, {
+    method,
+    ...(body === undefined ? {} : { body }),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export async function createMerchantDetail(
+  values: MerchantDetailValues
+): Promise<void> {
+  await sendWrite(DETAIL_PATH, 'POST', toWritePayload(values));
+}
+
+export async function updateMerchantDetail(
+  values: MerchantDetailValues
+): Promise<void> {
+  await sendWrite(
+    `${DETAIL_PATH}/${encodeURIComponent(values.frcsNo)}`,
+    'PUT',
+    toWritePayload(values)
+  );
+}
+
+export async function deleteMerchantDetail(frcsNo: string): Promise<void> {
+  await sendWrite(`${VOC_PATH}/${encodeURIComponent(frcsNo)}`, 'DELETE');
+}
 
lib/data/repositories/merchant-product-repository.ts (added)
+++ lib/data/repositories/merchant-product-repository.ts
@@ -0,0 +1,164 @@
+import 'server-only';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import { buildFileImageUrl } from '@/lib/data/repositories/file-repository';
+import {
+  joinProductBadges,
+  parseProductBadges,
+  type MerchantProduct,
+} from '@/lib/domain/merchant-product';
+import type { MerchantProductValues } from '@/lib/domain/merchant-product-form';
+
+/**
+ * 가맹몰 메뉴 Repository — edupay-backend origin/develop 15d409a 기준.
+ *
+ * ```
+ * 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}  논리 삭제
+ * ```
+ */
+
+const PRODUCT_PATH = '/api/v1/mngr/voc/product';
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readString(source: Record<string, unknown>, key: string): string | null {
+  const value = source[key];
+  if (typeof value === 'string' && value !== '') return value;
+  if (typeof value === 'number') return String(value);
+  return null;
+}
+
+function readNumber(source: Record<string, unknown>, key: string): number | null {
+  const value = source[key];
+  if (typeof value === 'number' && Number.isFinite(value)) return value;
+  if (typeof value === 'string' && value.trim() !== '') {
+    const parsed = Number(value);
+    return Number.isFinite(parsed) ? parsed : null;
+  }
+  return null;
+}
+
+function toProduct(raw: unknown): MerchantProduct[] {
+  if (!isRecord(raw)) return [];
+  const id = readString(raw, 'frcsProductSn');
+  const frcsNo = readString(raw, 'frcsNo');
+  if (!id || !frcsNo) return [];
+
+  return [
+    {
+      id,
+      frcsNo,
+      name: readString(raw, 'frcsProductSj') ?? '',
+      price: readNumber(raw, 'frcsProductPrice'),
+      description: readString(raw, 'frcsProductCn'),
+      badges: parseProductBadges(readString(raw, 'frcsBadge')),
+      imageFileId: readString(raw, 'atchFileId'),
+      imageUrl: buildFileImageUrl(readString(raw, 'atchFileId')),
+      sortOrder: readNumber(raw, 'sortOrdr'),
+      isVisible: (readString(raw, 'useYn') ?? 'Y').toUpperCase() !== 'N',
+    },
+  ];
+}
+
+export async function fetchMerchantProducts(frcsNo: string): Promise<MerchantProduct[]> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(
+    `${PRODUCT_PATH}/list/${encodeURIComponent(frcsNo)}`,
+    {
+      method: 'GET',
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+  return Array.isArray(result.data) ? result.data.flatMap(toProduct) : [];
+}
+
+export async function fetchMerchantProduct(
+  productSn: string
+): Promise<MerchantProduct | null> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(
+    `${PRODUCT_PATH}/${encodeURIComponent(productSn)}`,
+    {
+      method: 'GET',
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    // 없는 메뉴는 404 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다.
+    if (result.code === 404) return null;
+    throw new BackendRequestError(result);
+  }
+  return toProduct(result.data)[0] ?? null;
+}
+
+function toWritePayload(values: MerchantProductValues): Record<string, unknown> {
+  return {
+    frcsNo: values.frcsNo,
+    frcsProductSj: values.name,
+    frcsProductPrice: values.price,
+    frcsProductCn: values.description,
+    frcsBadge: joinProductBadges(values.badges),
+    atchFileId: values.imageFileId || null,
+    sortOrdr: values.sortOrder,
+    useYn: values.isVisible ? 'Y' : 'N',
+  };
+}
+
+async function sendWrite(
+  path: string,
+  method: 'POST' | 'PUT' | 'DELETE',
+  body?: unknown
+): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(path, {
+    method,
+    ...(body === undefined ? {} : { body }),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+}
+
+export async function createMerchantProduct(
+  values: MerchantProductValues
+): Promise<void> {
+  await sendWrite(PRODUCT_PATH, 'POST', toWritePayload(values));
+}
+
+export async function updateMerchantProduct(
+  productSn: string,
+  values: MerchantProductValues
+): Promise<void> {
+  await sendWrite(
+    `${PRODUCT_PATH}/${encodeURIComponent(productSn)}`,
+    'PUT',
+    toWritePayload(values)
+  );
+}
+
+export async function deleteMerchantProduct(productSn: string): Promise<void> {
+  await sendWrite(`${PRODUCT_PATH}/${encodeURIComponent(productSn)}`, 'DELETE');
+}
lib/data/repositories/sidebar-menu-repository.ts
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -98,6 +98,7 @@
           children: [
             { id: 'codes', label: '코드관리', href: '/system/codes' },
             { id: 'menus', label: '앱 메뉴관리', href: '/system/menus' },
+            { id: 'merchant-details', label: '가맹몰 상세 관리', href: '/system/merchant-details' },
           ],
         },
       ],
lib/domain/common-code.ts
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
@@ -39,6 +39,8 @@
     O: 'QUIZ_ANSWER_O',
     X: 'QUIZ_ANSWER_X',
   },
+  /** 가맹점 상세의 카테고리 — 백엔드 `MngrVocDetailMapper`가 이 그룹과 조인한다. */
+  merchantType: 'COM_FRCS_TYPE_CD',
 } as const;
 
 /** 값이 없는 항목의 화면 표기. */
lib/domain/file-module.ts
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
@@ -60,6 +60,18 @@
     description: '사용자 아이템 저장소',
     extensions: ['jpg', 'png', 'gif', 'svg'],
   },
+  /**
+   * 가맹점 상세·메뉴 이미지.
+   *
+   * TODO(백엔드): 저장소 설정 표에 바우처용 모듈이 없다. 없는 ID를 보내면 기본 저장소로
+   *   폴백해 동작은 하므로 잠정 이름을 두고 시작한다(사용자 확정) — 백엔드가 이 이름으로
+   *   등록하면 손댈 것이 없고, 다른 이름이면 여기 한 줄만 바꾼다.
+   */
+  merchantImage: {
+    id: 'MODULE_VOUCHER',
+    description: '가맹점 이미지 저장소(잠정)',
+    extensions: ['jpg', 'jpeg', 'png'],
+  },
   menuIcon: {
     id: 'MODULE_MENU_ICON',
     description: '메뉴 아이콘 저장소',
 
lib/domain/merchant-detail-form.ts (added)
+++ lib/domain/merchant-detail-form.ts
@@ -0,0 +1,106 @@
+import type { CommonCode } from '@/lib/domain/common-code';
+
+/**
+ * 가맹몰 상세 등록·수정 입력 규칙 — 순수 검증만 담는다.
+ *
+ * 기획 BO-MAL-002 ①의 필수 항목 중 업체명·연락처·우편번호는 가맹점 원본에서 오는 읽기 전용
+ * 값이라 여기서 검사하지 않는다. 남는 필수는 카테고리·노출 상태·바우처 사용·대표이미지다.
+ */
+
+export const DESCRIPTION_MAX_LENGTH = 200;
+export const CLOSED_DAYS_MAX_LENGTH = 100;
+export const SORT_ORDER_MAX = 9999;
+const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
+
+export type MerchantDetailValues = {
+  frcsNo: string;
+  categoryCode: string;
+  description: string;
+  openTime: string;
+  closeTime: string;
+  closedDays: string;
+  isVisible: boolean;
+  isVoucherUsable: boolean;
+  /** 빈 값이면 null — 기획 ⑦ 미입력 시 등록일 내림차순. */
+  sortOrder: number | null;
+  thumbnailFileId: string;
+  imageFileId: string;
+};
+
+export type MerchantDetailFormErrors = Partial<
+  Record<
+    | 'categoryCode'
+    | 'description'
+    | 'openTime'
+    | 'closeTime'
+    | 'closedDays'
+    | 'sortOrder'
+    | 'thumbnail'
+    | 'images',
+    string
+  >
+>;
+
+export type MerchantDetailFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: MerchantDetailFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_MERCHANT_DETAIL_FORM_STATE: MerchantDetailFormState = {
+  status: 'idle',
+};
+
+type ValidationResult =
+  | { ok: true; values: MerchantDetailValues }
+  | { ok: false; errors: MerchantDetailFormErrors };
+
+export function validateMerchantDetail(
+  values: MerchantDetailValues,
+  categories: readonly CommonCode[]
+): ValidationResult {
+  const errors: MerchantDetailFormErrors = {};
+
+  if (!categories.some((code) => code.code === values.categoryCode)) {
+    errors.categoryCode = '카테고리를 선택해 주세요.';
+  }
+
+  const description = values.description.trim();
+  if (description.length > DESCRIPTION_MAX_LENGTH) {
+    errors.description = `한줄 소개는 ${DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  const openTime = values.openTime.trim();
+  const closeTime = values.closeTime.trim();
+  if (openTime && !TIME_PATTERN.test(openTime)) {
+    errors.openTime = '영업 시작 시간 형식이 올바르지 않습니다.';
+  }
+  if (closeTime && !TIME_PATTERN.test(closeTime)) {
+    errors.closeTime = '영업 종료 시간 형식이 올바르지 않습니다.';
+  }
+
+  const closedDays = values.closedDays.trim();
+  if (closedDays.length > CLOSED_DAYS_MAX_LENGTH) {
+    errors.closedDays = `휴무일은 ${CLOSED_DAYS_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  if (
+    values.sortOrder !== null &&
+    (!Number.isInteger(values.sortOrder) ||
+      values.sortOrder < 0 ||
+      values.sortOrder > SORT_ORDER_MAX)
+  ) {
+    errors.sortOrder = `노출 순서는 0 이상 ${SORT_ORDER_MAX} 이하의 숫자로 입력해 주세요.`;
+  }
+
+  if (!values.thumbnailFileId) {
+    errors.thumbnail = '대표이미지를 등록해 주세요.';
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+  return {
+    ok: true,
+    values: { ...values, description, openTime, closeTime, closedDays },
+  };
+}
 
lib/domain/merchant-detail-query.ts (added)
+++ lib/domain/merchant-detail-query.ts
@@ -0,0 +1,99 @@
+import { MERCHANT_DETAIL_VISIBILITY_OPTIONS } from '@/lib/domain/merchant-detail';
+
+export const MERCHANT_DETAILS_PATH = '/system/merchant-details';
+
+export function merchantDetailPath(frcsNo: string): string {
+  return `${MERCHANT_DETAILS_PATH}/${encodeURIComponent(frcsNo)}`;
+}
+
+export function merchantProductNewPath(frcsNo: string): string {
+  return `${merchantDetailPath(frcsNo)}/products/new`;
+}
+
+export function merchantProductPath(frcsNo: string, productSn: string): string {
+  return `${merchantDetailPath(frcsNo)}/products/${encodeURIComponent(productSn)}`;
+}
+
+export const FILTER_ALL = '';
+
+export const MERCHANT_DETAIL_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type MerchantDetailPageSize =
+  (typeof MERCHANT_DETAIL_PAGE_SIZE_OPTIONS)[number];
+
+export const DEFAULT_MERCHANT_DETAIL_PAGE_SIZE: MerchantDetailPageSize = 10;
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
+
+/** 기획 BO-MAL-001 ① — 업체명·카테고리·노출상태·등록일 네 조건을 AND로 결합한다. */
+export type MerchantDetailQuery = {
+  keyword: string;
+  category: string;
+  visibility: string;
+  startDate: string;
+  endDate: string;
+  page: number;
+  pageSize: MerchantDetailPageSize;
+};
+
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string {
+  const value = params[key];
+  return (Array.isArray(value) ? value[0] : value) ?? '';
+}
+
+function readDate(params: RawSearchParams, key: string): string {
+  const raw = readParam(params, key);
+  return ISO_DATE.test(raw) ? raw : '';
+}
+
+function isPageSize(value: number): value is MerchantDetailPageSize {
+  return (MERCHANT_DETAIL_PAGE_SIZE_OPTIONS as readonly number[]).includes(value);
+}
+
+export function parseMerchantDetailQuery(
+  searchParams: RawSearchParams
+): MerchantDetailQuery {
+  const page = Number(readParam(searchParams, 'page'));
+  const pageSize = Number(readParam(searchParams, 'pageSize'));
+  const visibility = readParam(searchParams, 'visibility');
+
+  return {
+    keyword: readParam(searchParams, 'keyword').trim().slice(0, MAX_KEYWORD_LENGTH),
+    // 카테고리는 공통코드라 여기서 허용 목록을 알 수 없다 — 값 그대로 두고 서버가 거른다.
+    category: readParam(searchParams, 'category').trim().slice(0, MAX_KEYWORD_LENGTH),
+    visibility: MERCHANT_DETAIL_VISIBILITY_OPTIONS.some(
+      (option) => option.value === visibility
+    )
+      ? visibility
+      : FILTER_ALL,
+    startDate: readDate(searchParams, 'startDate'),
+    endDate: readDate(searchParams, 'endDate'),
+    page: Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE,
+    pageSize: isPageSize(pageSize) ? pageSize : DEFAULT_MERCHANT_DETAIL_PAGE_SIZE,
+  };
+}
+
+export function buildMerchantDetailHref(
+  query: MerchantDetailQuery,
+  overrides: Partial<MerchantDetailQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+
+  if (merged.keyword) params.set('keyword', merged.keyword);
+  if (merged.category) params.set('category', merged.category);
+  if (merged.visibility) params.set('visibility', merged.visibility);
+  if (merged.startDate) params.set('startDate', merged.startDate);
+  if (merged.endDate) params.set('endDate', merged.endDate);
+  if (merged.pageSize !== DEFAULT_MERCHANT_DETAIL_PAGE_SIZE) {
+    params.set('pageSize', String(merged.pageSize));
+  }
+  if (merged.page !== DEFAULT_PAGE) params.set('page', String(merged.page));
+
+  const queryString = params.toString();
+  return queryString
+    ? `${MERCHANT_DETAILS_PATH}?${queryString}`
+    : MERCHANT_DETAILS_PATH;
+}
 
lib/domain/merchant-detail.ts (added)
+++ lib/domain/merchant-detail.ts
@@ -0,0 +1,96 @@
+/**
+ * 가맹몰 상세 — 기존 가맹점(`TB_COM_VOUCHER`)에 1:1로 붙는 앱 노출용 정보(`TB_COM_VOUCHER_DTL`).
+ *
+ * 기획(BO-MAL-001~003 「지역상생몰 > 업체관리」)은 업체를 새로 만드는 화면이지만, 백엔드
+ * `POST /api/v1/mngr/voc/detail`은 이미 있는 가맹점(`frcsNo`)에만 상세를 붙인다. 그래서
+ * 「시스템관리 > 가맹몰 상세 관리」로 옮기며 업체명·연락처·주소는 가맹점 원본에서 읽기 전용으로
+ * 가져오고, 상세 항목만 등록·수정한다(사용자 확정).
+ */
+
+/** 목록 한 행. 가맹점 전체가 오며 상세 미등록 건은 `hasDetail`이 false다. */
+export type MerchantDetailSummary = {
+  /** 백엔드 `frcsNo` — 가맹점 코드이자 상세의 키. */
+  id: string;
+  name: string;
+  phoneNumber: string | null;
+  address: string | null;
+  addressDetail: string | null;
+  /** 백엔드 `thumbAtchFileId` — 대표 이미지. */
+  thumbnailFileId: string | null;
+  /** Repository가 만든 이미지 URL. 화면은 파일 id를 URL로 바꾸는 법을 모른다. */
+  thumbnailUrl: string | null;
+  /** 백엔드 `frcsTypeCd` — 카테고리 코드. 목록 SQL에 아직 없어 늘 null이다(보고함). */
+  categoryCode: string | null;
+  /** 백엔드 상세 `useYn`. VO에 필드가 없어 아직 오지 않는다(보고함). */
+  isVisible: boolean | null;
+  /** 상세 등록일(`d.FRST_REG_DT`). 없으면 상세가 아직 없는 가맹점이다. */
+  registeredAt: string | null;
+  /** 상세 행이 있는지 — 행 관리 버튼이 [상세 등록]과 [수정·비활성·삭제]로 갈린다. */
+  hasDetail: boolean;
+};
+
+/** 등록·수정 화면이 받는 한 건. 상세가 없으면 상세 항목이 전부 비어 있다. */
+export type MerchantDetail = MerchantDetailSummary & {
+  postalCode: string | null;
+  /** 백엔드 `frcsCn` — 한줄 소개. */
+  description: string | null;
+  /** 백엔드 `startHour` / `endHour` — "HH:mm". */
+  openTime: string | null;
+  closeTime: string | null;
+  /** 백엔드 `offDayCn`. */
+  closedDays: string | null;
+  /** 백엔드 `atchFileId` — 추가 이미지 묶음(한 id 아래 fileSn 1..n). */
+  imageFileId: string | null;
+  /** 묶음 안의 이미지 URL, fileSn 순. 백엔드에 파일 수를 묻는 API가 없어 Repository가 더듬는다. */
+  imageUrls: string[];
+  sortOrder: number | null;
+  /** 백엔드 `frstRgtrNm` / `lastMdfrNm` — 수정 화면의 「등록 정보」 구역. */
+  registeredBy: string | null;
+  modifiedAt: string | null;
+  modifiedBy: string | null;
+};
+
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+export function formatOptionalValue(
+  value: string | number | null | undefined
+): string {
+  return value === null || value === undefined || value === ''
+    ? EMPTY_FIELD_PLACEHOLDER
+    : String(value);
+}
+
+/** 기획 BO-MAL-001 ② — 노출 상태는 노출중·비활성 2종. */
+export const MERCHANT_DETAIL_VISIBILITY_OPTIONS = [
+  { value: 'Y', label: '노출중' },
+  { value: 'N', label: '비활성' },
+] as const;
+
+export function formatMerchantDetailStatusLabel(row: MerchantDetailSummary): string {
+  if (!row.hasDetail) {
+    return '미등록';
+  }
+  if (row.isVisible === null) {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+  return row.isVisible ? '노출중' : '비활성';
+}
+
+/** 기획 BO-MAL-002 ① — 바우처 사용 가능 여부. 백엔드에 담을 자리가 아직 없다(보고함). */
+export const MERCHANT_VOUCHER_USE_OPTIONS = [
+  { value: 'Y', label: '사용 가능' },
+  { value: 'N', label: '사용 불가' },
+] as const;
+
+/** 기획 BO-MAL-002 ⑤ — 대표 1장 + 추가 최대 11장(앱 슬라이더 최대 12장). */
+export const MAX_EXTRA_IMAGES = 11;
+/** 기획 BO-MAL-002 ⑤ — 장당 5MB 이하. */
+export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
+
+/** 상세 이미지 한 장의 화면 표현. 저장된 것은 `fileSn`으로, 새로 고른 것은 파일로 온다. */
+export type MerchantImageSlot = {
+  key: string;
+  previewUrl: string | null;
+  /** 저장된 이미지면 묶음 안의 번호(1..n). 새 파일은 null. */
+  fileSn: number | null;
+};
 
lib/domain/merchant-product-form.ts (added)
+++ lib/domain/merchant-product-form.ts
@@ -0,0 +1,88 @@
+import {
+  MAX_PRODUCT_BADGES,
+  MERCHANT_PRODUCT_BADGE_OPTIONS,
+  type MerchantProductBadge,
+} from '@/lib/domain/merchant-product';
+
+/** 기획 BO-MAL-004 ② — 필수는 메뉴명·가격·노출 여부 셋. */
+export const PRODUCT_NAME_MAX_LENGTH = 100;
+/** 기획 BO-MAL-004 ⑤ [의사결정 필요] 잠정값 — 설명 최대 글자수. */
+export const PRODUCT_DESCRIPTION_MAX_LENGTH = 120;
+export const PRODUCT_PRICE_MAX = 99_999_999;
+export const PRODUCT_SORT_ORDER_MAX = 9999;
+
+export type MerchantProductValues = {
+  frcsNo: string;
+  name: string;
+  price: number | null;
+  description: string;
+  badges: MerchantProductBadge[];
+  isVisible: boolean;
+  sortOrder: number | null;
+  imageFileId: string;
+};
+
+export type MerchantProductFormErrors = Partial<
+  Record<'name' | 'price' | 'description' | 'badges' | 'sortOrder' | 'image', string>
+>;
+
+export type MerchantProductFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: MerchantProductFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_MERCHANT_PRODUCT_FORM_STATE: MerchantProductFormState = {
+  status: 'idle',
+};
+
+type ValidationResult =
+  | { ok: true; values: MerchantProductValues }
+  | { ok: false; errors: MerchantProductFormErrors };
+
+export function validateMerchantProduct(
+  values: MerchantProductValues
+): ValidationResult {
+  const errors: MerchantProductFormErrors = {};
+
+  const name = values.name.trim();
+  if (!name) {
+    errors.name = '메뉴명을 입력해 주세요.';
+  } else if (name.length > PRODUCT_NAME_MAX_LENGTH) {
+    errors.name = `메뉴명은 ${PRODUCT_NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  if (
+    values.price === null ||
+    !Number.isInteger(values.price) ||
+    values.price < 0 ||
+    values.price > PRODUCT_PRICE_MAX
+  ) {
+    errors.price = '가격은 숫자만 입력해 주세요.';
+  }
+
+  const description = values.description.trim();
+  if (description.length > PRODUCT_DESCRIPTION_MAX_LENGTH) {
+    errors.description = `설명은 ${PRODUCT_DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  const badges = values.badges.filter((badge) =>
+    MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === badge)
+  );
+  if (badges.length > MAX_PRODUCT_BADGES) {
+    errors.badges = `배지는 최대 ${MAX_PRODUCT_BADGES}개까지 고를 수 있습니다.`;
+  }
+
+  if (
+    values.sortOrder !== null &&
+    (!Number.isInteger(values.sortOrder) ||
+      values.sortOrder < 0 ||
+      values.sortOrder > PRODUCT_SORT_ORDER_MAX)
+  ) {
+    errors.sortOrder = `노출 순서는 0 이상 ${PRODUCT_SORT_ORDER_MAX} 이하의 숫자로 입력해 주세요.`;
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+  return { ok: true, values: { ...values, name, description, badges } };
+}
 
lib/domain/merchant-product.ts (added)
+++ lib/domain/merchant-product.ts
@@ -0,0 +1,76 @@
+/**
+ * 가맹몰 메뉴 — 백엔드 `TB_COM_VOUCHER_PRODUCT`(`/api/v1/mngr/voc/product`).
+ * 기획(BO-MAL-004)은 「메뉴」, 백엔드는 product라 부른다. 코드는 백엔드를 따르고 화면 문구만
+ * 「메뉴」다 — 앱 메뉴관리(`system-menu`)와 이름이 겹치지 않게 하려는 뜻이다.
+ */
+export type MerchantProduct = {
+  /** 백엔드 `frcsProductSn`. */
+  id: string;
+  frcsNo: string;
+  name: string;
+  price: number | null;
+  description: string | null;
+  /** 기획 BO-MAL-004 ③ — 선택한 순서대로. */
+  badges: MerchantProductBadge[];
+  imageFileId: string | null;
+  /** Repository가 만든 이미지 URL. */
+  imageUrl: string | null;
+  sortOrder: number | null;
+  isVisible: boolean;
+};
+
+export const MERCHANT_PRODUCT_BADGE_OPTIONS = [
+  { value: 'BEST', label: 'BEST' },
+  { value: 'MAIN', label: '대표' },
+  { value: 'NEW', label: '신규' },
+  { value: 'SALE', label: '할인' },
+] as const;
+
+export type MerchantProductBadge =
+  (typeof MERCHANT_PRODUCT_BADGE_OPTIONS)[number]['value'];
+
+/** 기획 BO-MAL-004 ③ [의사결정 필요] 잠정값 — 배지 동시 선택 상한. */
+export const MAX_PRODUCT_BADGES = 2;
+
+const BADGE_SEPARATOR = ',';
+
+/**
+ * 배지 ↔ 백엔드 `frcsBadge`(문자열 하나). 앱·백엔드에 규약이 없어 여기서 정한다(사용자 확정):
+ * 코드를 쉼표로 잇고 순서를 보존한다. 예) `BEST,MAIN`
+ */
+export function parseProductBadges(raw: string | null): MerchantProductBadge[] {
+  if (!raw) {
+    return [];
+  }
+  return raw
+    .split(BADGE_SEPARATOR)
+    .map((token) => token.trim())
+    .filter((token): token is MerchantProductBadge =>
+      MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === token)
+    );
+}
+
+export function joinProductBadges(badges: readonly MerchantProductBadge[]): string {
+  return badges.join(BADGE_SEPARATOR);
+}
+
+export function formatProductBadgeLabel(badge: MerchantProductBadge): string {
+  return (
+    MERCHANT_PRODUCT_BADGE_OPTIONS.find((option) => option.value === badge)?.label ??
+    badge
+  );
+}
+
+export const MERCHANT_PRODUCT_VISIBILITY_OPTIONS = [
+  { value: 'Y', label: '노출' },
+  { value: 'N', label: '숨김' },
+] as const;
+
+export function formatProductVisibilityLabel(isVisible: boolean): string {
+  return isVisible ? '노출' : '숨김';
+}
+
+/** 기획 BO-MAL-004 ② — 숫자만 받고 노출 시 세 자리 콤마와 '원'을 붙인다. */
+export function formatProductPrice(price: number | null): string {
+  return price === null ? '-' : `${price.toLocaleString('ko-KR')}원`;
+}
Add a comment
List