# 관리자 페이즈 4 — 캠페인 상세 심화 실행 계획

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 프로토타입에서 유일하게 손으로 지은 심화 화면인 캠페인 상세를, 페이즈 0–3 이 만든 선언형 계약 위로 옮기고 실제 운영 깊이(탭 5종·상태 변경·진행 스텝퍼·조치 4종)를 채운다.

**Architecture:** 캠페인은 지금 `SV_TABLES` 가 아니라 `CAMPAIGNS` 엔터티 배열을 쓰고, 상세는 `detailIndex` 로 직접 인덱싱하는 전용 경로(`onCampaign`, [main.dc.html:1879](../../admin/main.dc.html) · [1763](../../admin/main.dc.html))로 렌더된다. 그래서 `buildEntityDetail(table, dcfg, row)` 를 쓸 수 없다. 이 계획은 먼저 캠페인을 테이블 계약 위로 올리고(Task 1), 상세 탭이 정적 하위 목록이 아니라 **자체 표·필터·행 액션을 가진 작업 화면**일 수 있도록 상세 템플릿을 한 번 넓힌 뒤(Task 2), 나머지를 전부 선언으로 채운다.

**Tech Stack:** 정적 프로토타입. 커스텀 `x-dc` 런타임(`support.js`) + `sc-if`/`sc-for` + `class Component extends DCLogic`. 빌드 도구 없음, 테스트 러너 없음, npm 의존성 없음. 검증은 `node tools/check-admin-data.mjs` + 브라우저.

**Spec:** [docs/specs/2026-08-19-admin-coverage-implementation-design.md](../specs/2026-08-19-admin-coverage-implementation-design.md) — 스테이지 P0, 페이즈 4 (“캠페인 상세 심화: 운영 탭 5종, 상태 변경, 진행 스텝퍼, 취소·강제 완료·복사·보고서 액션”)
**Audit:** [docs/specs/2026-08-19-admin-coverage-audit.md](../specs/2026-08-19-admin-coverage-audit.md) §5 “캠페인 상세 — `/campaign/:id`”

**선행 계획:** [2026-08-19-admin-coverage-foundation.md](./2026-08-19-admin-coverage-foundation.md) (페이즈 0–3, Task 1–16 완료). 이 계획은 거기서 만든 프리미티브를 **소비**한다.

## Global Constraints

- **커밋 메시지에 AI 어트리뷰션을 넣지 않는다.** `Co-Authored-By: Claude`, "Generated with", 🤖 전부 금지. 메시지는 한국어.
- **어휘는 프로토타입 현행을 따른다.** 리뷰어가 아니라 **크리에이터**. 코드 검토자를 뜻할 때는 검토자.
- **디자인 토큰만 쓴다.** `var(--ink)`, `var(--surface-1)`, `var(--surface-2)`, `var(--canvas)`, `var(--hairline)`, `var(--text-secondary)`, `var(--text-tertiary)`, `var(--text-on-ink)`, `var(--error)`, `var(--success)`, `var(--point-gold)`. 반경은 `var(--radius-sm|lg|xl|2xl|pill|circle|photo)`. 하드코딩 hex 금지.
- **상태 톤은 기존 `TONE` + `STATUS_TONE` 을 쓴다** ([main.dc.html:1284](../../admin/main.dc.html), [1289](../../admin/main.dc.html)).
- **날짜 기준일은 `2026.08.11`** (`TODAY`). 새 목 데이터의 날짜가 이와 모순되면 안 된다.
- **골든 스레드 고정**: 크리에이터 `보미 · bomi.daily@gmail.com · 010-9930-1147 · 인스타그램`, 기업 `주식회사 글로우드롭`, 협업 `글로우드롭 비타 C 앰플 리필 협업 · 330,000 · 1명`. 어느 태스크도 바꾸지 않는다. `tools/check-admin-data.mjs` 가 이미 단언한다.
- **인라인 스타일**을 쓴다. 이 파일에는 CSS 클래스 시스템이 없다.
- **주석은 한국어로 “왜”를 적는다.**
- **아이콘은 `SV_ADMIN_ICONS` 에 실재하는 이름만 쓴다.** 선행 계획에서 없는 이름(`close`)을 쓴 사고가 있었다 — 쓰기 전에 확인한다.
- **`admin/` 밖을 건드리지 않는다** — 예외는 `tools/check-admin-data.mjs`.
- **`disabled` 속성을 쓰지 않는다.** 이 코드베이스에 한 곳도 없다. 막힌 버튼은 핸들러 안에서 `if (blocked) return;` 로 막고 배경 토큰을 바꾼다(`submitPayout`/`payoutBtnStyle` 패턴).
- **`<sc-for>` 문법은 `list="{{ rows }}" as="row" hint-placeholder-count="N"`.** `each=` 는 존재하지 않는다.
- **`onChange` 핸들러는 이벤트를 받는다**: `(e) => ... e.target.value`.

## 캠페인 상태 어휘 (정본)

`prisma/models/campaign.prisma` 의 `enum CampaignStatus` 가 정본이며 **11개**다. 감사 문서는 “10상태”라 적었으나 실제 열거는 11개다 — 이 계획은 정본을 따른다.

```
임시저장 · 결제대기 · 등록대기중 · 승인대기중 · 모집대기중 · 모집중 · 진행대기중 · 진행중 · 검수중 · 완료 · 취소
```

프로토타입의 `STATUS_TONE` 은 현재 8개만 담고 있다 — `결제대기`, `등록대기중`, `승인대기중` 이 빠져 있다. Task 9 가 채운다.

## 검증 방식 (테스트 러너가 없다)

**A. 데이터·구조 → Node 체크 스크립트.** `node tools/check-admin-data.mjs`. 실패 시 non-zero. 이것이 빨강/초록이다.

**B. UI → 브라우저.** 이 워크트리용 정적 서버가 `http://127.0.0.1:8124/admin/main.dc.html` 에 떠 있다. 먼저 바이트 패리티:

```bash
curl -s http://127.0.0.1:8124/admin/main.dc.html | wc -c   # wc -c < admin/main.dc.html 와 같아야 한다
```

서버가 죽어 있으면 보고하고 멈춘다 — 직접 띄우거나 죽이지 않는다.

### 이 코드베이스에서 거짓 통과를 만든 함정 3가지

선행 계획에서 실제로 잘못된 “통과”를 낸 것들이다. 전부 그럴듯한 오답을 낸다.

1. **`setState` 는 비동기로 리렌더한다.** 같은 `browser_evaluate` 호출 안에서 DOM 을 읽으면 이전 트리를 본다. **반드시 별도 호출로 읽는다.**
2. **`innerHTML.indexOf(...)` 로 단언하지 않는다.** 페이지 자신의 인라인 스크립트 소스가 `document.body` 안에 있어서 한국어 문자열이 렌더되지 않아도 매치된다. 렌더된 엘리먼트 텍스트로 단언한다:
   `Array.from(document.querySelectorAll('span,h1,h2,p,button,td,th')).some(n => n.textContent.includes('...'))`
3. **Playwright 탭 상태가 `goto()` 를 넘어 살아남는다.** 항상 `?v=N` 캐시 버스터로 새로 이동한다.

---

## File Structure

세 파일만 바뀐다. 어느 지점을 고치는지 알고 시작한다.

| 파일 | 이 계획에서의 책임 |
| --- | --- |
| `admin/data/tables.js` | `/campaign` 테이블 신규(Task 1) + 탭 5종이 읽을 하위 테이블 5종(Task 3–7) |
| `admin/data/route-config.js` | `/campaign` 의 `segments`/`sortable`/`filters`/`detail` 선언, `detail.tabs[].table` 선언 |
| `admin/main.dc.html` | 전용 캠페인 경로 제거(Task 1), 운영 탭 렌더(Task 2), 스텝퍼·상태 변경·조치(Task 8–11) |
| `tools/check-admin-data.mjs` | 새 선언 형태 단언 |

---

## Task 1: 캠페인을 테이블 계약 위로 옮긴다

지금 캠페인만 다른 길로 렌더된다. 이걸 두면 페이즈 4 의 나머지가 전부 전용 코드가 된다.

**Files:**
- Modify: `admin/data/tables.js`, `admin/data/route-config.js`, `admin/main.dc.html` (`onCampaign` ~1879, `CAMPAIGNS[detailIndex]` ~1763, 목록 렌더 ~1895, 행 open ~2350)
- Modify: `tools/check-admin-data.mjs`

**Interfaces:**
- Consumes: `SV_TABLES`, `SV_ROUTE_CONFIG`, `rowId(table, row)`, `tableRows(table)`
- Produces: `SV_TABLES['/campaign']` — columns `상품유형,left | 캠페인명,left | 기업명,left | 상태,left | 채널,left | 노출,right | 모집,right | 신청,right | 선정,right | 등록일시,left`; `SV_ROUTE_CONFIG['/campaign']` with `segments`/`sortable`/`filters`

- [ ] **Step 1: 실패하는 단언을 먼저 실행한다**

`#/campaign` 에서:
```js
JSON.stringify({ pager: !!Array.from(document.querySelectorAll('button')).some(b => /다음/.test(b.textContent)), rows: document.querySelectorAll('tbody tr').length })
```
Expected: FAIL — 페이저 없음(전용 렌더라 테이블 계약이 안 붙어 있다).

- [ ] **Step 2: `CAMPAIGNS` 를 테이블로 승격한다**

`admin/data/tables.js` 에 `/campaign` 을 추가한다. 행은 기존 `CAMPAIGNS` 18건의 필드에서 **같은 값**으로 옮긴다(값을 새로 지어내지 않는다 — 골든 스레드와 기존 화면이 이 값을 쓴다). 컬럼 정의는 다른 테이블과 같은 `'라벨,정렬'` 형식을 쓴다.

```js
  '/campaign': {
    columns: ['상품유형,left', '캠페인명,left', '기업명,left', '상태,left', '채널,left',
              '노출,right', '모집,right', '신청,right', '선정,right', '등록일시,left'],
    rows: [
      ['배송형', '[비플레인] 녹두 약산성 클렌징 폼 150ml', '(주)어스랩', '모집중', 'instagram',
       '12,480', '20', '34', '0', '2026.07.28'],
      /* 나머지 17행은 CAMPAIGNS 의 값을 그대로 옮긴다. productType/name/company/status/ch/
       * view/recruit/applied/selected/createdAt 순서. 숫자는 기존 표시 형식(천 단위 쉼표)을 따른다. */
    ],
  },
```

- [ ] **Step 3: 라우트 설정을 선언한다**

`admin/data/route-config.js`:
```js
  '/campaign': {
    /* 상태 11종 전부를 세그먼트로 두면 탭이 화면을 넘는다. 운영자가 실제로 나눠 보는
     * 축만 세그먼트로 두고 나머지는 필터로 뺀다. */
    segments: { field: 3, options: ['전체', '모집중', '진행중', '검수중', '완료', '취소'] },
    sortable: [5, 6, 7, 8, 9],
    filters: [
      { label: '상품유형', field: 0, options: ['전체', '배송형', '방문형'] },
      { label: '채널', field: 4, options: ['전체', 'instagram', 'youtube', 'tiktok'] },
    ],
  },
```

- [ ] **Step 4: 전용 경로를 제거한다**

`main.dc.html` 에서 `onCampaign` 분기(~1879)와 그 목록 렌더(~1895), 행 `open` 의 `detailIndex` 설정(~2350)을 제거하고 공용 테이블 경로가 `/campaign` 을 처리하게 둔다. `CAMPAIGNS` 를 참조하는 코드가 남아 있으면 함께 정리한다 — 단, `CAMPAIGNS` 자체는 다른 화면이 쓸 수 있으니 `entities.js` 에서 지우지 않는다. 지운다면 참조가 0인지 먼저 확인한다.

- [ ] **Step 5: 체크 스크립트에 단언을 추가한다**

```js
  check('/campaign 행 수', win.SV_TABLES['/campaign'].rows.length === 18);
  check('/campaign 컬럼 수 일치', win.SV_TABLES['/campaign'].rows.every(r => r.length === win.SV_TABLES['/campaign'].columns.length));
```

- [ ] **Step 6: 통과를 확인한다**

`node tools/check-admin-data.mjs` → 통과. 브라우저에서 `#/campaign` 에 페이저·세그먼트·정렬·필터가 붙고, 행 클릭이 상세를 연다. **회귀 주의: 이 화면은 현재 잘 동작하는 유일한 심화 화면이다.** 옮기기 전 스크린샷과 옮긴 뒤를 비교해 정보가 사라지지 않았는지 확인한다.

- [ ] **Step 7: 커밋**

```bash
git add admin/data/tables.js admin/data/route-config.js admin/main.dc.html tools/check-admin-data.mjs
git commit -m "refactor(admin): 캠페인을 테이블 계약 위로 옮긴다 — 전용 렌더 경로 제거"
```

---

## Task 2: 운영 탭 — 상세 탭이 자체 표를 가질 수 있게 넓힌다

**이 계획의 유일한 프리미티브 확장이다.** 나머지 태스크는 전부 선언이다.

Task 14 의 `DetailCfg.tabs` 는 `sections`/`related` 인덱스 묶음만 고른다. Task 16 이 `related` 에 정렬·페이지네이션·빈 상태를 줬지만 **필터·정렬·행 액션·선택은 없다**. 페이즈 4 의 탭 5종은 각각 자체 표·필터·행 액션을 가진 작업 화면이라 `related` 로 표현되지 않는다. 스펙의 원칙("새 라우트가 새 렌더 코드를 요구하면 프리미티브가 부족하다는 신호이므로 모듈을 진행하기 전에 프리미티브를 넓힌다")에 따라 여기서 한 번 넓힌다.

**Files:**
- Modify: `admin/main.dc.html` (`buildEntityDetail` ~1575–1600, 상세 탭 마크업 ~1017), `admin/data/route-config.js`, `tools/check-admin-data.mjs`

**Interfaces:**
- Consumes: `buildEntityDetail(table, dcfg, row)`, `tableRows(table)`, `rowId(table, row)`, 테이블 계약의 페이징·필터·정렬
- Produces:
  - `DetailCfg.tabs[].table?: string` — 선언되면 그 탭의 본문은 `SV_TABLES[table]` 을 **테이블 계약으로** 렌더한다.
  - `DetailCfg.tabs[].match?: { field: number, from: number }` — 부모 행의 `from` 번째 셀과 하위 테이블의 `field` 번째 셀이 같은 행만 남긴다. 없으면 전체.
  - `DetailCfg.tabs[].rowActions?: { label: string, kind: 'confirm'|'reason'|'value', danger?: boolean }[]`
  - 상태 `opTab: { page: number, seg: string, filters: {} }` — 운영 탭 전용 목록 상태. 목록 화면의 `page`/`seg`/`filters` 와 **분리한다**(같이 쓰면 상세를 닫았을 때 목록의 페이지가 튄다).
  - renderVals: `entity.tabs[].isOp`, `entity.opRows`, `entity.opColumns`, `entity.opPager`, `entity.opEmpty`

- [ ] **Step 1: 실패하는 단언을 먼저 실행한다**

`route-config.js` 의 `/company` detail 에 임시로 `tabs[0].table = '/payment'` 를 넣고 기업 상세를 연다:
```js
JSON.stringify({ opTable: !!document.querySelector('[data-op-table]') })
```
Expected: FAIL — `false`. 선언을 무시한다. (확인 후 임시 선언은 되돌린다.)

- [ ] **Step 2: 빌더에 운영 탭 분기를 넣는다**

`buildEntityDetail` 안, 탭 계산 직후:

```js
      /* 운영 탭. related 는 정적 하위 목록이고, 이건 자체 표·필터·행 액션을 가진 작업 화면이다.
       * 부모 행의 한 셀로 하위 테이블을 걸러 "이 캠페인의 신청자"만 남긴다. */
      const opCfg = picked && picked.table ? picked : null;
      const opTable = opCfg ? window.SV_TABLES[opCfg.table] : null;
      const opAll = opTable
        ? (opCfg.match
            ? opTable.rows.filter(r => r[opCfg.match.field] === row[opCfg.match.from])
            : opTable.rows)
        : [];
```

`opAll` 을 목록 계약과 같은 방식으로 페이지네이션한다(`PAGE_SIZE` 가 아니라 상세 안이므로 `REL_PAGE_SIZE` 를 쓰지 않는다 — 운영 탭은 본문 전체를 차지하므로 목록과 같은 `PAGE_SIZE` 를 쓴다).

- [ ] **Step 3: `opTab` 상태를 리셋 집합에 넣는다**

**Task 11 이 `drawer` 를 리셋 집합에 넣지 않아 최종 리뷰의 유일한 Critical 을 냈다. Task 16 은 `relPage` 로 같은 실수를 반복하지 않았다. `opTab` 도 같은 모양의 상태다.**

`opTab` 을 기존 리셋 지점 전부에 합류시킨다: 초기 상태, `go()`, hashchange 두 분기, `closeRowDetail`, 행 열기, 그리고 **탭 전환**(다른 탭으로 가면 이전 탭의 페이지·필터가 남으면 안 된다).

- [ ] **Step 4: 마크업을 넣는다**

`entity.tabs` 렌더에서 `isOp` 인 탭은 `sections`/`related` 대신 표를 그린다. 표의 시각 언어는 목록 화면의 표와 같게 — 새로운 세 번째 표 스타일을 만들지 않는다. 컨테이너에 `data-op-table` 를 달아 검증이 잡을 수 있게 한다.

- [ ] **Step 5: 체크 스크립트**

```js
  for (const t of (cfg.detail && cfg.detail.tabs) || []) {
    if (t.table) {
      check(`${route}: 운영 탭 테이블 실재 (${t.table})`, !!win.SV_TABLES[t.table]);
      if (t.match) {
        check(`${route}: match.field 범위`, t.match.field < win.SV_TABLES[t.table].columns.length);
        check(`${route}: match.from 범위`, t.match.from < win.SV_TABLES[route].columns.length);
      }
    }
  }
```

- [ ] **Step 6: 검증**

`/company` 에 임시 운영 탭을 선언해 표가 뜨고 페이징이 되는지 확인한 뒤 되돌린다. 회원·기업 상세가 회귀하지 않았는지 확인한다(둘 다 운영 탭이 없으므로 기존 경로를 타야 한다).

- [ ] **Step 7: 커밋**

```bash
git add admin/main.dc.html admin/data/route-config.js tools/check-admin-data.mjs
git commit -m "feat(admin): 상세에 운영 탭 — 자체 표·필터·행 액션을 갖는 탭"
```

---

## Task 3–7: 탭 5종

다섯 태스크가 같은 모양이다. 각각 (a) 하위 테이블을 `tables.js` 에 추가하고, (b) `/campaign` 의 `detail.tabs` 에 선언하고, (c) 행 액션을 다이얼로그 계약에 연결하고, (d) 검증한다. **각 태스크는 독립 커밋이며 독립 리뷰 대상이다.**

부모 셀은 `캠페인명`(인덱스 1)을 매치 키로 쓴다.

| Task | 탭 | 하위 테이블 | 컬럼 | 행 액션 |
| --- | --- | --- | --- | --- |
| 3 | 신청자 목록 | `/campaign/applicants` | 신청일시,left · 크리에이터,left · 채널,left · 팔로워,right · 상태,left · 캠페인명,left | 선정(confirm) · 미선정(reason) |
| 4 | 선정된 크리에이터 | `/campaign/selected` | 선정일시,left · 크리에이터,left · 채널,left · 진행상태,left · 캠페인명,left | 선정취소(reason, danger) |
| 5 | 배송 관리 | `/campaign/shipping` | 크리에이터,left · 수령인,left · 연락처,left · 주소,left · 송장번호,left · 상태,left · 캠페인명,left | 송장 입력(value) |
| 6 | 영상 검수 요청 | `/campaign/video-inspection` | 요청일시,left · 크리에이터,left · 영상,left · 상태,left · 캠페인명,left | 승인(confirm) · 수정 요청(reason) |
| 7 | 예약 관리 | `/campaign/reservation` | 예약일시,left · 크리에이터,left · 인원,right · 상태,left · 캠페인명,left | 확정(confirm) · 반려(reason, danger) |

각 태스크 공통 단계:

- [ ] **Step 1: 실패 단언** — 캠페인 상세에서 해당 탭 라벨이 없음을 확인한다(렌더된 엘리먼트 텍스트로).
- [ ] **Step 2: 하위 테이블 추가** — `tables.js` 에 위 컬럼으로 추가한다. 행은 **골든 스레드와 모순되지 않게** 만들고, 최소 한 캠페인은 `PAGE_SIZE` 를 넘겨 페이징이 실제로 동작하는지 볼 수 있게 한다. 크리에이터 이름·이메일·연락처는 Task 6(선행 계획)의 `SEED_PEOPLE` 과 같은 사람 단위로 묶는다 — 이름과 이메일이 어긋나면 안 된다.
- [ ] **Step 3: 탭 선언** — `/campaign` 의 `detail.tabs` 에 `{ label, table, match: { field: <캠페인명 컬럼>, from: 1 }, rowActions: [...] }` 를 추가한다.
- [ ] **Step 4: 행 액션 연결** — `confirm`/`reason`/`value` 는 전부 Task 10(선행 계획)의 다이얼로그 계약을 탄다. **전용 다이얼로그를 만들지 않는다.** 성공 피드백은 Task 12(선행 계획)의 `toast()`.
- [ ] **Step 5: 검증** — 탭 전환 시 표가 바뀐다 · 부모 캠페인의 행만 나온다(다른 캠페인 행이 섞이지 않는다) · 페이징 동작 · 행 액션이 다이얼로그를 열고 토스트로 끝난다 · 탭을 바꾸면 `opTab` 이 리셋된다 · 빈 상태.
- [ ] **Step 6: 커밋** — `feat(admin): 캠페인 상세 <탭 이름> 탭`

**Task 5(배송 관리) 추가 주의:** 방문형 캠페인에는 배송이 없다. `productType` 이 `방문형` 이면 이 탭을 렌더하지 않는다. 조건부 탭이 필요하므로 `tabs[].when?: { field: number, notEquals: string }` 를 Task 5 에서 도입하고 체크 스크립트에 범위 단언을 추가한다.

**Task 7(예약 관리) 추가 주의:** 반대로 예약은 **방문형에만** 있다. 같은 `when` 을 `equals` 로 쓴다 — Task 5 에서 `notEquals` 만 만들지 말고 `equals`/`notEquals` 둘 다 지원한다.

---

## Task 8: 진행 스텝퍼

**Files:** `admin/main.dc.html`, `admin/data/route-config.js`

**Interfaces:**
- Consumes: `buildEntityDetail`, `STATUS_TONE`
- Produces: `DetailCfg.stepper?: { field: number, steps: string[] }`; renderVals `entity.stepper[]` 각 `{ label, state: 'done'|'current'|'todo' }`

- [ ] **Step 1: 실패 단언** — 상세에 스텝퍼가 없음을 확인한다.
- [ ] **Step 2: 선언과 빌더** — 단계는 감사가 적은 5개다: `모집중 → 크리에이터 선정 → 리뷰 등록 기간 → 검수중 → 리워드 지급`. 현재 상태(컬럼 3)를 단계에 매핑해 `done`/`current`/`todo` 를 계산한다. 매핑은 상수로 한 곳에 둔다 — 화면마다 다시 계산하면 어긋난다.
- [ ] **Step 3: 마크업** — 완료·현재·예정이 **색만으로 구분되지 않게** 한다. 색각 이상 운영자를 위해 현재 단계는 텍스트 강조(굵기 또는 라벨)로도 구분한다.
- [ ] **Step 4: 검증** — 상태가 다른 캠페인 3건(모집중·검수중·완료)에서 스텝퍼가 각각 다르게 그려진다. `취소` 캠페인에서 스텝퍼가 어떻게 보이는지 확인하고, 의미가 없으면 숨긴다.
- [ ] **Step 5: 커밋** — `feat(admin): 캠페인 진행 스텝퍼`

---

## Task 9: 상태 변경 (11종)

**Files:** `admin/main.dc.html`, `admin/data/route-config.js`

**Interfaces:**
- Consumes: 다이얼로그 계약(`askConfirm`), `toast()`, `STATUS_TONE`
- Produces: `STATUS_TONE` 에 `결제대기`/`등록대기중`/`승인대기중` 3종 추가; `DetailCfg.statusChange?: { field: number, options: string[] }`

- [ ] **Step 1: 실패 단언** — 상세에 상태 변경 컨트롤이 없음을 확인한다.
- [ ] **Step 2: `STATUS_TONE` 을 정본에 맞춘다** — 정본 11종(이 문서 상단) 전부가 톤을 갖게 한다. `결제대기`·`등록대기중`·`승인대기중` 은 전부 `대기` 톤이다.
- [ ] **Step 3: 컨트롤과 확인** — 상태 변경은 **되돌리기 어려운 조작**이므로 즉시 반영하지 않고 확인 다이얼로그를 거친다(`askConfirm`, 확인 버튼 라벨은 `확인`이 아니라 실제 동작을 말한다 — 예: `모집중으로 변경`). 성공 시 `toast()`.
- [ ] **Step 4: 검증** — 11종이 모두 선택 가능하다 · 선택하면 확인 다이얼로그가 뜬다 · 취소하면 상태가 그대로다 · 확인하면 배지와 스텝퍼가 함께 갱신된다(스텝퍼가 갱신되지 않으면 Task 8 과 이 태스크의 결합이 끊긴 것이다).
- [ ] **Step 5: 커밋** — `fix(admin): 캠페인 상태 어휘를 정본 11종으로 맞추고 상태 변경을 붙인다`

---

## Task 10: 조치 4종 — 취소 · 강제 완료 · 복사 · 성과 보고서

**Files:** `admin/main.dc.html`, `admin/data/route-config.js`

**Interfaces:**
- Consumes: 다이얼로그 계약(`askConfirm`/`askReason`/`askValue`), `toast()`, `DetailCfg.actions[]` (Task 13 선행 계획)
- Produces: `/campaign` 의 `detail.actions` 선언

- [ ] **Step 1: 실패 단언** — 네 조치가 모두 없음을 확인한다.
- [ ] **Step 2: 선언** — `detail.actions` 에 추가한다:
  - `캠페인 취소` — `reason`, `danger: true` (감사: 취소 사유 필요)
  - `강제 완료` — `reason`, `danger: true` (감사: 사유 + 일시)
  - `캠페인 복사` — `confirm`
  - `성과 보고서` — 이동. 감사가 “고아 화면”으로 지목한 `/campaign/:id/performance-report` 가 여기서 처음 진입점을 얻는다.
- [ ] **Step 3: 파괴적 조치 분리** — `취소`·`강제 완료` 는 `danger` 이므로 **시각적으로 분리**돼 마지막에 온다. 선행 계획 최종 리뷰에서 순서를 한 번 뒤집었다(`dangerIsLast`) — 그 규칙을 지킨다.
- [ ] **Step 4: 검증** — 네 조치가 모두 다이얼로그 계약을 탄다(전용 다이얼로그 0개) · 사유 없이 확인이 눌리지 않는다 · 파괴적 조치가 마지막이고 시각적으로 분리돼 있다(위치 실측) · 성과 보고서가 실제로 이동한다.
- [ ] **Step 5: 커밋** — `feat(admin): 캠페인 조치 4종 — 취소·강제 완료·복사·성과 보고서`

---

## Task 11: 안내·주의사항 모달과 매장 정보

**Files:** `admin/main.dc.html`, `admin/data/route-config.js`, `admin/data/tables.js`

- [ ] **Step 1: 실패 단언** — `확인하기` 버튼과 매장 정보 블록이 없음을 확인한다.
- [ ] **Step 2: 안내·주의사항 “확인하기”** — 감사 §5 가 지목한 모달. 다이얼로그 계약의 `confirm` 이 아니라 **읽기 전용 콘텐츠**이므로 Task 11(선행 계획)의 컨텍스트 드로어를 쓴다 — 새 오버레이를 만들지 않는다.
- [ ] **Step 3: 매장 정보 블록** — **방문형 캠페인에만** 렌더한다(Task 5 의 `when` 을 재사용한다). 상호·주소·연락처·영업시간.
- [ ] **Step 4: 검증** — 배송형 캠페인에는 매장 정보가 없다 · 방문형에는 있다 · 안내 드로어가 세 경로(배경·닫기·Esc)로 닫히고 포커스가 트리거로 돌아온다.
- [ ] **Step 5: 커밋** — `feat(admin): 캠페인 안내 드로어와 방문형 매장 정보`

---

## Task 12: 회귀 확인

**Files:** 없음 (검증만)

- [ ] **Step 1:** `node tools/check-admin-data.mjs` → 통과.
- [ ] **Step 2: 손대지 않은 화면 6개** — `#/dashboard`, `#/reviewer`, `#/company`, `#/payment`, `#/notice`, `#/reservation` 이 정상 렌더된다.
- [ ] **Step 3: 프리미티브 무회귀** — 회원 상세(탭 없음)와 기업 상세(탭 2개)가 페이즈 3 때와 같이 동작한다. **운영 탭 도입이 이 둘을 깨지 않았는지가 핵심이다.**
- [ ] **Step 4: 콘솔** — 오류가 알려진 2건(파킹된 SVG 플레이스홀더)을 넘지 않는다.
- [ ] **Step 5: 상태 누수** — 캠페인 상세에서 탭을 옮기고 페이징한 뒤 목록으로 나갔다가 다른 캠페인을 열었을 때 이전 탭·페이지·필터가 남지 않는다. **선행 계획에서 이 부류가 유일한 Critical 이었다.**
- [ ] **Step 6: 스크린샷** — 캠페인 상세(스텝퍼 포함) · 신청자 탭 · 배송 탭 · 상태 변경 다이얼로그 · 취소 사유 다이얼로그 5장.
- [ ] **Step 7: 커밋** — `docs(admin): 페이즈 4 회귀 확인 기록`

---

## Self-Review

**스펙 커버리지.** 스펙 페이즈 4 의 산출물 4항목 — 운영 탭 5종(Task 3–7) · 상태 변경(Task 9) · 진행 스텝퍼(Task 8) · 취소·강제 완료·복사·보고서 액션(Task 10). 감사 §5 의 누락 목록 중 남은 둘 — 안내/주의사항 모달과 매장 정보 블록 — 은 Task 11. Task 1·2 는 스펙에 없지만 필수다: 캠페인이 테이블 계약 위에 있지 않으면 선언형 상세를 쓸 수 없고, 운영 탭은 `related` 로 표현되지 않는다. 둘 다 그 이유를 태스크 안에 적었다.

**플레이스홀더.** Task 1 Step 2 의 `/campaign` 행은 17행을 “`CAMPAIGNS` 의 값을 그대로 옮긴다”로 두었다 — 값이 이미 코드베이스에 있고 여기 옮겨 적으면 두 벌이 되어 어긋나기 때문이다. 출처와 필드 순서를 명시했으므로 구현자가 판단할 여지는 없다. Task 3–7 의 하위 테이블 행은 새 목 데이터라 구현자가 만들어야 하며, 제약(골든 스레드 불모순 · 최소 한 캠페인은 `PAGE_SIZE` 초과 · `SEED_PEOPLE` 단위 일관성)을 명시했다.

**타입 일관성.** `DetailCfg.tabs[].table`/`match`/`rowActions`/`when` 은 Task 2 와 Task 5 에서 정의되고 Task 3–7 에서 같은 이름으로 소비된다. `opTab` 은 Task 2 에서 만들어 Task 12 에서 검증된다. `stepper`(Task 8)·`statusChange`(Task 9)·`actions`(Task 10)는 전부 `DetailCfg` 아래 형제 키다.

**알려진 위험.** Task 1 은 현재 잘 동작하는 유일한 심화 화면을 옮긴다 — 회귀 위험이 이 계획에서 가장 크고, 그래서 첫 태스크이며 옮기기 전후 비교를 요구한다.
