# 관리자 프로토타입 커버리지 — 기반 페이즈(0–3) 실행 계획

> **완료 (2026.08.19):** 페이즈 0–3, 태스크 1–15 완료. 회귀 점검 통과, 골든 스레드 데이터 결손(`/company` 에 `주식회사 글로우드롭` 행 부재)을 발견해 수정하고 `tools/check-admin-data.mjs` 에 단언을 추가했다. 배포(rsync)는 보류 — 배포 스크립트가 이 워크트리가 아니라 메인 저장소 작업 트리를 기준으로 하므로, 태스크 10–14 를 포함하지 않은 콘텐츠가 배포될 위험이 있어 사용자 결정 대기 중이다. **다음 단계: 페이즈 4–14 는 이 문서가 만든 프리미티브 위에 서므로, 각자 별도의 계획 문서로 작성해야 한다.**

> **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:** 프로토타입의 모든 목록·상세·다이얼로그가 기대는 공용 프리미티브 3종(테이블 계약 · 다이얼로그 계약 · 엔터티 상세 템플릿)을 만들고, 기존 26개 목록에 소급 적용한다.

**Architecture:** 목 데이터를 `admin/data/*.js` 전역 스크립트로 분리하고(`sv-icon.js` 가 `window.SV_ADMIN_ICONS` 를 쓰는 기존 방식과 동일), 라우트의 깊이를 파생이 아니라 **선언된 config**(`SV_ROUTE_CONFIG`)로 바꾼다. 공용 템플릿이 그 config 를 읽어 페이징·선택·세그먼트·필터·상세 섹션을 렌더한다.

**Tech Stack:** 정적 프로토타입. 커스텀 `x-dc` 런타임(`support.js`) + `sc-if`/`sc-for` 템플릿 + `class Component extends DCLogic`. 빌드 도구 없음, 테스트 러너 없음, npm 의존성 없음. 검증은 Node 데이터 체크 스크립트 + 브라우저 확인.

**Spec:** [docs/specs/2026-08-19-admin-coverage-implementation-design.md](../specs/2026-08-19-admin-coverage-implementation-design.md)
**Audit:** [docs/specs/2026-08-19-admin-coverage-audit.md](../specs/2026-08-19-admin-coverage-audit.md)

**이 계획의 범위:** 스펙의 페이즈 0–3. 페이즈 4–14 는 각각 별도 계획 문서로 작성한다 — 그 페이즈들은 여기서 만드는 프리미티브 위에만 서기 때문에, 프리미티브가 실재하기 전에는 정직하게 명세할 수 없다.

## 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` 을 쓴다**: `대기`→`var(--point-gold)`, `진행`→`var(--success)`, `종료`→`var(--text-tertiary)`, `취소`→`var(--error)`.
- **날짜 기준일은 `2026.08.11`** (`TODAY` 상수). 새 목 데이터의 날짜는 이 기준과 모순되지 않아야 한다.
- **골든 스레드 레코드는 고정**: 크리에이터 `보미 · bomi.daily@gmail.com · 010-9930-1147 · 인스타그램`, 기업 `주식회사 글로우드롭`, 협업 `글로우드롭 비타 C 앰플 리필 협업 · 330,000 · 1명`. 어느 태스크도 이 값을 바꾸지 않는다.
- **한국어 UI 문구는 기존 어투**: 평서형 종결("없어요", "바꿀 수 있어요"), 명령형 버튼 라벨("일괄 승인", "내보내기").
- **인라인 스타일**을 쓴다. 이 파일에는 CSS 클래스 시스템이 없다.
- **주석은 한국어로, "왜"를 적는다.** 기존 파일의 주석 밀도와 어투를 따른다.
- **`admin/` 밖의 파일을 건드리지 않는다** — 단, `tools/check-admin-data.mjs` 는 예외(신규 검증 스크립트).

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

이 저장소에는 jest/vitest/playwright 가 없고 `package.json` 도 없다. TDD 사이클은 다음 두 가지로 실현한다.

**A. 데이터·구조 태스크 → Node 체크 스크립트.** `tools/check-admin-data.mjs` 가 `admin/data/*.js` 를 읽어 불변식을 단언한다. 실패 시 non-zero 로 종료한다. 이것이 이 계획의 "빨강/초록"이다.

```bash
node tools/check-admin-data.mjs
```

**B. UI 태스크 → 브라우저 확인.** 로컬 정적 서버를 띄우고 브라우저 도구로 단언한다.

```bash
python3 -m http.server 8123
```

그다음 `http://localhost:8123/admin/main.dc.html#/reviewer` 로 이동해 `javascript_tool` 로 DOM 을 단언하고(예: 행 수, 버튼 존재), `read_console_messages` 로 콘솔이 깨끗한지 확인한다. **모든 UI 태스크는 구현 전에 "지금은 실패하는 단언"을 먼저 실행해 실패를 눈으로 확인한다.**

---

## File Structure

| 파일 | 책임 |
| --- | --- |
| `admin/data/tables.js` (신규) | `window.SV_TABLES` — 목록 26종의 hint/primary/actions/empty/thumb/columns/rows |
| `admin/data/entities.js` (신규) | `window.SV_ENTITIES` — CAMPAIGNS, PAYOUTS, BANNERS, SETTLEMENT_BRANDS, NOTIFS, MY_ACTIVITY, FALLBACK_ADMINS, ADMIN_META |
| `admin/data/settings.js` (신규) | `window.SV_SETTINGS` — SETTINGS, FORM_SECTIONS |
| `admin/data/route-config.js` (신규) | `window.SV_ROUTE_CONFIG` — 라우트별 세그먼트·필터·일괄 액션·상세 섹션 선언 |
| `admin/main.dc.html` (수정) | 템플릿 + 컴포넌트 클래스만. 목 데이터 상수 제거, 프리미티브 3종 추가 |
| `tools/check-admin-data.mjs` (신규) | 데이터 불변식 검증. 이 계획의 테스트 하네스 |

---

## Task 1: 목 데이터를 `data/tables.js` 로 분리한다

**Files:**
- Create: `admin/data/tables.js`
- Create: `tools/check-admin-data.mjs`
- Modify: `admin/main.dc.html` (helmet 에 script 추가; `const TABLES`/`Object.assign(TABLES, …)` 블록 삭제 후 참조로 교체)

**Interfaces:**
- Consumes: 없음 (첫 태스크)
- Produces: `window.SV_TABLES: Record<string, TableDef>` — `TableDef = { hint: string, primary: string, actions: string[], empty: string, thumb?: 'product'|'person'|'reels', thumbCol?: number, thumb2?: string, thumb2Col?: number, columns: [string, 'left'|'right'][], rows: (string | [string, string])[][] }`. 키는 라우트 경로(`'/reviewer'` 등) 26개.

- [ ] **Step 1: 검증 스크립트를 먼저 쓴다 (실패하는 상태로)**

`tools/check-admin-data.mjs` 생성:

```js
#!/usr/bin/env node
/* 프로토타입에는 테스트 러너가 없다. 이 스크립트가 목 데이터의 불변식을 지키는
 * 유일한 자동 검사다 — 데이터 파일을 그대로 평가해 window 전역을 들여다본다. */
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const win = {};
const load = f => {
  const src = readFileSync(join(ROOT, 'admin/data', f), 'utf8');
  new Function('window', src)(win);
};

const fails = [];
const check = (name, cond) => { if (!cond) fails.push(name); };

load('tables.js');

check('SV_TABLES 존재', !!win.SV_TABLES);
check('SV_TABLES 26개', Object.keys(win.SV_TABLES || {}).length === 26);
for (const [route, t] of Object.entries(win.SV_TABLES || {})) {
  check(`${route}: columns 있음`, Array.isArray(t.columns) && t.columns.length > 0);
  check(`${route}: rows 있음`, Array.isArray(t.rows) && t.rows.length > 0);
  check(`${route}: empty 문구 있음`, typeof t.empty === 'string' && t.empty.length > 0);
  for (const [i, row] of (t.rows || []).entries()) {
    check(`${route}[${i}]: 셀 수가 컬럼 수와 같음`, row.length === t.columns.length);
  }
}

if (fails.length) {
  console.error(`실패 ${fails.length}건:`);
  for (const f of fails) console.error('  ✗ ' + f);
  process.exit(1);
}
console.log('통과');
```

- [ ] **Step 2: 실패를 확인한다**

Run: `node tools/check-admin-data.mjs`
Expected: FAIL — `ENOENT: no such file or directory ... admin/data/tables.js`

- [ ] **Step 3: `admin/data/tables.js` 를 만든다**

`main.dc.html` 의 다음 블록을 **그대로** 옮긴다. 값은 한 글자도 바꾸지 않는다 — 이 태스크는 이동이지 편집이 아니다.

| 원본 위치 (현재 행 번호) | 내용 |
| --- | --- |
| 1052–1111 | `const TABLES = { … };` — `/reviewer` `/company` `/community` `/payment` |
| 1112–1210 | `Object.assign(TABLES, { … });` — `/product` `/reviewer-application-history` `/advertiser` `/notice` `/reservation` `/brand` `/gift-log` |
| 1211–1294 | `Object.assign(TABLES, { … });` — `/campaign/payment-waiting` `/campaign-registration-request` `/video-inspection` `/performance-report` `/demand-payment` `/collaboration-inspection` |
| 1295 | `const POST_COLS = [ … ];` |
| 1297–1424 | `Object.assign(TABLES, { … });` — `/faq` `/plan` `/guide` `/community/scheduled-posts` `/inquiry-management` `/bank-transfer-deposit` `/bank-transfer-refund` `/advertiser-access-submission` `/guest/product` |

`POST_COLS` 는 그것을 참조하는 네 번째 블록보다 **위에** 있어야 한다(원본과 같은 순서).

```js
/* 목록 목 데이터. main.dc.html 에서 분리한 이유:
 * 렌더 파일의 대부분이 데이터였고, 앞으로 페이즈마다 목록이 늘어난다.
 * sv-icon.js 가 window.SV_ADMIN_ICONS 를 쓰는 것과 같은 방식으로 전역에 얹는다. */
const POST_COLS = [['게시글 제목','left'],['진행상태','left'],['순서','right'],['조회수','right'],['시작일','left'],['종료일','left'],['작성일','left']];

const TABLES = {
  '/reviewer': { /* main.dc.html 에서 그대로 옮긴 내용 */ },
  // … 나머지 25개
};

window.SV_TABLES = TABLES;
```

- [ ] **Step 4: 체크 통과를 확인한다**

Run: `node tools/check-admin-data.mjs`
Expected: PASS — `통과`

- [ ] **Step 5: `main.dc.html` 을 전역 참조로 바꾼다**

helmet 블록에서 `sv-icon.js` 다음 줄(22행 부근)에 추가:

```html
<script src="data/tables.js?v=1"></script>
```

로직 스크립트에서 삭제한 상수 자리에 다음을 넣는다:

```js
/* 데이터는 helmet 의 data/*.js 가 window 에 얹는다. 로직은 읽기만 한다. */
const TABLES = window.SV_TABLES;
```

- [ ] **Step 6: 렌더가 동일한지 브라우저로 확인한다**

`python3 -m http.server 8123` 후 `http://localhost:8123/admin/main.dc.html#/reviewer` 에서:

```js
JSON.stringify({
  rows: document.querySelectorAll('tbody tr').length,
  count: document.body.innerText.match(/전체\s*([\d,]+)\s*건/)?.[1],
  err: null,
})
```

Expected: `rows: 9`, `count: "9"` — 분리 이전과 동일. `read_console_messages` 로 오류 0건.

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

```bash
git add admin/data/tables.js admin/main.dc.html tools/check-admin-data.mjs
git commit -m "refactor(admin): 목록 목 데이터를 data/tables.js 로 분리

렌더 파일의 대부분이 데이터였다. 앞으로 페이즈마다 목록이 늘어나므로
sv-icon.js 와 같은 방식으로 전역 스크립트에 얹고, 불변식은 노드 체크
스크립트가 지킨다."
```

---

## Task 2: 나머지 목 데이터를 `entities.js` · `settings.js` 로 분리한다

**Files:**
- Create: `admin/data/entities.js`, `admin/data/settings.js`
- Modify: `admin/main.dc.html`, `tools/check-admin-data.mjs`

**Interfaces:**
- Consumes: Task 1 의 로드 패턴(`window.SV_*`)
- Produces: `window.SV_ENTITIES = { CAMPAIGNS, PAYOUTS, BANNERS, SETTLEMENT_BRANDS, NOTIFS, MY_ACTIVITY, FALLBACK_ADMINS, ADMIN_META }`, `window.SV_SETTINGS = { SETTINGS, FORM_SECTIONS }`

- [ ] **Step 1: 체크에 단언을 추가한다**

`tools/check-admin-data.mjs` 의 `load('tables.js')` 아래에 추가:

```js
load('entities.js');
load('settings.js');

const E = win.SV_ENTITIES || {};
check('CAMPAIGNS 있음', Array.isArray(E.CAMPAIGNS) && E.CAMPAIGNS.length > 0);
check('PAYOUTS 있음', Array.isArray(E.PAYOUTS) && E.PAYOUTS.length > 0);
check('BANNERS 있음', Array.isArray(E.BANNERS) && E.BANNERS.length > 0);
check('골든 스레드: 보미 크리에이터 유지',
  (win.SV_TABLES['/reviewer'].rows || []).some(r => r.includes('보미') && r.includes('010-9930-1147')));
check('골든 스레드: 글로우드롭 협업 유지',
  (win.SV_TABLES['/demand-payment'].rows || []).some(r => r.includes('주식회사 글로우드롭')));

const S = win.SV_SETTINGS || {};
check('SETTINGS 4개', Object.keys(S.SETTINGS || {}).length === 4);
check('FORM_SECTIONS 3개', (S.FORM_SECTIONS || []).length === 3);
```

- [ ] **Step 2: 실패를 확인한다**

Run: `node tools/check-admin-data.mjs`
Expected: FAIL — `ENOENT ... admin/data/entities.js`

- [ ] **Step 3: 두 파일을 만든다**

`admin/data/entities.js` — `main.dc.html` 에서 `CAMPAIGNS`, `PAYOUTS`, `BANNERS`, `SETTLEMENT_BRANDS`, `NOTIFS`, `MY_ACTIVITY`, `FALLBACK_ADMINS`, `ADMIN_META` 를 그대로 옮기고 끝에:

```js
window.SV_ENTITIES = { CAMPAIGNS, PAYOUTS, BANNERS, SETTLEMENT_BRANDS, NOTIFS, MY_ACTIVITY, FALLBACK_ADMINS, ADMIN_META };
```

`admin/data/settings.js` — `SETTINGS`, `FORM_SECTIONS` 를 옮기고 끝에:

```js
window.SV_SETTINGS = { SETTINGS, FORM_SECTIONS };
```

`IMG_PRODUCT`/`IMG_PERSON`/`IMG_REELS`/`ME_AVATAR` 는 **옮기지 않는다** — `imgSrc` 와 같은 자리에 있어야 읽기 쉽다.

- [ ] **Step 4: 체크 통과를 확인한다**

Run: `node tools/check-admin-data.mjs`
Expected: PASS

- [ ] **Step 5: `main.dc.html` 을 참조로 바꾼다**

helmet 에 추가:

```html
<script src="data/entities.js?v=1"></script>
<script src="data/settings.js?v=1"></script>
```

로직에서:

```js
const { CAMPAIGNS, PAYOUTS, BANNERS, SETTLEMENT_BRANDS, NOTIFS, MY_ACTIVITY, FALLBACK_ADMINS, ADMIN_META } = window.SV_ENTITIES;
const { SETTINGS, FORM_SECTIONS } = window.SV_SETTINGS;
```

- [ ] **Step 6: 4개 화면을 브라우저로 확인한다**

`#/dashboard`, `#/campaign`, `#/reviewer-payout`, `#/banner` 를 차례로 열고 각각:

```js
JSON.stringify({ text: document.body.innerText.slice(0, 200), h: document.body.scrollHeight })
```

Expected: 각 화면이 분리 전과 같은 내용을 렌더. 콘솔 오류 0건.

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

```bash
git add admin/data admin/main.dc.html tools/check-admin-data.mjs
git commit -m "refactor(admin): 엔터티·설정 목 데이터를 data/ 로 분리

렌더 파일에는 템플릿과 컴포넌트만 남긴다. 골든 스레드 레코드는 체크
스크립트가 계속 지킨다."
```

---

## Task 3: 목록당 40–60행으로 목 데이터를 증량한다

**Files:**
- Modify: `admin/data/tables.js`, `tools/check-admin-data.mjs`

**Interfaces:**
- Consumes: `window.SV_TABLES` (Task 1)
- Produces: 동일 구조, 각 목록 `rows.length >= 40`. 기존 손으로 쓴 행은 앞쪽에 그대로 유지된다.

**왜:** 페이지네이션·정렬·다중 선택은 8행짜리 목록에서 아무것도 증명하지 못한다.

- [ ] **Step 1: 체크에 단언을 추가한다**

```js
for (const [route, t] of Object.entries(win.SV_TABLES || {})) {
  check(`${route}: 40행 이상`, (t.rows || []).length >= 40);
}
check('보미는 /reviewer 첫 10행 안에 있다',
  win.SV_TABLES['/reviewer'].rows.slice(0, 10).some(r => r.includes('보미')));
```

- [ ] **Step 2: 실패를 확인한다**

Run: `node tools/check-admin-data.mjs`
Expected: FAIL — `✗ /reviewer: 40행 이상` 외 25건

- [ ] **Step 3: 증량 헬퍼를 `tables.js` 끝에 추가한다**

```js
/* 행 증량. 손으로 쓴 행(앞쪽)은 그대로 두고 뒤에 파생 행을 붙인다 —
 * 페이징·정렬·선택을 8행으로는 검증할 수 없기 때문이다.
 * 시드 기반이라 새로고침해도 같은 데이터가 나온다(리뷰 중 값이 바뀌면 혼란스럽다). */
const SEED_NAMES = ['강민서','노윤아','문지호','배수빈','서준혁','신예린','안도현','임하람','장서윤','조태민','천유진','허가온'];
const seeded = (n, i) => { let h = 0; const s = String(n) + i; for (let k = 0; k < s.length; k++) h = (h * 31 + s.charCodeAt(k)) >>> 0; return h; };

const grow = (def, target) => {
  const base = def.rows;
  const out = base.slice();
  let i = 0;
  while (out.length < target) {
    const src = base[i % base.length];
    const h = seeded(def.columns.length, out.length);
    out.push(src.map((cell, c) => {
      if (Array.isArray(cell)) return cell;                       /* 상태 칩은 원본 분포를 유지한다 */
      if (def.columns[c][1] === 'right') {                        /* 수치는 흔들어 준다 */
        const digits = String(cell).replace(/[^\d]/g, '');
        if (!digits) return cell;
        const v = Math.max(0, Math.round(Number(digits) * (0.6 + (h % 90) / 100)));
        return String(cell).replace(/[\d,]+/, v.toLocaleString('ko-KR'));
      }
      if (/^\d{4}\.\d{2}\.\d{2}/.test(String(cell))) return cell;  /* 날짜는 기준일과 모순되면 안 되므로 둔다 */
      if (c === 1 && SEED_NAMES.length) return SEED_NAMES[h % SEED_NAMES.length];
      return cell;
    }));
    i++;
  }
  return { ...def, rows: out };
};

for (const route of Object.keys(TABLES)) {
  TABLES[route] = grow(TABLES[route], 40 + (seeded(route.length, 0) % 21));  /* 40–60 */
}
```

- [ ] **Step 4: 체크 통과를 확인한다**

Run: `node tools/check-admin-data.mjs`
Expected: PASS

- [ ] **Step 5: 브라우저로 확인한다**

`#/reviewer` 에서:

```js
JSON.stringify({
  rows: document.querySelectorAll('tbody tr').length,
  count: document.body.innerText.match(/전체\s*([\d,]+)\s*건/)?.[1],
  bomiFirstPage: document.body.innerText.includes('보미'),
})
```

Expected: `rows >= 40`, `count` 가 같은 수, `bomiFirstPage: true`. (이 시점에는 페이징이 없어 전부 렌더된다 — Task 5 가 이것을 자른다.)

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

```bash
git add admin/data/tables.js tools/check-admin-data.mjs
git commit -m "chore(admin): 목록 목 데이터를 40–60행으로 증량

페이징·정렬·다중 선택은 8행으로 아무것도 증명하지 못한다. 손으로 쓴 행은
앞쪽에 그대로 두고 시드 기반 파생 행을 뒤에 붙인다."
```

---

## Task 4: 라우트 config 계층과 안정적인 행 ID

**Files:**
- Create: `admin/data/route-config.js`
- Modify: `admin/main.dc.html`, `tools/check-admin-data.mjs`

**Interfaces:**
- Consumes: `window.SV_TABLES`
- Produces:
  - `window.SV_ROUTE_CONFIG: Record<string, RouteCfg>` where `RouteCfg = { segments?: { field: number, options: string[] }, filters?: { label: string, field: number, options: string[] }[], bulk?: string[], sortable?: number[], detail?: DetailCfg }` (`DetailCfg` 는 Task 14 에서 채운다)
  - `Component.prototype.cfg(route): RouteCfg` — 없으면 `{}` 를 돌려준다
  - `Component.prototype.rowId(table, row): string` — 행의 안정 식별자. 페이지를 넘겨도 상세가 같은 행을 가리키게 한다

**왜 지금:** 페이징이 들어오면 `rowIndex: ri` 는 페이지 상대 인덱스가 되어 잘못된 행을 연다. 페이징보다 먼저 고쳐야 한다.

- [ ] **Step 1: 체크에 단언을 추가한다**

```js
load('route-config.js');
const C = win.SV_ROUTE_CONFIG || {};
check('route-config 존재', Object.keys(C).length > 0);
for (const [route, cfg] of Object.entries(C)) {
  check(`${route}: 알려진 라우트`, !!win.SV_TABLES[route]);
  if (cfg.segments) {
    check(`${route}: segments.field 가 컬럼 범위 안`,
      cfg.segments.field < win.SV_TABLES[route].columns.length);
    check(`${route}: segments 첫 항목은 전체`, cfg.segments.options[0] === '전체');
  }
  for (const f of cfg.filters || []) {
    check(`${route}: filter "${f.label}" field 가 컬럼 범위 안`,
      f.field < win.SV_TABLES[route].columns.length);
  }
  for (const s of cfg.sortable || []) {
    check(`${route}: sortable ${s} 가 컬럼 범위 안`, s < win.SV_TABLES[route].columns.length);
  }
}
```

- [ ] **Step 2: 실패를 확인한다**

Run: `node tools/check-admin-data.mjs`
Expected: FAIL — `ENOENT ... route-config.js`

- [ ] **Step 3: `admin/data/route-config.js` 를 만든다**

라우트의 깊이를 선언한다. 세그먼트 값은 각 목록의 상태 칩 실제 값에서 가져온다.

```js
/* 라우트별 깊이 선언. 공용 테이블 템플릿이 이것을 읽어 세그먼트·필터·일괄
 * 액션·정렬을 만든다. 화면마다 코드를 쓰지 않고 여기 한 줄을 쓰는 것이
 * 101개 미커버 서피스를 다룰 수 있게 만드는 지점이다.
 * field 는 columns 의 인덱스다. */
window.SV_ROUTE_CONFIG = {
  '/reviewer': {
    segments: { field: 0, options: ['전체', '활동중', '심사중', '정지', '탈퇴'] },
    filters: [
      { label: '주 채널', field: 4, options: ['전체', '인스타그램', '유튜브', '틱톡', '네이버 블로그'] },
    ],
    bulk: ['SMS 발송', '이메일 발송'],
    sortable: [5, 6],
  },
  '/advertiser': {
    segments: { field: 1, options: ['전체', '활동중', '승인대기', '정지', '탈퇴'] },
    bulk: ['이메일 발송'],
    sortable: [5],
  },
  '/reviewer-application-history': {
    segments: { field: 2, options: ['전체', '선정대기', '선정완료', '리뷰등록', '정산완료', '반려'] },
    filters: [{ label: '채널', field: 1, options: ['전체', '인스타그램', '유튜브', '틱톡', '네이버 블로그'] }],
    bulk: ['선정 처리'],
    sortable: [4, 6],
  },
  '/video-inspection': {
    segments: { field: 3, options: ['전체', '검수대기', '검수중', '승인완료', '재업로드', '반려'] },
    filters: [{ label: '매칭', field: 5, options: ['전체', '매칭완료', '미매칭'] }],
    bulk: ['일괄 승인', '재업로드 요청'],
    sortable: [6],
  },
  '/campaign-registration-request': {
    segments: { field: 1, options: ['전체', '보완요청', '승인완료', '반려'] },
    bulk: ['일괄 승인', '반려'],
    sortable: [3, 4, 5],
  },
  '/reservation': {
    segments: { field: 2, options: ['전체', '승인대기', '승인완료', '이용완료', '반려', '노쇼'] },
    bulk: ['일괄 승인', '일괄 반려'],
    sortable: [3, 4],
  },
  '/payment': {
    segments: { field: 0, options: ['전체', '결제완료', '결제대기', '환불완료', '결제실패'] },
    bulk: ['환불 처리'],
    sortable: [2, 5],
  },
  '/bank-transfer-deposit': {
    segments: { field: 1, options: ['전체', '입금대기', '확인중', '입금완료', '취소'] },
    bulk: ['입금 확인', '요청 취소'],
    sortable: [2, 5],
  },
  '/bank-transfer-refund': {
    segments: { field: 1, options: ['전체', '환불대기', '확인중', '환불완료', '반려'] },
    bulk: ['환불 처리', '요청 반려'],
    sortable: [4],
  },
  '/demand-payment': {
    segments: { field: 2, options: ['전체', '승인 대기', '승인 완료'] },
    bulk: ['결제 승인', '승인 취소'],
    sortable: [4, 5],
  },
  '/collaboration-inspection': {
    segments: { field: 2, options: ['전체', '검수대기', '검수중', '수정요청', '승인완료'] },
    bulk: ['일괄 승인', '수정 요청'],
  },
  '/inquiry-management': {
    segments: { field: 1, options: ['전체', '답변대기', '답변완료', '보류'] },
    bulk: ['일괄 종결'],
    sortable: [5],
  },
  '/company': { bulk: ['SMS 발송', '이메일 발송'], sortable: [4, 5, 6] },
  '/product': { filters: [{ label: '상품 유형', field: 1, options: ['전체', '배송형', '방문형'] }], sortable: [3] },
  '/guest/product': { filters: [{ label: '상품 유형', field: 1, options: ['전체', '배송형', '방문형'] }], sortable: [3] },
  '/campaign/payment-waiting': { bulk: ['결제 안내 발송', '일괄 취소'], sortable: [2, 3, 4] },
  '/performance-report': { filters: [{ label: '협업채널', field: 1, options: ['전체', '인스타그램', '유튜브', '틱톡'] }], sortable: [3, 4, 5, 7] },
  '/brand': { segments: { field: 1, options: ['전체', '노출', '비노출', '숨김'] }, sortable: [3, 4, 6] },
  '/notice': { segments: { field: 1, options: ['전체', '노출', '비노출', '숨김'] }, sortable: [4, 5] },
  '/faq': { segments: { field: 1, options: ['전체', '노출', '비노출', '숨김'] }, sortable: [4] },
  '/plan': { segments: { field: 1, options: ['전체', '진행중', '대기', '종료'] }, sortable: [2, 3] },
  '/guide': { segments: { field: 1, options: ['전체', '진행중', '대기', '종료'] }, sortable: [2, 3] },
  '/community': { segments: { field: 1, options: ['전체', '공개', '비공개', '숨김'] }, sortable: [3, 5] },
  '/community/scheduled-posts': { segments: { field: 1, options: ['전체', '대기', '진행중', '종료'] }, bulk: ['발행 시각 변경'], sortable: [2, 4] },
  '/gift-log': { bulk: ['포인트 지급'], sortable: [2, 6, 7, 8] },
  '/advertiser-access-submission': { sortable: [3] },
};
```

- [ ] **Step 4: 체크 통과를 확인한다**

Run: `node tools/check-admin-data.mjs`
Expected: PASS

- [ ] **Step 5: helmet 에 추가하고 컴포넌트에 접근자를 만든다**

```html
<script src="data/route-config.js?v=1"></script>
```

컴포넌트 클래스 안(`tableRows` 옆)에 추가:

```js
  /* 라우트가 선언한 깊이. 선언이 없으면 빈 config — 화면이 깨지지 않고
   * 단순 목록으로 남는다(점진 적용을 위해서다). */
  cfg(route) {
    return (window.SV_ROUTE_CONFIG || {})[route] || {};
  }

  /* 행의 안정 식별자. 페이징이 들어오면 배열 인덱스는 페이지 상대값이 되어
   * 상세가 엉뚱한 행을 연다. 셀 값 자체로 식별한다. */
  rowId(row) {
    return row.map(c => (Array.isArray(c) ? c[0] : c)).join('');
  }
```

- [ ] **Step 6: `rowIndex` 를 `rowKey` 로 바꾼다**

상태 초기값에서 `rowIndex: null` → `rowKey: null`. `go()` 의 리셋과 `componentDidMount` 의 해시 리셋에서도 같이 바꾼다.

`tbRows` 의 `open` 을 바꾼다:

```js
open: () => this.setState({ rowKey: this.rowId(r) }),
```

`buildRowDetail` 의 첫 줄을 바꾼다:

```js
    const row = this.tableRows(table).find(r => this.rowId(r) === this.state.rowKey);
```

`isRowDetail` 계산을 바꾼다:

```js
    const isRowDetail = !!table && s.rowKey !== null;
```

`closeRowDetail` 을 바꾼다:

```js
      closeRowDetail: () => this.setState({ rowKey: null }),
```

- [ ] **Step 7: 행 상세가 여전히 열리는지 브라우저로 확인한다**

`#/reviewer` 에서:

```js
document.querySelectorAll('tbody tr')[2].click();
JSON.stringify({ detail: document.body.innerText.includes('상세 정보') })
```

Expected: `detail: true`, 그리고 열린 상세의 이름이 클릭한 세 번째 행의 이름과 같다.

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

```bash
git add admin/data/route-config.js admin/main.dc.html tools/check-admin-data.mjs
git commit -m "feat(admin): 라우트 깊이를 선언형 config 로 분리하고 행 ID를 안정화

화면마다 코드를 쓰는 대신 라우트가 세그먼트·필터·일괄 액션·정렬을 선언한다.
행 식별을 배열 인덱스에서 셀 값 기반으로 바꾼다 — 페이징이 들어오면 인덱스는
페이지 상대값이 되어 상세가 엉뚱한 행을 연다."
```

---

## Task 5: 페이지네이션

**Files:**
- Modify: `admin/main.dc.html`

**Interfaces:**
- Consumes: `cfg()`, `rowId()` (Task 4)
- Produces: 상태 `page: number`(1부터), `pageSize: 20`. renderVals 에 `tbPage`, `tbPageLabel`, `tbPrev`, `tbNext`, `tbPrevDisabled`, `tbNextDisabled`, `tbRangeLabel`, `hasPaging` 추가. `tbRows` 는 현재 페이지만 렌더한다.

**ux 게이트:** 이 태스크를 시작할 때 `get_guidance("admin console list pagination and result count")` 와 `get_checklist("table")` 을 호출한다. Table 체크리스트의 미충족 항목(헤더·행 스타일·간격·검색·액션·필터/정렬·반응형·페이지네이션) 중 이 태스크가 닫는 것은 **페이지네이션**이다. 나머지는 Task 6–9 가 닫는다. 근거 법칙: Cognitive Load, Chunking — 한 번에 읽을 양을 자르는 것이 목적이다.

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

브라우저 `#/reviewer` 에서:

```js
JSON.stringify({
  rendered: document.querySelectorAll('tbody tr').length,
  hasNext: !!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '다음'),
})
```

Expected: FAIL — `rendered` 가 40 이상이고 `hasNext: false`. 원하는 값은 `rendered: 20`, `hasNext: true`.

- [ ] **Step 2: 상태와 페이징 계산을 추가한다**

상태에 추가: `page: 1`. `go()` 와 해시 리셋, 그리고 검색어 변경(`onTbQuery`)에서 `page: 1` 로 되돌린다 — 3페이지에서 검색하면 빈 화면이 나오는 흔한 함정을 막는다.

`renderVals` 의 테이블 블록에 추가:

```js
      /* 페이징. 한 번에 읽을 양을 자르는 것이 목적이라(Cognitive Load) 20행으로 둔다.
       * 총 건수는 자르기 전 값이어야 한다 — 운영자는 "몇 건인지"를 먼저 본다. */
      ...(() => {
        const all = table ? this.tableRows(table) : [];
        const size = 20;
        const pages = Math.max(1, Math.ceil(all.length / size));
        const page = Math.min(s.page, pages);
        const from = (page - 1) * size;
        const slice = all.slice(from, from + size);
        return {
          hasPaging: !!table && all.length > size,
          tbPageLabel: `${page} / ${pages}`,
          tbRangeLabel: all.length ? `${nf(from + 1)}–${nf(from + slice.length)}` : '0',
          tbPrevDisabled: page <= 1, tbNextDisabled: page >= pages,
          tbPrevFg: page <= 1 ? TERT : INK, tbNextFg: page >= pages ? TERT : INK,
          tbPrev: () => this.setState(p => ({ page: Math.max(1, p.page - 1), rowKey: null })),
          tbNext: () => this.setState(p => ({ page: Math.min(pages, p.page + 1), rowKey: null })),
          pagedRows: slice,
        };
      })(),
```

`tbRows` 와 `tbCount` 를 바꾼다. `tbCount` 는 전체 건수를 유지하고, `tbRows` 는 `pagedRows` 를 쓴다. 위 IIFE 가 `pagedRows` 를 먼저 계산하도록 `tbRows` 계산을 그 아래로 옮기고 `this.tableRows(table)` 대신 잘린 배열을 쓴다:

```js
      tbRows: (table ? this.tableRows(table).slice((Math.min(s.page, Math.max(1, Math.ceil(this.tableRows(table).length / 20))) - 1) * 20, (Math.min(s.page, Math.max(1, Math.ceil(this.tableRows(table).length / 20))) - 1) * 20 + 20) : []).map(r => ({ open: () => this.setState({ rowKey: this.rowId(r) }), cells: r.map((cell, i) => {
```

- [ ] **Step 3: 템플릿에 페이징 바를 추가한다**

`isTable` 블록의 빈 상태 `sc-if` 바로 위에 넣는다:

```html
        <sc-if value="{{ hasPaging }}" hint-placeholder-val="{{ true }}">
          <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:4px">
            <span style="font-size:12px;color:var(--text-tertiary)">{{ tbRangeLabel }} / {{ tbCount }}건</span>
            <div style="display:flex;align-items:center;gap:6px">
              <button onClick="{{ tbPrev }}" disabled="{{ tbPrevDisabled }}" style="height:32px;padding:0 12px;border-radius:var(--radius-lg);background:var(--surface-1);font-size:12px;font-weight:500;color:{{ tbPrevFg }}">이전</button>
              <span style="font-family:'Poppins',sans-serif;font-size:12px;color:var(--text-secondary);min-width:52px;text-align:center">{{ tbPageLabel }}</span>
              <button onClick="{{ tbNext }}" disabled="{{ tbNextDisabled }}" style="height:32px;padding:0 12px;border-radius:var(--radius-lg);background:var(--surface-1);font-size:12px;font-weight:500;color:{{ tbNextFg }}">다음</button>
            </div>
          </div>
        </sc-if>
```

- [ ] **Step 4: 단언이 통과하는지 확인한다**

`#/reviewer` 에서 Step 1 의 스니펫을 다시 실행.
Expected: `rendered: 20`, `hasNext: true`.

이어서 다음 페이지 이동을 확인한다:

```js
Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '다음').click();
JSON.stringify({ label: document.body.innerText.match(/\d+ \/ \d+/)?.[0], rows: document.querySelectorAll('tbody tr').length })
```

Expected: `label: "2 / 3"` 류, `rows` 는 20 또는 마지막 페이지 잔여 수.

- [ ] **Step 5: 검색 시 1페이지로 돌아가는지 확인한다**

2페이지 상태에서 검색창에 `보미` 를 입력하고:

```js
JSON.stringify({ label: document.body.innerText.match(/\d+ \/ \d+/)?.[0], rows: document.querySelectorAll('tbody tr').length })
```

Expected: 결과가 보이고 빈 화면이 아니다.

- [ ] **Step 6: 손대지 않은 목록 3개를 확인한다**

`#/notice`, `#/payment`, `#/gift-log` 를 열어 각각 20행 + 페이징 바가 있고 콘솔 오류가 0건인지 확인한다.

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

```bash
git add admin/main.dc.html
git commit -m "feat(admin): 목록에 페이지네이션 추가

한 번에 읽을 양을 20행으로 자른다. 총 건수는 자르기 전 값을 유지한다 —
운영자는 몇 건인지를 먼저 본다. 검색하면 1페이지로 돌아간다."
```

---

## Task 6: 행 선택과 일괄 액션 바

**Files:**
- Modify: `admin/main.dc.html`

**Interfaces:**
- Consumes: `cfg()`, `rowId()`, 페이징(Task 5)
- Produces: 상태 `picked: string[]` (rowId 배열). renderVals 에 `hasBulk`, `bulkCount`, `bulkActions`, `clearPicked`, `allPicked`, `toggleAll`, `tbRows[].picked`, `tbRows[].toggle` 추가.

**ux 게이트:** `get_guidance("bulk actions on an admin table with row selection")`, `get_checklist("table")`. 이 태스크가 닫는 항목: **액션**. 근거 법칙: Selective Attention — 선택 중일 때만 액션 바를 띄워, 평상시 시선을 뺏지 않는다.

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

`#/reviewer` 에서:

```js
JSON.stringify({ checkboxes: document.querySelectorAll('tbody input[type=checkbox]').length })
```

Expected: FAIL — `0`. 원하는 값은 `20`.

- [ ] **Step 2: 상태와 선택 계산을 추가한다**

상태에 `picked: []` 추가. `go()`·해시 리셋에서 `picked: []` 로 되돌린다 — 라우트를 옮기고도 선택이 남아 있으면 엉뚱한 대상에 일괄 액션이 걸린다.

`renderVals` 에 추가:

```js
      /* 다중 선택. 액션 바는 선택이 있을 때만 나타난다(Selective Attention) —
       * 평상시에 늘 떠 있으면 화면 위쪽을 영구히 점유한다.
       * 전체 선택은 "지금 화면(현재 페이지)"만 고른다. 보이지 않는 것을 고르는 건
       * 운영자가 의도한 적 없는 일이다. */
      ...(() => {
        const bulk = table ? (this.cfg(s.active).bulk || []) : [];
        const pageIds = (table ? this.tableRows(table) : []).slice((s.page - 1) * 20, (s.page - 1) * 20 + 20).map(r => this.rowId(r));
        const allPicked = pageIds.length > 0 && pageIds.every(id => s.picked.includes(id));
        return {
          hasBulk: bulk.length > 0 && s.picked.length > 0,
          hasCheckbox: bulk.length > 0,
          bulkCount: nf(s.picked.length),
          bulkActions: bulk,
          allPicked,
          toggleAll: () => this.setState(p => ({ picked: allPicked ? p.picked.filter(id => !pageIds.includes(id)) : [...new Set([...p.picked, ...pageIds])] })),
          clearPicked: () => this.setState({ picked: [] }),
        };
      })(),
```

`tbRows` 의 각 행 객체에 추가:

```js
        picked: s.picked.includes(this.rowId(r)),
        toggle: e => { e.stopPropagation(); const id = this.rowId(r); this.setState(p => ({ picked: p.picked.includes(id) ? p.picked.filter(x => x !== id) : [...p.picked, id] })); },
```

- [ ] **Step 3: 템플릿에 체크박스 열과 액션 바를 추가한다**

`thead` 의 첫 `sc-for` 앞에 헤더 셀을 넣는다:

```html
              <sc-if value="{{ hasCheckbox }}" hint-placeholder-val="{{ false }}">
                <th style="width:40px;padding:0 0 10px 4px"><input type="checkbox" checked="{{ allPicked }}" onChange="{{ toggleAll }}" style="width:15px;height:15px;accent-color:var(--ink)"></th>
              </sc-if>
```

`tbody` 의 행 `tr` 안, 셀 `sc-for` 앞에:

```html
                  <sc-if value="{{ hasCheckbox }}" hint-placeholder-val="{{ false }}">
                    <td style="width:40px;padding:0 0 0 4px"><input type="checkbox" checked="{{ row.picked }}" onClick="{{ row.toggle }}" style="width:15px;height:15px;accent-color:var(--ink)"></td>
                  </sc-if>
```

건수 줄(`전체 … 건`) 바로 아래에 액션 바:

```html
        <sc-if value="{{ hasBulk }}" hint-placeholder-val="{{ false }}">
          <div style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:var(--radius-lg);background:var(--surface-2)">
            <span style="font-size:13px;font-weight:600">{{ bulkCount }}건 선택됨</span>
            <div style="flex:1"></div>
            <sc-for list="{{ bulkActions }}" as="b" hint-placeholder-count="2">
              <button style="height:32px;padding:0 12px;border-radius:var(--radius-lg);background:var(--canvas);font-size:12px;font-weight:500">{{ b }}</button>
            </sc-for>
            <button onClick="{{ clearPicked }}" style="height:32px;padding:0 10px;border-radius:var(--radius-lg);font-size:12px;color:var(--text-secondary)">선택 해제</button>
          </div>
        </sc-if>
```

- [ ] **Step 4: 단언이 통과하는지 확인한다**

```js
document.querySelectorAll('tbody input[type=checkbox]')[0].click();
document.querySelectorAll('tbody input[type=checkbox]')[1].click();
JSON.stringify({
  boxes: document.querySelectorAll('tbody input[type=checkbox]').length,
  bar: document.body.innerText.includes('2건 선택됨'),
  detailOpened: document.body.innerText.includes('상세 정보'),
})
```

Expected: `boxes: 20`, `bar: true`, `detailOpened: false` — 체크박스 클릭이 행 상세를 열면 안 된다(`stopPropagation`).

- [ ] **Step 5: 전체 선택이 현재 페이지만 고르는지 확인한다**

헤더 체크박스를 클릭하고 `document.body.innerText.match(/(\d+)건 선택됨/)[1]` 이 `20` 인지 확인한다(전체 40+ 가 아니라).

- [ ] **Step 6: config 없는 목록에는 체크박스가 없는지 확인한다**

`#/advertiser-access-submission` 에서 `document.querySelectorAll('tbody input[type=checkbox]').length` 가 `0` 인지 확인한다.

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

```bash
git add admin/main.dc.html
git commit -m "feat(admin): 목록에 행 선택과 일괄 액션 바 추가

액션 바는 선택이 있을 때만 나타난다 — 늘 떠 있으면 화면 위쪽을 영구히
점유한다. 전체 선택은 현재 페이지만 고른다. 보이지 않는 행을 고르는 것은
운영자가 의도한 적 없는 일이다."
```

---

## Task 7: 상태 세그먼트

**Files:**
- Modify: `admin/main.dc.html`

**Interfaces:**
- Consumes: `cfg()` 의 `segments`
- Produces: 상태 `seg: string`(기본 `'전체'`). renderVals 에 `hasSegments`, `segments[]` (`{ label, count, bg, fg, weight, pick }`). `tableRows()` 가 세그먼트로 먼저 거른다.

**ux 게이트:** `get_guidance("status segment tabs on an admin list")`, `get_checklist("tabs")`. 이 태스크가 닫는 항목: **라벨 · 콘텐츠 영역 · 스타일 · 항목 순서 · 상태**. 항목 순서는 운영 빈도순(대기 상태가 앞)으로 두고, `전체` 를 맨 앞에 고정한다.

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

`#/video-inspection` 에서:

```js
JSON.stringify({ seg: !!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '검수대기') })
```

Expected: FAIL — `false`.

- [ ] **Step 2: `tableRows` 가 세그먼트를 반영하게 한다**

```js
  tableRows(table) {
    const cfg = this.cfg(this.state.active);
    let rows = table.rows;
    /* 세그먼트가 먼저다 — 검색은 "지금 보고 있는 묶음 안에서" 찾는 것이 기대다. */
    if (cfg.segments && this.state.seg !== '전체') {
      const f = cfg.segments.field;
      rows = rows.filter(r => {
        const cell = r[f];
        return (Array.isArray(cell) ? cell[0] : cell) === this.state.seg;
      });
    }
    const q = this.state.tbQuery.trim().toLowerCase();
    if (!q) return rows;
    return rows.filter(r => r.some(c => String(Array.isArray(c) ? c[0] : c).toLowerCase().includes(q)));
  }
```

- [ ] **Step 3: 상태와 renderVals 를 추가한다**

상태에 `seg: '전체'` 추가. `go()`·해시 리셋에서 `seg: '전체', page: 1, picked: []` 로 되돌린다.

```js
      /* 상태 세그먼트. 건수를 라벨에 붙이는 이유 — 운영자는 "지금 밀린 게 몇 건"
       * 인지를 탭을 눌러 보기 전에 알고 싶어 한다. */
      ...(() => {
        const cfg = table ? this.cfg(s.active) : {};
        if (!cfg.segments) return { hasSegments: false, segments: [] };
        const f = cfg.segments.field;
        const val = c => (Array.isArray(c) ? c[0] : c);
        return {
          hasSegments: true,
          segments: cfg.segments.options.map(o => {
            const on = s.seg === o;
            const n = o === '전체' ? table.rows.length : table.rows.filter(r => val(r[f]) === o).length;
            return { label: o, count: nf(n), bg: on ? 'var(--ink)' : 'transparent', fg: on ? 'var(--text-on-ink)' : SECOND,
              weight: on ? 700 : 500, pick: () => this.setState({ seg: o, page: 1, picked: [], rowKey: null }) };
          }),
        };
      })(),
```

- [ ] **Step 4: 템플릿에 세그먼트 줄을 추가한다**

검색 줄과 건수 줄 사이에:

```html
        <sc-if value="{{ hasSegments }}" hint-placeholder-val="{{ false }}">
          <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
            <sc-for list="{{ segments }}" as="sg" hint-placeholder-count="4">
              <button onClick="{{ sg.pick }}" style="height:32px;padding:0 12px;border-radius:var(--radius-pill);background:{{ sg.bg }};color:{{ sg.fg }};font-size:12px;font-weight:{{ sg.weight }};display:flex;align-items:center;gap:6px">
                {{ sg.label }}<span style="font-family:'Poppins',sans-serif;font-size:11px;opacity:0.7">{{ sg.count }}</span>
              </button>
            </sc-for>
          </div>
        </sc-if>
```

- [ ] **Step 5: 단언이 통과하는지 확인한다**

`#/video-inspection` 에서:

```js
const b = Array.from(document.querySelectorAll('button')).find(x => x.textContent.trim().startsWith('검수대기'));
b.click();
JSON.stringify({ rows: document.querySelectorAll('tbody tr').length, onlyWaiting: !document.querySelector('tbody').innerText.includes('승인완료') })
```

Expected: `onlyWaiting: true`, 행 수가 전체보다 적다.

- [ ] **Step 6: 세그먼트 + 검색이 함께 동작하는지 확인한다**

`검수대기` 상태에서 검색어를 넣어 결과가 그 묶음 안에서만 걸러지는지 확인한다.

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

```bash
git add admin/main.dc.html
git commit -m "feat(admin): 목록에 상태 세그먼트 추가

세그먼트가 검색보다 먼저 걸러진다 — 검색은 지금 보고 있는 묶음 안에서
찾는 것이 기대다. 라벨에 건수를 붙여 탭을 눌러 보기 전에 밀린 양을 알 수 있게 한다."
```

---

## Task 8: 필터 모달과 적용된 필터 칩

**Files:**
- Modify: `admin/main.dc.html`

**Interfaces:**
- Consumes: `cfg()` 의 `filters`
- Produces: 상태 `filterOpen: boolean`, `filters: Record<string, string>`(라벨→선택값). renderVals 에 `hasFilterBtn`, `openFilter`, `closeFilter`, `filterOpen`, `filterGroupsCfg[]`, `appliedChips[]`, `resetTableFilters`, `filterCount`.

**ux 게이트:** `get_guidance("filter modal for an admin data table with applied filter chips")`, `get_checklist("table")`. 이 태스크가 닫는 항목: **필터/정렬** 중 필터. 근거 법칙: Cognitive Load — 조건을 모달에 접어 두고, 적용된 것만 칩으로 밖에 남긴다. **칩 없이 모달만 두면 안 된다**: 무엇이 걸려 있는지 보이지 않는 필터는 "결과가 왜 이것뿐이지" 라는 오해를 만든다.

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

`#/reviewer` 에서:

```js
JSON.stringify({ btn: !!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '필터') })
```

Expected: FAIL — `false`.

- [ ] **Step 2: `tableRows` 가 필터를 반영하게 한다**

세그먼트 필터링 직후, 검색 직전에 넣는다:

```js
    /* 선언된 필터. '전체'는 조건 없음이다. */
    for (const f of cfg.filters || []) {
      const v = this.state.filters[f.label];
      if (v && v !== '전체') {
        rows = rows.filter(r => {
          const cell = r[f.field];
          return (Array.isArray(cell) ? cell[0] : cell) === v;
        });
      }
    }
```

- [ ] **Step 3: 상태와 renderVals 를 추가한다**

상태에 `filterOpen: false, filters: {}` 추가. `go()`·해시 리셋에서 `filters: {}, filterOpen: false` 로 되돌린다.

```js
      /* 필터. 조건은 모달에 접어 두고, 적용된 것만 칩으로 밖에 남긴다 —
       * 보이지 않는 필터는 "결과가 왜 이것뿐이지"라는 오해를 만든다. */
      ...(() => {
        const cfg = table ? this.cfg(s.active) : {};
        const defs = cfg.filters || [];
        const applied = defs.filter(f => s.filters[f.label] && s.filters[f.label] !== '전체');
        return {
          hasFilterBtn: defs.length > 0,
          filterOpen: s.filterOpen,
          filterCount: applied.length ? String(applied.length) : '',
          hasApplied: applied.length > 0,
          openFilter: () => this.setState({ filterOpen: true }),
          closeFilter: () => this.setState({ filterOpen: false }),
          resetTableFilters: () => this.setState({ filters: {}, page: 1, picked: [], rowKey: null }),
          appliedChips: applied.map(f => ({
            label: `${f.label} · ${s.filters[f.label]}`,
            clear: () => this.setState(p => ({ filters: { ...p.filters, [f.label]: '전체' }, page: 1, rowKey: null })),
          })),
          filterGroupsCfg: defs.map(f => ({
            label: f.label,
            options: f.options.map(o => {
              const on = (s.filters[f.label] || '전체') === o;
              return { label: o, bg: on ? 'var(--ink)' : 'var(--surface-2)', fg: on ? 'var(--text-on-ink)' : SECOND, weight: on ? 700 : 500,
                pick: () => this.setState(p => ({ filters: { ...p.filters, [f.label]: o }, page: 1, picked: [], rowKey: null })) };
            }),
          })),
        };
      })(),
```

- [ ] **Step 4: 템플릿에 버튼·칩·모달을 추가한다**

검색 줄의 `<div style="flex:1"></div>` 뒤, 보조 액션 앞에:

```html
          <sc-if value="{{ hasFilterBtn }}" hint-placeholder-val="{{ false }}">
            <button onClick="{{ openFilter }}" style="height:38px;padding:0 14px;border-radius:var(--radius-lg);background:var(--surface-2);font-size:13px;font-weight:500;display:flex;align-items:center;gap:6px">
              <sv-icon name="filter" size="16"></sv-icon>필터<span style="font-family:'Poppins',sans-serif;font-size:11px;color:var(--text-tertiary)">{{ filterCount }}</span>
            </button>
          </sc-if>
```

세그먼트 줄 아래에 적용 칩:

```html
        <sc-if value="{{ hasApplied }}" hint-placeholder-val="{{ false }}">
          <div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
            <sc-for list="{{ appliedChips }}" as="ch" hint-placeholder-count="2">
              <button onClick="{{ ch.clear }}" style="height:28px;padding:0 10px;border-radius:var(--radius-pill);background:var(--surface-2);font-size:12px;font-weight:500;display:flex;align-items:center;gap:5px">
                {{ ch.label }}<sv-icon name="close" size="12"></sv-icon>
              </button>
            </sc-for>
            <button onClick="{{ resetTableFilters }}" style="height:28px;padding:0 8px;font-size:12px;color:var(--text-secondary)">모두 지우기</button>
          </div>
        </sc-if>
```

모달은 `isTable` 블록 바깥, 로그아웃 화면 `sc-if` 앞에 둔다:

```html
<sc-if value="{{ filterOpen }}" hint-placeholder-val="{{ false }}">
  <div style="position:fixed;inset:0;z-index:70;background:rgba(0,0,0,0.28);display:flex;align-items:center;justify-content:center;padding:24px">
    <div style="width:100%;max-width:420px;background:var(--canvas);border-radius:var(--radius-2xl);padding:22px;display:flex;flex-direction:column;gap:18px">
      <div style="display:flex;align-items:center;justify-content:space-between">
        <span style="font-size:16px;font-weight:600">필터</span>
        <button onClick="{{ closeFilter }}" style="width:30px;height:30px;border-radius:var(--radius-circle);display:flex;align-items:center;justify-content:center;color:var(--text-secondary)"><sv-icon name="close" size="16"></sv-icon></button>
      </div>
      <sc-for list="{{ filterGroupsCfg }}" as="g" hint-placeholder-count="2">
        <div style="display:flex;flex-direction:column;gap:8px">
          <span style="font-size:12px;color:var(--text-secondary)">{{ g.label }}</span>
          <div style="display:flex;flex-wrap:wrap;gap:6px">
            <sc-for list="{{ g.options }}" as="o" hint-placeholder-count="4">
              <button onClick="{{ o.pick }}" style="height:32px;padding:0 12px;border-radius:var(--radius-pill);background:{{ o.bg }};color:{{ o.fg }};font-size:12px;font-weight:{{ o.weight }}">{{ o.label }}</button>
            </sc-for>
          </div>
        </div>
      </sc-for>
      <div style="display:flex;gap:8px;justify-content:flex-end">
        <button onClick="{{ resetTableFilters }}" style="height:38px;padding:0 14px;border-radius:var(--radius-lg);background:var(--surface-2);font-size:13px;font-weight:500">초기화</button>
        <button onClick="{{ closeFilter }}" style="height:38px;padding:0 18px;border-radius:var(--radius-lg);background:{{ accent }};color:{{ accentInk }};font-size:13px;font-weight:700">적용</button>
      </div>
    </div>
  </div>
</sc-if>
```

- [ ] **Step 5: 단언이 통과하는지 확인한다**

```js
Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim().startsWith('필터')).click();
JSON.stringify({ modal: document.body.innerText.includes('주 채널') })
```

Expected: `modal: true`. 이어서 `유튜브` 를 눌러 닫고, 목록에 유튜브 행만 남고 칩 `주 채널 · 유튜브` 가 보이는지 확인한다.

- [ ] **Step 6: 칩으로 필터가 해제되는지 확인한다**

칩을 클릭하면 필터가 풀리고 전체 행 수로 돌아오는지 확인한다.

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

```bash
git add admin/main.dc.html
git commit -m "feat(admin): 목록 필터 모달과 적용된 필터 칩 추가

조건은 모달에 접어 두고 적용된 것만 칩으로 밖에 남긴다. 보이지 않는
필터는 결과가 왜 이것뿐인지 오해하게 만든다."
```

---

## Task 9: 컬럼 정렬

**Files:**
- Modify: `admin/main.dc.html`

**Interfaces:**
- Consumes: `cfg()` 의 `sortable`
- Produces: 상태 `tbSort: { field: number, dir: 'asc'|'desc' } | null`. `tbColumns[]` 에 `sortable`, `arrow`, `sort` 추가. `tableRows()` 가 마지막에 정렬한다.

**ux 게이트:** `get_guidance("sortable columns in an admin data table")`, `get_checklist("table")`. 이 태스크가 닫는 항목: **필터/정렬** 중 정렬. 정렬 가능한 컬럼만 헤더가 눌리는 것처럼 보여야 한다 — 전부 눌리게 해 두고 일부만 반응하는 것이 가장 나쁜 상태다.

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

`#/gift-log` 에서:

```js
JSON.stringify({ arrows: document.querySelectorAll('thead th').length && document.querySelector('thead').innerText.includes('↓') })
```

Expected: FAIL — `false`.

- [ ] **Step 2: `tableRows` 끝에 정렬을 추가한다**

```js
    /* 정렬. 숫자 컬럼은 숫자로, 나머지는 문자열로 비교한다 — "1,200"과 "980"을
     * 문자열로 비교하면 980이 위로 온다. */
    const srt = this.state.tbSort;
    if (srt) {
      const val = c => (Array.isArray(c) ? c[0] : c);
      const num = v => Number(String(v).replace(/[^\d.-]/g, ''));
      const numeric = table.columns[srt.field][1] === 'right';
      rows = rows.slice().sort((a, b) => {
        const x = val(a[srt.field]), y = val(b[srt.field]);
        const r = numeric ? (num(x) - num(y)) : String(x).localeCompare(String(y), 'ko');
        return srt.dir === 'asc' ? r : -r;
      });
    }
    return rows;
```

`tableRows` 는 세그먼트→필터→검색→정렬 순서로 끝난다. 기존 `return rows` 들을 정리해 마지막에 한 번만 반환한다.

- [ ] **Step 3: 상태와 `tbColumns` 를 바꾼다**

상태에 `tbSort: null` 추가. `go()`·해시 리셋에서 `tbSort: null`.

```js
      tbColumns: table ? table.columns.map((c, i) => {
        const sortable = (this.cfg(s.active).sortable || []).includes(i);
        const on = s.tbSort && s.tbSort.field === i;
        return { name: c[0], align: c[1], sortable,
          arrow: on ? (s.tbSort.dir === 'asc' ? '↑' : '↓') : '',
          fg: on ? INK : SECOND,
          sort: sortable ? () => this.setState(p => ({
            tbSort: p.tbSort && p.tbSort.field === i && p.tbSort.dir === 'desc' ? { field: i, dir: 'asc' } : { field: i, dir: 'desc' },
            page: 1, rowKey: null,
          })) : null };
      }) : [],
```

- [ ] **Step 4: 헤더 템플릿을 바꾼다**

`thead` 의 컬럼 `sc-for` 안 `th` 내용을 바꾼다:

```html
                <th style="text-align:{{ col.align }};padding:0 12px 10px;font-size:12px;font-weight:500;color:{{ col.fg }};white-space:nowrap">
                  <sc-if value="{{ col.sortable }}" hint-placeholder-val="{{ false }}">
                    <button onClick="{{ col.sort }}" style="font-size:12px;font-weight:500;color:inherit;display:inline-flex;align-items:center;gap:4px">{{ col.name }}<span style="font-size:10px">{{ col.arrow }}</span></button>
                  </sc-if>
                  <sc-if value="{{ col.plain }}" hint-placeholder-val="{{ true }}">{{ col.name }}</sc-if>
                </th>
```

`tbColumns` 매핑에 `plain: !sortable` 을 추가한다.

- [ ] **Step 5: 단언이 통과하는지 확인한다**

`#/gift-log` 에서:

```js
const th = Array.from(document.querySelectorAll('thead button')).find(b => b.textContent.includes('총 획득'));
th.click();
const first = document.querySelector('tbody tr').innerText;
th.click();
JSON.stringify({ hasArrow: document.querySelector('thead').innerText.includes('↑'), changed: document.querySelector('tbody tr').innerText !== first })
```

Expected: `hasArrow: true`, `changed: true`.

- [ ] **Step 6: 정렬 불가 컬럼은 버튼이 아닌지 확인한다**

같은 화면에서 `이메일` 헤더에 `button` 이 없는지 확인한다.

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

```bash
git add admin/main.dc.html
git commit -m "feat(admin): 정렬 가능한 컬럼 헤더 추가

정렬 가능한 컬럼만 눌리는 것처럼 보인다. 전부 눌리게 해 두고 일부만
반응하는 것이 가장 나쁜 상태다. 숫자 컬럼은 숫자로 비교한다."
```

---

## Task 10: 다이얼로그 호스트와 확인 다이얼로그

**Files:**
- Modify: `admin/main.dc.html`

**Interfaces:**
- Consumes: 없음
- Produces:
  - 상태 `dialog: { kind: 'confirm'|'reason'|'value', title: string, body: string, confirmLabel: string, danger: boolean, field?: string, placeholder?: string, unit?: string } | null`, `dialogInput: string`
  - `Component.prototype.askConfirm(opts)`, `.askReason(opts)`, `.askValue(opts)` — 다이얼로그를 연다
  - renderVals: `dialogOpen`, `dlgTitle`, `dlgBody`, `dlgConfirmLabel`, `dlgConfirmBg`, `dlgConfirmFg`, `dlgHasInput`, `dlgPlaceholder`, `dlgInput`, `onDlgInput`, `dlgConfirm`, `dlgCancel`, `dlgConfirmDisabled`

**ux 게이트:** `get_guidance("confirmation and destructive action dialogs in an admin console")`, `get_checklist("modal")`. 근거 법칙: 확인 다이얼로그의 버튼 라벨은 **동작을 말해야 한다**("삭제", "반려") — "확인"은 무엇을 확인하는지 말하지 않는다.

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

`#/campaign-registration-request` 에서 일괄 액션 바를 띄우고 `반려` 를 눌렀을 때:

```js
document.querySelectorAll('tbody input[type=checkbox]')[0].click();
Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '반려').click();
JSON.stringify({ dialog: document.body.innerText.includes('반려 사유') })
```

Expected: FAIL — `false`.

- [ ] **Step 2: 상태와 열기 메서드를 추가한다**

상태에 `dialog: null, dialogInput: ''` 추가.

```js
  /* 다이얼로그 3종. 화면마다 모달을 새로 만들지 않기 위해 타입으로 나눈다.
   *   confirm — 되돌리기 어려운 동작 앞의 확인
   *   reason  — 반려·취소처럼 사유가 기록되어야 하는 동작 (사유 없이 진행 불가)
   *   value   — 포인트·캐시·환불 금액처럼 값이 필요한 동작
   * onDone 은 프로토타입이라 실제 변경 대신 토스트만 띄운다. */
  askConfirm({ title, body, confirmLabel, danger = false, onDone }) {
    this._dlgDone = onDone;
    this.setState({ dialog: { kind: 'confirm', title, body, confirmLabel, danger }, dialogInput: '' });
  }
  askReason({ title, body, confirmLabel, placeholder = '사유를 입력해주세요', onDone }) {
    this._dlgDone = onDone;
    this.setState({ dialog: { kind: 'reason', title, body, confirmLabel, danger: true, placeholder }, dialogInput: '' });
  }
  askValue({ title, body, confirmLabel, placeholder = '금액을 입력해주세요', onDone }) {
    this._dlgDone = onDone;
    this.setState({ dialog: { kind: 'value', title, body, confirmLabel, danger: false, placeholder }, dialogInput: '' });
  }
```

- [ ] **Step 3: renderVals 를 추가한다**

```js
      /* 사유·값 다이얼로그는 입력이 비면 확정 버튼이 눌리지 않는다 —
       * 사유 없는 반려는 나중에 아무도 설명하지 못한다. */
      ...(() => {
        const d = s.dialog;
        const needsInput = !!d && (d.kind === 'reason' || d.kind === 'value');
        const empty = needsInput && s.dialogInput.trim() === '';
        return {
          dialogOpen: !!d,
          dlgTitle: d ? d.title : '', dlgBody: d ? d.body : '',
          dlgConfirmLabel: d ? d.confirmLabel : '',
          dlgHasInput: needsInput,
          dlgPlaceholder: d ? (d.placeholder || '') : '',
          dlgInput: s.dialogInput,
          dlgConfirmDisabled: empty,
          dlgConfirmBg: empty ? 'var(--surface-2)' : (d && d.danger ? 'var(--error)' : accent),
          dlgConfirmFg: empty ? TERT : (d && d.danger ? 'var(--text-on-ink)' : accentInk),
          onDlgInput: e => this.setState({ dialogInput: e.target.value }),
          dlgCancel: () => this.setState({ dialog: null, dialogInput: '' }),
          dlgConfirm: () => {
            if (empty) return;
            const done = this._dlgDone;
            this.setState({ dialog: null, dialogInput: '' });
            if (done) done(s.dialogInput);
          },
        };
      })(),
```

`accentInk` 는 이미 renderVals 위쪽에서 계산되어 있으므로 그 아래에 둔다.

- [ ] **Step 4: 템플릿에 다이얼로그를 추가한다**

필터 모달 아래에:

```html
<sc-if value="{{ dialogOpen }}" hint-placeholder-val="{{ false }}">
  <div style="position:fixed;inset:0;z-index:80;background:rgba(0,0,0,0.32);display:flex;align-items:center;justify-content:center;padding:24px">
    <div style="width:100%;max-width:380px;background:var(--canvas);border-radius:var(--radius-2xl);padding:22px;display:flex;flex-direction:column;gap:14px">
      <span style="font-size:16px;font-weight:600">{{ dlgTitle }}</span>
      <span style="font-size:13px;color:var(--text-secondary);line-height:20px">{{ dlgBody }}</span>
      <sc-if value="{{ dlgHasInput }}" hint-placeholder-val="{{ false }}">
        <textarea placeholder="{{ dlgPlaceholder }}" value="{{ dlgInput }}" onInput="{{ onDlgInput }}" style="min-height:84px;padding:12px;border-radius:var(--radius-lg);background:var(--surface-1);border:0;font-size:13px;font-family:'SUIT',sans-serif;color:var(--ink);resize:vertical"></textarea>
      </sc-if>
      <div style="display:flex;gap:8px;justify-content:flex-end;padding-top:4px">
        <button onClick="{{ dlgCancel }}" style="height:38px;padding:0 14px;border-radius:var(--radius-lg);background:var(--surface-2);font-size:13px;font-weight:500">취소</button>
        <button onClick="{{ dlgConfirm }}" disabled="{{ dlgConfirmDisabled }}" style="height:38px;padding:0 18px;border-radius:var(--radius-lg);background:{{ dlgConfirmBg }};color:{{ dlgConfirmFg }};font-size:13px;font-weight:700">{{ dlgConfirmLabel }}</button>
      </div>
    </div>
  </div>
</sc-if>
```

- [ ] **Step 5: 일괄 액션 버튼을 다이얼로그에 연결한다**

`bulkActions` 를 문자열 배열에서 객체 배열로 바꾼다:

```js
          bulkActions: bulk.map(label => ({
            label,
            run: () => {
              const n = s.picked.length;
              /* 반려·취소·삭제는 사유를 남긴다. 나머지는 확인만 받는다. */
              if (/반려|취소|삭제/.test(label)) {
                this.askReason({ title: label, body: `선택한 ${n}건에 대해 사유를 남깁니다.`, confirmLabel: label,
                  onDone: () => this.toast(`${n}건을 ${label}했어요`) });
              } else {
                this.askConfirm({ title: label, body: `선택한 ${n}건에 ${label}을 실행합니다.`, confirmLabel: label,
                  onDone: () => this.toast(`${n}건을 처리했어요`) });
              }
            },
          })),
```

템플릿의 일괄 액션 버튼에 `onClick="{{ b.run }}"` 과 `{{ b.label }}` 을 적용한다. `toast` 는 Task 13 에서 만들되, 이 태스크에서는 임시로 다음을 컴포넌트에 추가한다:

```js
  toast(text) { this.setState({ toastText: text }); }
```

상태에 `toastText: ''` 를 추가하고, 표시는 Task 13 에서 붙인다.

- [ ] **Step 6: 단언이 통과하는지 확인한다**

Step 1 의 스니펫을 다시 실행.
Expected: 다이얼로그가 열리고, 사유가 빈 상태에서 확정 버튼이 비활성(`disabled`)이며, 텍스트를 넣으면 활성화된다:

```js
const ta = document.querySelector('textarea');
ta.value = '서류 미비'; ta.dispatchEvent(new Event('input', { bubbles: true }));
JSON.stringify({ disabled: Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '반려').disabled })
```

Expected: `disabled: false`.

- [ ] **Step 7: 취소가 상태를 남기지 않는지 확인한다**

취소 후 다시 열었을 때 textarea 가 비어 있는지 확인한다.

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

```bash
git add admin/main.dc.html
git commit -m "feat(admin): 확인·사유·값 입력 다이얼로그 3종 추가

화면마다 모달을 새로 만들지 않도록 타입으로 나눈다. 사유가 비면 확정
버튼이 눌리지 않는다 — 사유 없는 반려는 나중에 아무도 설명하지 못한다.
버튼 라벨은 동작을 말한다."
```

---

## Task 11: 컨텍스트 사이드 드로어

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

**Interfaces:**
- Consumes: `cfg()`, `rowId()`
- Produces: 상태 `drawer: { title: string, subtitle: string, fields: {label,value}[] } | null`. `Component.prototype.openDrawer(opts)`. renderVals: `drawerOpen`, `drawerTitle`, `drawerSubtitle`, `drawerFields`, `closeDrawer`.

**왜 별도 프리미티브:** 기존 앱에서 기업/사용자/캠페인 상세는 목록을 떠나지 않고 옆에서 확인하는 물건이다(결제·예약·신청 이력에서 반복 호출). 페이지 전환으로 만들면 운영자가 목록의 스크롤 위치와 선택을 잃는다.

**ux 게이트:** `get_guidance("side drawer for read-only context beside an admin list")`, `get_checklist("single-item-detail")`.

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

`#/payment` 에서 행의 기업명 옆 컨텍스트 버튼이 없음을 확인한다:

```js
JSON.stringify({ ctx: !!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '기업 정보') })
```

Expected: FAIL — `false`.

- [ ] **Step 2: route-config 에 드로어 선언을 추가한다**

`/payment`, `/bank-transfer-deposit`, `/bank-transfer-refund`, `/reservation`, `/reviewer-application-history` 에 추가한다. 예:

```js
  '/payment': {
    segments: { field: 0, options: ['전체', '결제완료', '결제대기', '환불완료', '결제실패'] },
    bulk: ['환불 처리'],
    sortable: [2, 5],
    /* 목록을 떠나지 않고 옆에서 확인하는 맥락. label 은 버튼 문구,
     * fields 는 해당 행에서 가져올 컬럼 인덱스다. */
    drawer: { label: '기업 정보', titleField: 3, fields: [1, 2, 4, 5] },
  },
```

체크 스크립트에 단언을 추가한다:

```js
  if (cfg.drawer) {
    check(`${route}: drawer.titleField 범위`, cfg.drawer.titleField < win.SV_TABLES[route].columns.length);
    for (const f of cfg.drawer.fields) check(`${route}: drawer.field ${f} 범위`, f < win.SV_TABLES[route].columns.length);
  }
```

- [ ] **Step 3: 체크를 돌린다**

Run: `node tools/check-admin-data.mjs`
Expected: PASS

- [ ] **Step 4: 컴포넌트와 템플릿을 추가한다**

상태에 `drawer: null`. 컴포넌트에:

```js
  openDrawer({ title, subtitle, fields }) { this.setState({ drawer: { title, subtitle, fields } }); }
```

renderVals:

```js
      drawerOpen: !!s.drawer,
      drawerTitle: s.drawer ? s.drawer.title : '',
      drawerSubtitle: s.drawer ? s.drawer.subtitle : '',
      drawerFields: s.drawer ? s.drawer.fields : [],
      closeDrawer: () => this.setState({ drawer: null }),
```

`tbRows` 의 행 객체에 드로어 트리거를 추가한다:

```js
        hasCtx: !!this.cfg(s.active).drawer,
        ctxLabel: (this.cfg(s.active).drawer || {}).label || '',
        openCtx: e => {
          e.stopPropagation();
          const d = this.cfg(s.active).drawer;
          const val = c => (Array.isArray(c) ? c[0] : c);
          this.openDrawer({
            title: val(r[d.titleField]),
            subtitle: table.columns[d.titleField][0],
            fields: d.fields.map(i => ({ label: table.columns[i][0], value: val(r[i]) })),
          });
        },
```

행의 마지막 `td` 뒤에 트리거 셀을 추가하고, 드로어 자체를 다이얼로그 아래에 둔다:

```html
<sc-if value="{{ drawerOpen }}" hint-placeholder-val="{{ false }}">
  <div style="position:fixed;inset:0;z-index:75;background:rgba(0,0,0,0.24);display:flex;justify-content:flex-end">
    <aside style="width:100%;max-width:380px;height:100%;background:var(--canvas);padding:24px;display:flex;flex-direction:column;gap:18px;overflow-y:auto">
      <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
        <div style="display:flex;flex-direction:column;gap:4px">
          <span style="font-size:12px;color:var(--text-tertiary)">{{ drawerSubtitle }}</span>
          <span style="font-size:17px;font-weight:600">{{ drawerTitle }}</span>
        </div>
        <button onClick="{{ closeDrawer }}" style="width:30px;height:30px;border-radius:var(--radius-circle);display:flex;align-items:center;justify-content:center;color:var(--text-secondary)"><sv-icon name="close" size="16"></sv-icon></button>
      </div>
      <div style="background:var(--surface-1);border-radius:var(--radius-xl);padding:4px 16px">
        <sc-for list="{{ drawerFields }}" as="f" hint-placeholder-count="4">
          <div style="display:flex;gap:16px;padding:12px 0;border-bottom:1px solid var(--hairline)">
            <span style="width:120px;flex-shrink:0;font-size:12px;color:var(--text-secondary)">{{ f.label }}</span>
            <span style="font-size:13px;font-weight:500">{{ f.value }}</span>
          </div>
        </sc-for>
      </div>
    </aside>
  </div>
</sc-if>
```

- [ ] **Step 5: 단언이 통과하는지 확인한다**

`#/payment` 에서 컨텍스트 버튼을 클릭:

```js
Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '기업 정보').click();
JSON.stringify({ drawer: !!document.querySelector('aside'), rowDetailOpen: document.body.innerText.includes('상세 정보') })
```

Expected: `drawer: true`, `rowDetailOpen: false`.

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

```bash
git add admin/main.dc.html admin/data/route-config.js tools/check-admin-data.mjs
git commit -m "feat(admin): 목록 옆 컨텍스트 사이드 드로어 추가

기업·사용자·캠페인 맥락은 목록을 떠나지 않고 확인하는 물건이다. 페이지
전환으로 만들면 운영자가 스크롤 위치와 선택을 잃는다."
```

---

## Task 12: UX 상태 — 로딩 · 오류 · 무결과 · 토스트

**Files:**
- Modify: `admin/main.dc.html`

**Interfaces:**
- Consumes: Task 10 의 `toast()`
- Produces: 상태 `loading: boolean`, `loadError: boolean`, `toastText: string`. renderVals: `isLoading`, `isLoadError`, `retryLoad`, `hasToast`, `toastText`, `tbNoResult`, `tbNoResultReset`. 템플릿에 스켈레톤·오류·무결과·토스트 블록.

**왜:** 감사에서 이 네 상태가 통째로 비어 있다. 프로토타입이 상태를 보여주지 못하면 엔지니어가 상태를 구현할 근거가 없다.

**ux 게이트:** `get_guidance("loading, error and empty states for an admin list")`, `get_checklist("empty-state-web-app")`. 무결과 빈 상태는 **원인과 탈출구**를 함께 준다 — "결과가 없습니다"만 있으면 운영자는 필터가 걸린 줄 모른다.

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

`#/reviewer` 에서 검색어에 아무 데도 없는 문자열을 넣고:

```js
const i = document.querySelector('input[type=text]');
i.value = 'zzzznotfound'; i.dispatchEvent(new Event('input', { bubbles: true }));
JSON.stringify({ text: document.body.innerText.includes('검색어와 맞는'), reset: !!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '검색어 지우기') })
```

Expected: FAIL — 현재는 목록의 기본 빈 문구("크리에이터가 없습니다")가 나온다.

- [ ] **Step 2: 무결과 상태를 분리한다**

renderVals 의 빈 상태 계산을 바꾼다:

```js
      /* 빈 상태는 두 가지다. 데이터가 없는 것과, 조건이 걸러 낸 것.
       * 후자에 탈출구를 주지 않으면 운영자는 필터가 걸린 줄 모른다. */
      tbEmpty: !!table && this.tableRows(table).length === 0 && !s.tbQuery && !this.cfgFiltering(),
      tbNoResult: !!table && this.tableRows(table).length === 0 && (!!s.tbQuery || this.cfgFiltering()),
      tbNoResultReset: () => this.setState({ tbQuery: '', filters: {}, seg: '전체', page: 1 }),
```

컴포넌트에 헬퍼를 추가한다:

```js
  /* 지금 뭔가 걸러지고 있는가. 무결과 문구를 고르는 데 쓴다. */
  cfgFiltering() {
    const cfg = this.cfg(this.state.active);
    if (this.state.seg !== '전체') return true;
    return (cfg.filters || []).some(f => this.state.filters[f.label] && this.state.filters[f.label] !== '전체');
  }
```

- [ ] **Step 3: 상태 블록들을 템플릿에 추가한다**

기존 `tbEmpty` 블록 아래에:

```html
        <sc-if value="{{ tbNoResult }}" hint-placeholder-val="{{ false }}">
          <div style="padding:72px 0;display:flex;flex-direction:column;align-items:center;gap:10px">
            <span style="font-size:13px;color:var(--text-secondary)">검색어와 맞는 결과가 없어요</span>
            <button onClick="{{ tbNoResultReset }}" style="height:34px;padding:0 14px;border-radius:var(--radius-lg);background:var(--surface-2);font-size:12px;font-weight:500">검색어 지우기</button>
          </div>
        </sc-if>
```

`isTable` 블록 맨 위(검색 줄 앞)에 로딩·오류를 넣는다:

```html
        <sc-if value="{{ isLoading }}" hint-placeholder-val="{{ false }}">
          <div style="display:flex;flex-direction:column;gap:10px;padding-top:8px">
            <sc-for list="{{ skeletonRows }}" as="sk" hint-placeholder-count="6">
              <div style="height:44px;border-radius:var(--radius-lg);background:var(--surface-1)"></div>
            </sc-for>
          </div>
        </sc-if>
        <sc-if value="{{ isLoadError }}" hint-placeholder-val="{{ false }}">
          <div style="padding:72px 0;display:flex;flex-direction:column;align-items:center;gap:10px">
            <span style="font-size:13px;color:var(--text-secondary)">목록을 불러오지 못했어요</span>
            <button onClick="{{ retryLoad }}" style="height:34px;padding:0 14px;border-radius:var(--radius-lg);background:var(--surface-2);font-size:12px;font-weight:500">다시 시도</button>
          </div>
        </sc-if>
```

토스트는 최상위(로그아웃 화면 앞)에:

```html
<sc-if value="{{ hasToast }}" hint-placeholder-val="{{ false }}">
  <div style="position:fixed;left:50%;bottom:32px;transform:translateX(-50%);z-index:95;padding:12px 18px;border-radius:var(--radius-pill);background:var(--ink);color:var(--text-on-ink);font-size:13px;font-weight:500">{{ toastText }}</div>
</sc-if>
```

renderVals 에 추가:

```js
      /* 로딩·오류는 프로토타입에서 자연 발생하지 않는다. 리뷰어가 상태를 볼 수
       * 있도록 계정 패널에서 켤 수 있게 두고, 기본값은 꺼짐이다. */
      isLoading: s.loading, isLoadError: s.loadError,
      skeletonRows: [1, 2, 3, 4, 5, 6],
      retryLoad: () => this.setState({ loadError: false }),
      hasToast: !!s.toastText,
      toastText: s.toastText,
```

상태에 `loading: false, loadError: false` 를 추가한다. 계정 패널에 두 개의 토글을 추가해 리뷰어가 상태를 확인할 수 있게 한다(라벨: `로딩 상태 보기`, `오류 상태 보기`).

- [ ] **Step 4: 토스트가 사라지게 한다**

`toast()` 를 바꾼다:

```js
  toast(text) {
    this.setState({ toastText: text });
    clearTimeout(this._toastTimer);
    this._toastTimer = setTimeout(() => this.setState({ toastText: '' }), 2600);
  }
```

- [ ] **Step 5: 단언이 통과하는지 확인한다**

Step 1 의 스니펫을 다시 실행.
Expected: `text: true`, `reset: true`. 그리고 `검색어 지우기` 를 누르면 목록이 돌아온다.

- [ ] **Step 6: 토스트를 확인한다**

일괄 액션을 실행해 토스트가 뜨고 약 2.6초 뒤 사라지는지 확인한다.

- [ ] **Step 7: 로딩·오류 토글을 확인한다**

계정 패널에서 두 토글을 켜서 스켈레톤과 오류+다시 시도가 나오는지 확인한다.

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

```bash
git add admin/main.dc.html
git commit -m "feat(admin): 로딩·오류·무결과·토스트 상태 추가

빈 상태는 두 가지다 — 데이터가 없는 것과 조건이 걸러 낸 것. 후자에
탈출구를 주지 않으면 운영자는 필터가 걸린 줄 모른다. 로딩·오류는
프로토타입에서 자연 발생하지 않으므로 계정 패널에서 켜 볼 수 있게 둔다."
```

---

## Task 13: 엔터티 상세 템플릿 — 회원 상세

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

**Interfaces:**
- Consumes: `cfg()`, `rowId()`, 다이얼로그(Task 10), 토스트(Task 12)
- Produces:
  - `DetailCfg = { sections: { title: string, fields: number[] }[], related?: { title: string, columns: string[], rows: string[][] }[], logs?: boolean, actions?: { label: string, kind: 'confirm'|'reason'|'value', danger?: boolean }[] }`
  - `Component.prototype.buildEntityDetail(table, cfg, row)` → `{ title, subtitle, status, toneColor, thumbSrc, hasThumb, thumbRadius, metrics[], sections[], related[], logs[], actions[] }`
  - `/reviewer` 의 `detail` 선언

**왜:** 오늘의 `buildRowDetail` 은 테이블 컬럼에서 필드를 파생한다. 그래서 어떤 엔터티도 실제 깊이를 갖지 못한다. 선언형으로 바꾸되, **가장 두꺼운 엔터티(회원)로 먼저 검증한다** — 표현하지 못하면 다른 모듈이 채택하기 전에 탈출구를 만든다.

**ux 게이트:** `get_guidance("admin user detail page with account status, point history and moderation actions")`, `get_checklist("single-item-detail")`, `get_checklist("user-management")`. 두 체크리스트의 미충족 항목을 이 태스크의 하위 단계로 적어 두고, 건너뛰는 항목은 이유와 함께 기록한다.

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

`#/reviewer` 에서 첫 행을 열고:

```js
document.querySelector('tbody tr').click();
JSON.stringify({
  points: document.body.innerText.includes('포인트 증감 내역'),
  penalty: document.body.innerText.includes('페널티 내역'),
  actions: !!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '포인트 수동 변경'),
})
```

Expected: FAIL — 셋 다 `false`.

- [ ] **Step 2: `/reviewer` 에 상세를 선언한다**

`admin/data/route-config.js` 의 `/reviewer` 에 추가:

```js
    /* 상세 선언. 컬럼에서 파생하지 않고 화면이 무엇을 보여줄지 여기서 정한다 —
     * 목록에 없는 정보(포인트·페널티·로그)가 상세의 핵심이기 때문이다. */
    detail: {
      sections: [
        { title: '기본 정보', fields: [1, 2, 3, 4, 6] },
        { title: '활동', fields: [5] },
      ],
      related: [
        { title: '포인트 증감 내역', columns: ['일시', '내용', '증감', '잔액'], rows: [
          ['2026.08.11 09:24', '캠페인 리워드 지급', '+3,000', '11,900'],
          ['2026.08.04 15:02', '포인트 출금', '-8,000', '8,900'],
          ['2026.07.28 11:40', '출석 보상', '+400', '16,900'],
        ] },
        { title: '페널티 내역', columns: ['일시', '사유', '점수', '처리자'], rows: [
          ['2026.06.02 14:10', '리뷰 등록 기한 초과', '+2', '이수현'],
          ['2026.03.18 10:44', '이의 신청 인정', '-2', '김아현'],
        ] },
        { title: '참여 캠페인', columns: ['캠페인명', '상태', '신청일'], rows: [
          ['[비플레인] 녹두 약산성 클렌징 폼 150ml', '리뷰등록', '2026.08.09'],
          ['[메디힐] 에센셜 마스크팩 30매', '정산완료', '2026.07.10'],
        ] },
      ],
      logs: true,
      actions: [
        { label: '포인트 수동 변경', kind: 'value' },
        { label: '페널티 부여', kind: 'value' },
        { label: 'SMS 발송', kind: 'confirm' },
        { label: '회원 정지', kind: 'reason', danger: true },
      ],
    },
```

- [ ] **Step 3: `buildEntityDetail` 을 만든다**

```js
  /* 선언형 상세. buildRowDetail 과 달리 컬럼에서 파생하지 않는다 —
   * 상세의 핵심은 목록에 없는 정보(로그·이력·조치)이기 때문이다.
   * 선언이 없는 라우트는 기존 buildRowDetail 로 남는다(점진 적용). */
  buildEntityDetail(table, dcfg, row) {
    const val = c => (Array.isArray(c) ? c[0] : c);
    const chipAt = row.findIndex(c => Array.isArray(c));
    const titleIdx = row.findIndex((c, i) => i !== chipAt);
    const thumbCol = table.thumbCol ?? 0;
    return {
      hasThumb: !!table.thumb,
      thumbSrc: table.thumb ? imgSrc(table.thumb, val(row[thumbCol])) : '',
      thumbRadius: table.thumb === 'person' ? 'var(--radius-circle)' : 'var(--radius-photo)',
      title: val(row[titleIdx]),
      subtitle: table.columns[titleIdx][0],
      status: chipAt >= 0 ? row[chipAt][0] : false,
      toneColor: chipAt >= 0 ? TONE[row[chipAt][1]] : 'transparent',
      sections: dcfg.sections.map(sec => ({
        title: sec.title,
        fields: sec.fields.map(i => ({ label: table.columns[i][0], value: val(row[i]) })),
      })),
      related: (dcfg.related || []).map(rel => ({
        title: rel.title, columns: rel.columns,
        rows: rel.rows.map(r => ({ cells: r.map((v, i) => ({ text: v, align: i === 0 ? 'left' : (i === rel.columns.length - 1 ? 'right' : 'left') })) })),
      })),
      logs: dcfg.logs ? [
        { at: '2026.08.11 09:24', what: '정보를 수정했어요', who: currentAdmin().name },
        { at: '2026.08.04 15:02', what: '상태를 변경했어요', who: '이수현' },
        { at: '2026.07.28 11:40', what: '항목을 등록했어요', who: currentAdmin().name },
      ] : [],
      actions: (dcfg.actions || []).map(a => ({
        label: a.label,
        bg: a.danger ? 'var(--surface-2)' : 'var(--surface-2)',
        fg: a.danger ? 'var(--error)' : 'var(--ink)',
        run: () => {
          const opts = { title: a.label, body: `${val(row[titleIdx])} 님에게 ${a.label}을 실행합니다.`, confirmLabel: a.label,
            onDone: () => this.toast(`${a.label} 처리했어요`) };
          if (a.kind === 'reason') this.askReason({ ...opts, placeholder: '사유를 입력해주세요' });
          else if (a.kind === 'value') this.askValue({ ...opts, placeholder: '값을 입력해주세요' });
          else this.askConfirm(opts);
        },
      })),
    };
  }
```

- [ ] **Step 4: renderVals 를 분기한다**

```js
      /* 선언이 있으면 엔터티 상세, 없으면 기존 행 미리보기. */
      ...(() => {
        const dcfg = table ? this.cfg(s.active).detail : null;
        const row = table && s.rowKey !== null ? this.tableRows(table).find(r => this.rowId(r) === s.rowKey) : null;
        const useEntity = !!dcfg && !!row;
        return {
          isEntityDetail: useEntity,
          isRowPreview: !!table && s.rowKey !== null && !useEntity,
          entity: useEntity ? this.buildEntityDetail(table, dcfg, row) : { sections: [], related: [], logs: [], actions: [], tabs: [], hasTabs: false, hasThumb: false },
        };
      })(),
```

기존 `isRowDetail` 을 쓰는 템플릿 조건을 `isRowPreview` 로 바꾼다.

- [ ] **Step 5: 엔터티 상세 템플릿을 추가한다**

기존 행 미리보기 블록 아래에:

```html
    <sc-if value="{{ isEntityDetail }}" hint-placeholder-val="{{ false }}">
      <div style="padding:6px 32px 48px;display:flex;flex-direction:column;gap:22px;max-width:1080px">
        <div style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap">
          <sc-if value="{{ entity.hasThumb }}" hint-placeholder-val="{{ true }}">
            <span style="width:72px;height:72px;border-radius:{{ entity.thumbRadius }};background:var(--surface-2);flex-shrink:0;overflow:hidden;display:block">
              <img src="{{ entity.thumbSrc }}" alt="{{ entity.title }}" loading="lazy" style="object-fit:cover;width:100%;height:100%;display:block">
            </span>
          </sc-if>
          <div style="display:flex;flex-direction:column;gap:6px;min-width:0;flex:1">
            <span style="font-size:12px;color:var(--text-tertiary)">{{ entity.subtitle }}</span>
            <span style="font-size:19px;font-weight:600">{{ entity.title }}</span>
          </div>
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
            <sc-for list="{{ entity.actions }}" as="a" hint-placeholder-count="3">
              <button onClick="{{ a.run }}" style="height:36px;padding:0 14px;border-radius:var(--radius-lg);background:{{ a.bg }};color:{{ a.fg }};font-size:13px;font-weight:500">{{ a.label }}</button>
            </sc-for>
            <button onClick="{{ closeRowDetail }}" style="height:36px;padding:0 14px;border-radius:var(--radius-lg);background:var(--surface-2);font-size:13px;font-weight:500">목록으로</button>
          </div>
        </div>

        <sc-for list="{{ entity.sections }}" as="sec" hint-placeholder-count="2">
          <section style="display:flex;flex-direction:column;gap:12px">
            <h2 style="margin:0;font-size:15px;font-weight:600">{{ sec.title }}</h2>
            <div style="background:var(--surface-1);border-radius:var(--radius-2xl);padding:6px 20px">
              <sc-for list="{{ sec.fields }}" as="f" hint-placeholder-count="5">
                <div style="display:flex;gap:20px;padding:13px 0;border-bottom:1px solid var(--hairline)">
                  <span style="width:160px;flex-shrink:0;font-size:13px;color:var(--text-secondary)">{{ f.label }}</span>
                  <span style="font-size:13px;font-weight:500">{{ f.value }}</span>
                </div>
              </sc-for>
            </div>
          </section>
        </sc-for>

        <sc-for list="{{ entity.related }}" as="rel" hint-placeholder-count="3">
          <section style="display:flex;flex-direction:column;gap:12px">
            <h2 style="margin:0;font-size:15px;font-weight:600">{{ rel.title }}</h2>
            <div style="background:var(--surface-1);border-radius:var(--radius-2xl);padding:6px 20px;overflow-x:auto">
              <table style="width:100%;border-collapse:collapse">
                <thead><tr>
                  <sc-for list="{{ rel.columns }}" as="c" hint-placeholder-count="4">
                    <th style="text-align:left;padding:10px 8px;font-size:11px;font-weight:500;color:var(--text-tertiary);white-space:nowrap">{{ c }}</th>
                  </sc-for>
                </tr></thead>
                <tbody>
                  <sc-for list="{{ rel.rows }}" as="rr" hint-placeholder-count="3">
                    <tr>
                      <sc-for list="{{ rr.cells }}" as="cc" hint-placeholder-count="4">
                        <td style="text-align:{{ cc.align }};padding:11px 8px;font-size:13px;border-top:1px solid var(--hairline);white-space:nowrap">{{ cc.text }}</td>
                      </sc-for>
                    </tr>
                  </sc-for>
                </tbody>
              </table>
            </div>
          </section>
        </sc-for>

        <section style="display:flex;flex-direction:column;gap:12px">
          <h2 style="margin:0;font-size:15px;font-weight:600">관리자 로그</h2>
          <div style="background:var(--surface-1);border-radius:var(--radius-2xl);padding:6px 20px">
            <sc-for list="{{ entity.logs }}" as="lg" hint-placeholder-count="3">
              <div style="display:flex;gap:16px;padding:12px 0;border-bottom:1px solid var(--hairline)">
                <span style="width:150px;flex-shrink:0;font-family:'Poppins',sans-serif;font-size:12px;color:var(--text-tertiary)">{{ lg.at }}</span>
                <span style="font-size:13px;flex:1">{{ lg.what }}</span>
                <span style="font-size:12px;color:var(--text-secondary)">{{ lg.who }}</span>
              </div>
            </sc-for>
          </div>
        </section>
      </div>
    </sc-if>
```

- [ ] **Step 6: 단언이 통과하는지 확인한다**

Step 1 의 스니펫을 다시 실행.
Expected: 셋 다 `true`.

- [ ] **Step 7: 액션이 다이얼로그로 이어지는지 확인한다**

```js
Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '회원 정지').click();
JSON.stringify({ dlg: document.body.innerText.includes('사유'), danger: true })
```

Expected: 사유 다이얼로그가 열린다.

- [ ] **Step 8: 선언 없는 라우트가 기존대로인지 확인한다**

`#/notice` 에서 행을 열어 기존 행 미리보기가 그대로 나오는지 확인한다.

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

```bash
git add admin/main.dc.html admin/data/route-config.js
git commit -m "feat(admin): 선언형 엔터티 상세 템플릿과 크리에이터 상세

컬럼에서 파생하던 행 미리보기 대신, 라우트가 상세에 무엇을 보여줄지
선언한다. 상세의 핵심은 목록에 없는 정보(포인트·페널티·로그·조치)다.
선언이 없는 라우트는 기존 미리보기로 남는다."
```

---

## Task 14: 기업 상세 — 탭을 가진 두 번째 검증

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

**Interfaces:**
- Consumes: Task 13 의 `DetailCfg`, `buildEntityDetail`
- Produces: `DetailCfg.tabs?: { label: string, sections: number[], related: number[] }[]` — 탭이 선언되면 상세가 탭으로 나뉜다. `buildEntityDetail` 이 `hasTabs`, `tabs[]`, 그리고 활성 탭에 해당하는 `sections`/`related` 만 반환한다. 상태 `detailTab: string`.

**왜 두 번째 검증인가:** 회원 상세는 탭이 없다. 기업 상세는 탭 2개(기업 정보 / 샵바이 파트너 정보)와 하위 목록 5개를 가진다. 템플릿이 이 둘을 모두 표현하지 못하면 다른 모듈이 채택하기 전에 지금 고쳐야 한다.

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

`#/company` 에서 첫 행을 열고:

```js
document.querySelector('tbody tr').click();
JSON.stringify({ tabs: !!Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '샵바이 파트너 정보') })
```

Expected: FAIL — `false`.

- [ ] **Step 2: `/company` 에 탭 있는 상세를 선언한다**

```js
  '/company': {
    bulk: ['SMS 발송', '이메일 발송'],
    sortable: [4, 5, 6],
    detail: {
      /* 탭은 정보를 나누는 것이 아니라 맥락을 나눈다 — 샵바이 정보는
       * 연동한 기업에게만 의미가 있어서 기본 화면을 차지하면 안 된다. */
      tabs: [
        { label: '기업 정보', sections: [0], related: [0, 1, 2, 3] },
        { label: '샵바이 파트너 정보', sections: [1], related: [] },
      ],
      sections: [
        { title: '기본 정보', fields: [0, 1, 2, 3] },
        { title: '파트너 어드민 정보', fields: [1, 3] },
      ],
      related: [
        { title: '소속 회원', columns: ['이름', '직책', '연락처'], rows: [
          ['김도현', '마케팅 팀장', '010-4412-8890'],
          ['이나래', '마케터', '010-2277-4419'],
        ] },
        { title: '등록 상품', columns: ['상품명', '유형', '등록일'], rows: [
          ['녹두 약산성 클렌징 폼 150ml', '배송형', '2026.07.26'],
        ] },
        { title: '캐시 로그', columns: ['일시', '내용', '증감', '잔액'], rows: [
          ['2026.08.07 15:33', '무통장 충전', '+2,000,000', '2,480,000'],
          ['2026.08.01 09:12', '캠페인 결제', '-1,650,000', '480,000'],
        ] },
        { title: '기업 로그', columns: ['일시', '내용', '처리자'], rows: [
          ['2026.08.07 15:33', '입금을 확인했어요', '이수현'],
        ] },
      ],
      logs: false,
      actions: [
        { label: '캐시 수동 변경', kind: 'value' },
        { label: '이벤트 캐시 지급', kind: 'value' },
        { label: '구독 해제', kind: 'confirm' },
      ],
    },
  },
```

체크에 단언을 추가한다:

```js
  const d = cfg.detail;
  if (d && d.tabs) {
    for (const t of d.tabs) {
      for (const i of t.sections) check(`${route}: tab "${t.label}" section ${i} 존재`, !!d.sections[i]);
      for (const i of t.related) check(`${route}: tab "${t.label}" related ${i} 존재`, !!(d.related || [])[i]);
    }
  }
```

- [ ] **Step 3: 체크를 돌린다**

Run: `node tools/check-admin-data.mjs`
Expected: PASS

- [ ] **Step 4: `buildEntityDetail` 에 탭을 추가한다**

`sections`/`related` 계산 앞에 넣는다:

```js
    /* 탭이 선언되면 활성 탭이 고른 섹션·목록만 렌더한다.
     * 탭이 없으면 전부 렌더한다 — 회원 상세가 그 경우다. */
    const tabsCfg = dcfg.tabs || null;
    const activeTab = tabsCfg ? (this.state.detailTab || tabsCfg[0].label) : null;
    const picked = tabsCfg ? tabsCfg.find(t => t.label === activeTab) || tabsCfg[0] : null;
    const secIdx = picked ? picked.sections : dcfg.sections.map((_, i) => i);
    const relIdx = picked ? picked.related : (dcfg.related || []).map((_, i) => i);
```

`sections`/`related` 를 인덱스로 거른다:

```js
      hasTabs: !!tabsCfg,
      tabs: (tabsCfg || []).map(t => {
        const on = t.label === activeTab;
        return { label: t.label, bg: on ? 'var(--canvas)' : 'transparent', fg: on ? INK : SECOND, weight: on ? 600 : 500,
          pick: () => this.setState({ detailTab: t.label }) };
      }),
      sections: secIdx.map(i => dcfg.sections[i]).map(sec => ({
        title: sec.title,
        fields: sec.fields.map(i => ({ label: table.columns[i][0], value: val(row[i]) })),
      })),
      related: relIdx.map(i => (dcfg.related || [])[i]).map(rel => ({
        title: rel.title, columns: rel.columns,
        rows: rel.rows.map(r => ({ cells: r.map((v, i) => ({ text: v, align: i === 0 ? 'left' : (i === rel.columns.length - 1 ? 'right' : 'left') })) })),
      })),
```

상태에 `detailTab: null` 을 추가하고, `go()`·해시 리셋·행 열기에서 `detailTab: null` 로 되돌린다 — 다른 기업을 열었는데 이전 탭이 남아 있으면 안 된다.

- [ ] **Step 5: 탭 UI 를 템플릿에 추가한다**

헤더 아래, 섹션 `sc-for` 앞에:

```html
        <sc-if value="{{ entity.hasTabs }}" hint-placeholder-val="{{ false }}">
          <div style="display:flex;gap:4px;padding:4px;border-radius:var(--radius-lg);background:var(--surface-1);width:max-content">
            <sc-for list="{{ entity.tabs }}" as="t" hint-placeholder-count="2">
              <button onClick="{{ t.pick }}" style="height:32px;padding:0 14px;border-radius:var(--radius-sm);background:{{ t.bg }};color:{{ t.fg }};font-size:13px;font-weight:{{ t.weight }}">{{ t.label }}</button>
            </sc-for>
          </div>
        </sc-if>
```

- [ ] **Step 6: 단언이 통과하는지 확인한다**

Step 1 의 스니펫을 다시 실행.
Expected: `tabs: true`. 이어서 탭을 눌러 섹션이 바뀌는지 확인한다:

```js
Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === '샵바이 파트너 정보').click();
JSON.stringify({ partner: document.body.innerText.includes('파트너 어드민 정보'), members: document.body.innerText.includes('소속 회원') })
```

Expected: `partner: true`, `members: false`.

- [ ] **Step 7: 회원 상세가 여전히 탭 없이 전부 보이는지 확인한다**

`#/reviewer` 첫 행을 열어 포인트·페널티·참여 캠페인 세 목록이 동시에 보이는지 확인한다.

- [ ] **Step 8: 다른 기업을 열면 탭이 초기화되는지 확인한다**

샵바이 탭을 연 상태에서 목록으로 돌아가 다른 행을 열면 `기업 정보` 탭이 선택되어 있는지 확인한다.

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

```bash
git add admin/main.dc.html admin/data/route-config.js tools/check-admin-data.mjs
git commit -m "feat(admin): 상세 템플릿에 탭 지원과 기업 상세 추가

탭은 정보를 나누는 게 아니라 맥락을 나눈다 — 샵바이 정보는 연동한
기업에게만 의미가 있어 기본 화면을 차지하면 안 된다. 탭이 없는 회원
상세는 전부 펼친 채로 남는다."
```

---

## Task 15: 회귀 확인과 배포

**Files:** 없음 (검증과 배포만)

**Interfaces:**
- Consumes: Task 1–14 전부

- [ ] **Step 1: 데이터 체크를 돌린다**

Run: `node tools/check-admin-data.mjs`
Expected: PASS

- [ ] **Step 2: 손대지 않은 화면 6개를 걷는다**

`#/dashboard`, `#/campaign`(목록→상세→폼), `#/banner`, `#/reviewer-payout`, `#/collaboration-settlement`, `#/profile` 을 차례로 열고 각각:

```js
JSON.stringify({ len: document.body.innerText.length, stub: document.body.innerText.includes('다음 배치에서 리디자인해요') })
```

Expected: 모든 화면에서 `stub: false`, 내용이 렌더된다.

- [ ] **Step 3: 프리미티브가 붙은 목록 6개를 걷는다**

`#/reviewer`, `#/video-inspection`, `#/payment`, `#/reservation`, `#/company`, `#/gift-log` 에서 각각 페이징·세그먼트(있으면)·선택(있으면)·정렬(있으면)·행 상세를 한 번씩 눌러 본다.

- [ ] **Step 4: 콘솔이 깨끗한지 확인한다**

`read_console_messages` 로 오류 0건을 확인한다.

- [ ] **Step 5: 스크린샷을 남긴다**

`#/reviewer`(목록, 선택 상태), `#/reviewer` 상세, `#/company` 상세(샵바이 탭), 필터 모달, 사유 다이얼로그 — 5장.

- [ ] **Step 6: 배포한다**

```bash
cd /Users/anubilegdemberel/Documents/startview-prototype && for d in creator advertiser admin; do rsync -az --delete --exclude='.DS_Store' "$d/" "ncp-svplugin:~/prototype/$d/"; done
```

- [ ] **Step 7: 라이브를 확인한다**

```bash
curl -s -o /dev/null -w "%{http_code}\n" https://prototype.startview.co.kr/admin/data/route-config.js
```

Expected: `200`

- [ ] **Step 8: 계획 문서에 완료를 기록하고 커밋한다**

이 계획 문서 상단에 완료일과 후속 계획(페이즈 4–14)이 필요하다는 메모를 남기고 커밋한다.

```bash
git add docs/plans/2026-08-19-admin-coverage-foundation.md
git commit -m "docs(admin): 기반 페이즈 실행 계획 완료 기록"
```

---

## 후속 계획

이 계획이 끝나면 스펙의 페이즈 4–14 를 각각 별도 계획으로 작성한다. 각 계획은 여기서 만든 프리미티브를 **소비**한다 — `SV_ROUTE_CONFIG` 에 선언을 추가하고, `askConfirm`/`askReason`/`askValue`/`openDrawer` 를 호출하고, `DetailCfg` 로 상세를 선언한다. 새 라우트가 새 렌더 코드를 요구한다면 그것은 프리미티브가 부족하다는 신호이므로, 모듈을 진행하기 전에 프리미티브를 넓힌다.

| 다음 계획 | 스펙 페이즈 |
| --- | --- |
| `2026-XX-XX-admin-campaign-detail-tabs.md` | 4 |
| `2026-XX-XX-admin-inquiry.md` | 5 |
| `2026-XX-XX-admin-money-screens.md` | 6 |
| `2026-XX-XX-admin-brand-zone.md` | 7 |
| `2026-XX-XX-admin-product-and-report.md` | 8 |
| `2026-XX-XX-admin-authoring-forms.md` | 9 |
| `2026-XX-XX-admin-remaining-details.md` | 10 |
| `2026-XX-XX-admin-permissions.md` | 11 |
| `2026-XX-XX-admin-flagged-modules.md` | 12 |
| `2026-XX-XX-admin-remaining-modules.md` | 13 |
| `2026-XX-XX-admin-polish.md` | 14 |
