<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지. -->

# 설계서: 프론트 - 어드민 페이지 (#282)

> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #282 · 관련 ADR: 없음
> · 구현 파일: `frontend/src/app/admin/page.tsx` · 테스트: TBD (현재 없음)

## 1. 목적 (Why)
운영자가 채널 등록·스캔, 영상 자막/LLM 추출, 식당 정보 보정 및 예약처(테이블링/캐치테이블) 연결, 유저 권한 관리, 백그라운드 데몬 스케줄을 **단일 페이지**에서 일관된 패턴으로 다룰 수 있게 한다.

## 2. 범위 (Scope)
- **포함**:
  - 5개 탭: 채널 / 영상 / 식당 / 유저 / 데몬
  - 채널: 추가·수정(설명/태그/순서)·삭제·증분 스캔·전체 스캔
  - 영상: 채널·상태·제목 필터, 정렬, 페이지네이션, 행 선택, 상세 패널, 단건/벌크 자막 수집, 단건/벌크 LLM 추출(SSE 스트리밍 진행률), 식당 인라인 편집/수동 추가, 벡터 재생성, 음식종류/메뉴태그 재분류
  - 식당: 검색·정렬·페이지네이션, 인라인 편집(주소/지역/좌표 등), 테이블링/캐치테이블 단건·벌크 검색·연결·해제·전부 초기화, 연결된 영상 목록
  - 유저: 페이지네이션, 관리자 토글, 선택 유저의 찜/리뷰/메모 패널
  - 데몬: 스캔/처리 스케줄 활성화·주기 설정, 수동 실행, 마지막 실행시각 표시
  - Redis 캐시 플러시(헤더), 관리자/비관리자 모드(읽기 전용 뱃지)
- **제외 (out of scope)**:
  - 어드민 권한 부여 정책 / OAuth 흐름 (LoginMenu, 백엔드)
  - 백엔드 API 스키마 / SSE 이벤트 명세 (각 백엔드 설계서)
  - 다국어, 다크모드 별도 디자인 (브랜드 토큰 기본 적용)

## 3. 인수조건 (이미 구현된 동작 기준)
- [x] 비로그인 사용자는 "로그인이 필요합니다" 안내가 표시된다.
- [x] 로그인했지만 `is_admin !== true`이면 "읽기 전용" 뱃지가 헤더에 표시되고, 모든 변경 액션 버튼이 숨겨지고 입력 필드가 `disabled`된다.
- [x] 헤더의 캐시 초기화 버튼은 관리자에게만 보이고 confirm 후 `api.flushCache()` 호출.
- [x] **채널 탭**: ID/이름/필터 입력으로 추가, 행 클릭으로 설명/태그/순서 인라인 편집, 채널별 "스캔"/"전체 스캔" 결과를 인라인 표시.
- [x] **영상 탭**: 채널·상태·제목 필터, 4개 키 정렬 토글(↕/↑/↓), 페이지당 15개 페이지네이션, 체크박스 다중 선택, 행 클릭 시 상세 패널 오픈/토글.
- [x] 영상 상세: 자막 자동/수동/생성됨 모드 토글, 자막 수동 가져오기, 프롬프트 표시·복사, 추출된 식당 인라인 편집/삭제/수동 추가, 제목 인라인 수정.
- [x] 벌크 자막/LLM/벡터/음식종류/메뉴태그 작업은 SSE 스트리밍 진행률 카드로 실시간 표시되고, 완료 시 목록을 재로드한다.
- [x] **식당 탭**: 이름 검색, 6개 키 정렬, 페이지네이션, 행 선택 상세 패널, 9개 필드 인라인 편집, 테이블링/캐치테이블 검색·연결·해제, 연결된 영상 목록.
- [x] 벌크 테이블링/캐치테이블 연결은 진행률 막대(선형)로 표시되고 완료 시 alert.
- [x] **유저 탭**: 20명 페이지네이션, 관리자 ON/OFF 토글, 유저 선택 시 찜/리뷰/메모 3분할 패널.
- [x] **데몬 탭**: 스캔/처리 enable+주기 설정, 처리 건수(1~50), 수동 실행, 결과 메시지 색상 분기(성공/실패).

## 4. 컨텍스트 & 제약
- **프레임워크**: Next.js 16 App Router, Client Component (`"use client"`)
- **인증**: `useAuth()` — `user`, `isLoading`, `user.is_admin`. 토큰은 `localStorage["tasteby_token"]`에서 직접 읽어 `Authorization: Bearer` 헤더 부착 (SSE/벌크 fetch 호출 시).
- **데이터 호출**:
  - 일반 CRUD: `@/lib/api` 래퍼
  - SSE 스트리밍: `fetch(... POST ...)` + `ReadableStream.getReader()` + `TextDecoder` + `data: ` 라인 파싱
- **스타일**: Tailwind + Saffron 토큰 (`bg-brand-*`, `bg-surface`, `text-brand-*`), 색상 분기로 작업 종류 구분 (자막=brand, LLM=purple, 벡터=teal, 음식분류=amber, 메뉴태그=brand, 테이블링=brand, 캐치테이블=violet)
- **제약**:
  - SSE 처리가 컴포넌트 내 인라인으로 작성되어 있어 코드량 큼 (2,742 LOC)
  - 페이지네이션은 클라이언트 측 (전체 list fetch 후 slice). 데이터 증가 시 서버 페이징 필요
  - 영상/식당 필터링도 클라이언트 측 (`Array.filter`)
- **CORS**: `WebConfig`의 allowedMethods에 DELETE/POST가 포함되어야 함 (이미 포함됨)
- **가정**: 백엔드가 일관된 SSE 이벤트 (`processing`, `done`, `error`, `complete`, `wait`)를 보낸다.

## 5. 아키텍처 개요
- 파일 구조 (단일 파일 내부 패널 분할):
  - `AdminPage` (export default) — 탭 상태 + 헤더 + 인증 가드 + 패널 라우팅
  - `CacheFlushButton` — 헤더용 캐시 플러시
  - `ChannelsPanel` — 채널 탭
  - `VideosPanel` — 영상 탭 + 인라인 상세 + 다수 SSE 핸들러
  - `RestaurantsPanel` — 식당 탭 + 인라인 상세 + 예약처 연결
  - `UsersPanel` — 유저 탭 + 상세 패널
  - `DaemonPanel` — 데몬 탭
- 외부 의존:
  - `@/lib/api` (Channel, Video, VideoDetail, VideoLink, Restaurant, DaemonConfig, getAdminUsers* 등)
  - `@/lib/auth-context` (`useAuth`)

```
┌──────────────────────── AdminPage ────────────────────────┐
│  useAuth() → user, isLoading                             │
│  isAdmin = user?.is_admin === true                       │
│                                                          │
│  [Header] logo | "Admin" | 읽기전용? | 캐시초기화? | 메인↗ │
│  [Nav]   channels videos restaurants users daemon        │
│                                                          │
│  <main>                                                   │
│    tab==='channels'   → ChannelsPanel(isAdmin)            │
│    tab==='videos'     → VideosPanel(isAdmin)              │
│    tab==='restaurants'→ RestaurantsPanel(isAdmin)         │
│    tab==='users'      → UsersPanel()                      │
│    tab==='daemon'     → DaemonPanel(isAdmin)              │
└──────────────────────────────────────────────────────────┘

SSE 패턴 (벌크/벡터/리맵 공통):
   fetch(POST endpoint, Authorization)
     → resp.body.getReader()
     → loop: read → decode → split("\n") → "data: ".substring
     → JSON.parse → ev.type 분기
        processing | wait | done | error | complete
     → setBulkProgress({...})
```

- **I/O ↔ 순수 로직 경계**:
  - I/O: 모든 `api.*`, `fetch`, `localStorage`, `confirm/alert`, SSE 스트림
  - 순수: 필터/정렬/페이지 슬라이스 (`filteredVideos`, `sortedVideos`, `pagedVideos`), 정렬 토글, `toggleSelectAll`, `sortIcon`, `statusColor` 매핑

## 6. 데이터 모델
타입 (in-file 또는 `@/lib/api`):

```ts
type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon";

interface Channel {
  id: string; channel_id: string; channel_name: string;
  title_filter: string | null; description: string | null;
  tags: string | null; sort_order: number | null; video_count: number;
}

type VideoStatus = "pending" | "processing" | "done" | "error" | "skip";
interface Video {
  id: string; channel_name: string; title: string; status: VideoStatus;
  has_transcript: boolean; has_llm: boolean;
  restaurant_count: number; matched_count: number;
  published_at: string | null;
}
interface VideoDetail extends Video {
  transcript: string | null; llm_response: string | null;
  restaurants: ExtractedRestaurant[];
  prompt?: string;
}

interface Restaurant {
  id: string; name: string; address: string | null; region: string | null;
  cuisine_type: string | null; price_range: string | null;
  phone: string | null; website: string | null;
  latitude: number | null; longitude: number | null;
  rating: number | null; rating_count: number | null;
  business_status: "OPERATIONAL" | "CLOSED_TEMPORARILY" | "CLOSED_PERMANENTLY" | null;
  google_place_id: string | null;
  tabling_url: string | null;     // "NONE" = 검색 완료 결과 없음
  catchtable_url: string | null;
}

interface AdminUser {
  id: string; email: string | null; nickname: string | null; avatar_url: string | null;
  is_admin: boolean; provider: string | null; created_at: string | null;
  favorite_count: number; review_count: number; memo_count: number;
}

interface DaemonConfig {
  scan_enabled: boolean; scan_interval_min: number;
  process_enabled: boolean; process_interval_min: number; process_limit: number;
  last_scan_at: string | null; last_process_at: string | null; updated_at: string | null;
}

// SSE 진행률 상태
type BulkProgress = { label: string; total: number; current: number; currentTitle: string;
  results: { title: string; detail: string; error?: boolean }[]; waiting?: number };
type VectorProgress = { phase: string; current: number; total: number; name?: string };
type RemapProgress = { current: number; total: number; updated: number };
```

- **경계 검증**:
  - 채널 추가: `newId.trim() && newName.trim()` 필수
  - 데몬 `process_limit`: 1~50 (input min/max), 음수 방지
  - 인라인 편집 좌표/숫자: input type=text → 백엔드 파싱 의존
  - 페이지 인덱스: `Math.max(0, ...)` / `Math.min(totalPages-1, ...)`로 클램프

## 7. 함수 명세 (Function Specs)

| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `AdminPage` | 탭 라우팅 + 인증 가드 | `() => JSX` | - | JSX | isLoading/!user 분기 | **복잡** |
| `CacheFlushButton` | Redis 캐시 플러시 | `() => JSX` | - | JSX | `alert` | 단순 |
| `ChannelsPanel` | 채널 CRUD + 스캔 | `({ isAdmin }) => JSX` | `isAdmin` | JSX | catch+alert | **복잡** |
| `handleAdd` (Ch) | 채널 생성 | `() => Promise<void>` | state | void | alert(e.message) | 단순 |
| `handleSaveChannel` | 채널 메타 저장 | `(id) => Promise<void>` | id | void | alert | 단순 |
| `handleDelete` (Ch) | 채널 삭제 (confirm) | `(id, name) => Promise<void>` | id, name | void | alert | 단순 |
| `handleScan` | 채널 스캔(증분/전체) | `(channelId, full?) => Promise<void>` | id, full | void (scanResult map) | 인라인 메시지 | 단순 |
| `VideosPanel` | 영상 목록·필터·정렬·페이지·상세·벌크 | `({ isAdmin }) => JSX` | isAdmin | JSX | 다중 catch | **복잡** |
| `handleSelectVideo` | 상세 토글/로드 | `(v) => Promise<void>` | Video | void | alert | 단순 |
| `handleProcess` | 대기 영상 일괄 처리 | `() => Promise<void>` | - | void | 인라인 메시지 | 단순 |
| `startBulkStream` | 자막/LLM 벌크 SSE 처리 | `(mode, ids?) => Promise<void>` | `"transcript"\|"extract"`, ids? | void | network/parse fail | **복잡** |
| `startRebuildVectors` | 전체 벡터 재생성 SSE | `() => Promise<void>` | - | void | alert | **복잡** |
| `startRemapCuisine` | 음식 종류 재분류 SSE | `() => Promise<void>` | - | void | alert | **복잡** |
| `startRemapFoods` | 메뉴 태그 재생성 SSE | `() => Promise<void>` | - | void | alert | **복잡** |
| `handleSort` (V) | 정렬 키/방향 토글 | `(key) => void` | VideoSortKey | void | - | 단순 |
| `toggleSelect` / `toggleSelectAll` | 행 선택 관리 | `(id?) => void` | id | void | - | 단순 |
| `handleBulkSkip` / `handleBulkDelete` | 선택 행 일괄 처리 | `() => Promise<void>` | - | void | 실패 카운트 alert | 단순 |
| `RestaurantsPanel` | 식당 CRUD + 예약처 연결 | `({ isAdmin }) => JSX` | isAdmin | JSX | alert | **복잡** |
| `handleSelect` (R) | 식당 상세 로드/폼 prefill | `(r) => void` | Restaurant | void | - | 단순 |
| `handleSave` (R) | 식당 업데이트 | `() => Promise<void>` | editForm | void | alert | 단순 |
| `handleDelete` (R) | 식당 삭제 (confirm) | `() => Promise<void>` | - | void | alert | 단순 |
| 벌크 테이블링/캐치테이블 | 미연결 식당 일괄 검색 SSE | inline async | - | void | alert | **복잡** |
| `UsersPanel` | 유저 목록 + 상세 | `() => JSX` | - | JSX | console.error | 단순 |
| `loadUsers` | 페이지별 유저 fetch | `(p) => Promise<void>` | page | void | console.error | 단순 |
| `handleSelectUser` | 유저 상세(찜/리뷰/메모) 병렬 로드 | `(u) => Promise<void>` | AdminUser | void | console.error | 단순 |
| `DaemonPanel` | 데몬 설정/수동 실행 | `({ isAdmin }) => JSX` | isAdmin | JSX | result 메시지 | 단순 |
| `handleSave` (D) | 설정 저장 | `() => Promise<void>` | state | void | result 메시지 | 단순 |
| `handleRunScan` / `handleRunProcess` | 수동 실행 | `() => Promise<void>` | - | void | result 메시지 | 단순 |

> 복잡 기준: SSE 5종(`startBulkStream`, `startRebuildVectors`, `startRemapCuisine`, `startRemapFoods`, 벌크 예약처) 및 각 Panel은 외부 I/O+상태기계+분기 다수 → 별도 `fn-*.md` 설계서 후보.

## 8. 흐름 / 알고리즘
**① 페이지 부트스트랩**
1. `useAuth()` 로딩 중 → "로딩 중..."
2. `!user` → 로그인 안내 + 메인 링크
3. `tab` 상태(`"channels"` 기본) → 해당 패널 렌더, `isAdmin` 전파

**② 영상 벌크 처리 (SSE 패턴 전형)**
```
1. 선택 ids 또는 pending count 확인
2. confirm → setRunning(true), setBulkProgress(초기값)
3. fetch(POST /api/videos/bulk-{transcript|extract}, Authorization)
4. while (chunk = await reader.read()) {
     buf += decode; lines = buf.split("\n"); buf = pop
     for line of lines if line.startsWith("data: "):
       ev = JSON.parse(line.slice(6))
       switch (ev.type) {
         processing → current=index+1, currentTitle
         wait       → waiting=delay
         done       → results.push({title, detail})
         error      → results.push({title, detail, error:true})
         complete   → setRunning(false), load()
       }
   }
5. finally: setRunning(false), load()
```

**③ 정렬/필터/페이지 (영상·식당 공통 패턴)**
- `filtered = videos.filter(predicate)` → `sorted = [...filtered].sort` → `paged = sorted.slice(page*perPage, (page+1)*perPage)`
- 정렬: `sortKey` 동일 시 방향 토글, 다르면 새 키+asc=true

**④ 유저 상세**
- 행 클릭 → 같은 유저면 닫기, 아니면 `Promise.all([favorites, reviews, memos])` 병렬 로드 → 3분할 그리드

**⑤ 데몬 수동 실행**
- 결과 메시지에 "실패"/"API" 포함 여부로 빨강/초록 색상 분기

## 9. 엣지케이스 & 에러 처리
- **권한 없음(읽기 전용)**: 모든 변경 액션 버튼 미렌더 + 입력 `disabled`. 단, 헤더에 "읽기 전용" 뱃지 노출하여 모드를 명시.
- **isLoading 동안 빠른 클릭**: 페이지 자체가 로딩 화면이라 차단됨.
- **SSE 도중 네트워크 끊김**: `while` 루프 종료 → finally에서 `setRunning(false)` + `load()`. 진행률은 마지막 값에 멈춤 (재시도는 사용자 수동).
- **SSE 부분 라인**: `buf`에 미완료 라인 보관, 다음 chunk와 합쳐서 파싱 (`buf = lines.pop()`).
- **JSON.parse 실패**: 개별 라인 try/catch → 무시하고 계속.
- **pending.count === 0**: alert 후 조기 종료.
- **bulk 중복 실행 방지**: 같은 또는 충돌 작업 버튼들에 `disabled={... || ...}` 다중 조건.
- **확인 다이얼로그 취소**: 모든 파괴적 액션은 `confirm()` 우선.
- **테이블링 URL "NONE"**: 검색 완료-결과없음 의미. UI에서 별도 텍스트로 처리.
- **알 수 없는 유저**: `nickname || email || "?"` → 첫글자 대문자 폴백.
- **페이지 인덱스 boundary**: `Math.max(0, p-1)` / `Math.min(totalPages-1, p+1)`.
- **선택 행 삭제 실패 누적**: 카운트하여 마지막에 `${failed}개 삭제 실패` alert.
- **인라인 편집 중 데이터 새로고침**: `setEditingRestIdx(null)` 등으로 리셋.
- **alert/confirm 의존**: 모바일 어드민에서는 alert UX가 거칠지만 어드민 한정으로 허용.

## 10. 테스트 계획
현재 자동화 테스트 없음 (TBD). 권장 구성:
- **단위 (Vitest + RTL)**:
  1. `AdminPage` 인증 가드 — `user=null` / `user.is_admin=false` 분기 렌더
  2. `ChannelsPanel.handleAdd` — 빈 입력 차단, 정상 호출 시 reload
  3. `VideosPanel` 필터/정렬/페이징 순수 로직 (`filteredVideos`/`sortedVideos`/`pagedVideos`) 추출 후 테스트
  4. `toggleSelectAll` — 전체 선택/해제 토글
  5. `statusColor` 매핑
- **통합 (MSW + 가짜 SSE)**:
  - `startBulkStream("transcript")` — SSE 라인 5종 시퀀스 주입 → 진행률 상태 전이 검증
  - 부분 라인 분할(`buf.split("\n")`) 케이스
  - 권한 없는 사용자에 대해 변경 API 호출이 시도되지 않는지
- **E2E (Playwright)**:
  - 관리자 로그인 → 채널 추가 → 스캔 → 영상 상태 변화 확인
  - 식당 상세에서 좌표 수정 후 저장 → 목록 갱신
- **드라이런 전략**: `process.env.NEXT_PUBLIC_API_URL`을 MSW로 가로채 SSE를 ReadableStream으로 모킹.

## 11. 리스크 & 대안 검토
- **단일 파일 2,742 LOC**: 가독성·테스트성 저하. 대안: 탭별 파일 분리(`admin/_components/*Panel.tsx`). 채택 보류 (안정성 우선), ADR 후보.
- **클라이언트 측 필터/정렬/페이징**: 데이터 증가 시 메모리·렌더 비용. 대안: 서버 페이징·정렬. 영상/식당이 수만 건 도달 시 전환 필수.
- **SSE 코드 중복**: 5개 핸들러가 동일 패턴. 대안: `useSSEStream(endpoint, onEvent)` 커스텀 훅. 추후 리팩토링 권장.
- **`localStorage` 직접 접근**: api 레이어 외 4곳에서 토큰을 직접 읽음. 대안: `api.fetchStream()` 헬퍼 도입.
- **alert/confirm UX**: 일관된 토스트/모달 시스템 부재. 어드민 한정이므로 유지.
- **권한 분기 누락 위험**: `isAdmin` 가드를 모든 액션 버튼에 수동으로 분산 → 신규 액션 추가 시 가드 누락 가능. 대안: `<AdminGate>` 래퍼 컴포넌트.
- **타입 캐스팅**: `(res as Record<string, unknown>).filtered` 등 ad-hoc 캐스팅 → 응답 스키마를 타입으로 명세하는 것이 안전.
- **에러 swallowing**: 다수의 `catch { /* ignore */ }` — 운영자 디버깅 어려움. 콘솔 로깅 보강 권장.

## 12. 미해결 질문 (Open Questions)
- 어드민 페이지를 탭별 라우트(`/admin/channels`, `/admin/videos`...)로 쪼개야 하는가? (북마크/딥링크 측면)
- SSE 도중 페이지를 떠나면 진행률이 손실됨 — 서버 측 작업 상태 폴링 API가 필요한가?
- 벌크 작업 동시 실행을 허용해야 할 시나리오가 있는가? (현재는 상호배타)
- 캐치테이블/테이블링 외 추가 예약처(망고플레이트 등)가 들어올 때 어드민 UI 패턴은?
- 권한 모델 확장(편집자/뷰어 등 다단계)이 필요한가? 현재는 admin/non-admin 이진.
- 사용자 삭제·일괄 차단 등 운영 기능은 별도 설계서로 분리할 것인가?
- 어드민 활동 감사 로그(누가 무엇을 언제 변경했는지) 표시는?
