Browse Source

no message

KIM-JINO5 1 month ago
parent
commit
a54a56e18c
96 changed files with 7661 additions and 1571 deletions
  1. 2 1
      .claude/launch.json
  2. 342 0
      .claude/plan/studio-settlement.md
  3. 422 0
      .claude/plan/studio-wallet.md
  4. 1 3
      .vscode/launch.json
  5. 8 0
      .vscode/mcp.json
  6. 1 0
      CLAUDE.md
  7. 94 0
      app/(main)/crew/consent/[sessionID]/page.tsx
  8. 61 0
      app/(main)/crew/consent/[sessionID]/style.scss
  9. 11 2
      app/(main)/watch/[channelSID]/ChatSidebar.tsx
  10. 188 0
      app/(main)/watch/[channelSID]/DonationModal.tsx
  11. 14 3
      app/(main)/watch/[channelSID]/WatchView.tsx
  12. 9 0
      app/(main)/watch/[channelSID]/chat-sidebar.scss
  13. 236 0
      app/(main)/watch/[channelSID]/donation-modal.scss
  14. 19 5
      app/component/Profile.tsx
  15. 21 44
      app/globals.scss
  16. 10 10
      app/remote/[widgetToken]/page.tsx
  17. 0 0
      app/remote/[widgetToken]/style.scss
  18. 43 47
      app/studio/Sidebar.tsx
  19. 176 1
      app/studio/dashboard/page.tsx
  20. 335 4
      app/studio/dashboard/style.scss
  21. 47 11
      app/studio/donation/alert/_components/AlertListPanel.tsx
  22. 55 0
      app/studio/donation/alert/style.scss
  23. 307 0
      app/studio/donation/crew/[id]/_components/CrewMembersTab.tsx
  24. 140 0
      app/studio/donation/crew/[id]/_components/CrewSessionTab.tsx
  25. 105 0
      app/studio/donation/crew/[id]/_components/CrewSettingsTab.tsx
  26. 181 0
      app/studio/donation/crew/[id]/_components/CrewWidgetTab.tsx
  27. 96 0
      app/studio/donation/crew/[id]/page.tsx
  28. 489 0
      app/studio/donation/crew/[id]/style.scss
  29. 75 49
      app/studio/donation/crew/page.tsx
  30. 28 0
      app/studio/donation/crew/style.scss
  31. 371 0
      app/studio/donation/crew/widget/_components/CrewWidgetFormPanel.tsx
  32. 127 0
      app/studio/donation/crew/widget/_components/CrewWidgetListPanel.tsx
  33. 112 0
      app/studio/donation/crew/widget/_components/CrewWidgetPreviewPanel.tsx
  34. 30 0
      app/studio/donation/crew/widget/add/page.tsx
  35. 35 0
      app/studio/donation/crew/widget/constants.ts
  36. 24 0
      app/studio/donation/crew/widget/context.tsx
  37. 44 0
      app/studio/donation/crew/widget/edit/[id]/page.tsx
  38. 42 0
      app/studio/donation/crew/widget/layout.tsx
  39. 8 0
      app/studio/donation/crew/widget/list/page.tsx
  40. 15 0
      app/studio/donation/crew/widget/page.tsx
  41. 467 0
      app/studio/donation/crew/widget/style.scss
  42. 61 0
      app/studio/donation/crew/widget/types.ts
  43. 1 1
      app/studio/donation/goal/_components/GoalListPanel.tsx
  44. 1 1
      app/studio/donation/rank/_components/RankListPanel.tsx
  45. 1 1
      app/studio/settings/page.tsx
  46. 273 0
      app/studio/settlement/account/page.tsx
  47. 37 0
      app/studio/settlement/constants.ts
  48. 5 0
      app/studio/settlement/layout.tsx
  49. 0 8
      app/studio/settlement/page.tsx
  50. 417 0
      app/studio/settlement/style.scss
  51. 156 0
      app/studio/settlement/tax/page.tsx
  52. 4 2
      app/studio/style.scss
  53. 105 0
      app/studio/wallet/balance/page.tsx
  54. 31 0
      app/studio/wallet/constants.ts
  55. 5 0
      app/studio/wallet/layout.tsx
  56. 209 0
      app/studio/wallet/revenue/page.tsx
  57. 482 0
      app/studio/wallet/style.scss
  58. 265 0
      app/studio/wallet/withdraw/page.tsx
  59. 17 11
      app/styles/profile.scss
  60. 0 67
      app/styles/quill.scss
  61. 3 3
      app/widget/crew/[widgetToken]/page.tsx
  62. 0 0
      app/widget/crew/[widgetToken]/style.scss
  63. 3 3
      app/widget/goal/[widgetToken]/page.tsx
  64. 0 0
      app/widget/goal/[widgetToken]/style.scss
  65. 3 3
      app/widget/rank/[widgetToken]/page.tsx
  66. 0 0
      app/widget/rank/[widgetToken]/style.scss
  67. 0 57
      components/ui/accordion.tsx
  68. 8 8
      components/ui/avatar.tsx
  69. 11 4
      components/ui/button.tsx
  70. 8 7
      components/ui/checkbox.tsx
  71. 14 4
      components/ui/collapsible.tsx
  72. 51 28
      components/ui/dialog.tsx
  73. 115 80
      components/ui/dropdown-menu.tsx
  74. 1 1
      components/ui/input.tsx
  75. 5 6
      components/ui/label.tsx
  76. 32 78
      components/ui/select.tsx
  77. 11 10
      components/ui/separator.tsx
  78. 23 23
      components/ui/sheet.tsx
  79. 15 7
      components/ui/sidebar.tsx
  80. 1 1
      components/ui/textarea.tsx
  81. 21 16
      components/ui/tooltip.tsx
  82. 32 2
      contexts/signalrProvider.tsx
  83. 4 4
      hooks/useDonationHub.ts
  84. 172 922
      package-lock.json
  85. 3 11
      package.json
  86. 2 22
      tailwind.config.ts
  87. 28 0
      types/response/crew/member.ts
  88. 42 0
      types/response/crew/session.ts
  89. 34 0
      types/response/crew/widgetConfig.ts
  90. 21 0
      types/response/settlement/account.ts
  91. 19 0
      types/response/settlement/tax.ts
  92. 39 0
      types/response/studio/dashboard.ts
  93. 16 0
      types/response/wallet/balance.ts
  94. 28 0
      types/response/wallet/revenue.ts
  95. 31 0
      types/response/wallet/withdraw.ts
  96. 4 0
      types/scss.d.ts

+ 2 - 1
.claude/launch.json

@@ -5,7 +5,8 @@
       "name": "frontend",
       "runtimeExecutable": "npm",
       "runtimeArgs": ["run", "dev"],
-      "port": 3000
+      "port": 3000,
+      "autoPort": false
     }
   ]
 }

+ 342 - 0
.claude/plan/studio-settlement.md

@@ -0,0 +1,342 @@
+# Implementation Plan: Studio Settlement (정산) Pages
+
+## Task Type
+- [x] Frontend
+- [ ] Backend
+- [ ] Fullstack
+
+---
+
+## Research Summary: "세금계산서" 는 잘못된 명칭
+
+### 핵심 발견
+**세금계산서(Tax Invoice)는 부가세(VAT) 문서로, 3.3% 원천징수 모델에서는 해당 없음.**
+
+현재 dpot의 크리에이터 정산 방식:
+- 크리에이터 = 비사업자 프리랜서 → **사업소득 3.3% 원천징수** (소득세 3% + 지방소득세 0.3%)
+- **플랫폼**이 원천징수의무자로서 3.3%를 원천징수 후 국세청에 납부
+- **플랫폼**이 매년 3월 10일까지 **사업소득 지급명세서**를 국세청에 제출
+- **크리에이터**는 5월에 **종합소득세** 신고 시 기납부세액으로 처리
+
+### 세금계산서가 필요한 경우 (현재 해당 없음)
+- 과세사업자(921505) 크리에이터 → 크리에이터가 플랫폼에 세금계산서 발행 + 10% VAT 추가 지급
+- 면세사업자(940306) 크리에이터 → 크리에이터가 플랫폼에 계산서(VAT 없음) 발행
+- **→ 추후 사업자 크리에이터 지원 시 확장 가능하도록 설계**
+
+### 한국 플랫폼 사례
+| 플랫폼 | 세금 관련 문서 제공 방식 |
+|--------|------------------------|
+| CHZZK | 정산내역에서 월별 내역 확인, 원천징수영수증은 홈택스에서 조회 |
+| SOOP | 원천징수영수증 직접 미제공, 지급명세서 국세청 제출 후 홈택스에서 조회 |
+| 투네이션 | 월별 정산 내역만 제공, 원천징수영수증은 홈택스에서 5월 이후 조회 안내 |
+
+### 결론: 메뉴명 변경 권장
+- ~~세금계산서~~ → **"원천징수 내역"** 또는 **"세금 서류"**
+- 실제 내용: 월별 원천징수 내역 + 연간 요약 + 종합소득세 신고 안내
+
+---
+
+## Design Decision
+
+### 메뉴 구조 (사이드바 수정)
+| 현재 | 변경 후 | 경로 |
+|------|---------|------|
+| 계좌 관리 | 계좌 관리 (유지) | `/studio/settlement/account` |
+| 세금계산서 | **원천징수 내역** | `/studio/settlement/tax` |
+
+### 2-Page Structure
+
+**1. 계좌 관리** (`/studio/settlement/account`)
+- 출금 계좌 등록/수정 폼
+- 은행 선택, 계좌번호, 예금주 입력
+- 등록 상태 표시 (미등록/인증완료)
+- 1원 인증은 Backend 의존 → 우선 기본 폼만 구현
+
+**2. 원천징수 내역** (`/studio/settlement/tax`)
+- 연간 요약 카드 (총 지급액, 소득세, 지방소득세)
+- 월별 원천징수 내역 테이블
+- 종합소득세 신고 안내 가이드
+
+---
+
+## Implementation Steps
+
+### Step 1: Type Definitions
+
+**File: `types/response/settlement/account.ts`** (NEW)
+```typescript
+export interface SettlementAccountResponse {
+  hasAccount: boolean;
+  bankCode: string|null;       // '004' (KB), '088' (신한) 등
+  bankName: string|null;
+  accountNumber: string|null;  // 마스킹: '****5678'
+  accountHolder: string|null;
+  isVerified: boolean;
+  registeredAt: string|null;
+  updatedAt: string|null;
+}
+
+export interface SaveAccountRequest {
+  bankCode: string;
+  accountNumber: string;
+  accountHolder: string;
+}
+```
+
+**File: `types/response/settlement/tax.ts`** (NEW)
+```typescript
+export interface WithholdingTaxSummaryResponse {
+  year: number;
+  annualSummary: {
+    totalGrossAmount: number;     // 연간 총 지급액
+    totalIncomeTax: number;       // 연간 소득세 (3%)
+    totalLocalTax: number;        // 연간 지방소득세 (0.3%)
+    totalNetAmount: number;       // 연간 실수령액
+  };
+  monthlyList: WithholdingTaxMonthItem[];
+}
+
+export interface WithholdingTaxMonthItem {
+  month: number;                  // 1~12
+  grossAmount: number;            // 해당월 총 지급액
+  incomeTax: number;              // 소득세 (3%)
+  localTax: number;               // 지방소득세 (0.3%)
+  netAmount: number;              // 실수령액
+  paymentCount: number;           // 정산 건수
+}
+```
+
+---
+
+### Step 2: Constants
+
+**File: `app/studio/settlement/constants.ts`** (NEW)
+```typescript
+export const BANK_LIST = [
+  { code: '004', name: 'KB국민은행' },
+  { code: '088', name: '신한은행' },
+  { code: '020', name: '우리은행' },
+  { code: '081', name: '하나은행' },
+  { code: '011', name: 'NH농협은행' },
+  { code: '023', name: 'SC제일은행' },
+  { code: '027', name: '한국씨티은행' },
+  { code: '071', name: '우체국' },
+  { code: '031', name: 'DGB대구은행' },
+  { code: '032', name: '부산은행' },
+  { code: '039', name: '경남은행' },
+  { code: '034', name: '광주은행' },
+  { code: '035', name: '제주은행' },
+  { code: '037', name: '전북은행' },
+  { code: '007', name: '수협은행' },
+  { code: '045', name: '새마을금고' },
+  { code: '048', name: '신협' },
+  { code: '090', name: '카카오뱅크' },
+  { code: '092', name: '토스뱅크' },
+  { code: '089', name: '케이뱅크' },
+];
+```
+
+---
+
+### Step 3: Layout & Style
+
+**File: `app/studio/settlement/layout.tsx`** (NEW)
+- children 패스스루 + style.scss import
+
+**File: `app/studio/settlement/style.scss`** (NEW)
+- BEM Block: `.settlement`
+- 기존 wallet 스타일 패턴 재사용
+
+---
+
+### Step 4: 계좌 관리 페이지
+
+**File: `app/studio/settlement/account/page.tsx`** (NEW)
+
+**Layout:**
+```
+┌─────────────────────────────────────────────────┐
+│  계좌 관리                                       │
+├─────────────────────────────────────────────────┤
+│                                                  │
+│  ┌─ 등록된 계좌 ────────────────────────┐       │
+│  │ KB국민은행 ****5678 (홍길동)          │       │
+│  │ ✓ 인증 완료 · 등록일 2026.03.15      │       │
+│  │ [수정하기]                           │       │
+│  └──────────────────────────────────────┘       │
+│                                                  │
+│  ── OR (미등록 시) ──                            │
+│                                                  │
+│  ┌─ 계좌 등록 ──────────────────────────┐       │
+│  │ 은행    [KB국민은행 ▾]               │       │
+│  │ 계좌번호 [__________________]         │       │
+│  │ 예금주   [__________________]         │       │
+│  │                                      │       │
+│  │ ※ 본인 명의 계좌만 등록 가능           │       │
+│  │ ※ 출금 시 등록된 계좌로 입금됩니다      │       │
+│  │                                      │       │
+│  │ [등록하기]                            │       │
+│  └──────────────────────────────────────┘       │
+│                                                  │
+└─────────────────────────────────────────────────┘
+```
+
+**구현:**
+- 상태: `mode` ('view' | 'edit') — 등록됨이면 view, 미등록이면 edit
+- 등록된 계좌 표시: 은행명, 마스킹 계좌번호, 예금주, 인증상태, 등록일
+- 수정 모드: 은행 select, 계좌번호 input, 예금주 input
+- Validation: 계좌번호 숫자만, 7~16자리
+- API: `GET /api/studio/settlement/account`
+- API: `POST /api/studio/settlement/account`
+
+---
+
+### Step 5: 원천징수 내역 페이지
+
+**File: `app/studio/settlement/tax/page.tsx`** (NEW)
+
+**Layout:**
+```
+┌─────────────────────────────────────────────────┐
+│  원천징수 내역                                    │
+├─────────────────────────────────────────────────┤
+│  연도 선택: [2026 ▾]                              │
+├───────────┬───────────┬───────────┬──────────────┤
+│ 총 지급액 │ 소득세(3%)│지방세(0.3%)│ 실수령액     │
+│ 1,520,000 │ -45,600  │ -4,560    │ 1,469,840    │
+├───────────┴───────────┴───────────┴──────────────┤
+│                                                  │
+│  월별 상세                                        │
+├──────────────────────────────────────────────────┤
+│ 월 | 지급액 | 소득세 | 지방소득세 | 실수령 | 건수  │
+│ 4  | 129,000 | -3,870 | -387    | 124,743 | 8   │
+│ 3  | 250,000 | -7,500 | -750    | 241,750 | 15  │
+│ ...                                              │
+├──────────────────────────────────────────────────┤
+│                                                  │
+│  ℹ 종합소득세 신고 안내                             │
+│  ┌──────────────────────────────────────────┐   │
+│  │ · 본 소득은 사업소득으로 분류됩니다.        │   │
+│  │ · 연말정산 대상이 아닙니다.                 │   │
+│  │ · 매년 5월 종합소득세를 직접 신고해야 합니다. │   │
+│  │ · 국세청 홈택스에서 지급명세서를 확인할 수    │   │
+│  │   있습니다. (My홈택스 > 지급명세서 제출내역)  │   │
+│  │ · 플랫폼은 매년 3/10까지 지급명세서를 국세청  │   │
+│  │   에 제출합니다.                           │   │
+│  └──────────────────────────────────────────┘   │
+│                                                  │
+└──────────────────────────────────────────────────┘
+```
+
+**구현:**
+- 연도 선택 드롭다운 (현재 연도 기본)
+- 연간 요약 카드 4개: 총 지급액, 소득세, 지방소득세, 실수령액
+- 월별 테이블 (12개월, 역순 — 최신 월 상단)
+- 종합소득세 신고 안내 정보 박스
+- API: `GET /api/studio/settlement/tax?year={year}`
+
+---
+
+### Step 6: 사이드바 메뉴명 수정
+
+**File: `app/studio/Sidebar.tsx`** (MODIFY)
+- `세금계산서` → `원천징수 내역` 으로 텍스트만 변경
+
+---
+
+## Key Files
+
+| File | Operation | Description |
+|------|-----------|-------------|
+| `types/response/settlement/account.ts` | **Create** | 계좌 관리 타입 |
+| `types/response/settlement/tax.ts` | **Create** | 원천징수 내역 타입 |
+| `app/studio/settlement/constants.ts` | **Create** | 은행 목록 등 상수 |
+| `app/studio/settlement/layout.tsx` | **Create** | style import 레이아웃 |
+| `app/studio/settlement/style.scss` | **Create** | 전체 settlement 스타일 |
+| `app/studio/settlement/account/page.tsx` | **Create** | 계좌 관리 페이지 |
+| `app/studio/settlement/tax/page.tsx` | **Create** | 원천징수 내역 페이지 |
+| `app/studio/settlement/mock.ts` | **Create** | Mock 데이터 |
+| `app/studio/settlement/page.tsx` | **Delete** | 기존 플레이스홀더 삭제 |
+| `app/studio/Sidebar.tsx:L343` | **Modify** | '세금계산서' → '원천징수 내역' |
+
+---
+
+## Risks and Mitigation
+
+| Risk | Mitigation |
+|------|------------|
+| Backend API 미존재 | Mock 데이터로 UI 구현, API 후속 연동 |
+| 1원 인증 미구현 | 기본 계좌 등록 폼만 우선 구현, 1원 인증은 Backend 연동 시 추가 |
+| 사업자 크리에이터 지원 | 현재는 개인(3.3%) 전용, 타입에 사업자 유형 필드 예약 |
+| 원천징수 금액 정확성 | Frontend는 표시만, 계산은 Backend 담당 |
+
+---
+
+## Implementation Order
+
+1. Types + Constants (의존성 없음)
+2. Layout + Style (공통 기반)
+3. Mock 데이터
+4. Account 페이지 (출금 페이지에서 이미 링크됨 — 우선순위 높음)
+5. Tax 페이지 (정보 표시 위주)
+6. Sidebar 텍스트 수정
+7. 기존 placeholder 삭제
+
+---
+
+## 사업자 유형별 세금 처리 (Research)
+
+### 크리에이터 유형 분류
+
+| 유형 | 원천징수 3.3% | VAT 10% | 세금계산서 | 정산 금액 |
+|------|:---:|:---:|:---:|------|
+| 개인 비사업자 | O | X | 발행 불가 | 공급가액 - 3.3% |
+| 면세 개인사업자 (940306) | O | X | 계산서(면세) | 공급가액 - 3.3% |
+| 과세 개인사업자 (921505) | X | O | 세금계산서 필수 | 공급가액 + VAT 10% |
+| 법인사업자 | X | O | 세금계산서 필수 | 공급가액 + VAT 10% |
+
+### 유형별 상세
+
+**1. 개인 비사업자 (현재 기본)**
+- 주민등록번호 + 계좌만 필요
+- 플랫폼이 3.3% 원천징수 → 국세청 납부 + 지급명세서 제출
+- 크리에이터: 5월 종합소득세 신고
+
+**2. 면세 개인사업자 (940306: 1인 미디어 콘텐츠 창작자)**
+- 사업자등록번호 필요
+- 원천징수 3.3% 동일 적용 (면세 인적용역이므로)
+- 비사업자와 정산 흐름 동일하나, 경비처리/세액감면 가능
+- 2월: 사업장현황신고 / 5월: 종합소득세 신고
+
+**3. 과세 개인사업자 (921505: 미디어콘텐츠창작업)**
+- 사업자등록번호 필요
+- 원천징수 없음 → 크리에이터가 세금계산서 발행
+- 플랫폼은 공급가액 + VAT 10% 지급
+- 크리에이터: 부가세 신고(반기) + 종합소득세(5월)
+- CHZZK 사례: 역발행(플랫폼이 초안 생성, 크리에이터 승인) 방식
+
+**4. 법인사업자**
+- 법인 사업자등록번호 필요
+- 과세 개인사업자와 동일 흐름 (세금계산서 + VAT)
+- 법인세(10~25%) 적용, 종합소득세 대상 아님
+- 전자세금계산서 의무 발행 (매출 무관)
+
+### 현재 구현 범위
+- **V1 (현재)**: 개인 비사업자 전용 — 3.3% 원천징수 내역만 표시
+- **V2 (추후)**: 크리에이터 유형 선택 UI + 사업자등록번호 입력 + 유형별 정산 분기
+
+### V2 확장 시 필요한 UI 변경
+1. 설정에 크리에이터 유형 선택: 개인/면세사업자/과세사업자/법인
+2. 사업자등록번호 입력 + 국세청 API 상태조회
+3. 과세/법인: 세금계산서 역발행 워크플로우 (월 마감 → 초안 생성 → 승인)
+4. 원천징수 내역 페이지: 유형에 따라 표시 내용 분기
+   - 개인/면세: 현재와 동일 (3.3% 원천징수)
+   - 과세/법인: 세금계산서 발행내역 + VAT 내역
+
+---
+
+## Notes
+- **세금계산서 → 원천징수 내역**: 기술적으로 정확한 명칭으로 변경 완료
+- 공식 원천징수영수증은 국세청 홈택스에서 조회 — 플랫폼은 참고용 내역만 제공
+- 계좌 등록은 출금 기능의 전제 조건 → 출금 페이지에서 이미 `/studio/settlement/account` 링크
+- SESSION_ID: N/A (external model wrapper 미사용)

+ 422 - 0
.claude/plan/studio-wallet.md

@@ -0,0 +1,422 @@
+# Implementation Plan: Studio Wallet (지갑) Pages
+
+## Task Type
+- [x] Frontend
+- [ ] Backend (API endpoints assumed to exist or will be created separately)
+- [ ] Fullstack
+
+---
+
+## Research Summary
+
+### Korean Creator Platform Patterns (CHZZK, SOOP, Toonation, YouTube)
+- All major platforms use **separate sub-pages** under a "Studio" hub: 수익 요약 / 후원 내역 / 정산 내역 / 정산 정보
+- CHZZK: 수익창출 → 정산내역, 정산정보, 후원내역, 구독내역, 광고수익
+- SOOP: 별풍선 환전, 광고수익 환전, 구독수익 (각각 별도 플로우)
+- Toonation: 대시보드(잔액) + 정산등록 + 정산신청(별도)
+
+### Tax & Fee Structure (Korean regulations)
+- **원천징수 3.3%** (소득세 3% + 지방소득세 0.3%) — 사업소득 기준
+- **부가세**: 물적시설 없는 1인 크리에이터 = 면세사업자 (VAT 없음)
+- **플랫폼 수수료**: 설정값으로 관리 (backend config, 추후 결정)
+
+### Design Decision: 3-Page Structure
+현재 사이드바에 이미 정의된 3개 메뉴를 그대로 유지:
+
+| 메뉴 | 경로 | 역할 |
+|------|------|------|
+| **잔액** | `/studio/wallet/balance` | 현재 잔액 요약 + 최근 내역 |
+| **수익** | `/studio/wallet/revenue` | 후원 수입 상세 내역 (기간별) |
+| **출금** | `/studio/wallet/withdraw` | 출금 신청 + 출금 내역 |
+
+---
+
+## Technical Solution
+
+### Architecture
+- 각 페이지는 `'use client'` 클라이언트 컴포넌트
+- `fetchApi<T>()` → `/api/studio/wallet/[...path]` Route Handler → Backend
+- 기존 `charge-logs` 패턴 기반 (상태 관리, 기간 필터, 페이지네이션)
+- BEM 클래스 네이밍, studio-page 공통 스타일 활용
+- 반응형: PC 그리드 테이블 + 모바일 DL 레이아웃
+
+### Balance Data Model (DropdownData 확장)
+기존 `DropdownData`의 `spendableBalance`(P), `withdrawableBalance`(M) 활용.
+각 페이지별 전용 API 응답 타입 신규 생성.
+
+---
+
+## Implementation Steps
+
+### Step 1: Type Definitions
+
+**File: `types/response/wallet/balance.ts`** (NEW)
+```typescript
+export interface WalletBalanceResponse {
+  spendableBalance: number;       // 포인트 (P) 잔액
+  withdrawableBalance: number;    // 머니 (M) 잔액
+  totalEarned: number;            // 누적 수익
+  totalWithdrawn: number;         // 누적 출금
+  pendingWithdrawal: number;      // 출금 대기 중 금액
+  recentTransactions: {
+    id: number;
+    type: 'donation_received'|'withdrawal'|'fee'|'adjustment';
+    amount: number;
+    balance: number;
+    description: string;
+    createdAt: string;
+  }[];
+}
+```
+
+**File: `types/response/wallet/revenue.ts`** (NEW)
+```typescript
+export interface WalletRevenueResponse {
+  total: number;
+  summary: {
+    grossAmount: number;         // 총 후원 금액
+    platformFee: number;         // 플랫폼 수수료
+    netAmount: number;           // 순수익 (수수료 차감)
+  };
+  list: {
+    id: number;
+    donorName: string;
+    donorSID: string|null;
+    grossAmount: number;
+    platformFee: number;
+    netAmount: number;
+    type: 'donation'|'crew_donation';
+    crewName: string|null;
+    createdAt: string;
+  }[];
+}
+```
+
+**File: `types/response/wallet/withdraw.ts`** (NEW)
+```typescript
+export interface WalletWithdrawHistoryResponse {
+  total: number;
+  withdrawableBalance: number;
+  hasBankAccount: boolean;
+  list: {
+    id: number;
+    requestedAmount: number;     // 신청 금액
+    withholdingTax: number;      // 원천징수 (3.3%)
+    netAmount: number;           // 실수령액
+    status: 'Pending'|'Processing'|'Completed'|'Rejected';
+    bankName: string;
+    accountNumber: string;       // 마스킹 처리 (뒤 4자리만)
+    requestedAt: string;
+    completedAt: string|null;
+    rejectedReason: string|null;
+  }[];
+}
+
+export interface WithdrawRequest {
+  amount: number;
+}
+```
+
+---
+
+### Step 2: Shared Layout & Constants
+
+**File: `app/studio/wallet/layout.tsx`** (NEW)
+```typescript
+// 단순 children 패스스루 + style import
+import './style.scss';
+export default function WalletLayout({ children }) {
+  return children;
+}
+```
+
+**File: `app/studio/wallet/constants.ts`** (NEW)
+```typescript
+export const PERIOD_TABS = [
+  { label: '오늘', value: 0 },
+  { label: '1주일', value: 1 },
+  { label: '1개월', value: 2 },
+  { label: '3개월', value: 3 },
+  { label: '6개월', value: 4 },
+];
+
+export const WITHDRAW_STATUS_MAP: Record<string, { label: string; cls: string }> = {
+  Pending:    { label: '대기',   cls: 'status--pending' },
+  Processing: { label: '처리중', cls: 'status--processing' },
+  Completed:  { label: '완료',   cls: 'status--completed' },
+  Rejected:   { label: '거절',   cls: 'status--rejected' },
+};
+
+export const REVENUE_TYPE_MAP: Record<string, string> = {
+  donation: '후원',
+  crew_donation: '크루 후원',
+};
+
+export const WITHHOLDING_TAX_RATE = 0.033; // 3.3%
+export const MIN_WITHDRAW_AMOUNT = 40000;  // 최소 출금 금액
+```
+
+---
+
+### Step 3: Balance Page (잔액)
+
+**File: `app/studio/wallet/balance/page.tsx`** (NEW)
+
+**Layout:**
+```
+┌─────────────────────────────────────────────┐
+│  잔액 현황                                   │
+├───────────┬───────────┬────────────────────────┤
+│  머니(M)  │ 누적수익  │ 누적출금               │
+│  80,000   │ 350,000   │ 270,000                │
+├───────────┴───────────┴────────────────────────┤
+│                           [출금하기]            │
+├─────────────────────────────────────────────────┤
+│  최근 거래 내역                                  │
+│  일시 | 유형 | 내용 | 금액 | 잔액                │
+│  ...                                            │
+│  ...                                            │
+└─────────────────────────────────────────────────┘
+```
+
+**구현 내용:**
+- 3개 Summary Card: M잔액(출금 가능), 누적수익, 누적출금
+- 빠른 액션: 출금하기 (`/studio/wallet/withdraw` 이동)
+- 최근 거래 10건 (페이지네이션 없음, 간략 리스트)
+- API: `GET /api/studio/wallet/balance`
+
+---
+
+### Step 4: Revenue Page (수익)
+
+**의존성 추가:** `npm install recharts` (React 친화적 차트 라이브러리, ~45KB gzip)
+
+**File: `app/studio/wallet/revenue/page.tsx`** (NEW)
+
+**Layout:**
+```
+┌─────────────────────────────────────────────────┐
+│  수익 내역                                       │
+├────────────────┬────────────────┬─────────────────┤
+│ 총 후원 금액   │ 플랫폼 수수료  │ 순수익          │
+│ 500,000원      │ -50,000원      │ 450,000원       │
+├───────────────────────────────────────────────────┤
+│  [오늘] [1주일] [1개월] [3개월] [6개월]            │
+├───────────────────────────────────────────────────┤
+│  수익 추이 (AreaChart)                            │
+│  ┌─────────────────────────────────────────────┐ │
+│  │    ╱\                                       │ │
+│  │   ╱  \      ╱\   ╱\                        │ │
+│  │  ╱    \    ╱  ╱    \                       │ │
+│  │ ╱      \  ╱  ╱      ───                    │ │
+│  │╱        ╲╱  ╱                               │ │
+│  └──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┘ │
+│     4/1 4/2 4/3 4/4 ...                         │
+│  ■ 순수익  ■ 수수료                              │
+├───────────────────────────────────────────────────┤
+│  합계: 128                                        │
+├───────────────────────────────────────────────────┤
+│  일시 | 후원자 | 유형 | 후원금액 | 수수료 | 순수익  │
+│  04.14 | 유저A | 후원 | 10,000  | -1,000 | 9,000  │
+│  ...                                              │
+├───────────────────────────────────────────────────┤
+│  < 1 2 3 4 5 >                                    │
+└───────────────────────────────────────────────────┘
+```
+
+**차트 구현:**
+- `recharts`의 `AreaChart` + `ResponsiveContainer` 사용
+- X축: 날짜 (기간별 자동 그룹핑 — 일간/주간/월간)
+- Y축: 금액 (toLocaleString 포맷)
+- Area 2개: 순수익 (primary color, fill opacity 0.3), 수수료 (muted color, fill opacity 0.15)
+- Tooltip: 날짜 + 순수익 + 수수료 금액 표시
+- 높이 고정 280px, 반응형 너비
+- API: `GET /api/studio/wallet/revenue/chart?type={period}` (차트용 집계 데이터)
+
+**차트 응답 타입 추가 (`types/response/wallet/revenue.ts`):**
+```typescript
+export interface RevenueChartItem {
+  date: string;           // '2026-04-01'
+  grossAmount: number;
+  platformFee: number;
+  netAmount: number;
+}
+```
+
+**구현 내용:**
+- Summary 3칸: 총 후원 금액, 플랫폼 수수료, 순수익 (선택 기간 기준)
+- 기간 필터 탭 (charge-logs와 동일 패턴)
+- 수익 추이 차트 (AreaChart — 기간 필터 연동)
+- 테이블: 일시, 후원자, 유형(후원/크루후원), 후원금액, 수수료, 순수익
+- 페이지네이션 (20건/페이지)
+- 반응형: PC grid + Mobile dl (차트는 모바일에서 높이 200px로 축소)
+- API: `GET /api/studio/wallet/revenue?type={period}&page={n}&perPage=20`
+
+---
+
+### Step 5: Withdrawal Page (출금)
+
+**File: `app/studio/wallet/withdraw/page.tsx`** (NEW)
+
+**Layout:**
+```
+┌─────────────────────────────────────────────────┐
+│  출금                                            │
+├─────────────────────────────────────────────────┤
+│  출금 가능 잔액: 80,000원 (M)                    │
+│                                                  │
+│  출금 금액  [_______________] 원                  │
+│                                                  │
+│  ┌─ 예상 차감 ──────────────────┐               │
+│  │ 신청 금액      80,000원     │               │
+│  │ 원천징수(3.3%) -2,640원     │               │
+│  │ 실수령액       77,360원     │               │
+│  └─────────────────────────────┘               │
+│                                                  │
+│  입금 계좌: 국민은행 ****5678 (홍길동)           │
+│  [계좌 변경 →]                                   │
+│                                                  │
+│  ※ 최소 출금 금액: 40,000원                      │
+│  ※ 원천징수 3.3% (소득세 3% + 지방소득세 0.3%)   │
+│  ※ 매월 10일까지 신청 → 당월 말 입금              │
+│                                                  │
+│  [출금 신청]                                     │
+├─────────────────────────────────────────────────┤
+│  출금 내역                                       │
+│  합계: 15     [오늘] [1주일] [1개월] [3개월] [6개월] │
+├─────────────────────────────────────────────────┤
+│  신청일 | 금액 | 원천징수 | 실수령 | 계좌 | 상태  │
+│  ...                                             │
+├─────────────────────────────────────────────────┤
+│  < 1 2 3 4 5 >                                   │
+└─────────────────────────────────────────────────┘
+```
+
+**구현 내용:**
+- 상단: 출금 가능 잔액 강조 표시
+- 출금 신청 폼: 금액 입력 → 실시간 원천징수/실수령액 계산 프리뷰
+- 계좌 정보 표시 (마스킹) + 정산 페이지 링크
+- 안내 문구 (최소 금액, 세금, 일정)
+- 계좌 미등록 시: 경고 + 계좌 등록 유도
+- 하단: 출금 내역 테이블 (기간 필터 + 페이지네이션)
+- API: `GET /api/studio/wallet/withdraw?type={period}&page={n}&perPage=20`
+- API: `POST /api/studio/wallet/withdraw` (출금 신청)
+
+---
+
+### Step 6: SCSS Styling
+
+**File: `app/studio/wallet/style.scss`** (NEW)
+
+**BEM Block: `.wallet`**
+
+```scss
+// ── Summary Cards ──
+.wallet__cards            // display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px;
+.wallet__card             // border, radius 8px, padding 20px
+.wallet__card-label       // 0.8125rem, muted color
+.wallet__card-value       // 1.5rem, font-weight 700
+.wallet__card-value--point   // P color
+.wallet__card-value--money   // M color (brand)
+.wallet__card-value--danger  // negative amounts
+
+// ── Actions ──
+.wallet__actions          // flex, gap 8px, justify center
+.wallet__action-btn       // studio-page__btn 스타일 재사용
+
+// ── Filter ──
+.wallet__header           // flex, space-between (charge-logs__header 패턴)
+.wallet__summary          // 합계 텍스트
+.wallet__tabs             // flex, button 탭 (charge-logs__tabs 패턴)
+
+// ── Table ──
+.wallet__table-wrap       // studio-page__table-wrap 재사용
+.wallet__table            // studio-page__table 재사용 + 커스텀 컬럼
+.wallet__amount--plus     // color success
+.wallet__amount--minus    // color danger
+
+// ── Withdraw Form ──
+.wallet__withdraw-form    // border, radius 8px, padding 24px, max-width 560px
+.wallet__withdraw-balance // 잔액 강조 (1.25rem, font-weight 600)
+.wallet__withdraw-field   // label + input 그룹
+.wallet__withdraw-input   // crew-widget-form__input 패턴
+.wallet__withdraw-preview // 차감 프리뷰 박스 (muted bg, border)
+.wallet__withdraw-row     // flex, justify space-between
+.wallet__withdraw-total   // font-weight 700, border-top
+.wallet__withdraw-info    // 안내문 (0.8125rem, muted)
+.wallet__withdraw-account // 계좌 정보 표시
+.wallet__withdraw-submit  // studio-page__btn--primary
+
+// ── Status Badges ──
+.wallet__status--pending     // color warning
+.wallet__status--processing  // color primary
+.wallet__status--completed   // color success
+.wallet__status--rejected    // color danger
+
+// ── Responsive ──
+@media (max-width: 768px)
+  .wallet__cards: grid 2 columns
+  Table: hide ol, show dl
+```
+
+---
+
+### Step 7: API Route Handler
+
+**File: `app/api/studio/wallet/[...path]/route.ts`** (NEW — 또는 기존 `/api/studio/[...path]` 활용)
+
+기존 `app/api/studio/[...path]/route.ts` 프록시가 있으면 별도 생성 불필요.
+없으면 동일한 프록시 패턴으로 생성:
+
+```typescript
+// GET/POST → fetchJson('/api/studio/wallet/{path}')
+```
+
+---
+
+## Key Files
+
+| File | Operation | Description |
+|------|-----------|-------------|
+| `types/response/wallet/balance.ts` | **Create** | 잔액 API 응답 타입 |
+| `types/response/wallet/revenue.ts` | **Create** | 수익 API 응답 타입 |
+| `types/response/wallet/withdraw.ts` | **Create** | 출금 요청/응답 타입 |
+| `app/studio/wallet/layout.tsx` | **Create** | style.scss import용 레이아웃 |
+| `app/studio/wallet/constants.ts` | **Create** | 공통 상수 (탭, 상태맵, 세율) |
+| `app/studio/wallet/style.scss` | **Create** | 전체 wallet 스타일 |
+| `app/studio/wallet/balance/page.tsx` | **Create** | 잔액 현황 페이지 |
+| `app/studio/wallet/revenue/page.tsx` | **Create** | 수익 내역 페이지 |
+| `app/studio/wallet/withdraw/page.tsx` | **Create** | 출금 신청 + 내역 페이지 |
+| `app/api/studio/wallet/[...path]/route.ts` | **Create** (필요 시) | API 프록시 |
+| `app/studio/Sidebar.tsx` | None | 이미 3개 메뉴 정의됨 |
+
+---
+
+## Risks and Mitigation
+
+| Risk | Mitigation |
+|------|------------|
+| Backend API 미존재 | Mock 데이터로 UI 먼저 구현, API 연동은 후속 작업 |
+| 플랫폼 수수료율 미확정 | `constants.ts`에 변수화, backend config에서 받아오도록 설계 |
+| 원천징수 계산 정확성 | Frontend는 프리뷰만 표시, 실제 계산은 Backend에서 수행 |
+| 계좌 등록 미구현 | 출금 페이지에서 정산 → 계좌관리 페이지로 유도 |
+| 충전 팝업 연동 | 기존 `/charge` 페이지 팝업 패턴 그대로 활용 |
+
+---
+
+## Implementation Order
+
+1. **Types** → constants 먼저 (의존성 없음)
+2. **layout.tsx** + **style.scss** (공통 기반)
+3. **balance/page.tsx** (가장 단순, 전체 구조 검증)
+4. **revenue/page.tsx** (charge-logs 패턴 복사 + 커스터마이즈)
+5. **withdraw/page.tsx** (폼 + 리스트 복합, 가장 복잡)
+6. **API route handler** (필요 시)
+
+---
+
+## Notes
+- Backend API 스펙은 별도 협의 필요 (이 계획은 Frontend만 다룸)
+- 정산 메뉴 (계좌관리, 세금계산서)는 별도 계획으로 분리
+- 출금 신청 시 confirm dialog 필수 (금액 + 실수령액 재확인)
+- SESSION_ID: N/A (external model wrapper 미사용)

+ 1 - 3
.vscode/launch.json

@@ -9,12 +9,10 @@
 		},
 		{
 			"name": "Next.js: debug client-side",
-			"type": "firefox",
+			"type": "chrome",
 			"request": "launch",
 			"url": "https://localhost:3000",
 			"webRoot": "${workspaceFolder}",
-			"reAttach": true,
-			"firefoxExecutable": "C:\\Program Files\\Mozilla Firefox\\firefox.exe",
 			"timeout": 30
 		},
 		{

+ 8 - 0
.vscode/mcp.json

@@ -0,0 +1,8 @@
+{
+    "servers": {
+        "shacdn":  {
+            "command":  "npx",
+            "args": ["shadcn@latest", "mcp"]
+        }
+    }
+}

+ 1 - 0
CLAUDE.md

@@ -77,6 +77,7 @@ middleware.ts          → 인증 미들웨어 (토큰 검증, 리다이렉트)
 - type 을 지정할 때 `|` 문자 양쪽에 공백 제거해(예시로 `string|null|undefined`)
 - button 태그에 submit type이 아니면 `type="button"` 필수
 - C# 람다 `) => {` 한 줄에 붙여 작성 (개행 금지) — Frontend에서도 동일 스타일 적용
+- `if (loading) return <p className="studio-page__empty">준비 중...</p>;` 이런 식으로 한 줄로 처리하지 말고 중괄호 사용해줘 그리고  다음 줄로 개행을 하도록 해
 
 ## Architecture Rules
 

+ 94 - 0
app/(main)/crew/consent/[sessionID]/page.tsx

@@ -0,0 +1,94 @@
+'use client';
+
+import './style.scss';
+import { useState, useEffect } from 'react';
+import { useParams } from 'next/navigation';
+import { fetchApi } from '@/lib/utils/client';
+
+type ConsentInfo = {
+	crewSessionID: number;
+	title: string;
+	crewName: string;
+	isConsented: boolean;
+	crewMemberID: number;
+};
+
+export default function CrewConsentPage()
+{
+	const { sessionID } = useParams<{ sessionID: string }>();
+	const [info, setInfo] = useState<ConsentInfo|null>(null);
+	const [loading, setLoading] = useState(true);
+	const [agreed, setAgreed] = useState(false);
+	const [submitting, setSubmitting] = useState(false);
+	const [done, setDone] = useState(false);
+
+	useEffect(() => {
+		fetchApi<ConsentInfo>(`/api/crew/consent/info/${sessionID}`)
+			.then(res => {
+				const data = res.data;
+				setInfo(data ?? null);
+				if (data?.isConsented) setDone(true);
+			})
+			.catch(() => {})
+			.finally(() => setLoading(false));
+	}, [sessionID]);
+
+	const handleConsent = async () => {
+		if (!info || !agreed) return;
+		setSubmitting(true);
+		try {
+			await fetchApi('/api/crew/session/consent', {
+				method: 'POST',
+				body: {
+					crewSessionID: info.crewSessionID,
+					crewMemberID: info.crewMemberID
+				}
+			});
+			setDone(true);
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '동의 처리에 실패했습니다.');
+		} finally {
+			setSubmitting(false);
+		}
+	};
+
+	if (loading) return <div className="crew-consent"><p>준비 중...</p></div>;
+	if (!info) return <div className="crew-consent"><p>세션 정보를 찾을 수 없습니다.</p></div>;
+
+	return (
+		<div className="crew-consent">
+			<h1 className="crew-consent__title">크루 방송 참여 동의</h1>
+			<p className="crew-consent__subtitle">크루장이 방송 참여를 요청했습니다</p>
+
+			<div className="crew-consent__info">
+				<div className="crew-consent__row">
+					<span className="crew-consent__row-label">크루</span>
+					<span className="crew-consent__row-value">{info.crewName}</span>
+				</div>
+				<div className="crew-consent__row">
+					<span className="crew-consent__row-label">방송 제목</span>
+					<span className="crew-consent__row-value">{info.title}</span>
+				</div>
+			</div>
+
+			{done ? (
+				<div className="crew-consent__done">✓ 동의가 완료되었습니다. 전원 동의 시 방송이 시작됩니다.</div>
+			) : (
+				<>
+					<div className="crew-consent__check">
+						<input type="checkbox" id="agree" checked={agreed} onChange={e => setAgreed(e.target.checked)} />
+						<label htmlFor="agree">크루 방송 참여에 동의합니다</label>
+					</div>
+					<button
+						type="button"
+						className="studio-page__btn studio-page__btn--primary"
+						onClick={handleConsent}
+						disabled={!agreed || submitting}
+					>
+						{submitting ? '처리 중...' : '동의하기'}
+					</button>
+				</>
+			)}
+		</div>
+	);
+}

+ 61 - 0
app/(main)/crew/consent/[sessionID]/style.scss

@@ -0,0 +1,61 @@
+.crew-consent {
+	max-width: 480px;
+	margin: 40px auto;
+	padding: 32px;
+	background: var(--card);
+	border: 1px solid var(--border);
+	border-radius: 12px;
+	text-align: center;
+
+	&__title {
+		font-size: 1.25rem;
+		font-weight: 600;
+		margin-bottom: 8px;
+	}
+
+	&__subtitle {
+		color: var(--muted-foreground);
+		font-size: 0.875rem;
+		margin-bottom: 24px;
+	}
+
+	&__info {
+		padding: 16px;
+		background: var(--accent);
+		border-radius: 8px;
+		margin-bottom: 24px;
+		text-align: left;
+	}
+
+	&__row {
+		display: flex;
+		justify-content: space-between;
+		padding: 4px 0;
+		font-size: 0.875rem;
+
+		&-label {
+			color: var(--muted-foreground);
+		}
+
+		&-value {
+			font-weight: 500;
+		}
+	}
+
+	&__check {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		gap: 8px;
+		margin-bottom: 20px;
+		font-size: 0.875rem;
+	}
+
+	&__done {
+		padding: 16px;
+		background: hsl(var(--primary) / 0.1);
+		border-radius: 8px;
+		color: var(--primary);
+		font-weight: 500;
+	}
+}

+ 11 - 2
app/(main)/watch/[channelSID]/ChatSidebar.tsx

@@ -4,14 +4,18 @@ import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 're
 import useAuth from '@/hooks/useAuth';
 import useChat from '@/hooks/useChat';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faUsers, faEllipsisVertical, faPaperPlane, faTrashCan, faMagnifyingGlassPlus, faMagnifyingGlassMinus, faRotateRight, faClock } from '@fortawesome/free-solid-svg-icons';
+import { faUsers, faEllipsisVertical, faPaperPlane, faTrashCan, faMagnifyingGlassPlus, faMagnifyingGlassMinus, faRotateRight, faClock, faCoins } from '@fortawesome/free-solid-svg-icons';
 import './chat-sidebar.scss';
 
 const MIN_FONT_SIZE = 11;
 const MAX_FONT_SIZE = 19;
 const DEFAULT_FONT_SIZE = 13;
 
-export default function ChatSidebar() {
+type ChatSidebarProps = {
+	onDonate?: () => void;
+};
+
+export default function ChatSidebar({ onDonate }: ChatSidebarProps = {}) {
 	const { isAuthenticated } = useAuth();
 	const { messages, systemMessages, participantCount, participants, sendMessage, clearMessages, refreshChat, requestParticipants, chatConnected } = useChat();
 	const [inputValue, setInputValue] = useState('');
@@ -201,6 +205,11 @@ export default function ChatSidebar() {
 							maxLength={500}
 							disabled={!chatConnected}
 						/>
+						{onDonate && (
+							<button type='button' title='후원하기' onClick={onDonate} className='chat-donate-btn'>
+								<FontAwesomeIcon icon={faCoins} />
+							</button>
+						)}
 						<button type='button' title='전송' onClick={handleSend} disabled={!chatConnected || !inputValue.trim()}>
 							<FontAwesomeIcon icon={faPaperPlane} />
 						</button>

+ 188 - 0
app/(main)/watch/[channelSID]/DonationModal.tsx

@@ -0,0 +1,188 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import './donation-modal.scss';
+
+type CrewMemberInfo = {
+	crewMemberID: number;
+	nickname: string;
+	thumb: string|null;
+	channelName: string|null;
+};
+
+type ActiveCrew = {
+	crewSessionID: number;
+	title: string;
+	crewName: string;
+	members: CrewMemberInfo[];
+}|null;
+
+type Props = {
+	channelSID: string;
+	onClose: () => void;
+};
+
+export default function DonationModal({ channelSID, onClose }: Props)
+{
+	const [amount, setAmount] = useState(1000);
+	const [message, setMessage] = useState('');
+	const [sendName, setSendName] = useState('');
+	const [activeCrew, setActiveCrew] = useState<ActiveCrew>(null);
+	const [selectedMember, setSelectedMember] = useState<number|null>(null);
+	const [sending, setSending] = useState(false);
+	const [done, setDone] = useState(false);
+
+	const presetAmounts = [1000, 3000, 5000, 10000, 30000, 50000];
+
+	useEffect(() => {
+		// 활성 크루 세션 조회
+		fetchApi<ActiveCrew>(`/api/donation/crew/active/${channelSID}`)
+			.then(res => {
+				if (res.data) setActiveCrew(res.data);
+			})
+			.catch(() => {});
+	}, [channelSID]);
+
+	const handleSend = async () => {
+		if (amount < 1000) {
+			alert('최소 후원 금액은 1,000원입니다.');
+			return;
+		}
+		if (!sendName.trim()) {
+			alert('보내는 사람 이름을 입력해 주세요.');
+			return;
+		}
+
+		setSending(true);
+		try {
+			const body: Record<string, unknown> = {
+				channelSID,
+				amount,
+				message: message || null,
+				sendName: sendName.trim()
+			};
+
+			if (activeCrew && selectedMember) {
+				body.crewSessionID = activeCrew.crewSessionID;
+				body.crewMemberID = selectedMember;
+			}
+
+			await fetchApi('/api/donation/send', {
+				method: 'POST',
+				body
+			});
+			setDone(true);
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '후원에 실패했습니다.');
+		} finally {
+			setSending(false);
+		}
+	};
+
+	if (done) {
+		return (
+			<div className="donation-modal">
+				<div className="donation-modal__overlay" onClick={onClose} />
+				<div className="donation-modal__box">
+					<div className="donation-modal__done">
+						<div className="donation-modal__done-icon">🎉</div>
+						<p className="donation-modal__done-text">{amount.toLocaleString()}원 후원 완료!</p>
+						<button type="button" className="donation-modal__btn donation-modal__btn--primary" onClick={onClose}>닫기</button>
+					</div>
+				</div>
+			</div>
+		);
+	}
+
+	return (
+		<div className="donation-modal">
+			<div className="donation-modal__overlay" onClick={onClose} />
+			<div className="donation-modal__box">
+				<div className="donation-modal__header">
+					<h2 className="donation-modal__title">후원하기</h2>
+					<button type="button" className="donation-modal__close" onClick={onClose}>&times;</button>
+				</div>
+
+				{/* 보내는 사람 */}
+				<div className="donation-modal__field">
+					<label>닉네임</label>
+					<input type="text" value={sendName} onChange={e => setSendName(e.target.value)} placeholder="보내는 사람" maxLength={20} />
+				</div>
+
+				{/* 금액 */}
+				<div className="donation-modal__field">
+					<label>금액</label>
+					<div className="donation-modal__presets">
+						{presetAmounts.map(a => (
+							<button
+								type="button"
+								key={a}
+								className={`donation-modal__preset${amount === a ? ' donation-modal__preset--active' : ''}`}
+								onClick={() => setAmount(a)}
+							>
+								{a.toLocaleString()}원
+							</button>
+						))}
+					</div>
+					<input
+						type="number"
+						min={1000}
+						max={10000000}
+						step={1000}
+						value={amount}
+						onChange={e => setAmount(Number(e.target.value))}
+					/>
+				</div>
+
+				{/* 메시지 */}
+				<div className="donation-modal__field">
+					<label>메시지 (선택)</label>
+					<textarea value={message} onChange={e => setMessage(e.target.value)} placeholder="응원 메시지를 남겨주세요" maxLength={100} rows={2} />
+				</div>
+
+				{/* 크루원 선택 */}
+				{activeCrew && activeCrew.members.length > 0 && (
+					<div className="donation-modal__crew">
+						<label className="donation-modal__crew-label">
+							크루원에게 후원 <span className="donation-modal__crew-tag">{activeCrew.crewName}</span>
+						</label>
+						<div className="donation-modal__crew-list">
+							<button
+								type="button"
+								className={`donation-modal__crew-item${selectedMember === null ? ' donation-modal__crew-item--active' : ''}`}
+								onClick={() => setSelectedMember(null)}
+							>
+								<div className="donation-modal__crew-thumb donation-modal__crew-thumb--default">채널</div>
+								<span>채널 주인</span>
+							</button>
+							{activeCrew.members.map(m => (
+								<button
+									type="button"
+									key={m.crewMemberID}
+									className={`donation-modal__crew-item${selectedMember === m.crewMemberID ? ' donation-modal__crew-item--active' : ''}`}
+									onClick={() => setSelectedMember(m.crewMemberID)}
+								>
+									{m.thumb ? (
+										<img src={m.thumb} alt="" className="donation-modal__crew-thumb" />
+									) : (
+										<div className="donation-modal__crew-thumb donation-modal__crew-thumb--default">{m.nickname.charAt(0)}</div>
+									)}
+									<span>{m.nickname}</span>
+								</button>
+							))}
+						</div>
+					</div>
+				)}
+
+				{/* 전송 */}
+				<div className="donation-modal__footer">
+					<button type="button" className="donation-modal__btn" onClick={onClose}>취소</button>
+					<button type="button" className="donation-modal__btn donation-modal__btn--primary" onClick={handleSend} disabled={sending}>
+						{sending ? '전송 중...' : `${amount.toLocaleString()}원 후원`}
+					</button>
+				</div>
+			</div>
+		</div>
+	);
+}

+ 14 - 3
app/(main)/watch/[channelSID]/WatchView.tsx

@@ -1,7 +1,9 @@
 'use client';
 
+import { useState } from 'react';
 import { ChannelDetail } from '@/types/channel';
 import ChatSidebar from './ChatSidebar';
+import DonationModal from './DonationModal';
 import Link from 'next/link';
 import './style.scss';
 
@@ -10,6 +12,7 @@ type Props = {
 };
 
 export default function WatchView({ channel }: Props) {
+	const [showDonation, setShowDonation] = useState(false);
 	const embedUrl = channel.videoId
 		? `https://www.youtube.com/embed/${channel.videoId}?autoplay=1&mute=1`
 		: null;
@@ -45,17 +48,25 @@ export default function WatchView({ channel }: Props) {
 						{channel.handle && <span className="watch-page__handle">@{channel.handle}</span>}
 					</div>
 					<div className="watch-page__actions">
-						<Link href={`/donation/${channel.channelSID}`} className="watch-page__donate-btn">
+						<button type="button" className="watch-page__donate-btn" onClick={() => setShowDonation(true)}>
 							💰 후원하기
-						</Link>
+						</button>
 					</div>
 				</div>
 			</div>
 
 			{/* 우측 채팅 */}
 			<div className="watch-page__chat">
-				<ChatSidebar />
+				<ChatSidebar onDonate={() => setShowDonation(true)} />
 			</div>
+
+			{/* 후원 모달 */}
+			{showDonation && (
+				<DonationModal
+					channelSID={channel.channelSID}
+					onClose={() => setShowDonation(false)}
+				/>
+			)}
 		</div>
 	);
 }

+ 9 - 0
app/(main)/watch/[channelSID]/chat-sidebar.scss

@@ -235,6 +235,15 @@
 					cursor: not-allowed;
 				}
 			}
+
+			.chat-donate-btn {
+				background: #FFD700;
+				color: #333;
+
+				&:hover {
+					background: #FFC400;
+				}
+			}
 		}
 
 		.chat-login-notice {

+ 236 - 0
app/(main)/watch/[channelSID]/donation-modal.scss

@@ -0,0 +1,236 @@
+.donation-modal {
+	position: fixed;
+	inset: 0;
+	z-index: 100;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+
+	&__overlay {
+		position: absolute;
+		inset: 0;
+		background: rgba(0, 0, 0, 0.5);
+	}
+
+	&__box {
+		position: relative;
+		width: 100%;
+		max-width: 420px;
+		max-height: 90vh;
+		overflow-y: auto;
+		background: var(--card);
+		border: 1px solid var(--border);
+		border-radius: 12px;
+		padding: 24px;
+	}
+
+	&__header {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		margin-bottom: 20px;
+	}
+
+	&__title {
+		font-size: 1.1rem;
+		font-weight: 600;
+		margin: 0;
+	}
+
+	&__close {
+		background: none;
+		border: none;
+		font-size: 1.5rem;
+		cursor: pointer;
+		color: var(--muted-foreground);
+		line-height: 1;
+	}
+
+	&__field {
+		margin-bottom: 16px;
+
+		> label {
+			display: block;
+			font-size: 0.8125rem;
+			font-weight: 500;
+			margin-bottom: 6px;
+			color: var(--foreground);
+		}
+
+		> input, > textarea {
+			width: 100%;
+			padding: 8px 12px;
+			border: 1px solid var(--border);
+			border-radius: 6px;
+			font-size: 0.875rem;
+			background: var(--background);
+			color: var(--foreground);
+			resize: none;
+		}
+	}
+
+	&__presets {
+		display: flex;
+		flex-wrap: wrap;
+		gap: 6px;
+		margin-bottom: 8px;
+	}
+
+	&__preset {
+		padding: 6px 12px;
+		border: 1px solid var(--border);
+		border-radius: 20px;
+		background: var(--background);
+		color: var(--foreground);
+		font-size: 0.8125rem;
+		cursor: pointer;
+		transition: all 0.15s;
+
+		&:hover {
+			border-color: var(--primary);
+		}
+
+		&--active {
+			background: hsl(var(--primary));
+			color: hsl(var(--primary-foreground));
+			border-color: hsl(var(--primary));
+		}
+	}
+
+	// 크루원 선택
+	&__crew {
+		margin-bottom: 16px;
+		padding: 12px;
+		background: var(--accent);
+		border-radius: 8px;
+	}
+
+	&__crew-label {
+		display: block;
+		font-size: 0.8125rem;
+		font-weight: 500;
+		margin-bottom: 8px;
+	}
+
+	&__crew-tag {
+		display: inline-block;
+		padding: 2px 8px;
+		background: hsl(var(--primary) / 0.15);
+		color: hsl(var(--primary));
+		border-radius: 4px;
+		font-size: 0.75rem;
+		font-weight: 600;
+		margin-left: 4px;
+	}
+
+	&__crew-list {
+		display: flex;
+		flex-wrap: wrap;
+		gap: 8px;
+	}
+
+	&__crew-item {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		gap: 4px;
+		padding: 8px 12px;
+		border: 2px solid var(--border);
+		border-radius: 8px;
+		background: var(--background);
+		cursor: pointer;
+		font-size: 0.75rem;
+		min-width: 72px;
+		transition: all 0.15s;
+
+		&:hover {
+			border-color: hsl(var(--primary) / 0.5);
+		}
+
+		&--active {
+			border-color: hsl(var(--primary));
+			background: hsl(var(--primary) / 0.05);
+		}
+
+		> span {
+			font-weight: 500;
+			max-width: 64px;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+		}
+	}
+
+	&__crew-thumb {
+		width: 40px;
+		height: 40px;
+		border-radius: 50%;
+		object-fit: cover;
+
+		&--default {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			background: var(--muted);
+			color: var(--muted-foreground);
+			font-size: 0.875rem;
+			font-weight: 600;
+		}
+	}
+
+	// 푸터
+	&__footer {
+		display: flex;
+		gap: 8px;
+		justify-content: flex-end;
+		margin-top: 20px;
+	}
+
+	&__btn {
+		padding: 8px 20px;
+		border-radius: 6px;
+		font-size: 0.875rem;
+		font-weight: 500;
+		cursor: pointer;
+		border: 1px solid var(--border);
+		background: var(--background);
+		color: var(--foreground);
+		transition: all 0.15s;
+
+		&:hover {
+			background: var(--accent);
+		}
+
+		&--primary {
+			background: hsl(var(--primary));
+			color: hsl(var(--primary-foreground));
+			border-color: hsl(var(--primary));
+
+			&:hover {
+				opacity: 0.9;
+			}
+
+			&:disabled {
+				opacity: 0.5;
+				cursor: not-allowed;
+			}
+		}
+	}
+
+	// 완료 화면
+	&__done {
+		text-align: center;
+		padding: 24px 0;
+	}
+
+	&__done-icon {
+		font-size: 3rem;
+		margin-bottom: 12px;
+	}
+
+	&__done-text {
+		font-size: 1.1rem;
+		font-weight: 600;
+		margin-bottom: 20px;
+	}
+}

+ 19 - 5
app/component/Profile.tsx

@@ -3,12 +3,12 @@
 import '../styles/profile.scss';
 import { useEffect, useState } from 'react';
 import Link from 'next/link';
-import { GoogleOAuthProvider, GoogleLogin, useGoogleOneTapLogin } from '@react-oauth/google';
+import { GoogleOAuthProvider, /* GoogleLogin, useGoogleOneTapLogin */ } from '@react-oauth/google';
 import useAuth from '@/hooks/useAuth';
 import { useConfigContext } from '@/contexts/configProvider';
 import { fetchApi } from '@/lib/utils/client';
 import { DropdownData } from '@/types/response/mypage/dropdown';
-import { LoginResponse } from '@/types/response/auth';
+// import { LoginResponse } from '@/types/response/auth';
 import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
 import SignalSteamIcon from '@/public/icons/user/signal-steam.svg';
 
@@ -40,14 +40,16 @@ export default function Profile()
 }
 
 function ProfileInner() {
-	const { member, login, logout } = useAuth();
+	const { member, /* login, */ logout } = useAuth();
 	const [open, setOpen] = useState(false);
 	const [data, setData] = useState<DropdownData|null>(null);
 
 	// 구글 로그인 핸들러
+	/*
 	const handleGoogleLogin = async (credentialResponse: { credential?: string }) => {
 		try {
-			const res = await fetchApi<LoginResponse>('/api/auth/google-login', {
+
+			await fetchApi<LoginResponse>('/api/auth/google-login', {
 				method: 'POST',
 				body: {
 					credential: credentialResponse.credential
@@ -59,13 +61,16 @@ function ProfileInner() {
 			// silent
 		}
 	};
+	*/
 
 	// Google One Tap — 비로그인 상태에서만 활성화
+	/*
 	useGoogleOneTapLogin({
 		onSuccess: handleGoogleLogin,
 		onError: () => {},
 		disabled: !!member
 	});
+	*/
 
 	// 드롭다운 열 때 잔액 조회
 	const loadData = async () => {
@@ -98,7 +103,9 @@ function ProfileInner() {
 	// ── 비로그인 ──────────────────────────────────
 	if (!member) {
 		return (
-			<DropdownMenu>
+			<>
+			{/*
+			<DropdownMenu open={open} onOpenChange={setOpen}>
 				<DropdownMenuTrigger asChild>
 					<label className="profile-dropdown__trigger--guest">
 						로그인
@@ -116,6 +123,13 @@ function ProfileInner() {
 					</div>
 				</DropdownMenuContent>
 			</DropdownMenu>
+			*/}
+			<div className="profile-dropdown__guest">
+				<Link href="/login" className="profile-dropdown__guest-link">로그인</Link>
+				<span className="profile-dropdown__guest-divider">|</span>
+				<Link href="/register" className="profile-dropdown__guest-link">회원가입</Link>
+			</div>
+			</>
 		);
 	}
 

+ 21 - 44
app/globals.scss

@@ -38,7 +38,7 @@ body {
         --destructive-foreground: 0 0% 98%;
         --border: 0 0% 89.8%;
         --input: 0 0% 89.8%;
-        --ring: 0 0% 3.9%;
+        --ring: 215 100% 50%;
         --chart-1: 12 76% 61%;
         --chart-2: 173 58% 39%;
         --chart-3: 197 37% 24%;
@@ -118,7 +118,7 @@ body {
         --destructive-foreground: 0 0% 98%;
         --border: 0 0% 14.9%;
         --input: 0 0% 14.9%;
-        --ring: 0 0% 83.1%;
+        --ring: 215 100% 60%;
         --chart-1: 220 70% 50%;
         --chart-2: 160 60% 45%;
         --chart-3: 30 80% 55%;
@@ -188,54 +188,13 @@ body {
     }
 }
 
-// 웹 에디터 글씨 크기기
-.ql-snow .ql-picker.ql-size .ql-picker-label::before,
-.ql-snow .ql-picker.ql-size .ql-picker-item::before {
-    content: attr(data-value) !important;
-    font-size: 14px;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="11px"]::before {
-    content: "11px" !important;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="13px"]::before {
-    content: "13px" !important;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="15px"]::before {
-    content: "15px" !important;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before {
-    content: "16px" !important;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="19px"]::before {
-    content: "19px" !important;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before {
-    content: "24px" !important;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before {
-    content: "28px" !important;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before {
-    content: "30px" !important;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before {
-    content: "34px" !important;
-}
-.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before {
-    content: "38px" !important;
-}
-.ql-snow .ql-toolbar button.ql-file {
-	background: transparent;
-	border: none;
-	padding: 0;
-}
-
 select, input, textarea {
     font-size: 1rem;
     padding: 5px;
     color: var(--text-primary);
     background: var(--bg-input);
     border: 1px solid var(--border-strong);
+	border-radius: 3px;
 	line-height: inherit;
     transition: border-color 0.3s ease;
 
@@ -251,6 +210,24 @@ select, input, textarea {
     }
 }
 
+// shadcn 폼 컨트롤 포커스 글로우 (Select trigger, Checkbox 등)
+[role="input"]
+[role="combobox"],
+[role="checkbox"] {
+    transition: border-color 0.3s ease;
+
+    &:focus,
+    &:hover {
+        outline: none;
+        border-color: var(--text-link);
+    }
+
+    &:focus {
+        -webkit-box-shadow: inset 1px 2px 2px rgba(0, 0, 0, 0.05), 0 0 4px rgba(44, 112, 170, 0.8);
+        box-shadow: inset 1px 2px 2px rgba(0, 0, 0, 0.05), 0 0 4px rgba(44, 112, 170, 0.8);
+    }
+}
+
 // 모든 버튼 속성값
 .btn {
     display: inline-block;

+ 10 - 10
app/remote/[channelSID]/page.tsx → app/remote/[widgetToken]/page.tsx

@@ -7,7 +7,7 @@ import { fetchApi } from '@/lib/utils/client';
 import './style.scss';
 
 type Props = {
-	params: Promise<{ channelSID: string }>;
+	params: Promise<{ widgetToken: string }>;
 };
 
 type AlertQueueItem = {
@@ -32,7 +32,7 @@ const STATUS_LABEL: Record<string, string> = {
 };
 
 export default function RemotePage({ params }: Props) {
-	const { channelSID } = use(params);
+	const { widgetToken } = use(params);
 	const apiBase = '/api/donation/remote';
 	const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
 
@@ -51,7 +51,7 @@ export default function RemotePage({ params }: Props) {
 
 	const loadState = async () => {
 		try {
-			const res = await fetchApi<DonationRemoteState & { queue: AlertQueueItem[] }>(`${apiBase}/state/${channelSID}`, { silent: true });
+			const res = await fetchApi<DonationRemoteState & { queue: AlertQueueItem[] }>(`${apiBase}/state/${widgetToken}`, { silent: true });
 			if (res.data) {
 				setState({ isPaused: res.data.isPaused, isAccepting: res.data.isAccepting, isAudioOnly: res.data.isAudioOnly, isVideoOnly: res.data.isVideoOnly });
 				setQueue(res.data.queue || []);
@@ -72,12 +72,12 @@ export default function RemotePage({ params }: Props) {
 		});
 
 		conn.start().then(() => {
-			conn.invoke('JoinChannel', channelSID);
+			conn.invoke('JoinChannel', widgetToken);
 			setConnected(true);
 		}).catch(() => {});
 
 		conn.onclose(() => setConnected(false));
-		conn.onreconnected(() => { conn.invoke('JoinChannel', channelSID); setConnected(true); });
+		conn.onreconnected(() => { conn.invoke('JoinChannel', widgetToken); setConnected(true); });
 		connectionRef.current = conn;
 	};
 
@@ -85,29 +85,29 @@ export default function RemotePage({ params }: Props) {
 	const togglePause = async () => {
 		const next = { ...state, isPaused: !state.isPaused };
 		setState(next);
-		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
+		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
 	};
 
 	const toggleAccepting = async () => {
 		const next = { ...state, isAccepting: !state.isAccepting };
 		setState(next);
-		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
+		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
 	};
 
 	const toggleAudioOnly = async () => {
 		const next = { ...state, isAudioOnly: !state.isAudioOnly, isVideoOnly: false };
 		setState(next);
-		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
+		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
 	};
 
 	const toggleVideoOnly = async () => {
 		const next = { ...state, isVideoOnly: !state.isVideoOnly, isAudioOnly: false };
 		setState(next);
-		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
+		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
 	};
 
 	const skipCurrent = async () => {
-		await fetchApi(`${apiBase}/skip/${channelSID}`, { method: 'POST', silent: true });
+		await fetchApi(`${apiBase}/skip/${widgetToken}`, { method: 'POST', silent: true });
 	};
 
 	const ignoreAlert = async (alertID: number) => {

+ 0 - 0
app/remote/[channelSID]/style.scss → app/remote/[widgetToken]/style.scss


+ 43 - 47
app/studio/Sidebar.tsx

@@ -4,26 +4,26 @@ import { useState, useEffect, useCallback } from 'react';
 import Link from 'next/link';
 import Image from 'next/image';
 import { usePathname } from 'next/navigation';
-import { ChevronsUpDown } from 'lucide-react';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import {
-	faGauge,
-	faHeart,
-	faChevronDown,
-	faBell,
-	faBullseye,
-	faTrophy,
-	faUsers,
-	faWallet,
-	faCoins,
-	faChartLine,
-	faArrowRightFromBracket,
-	faCalculator,
-	faBuildingColumns,
-	faFileLines,
-	faArrowLeft,
-	faCircleExclamation,
-} from '@fortawesome/free-solid-svg-icons';
+	ArrowLeft,
+	Bell,
+	Calculator,
+	ChartLine,
+	ChevronDown,
+	ChevronsUpDown,
+	CircleAlert,
+	Coins,
+	Gauge,
+	Heart,
+	Landmark,
+	LogOut,
+	FileText,
+	Target,
+	Trophy,
+	User,
+	Users,
+	Wallet,
+} from 'lucide-react';
 import {
 	Sidebar as SidebarRoot,
 	SidebarContent,
@@ -49,7 +49,6 @@ import {
 } from '@/components/ui/dropdown-menu';
 import { fetchApi } from '@/lib/utils/client';
 import type { StudioSettingsResponse } from '@/types/response/studio/settings';
-import { User } from 'lucide-react';
 
 export default function Sidebar()
 {
@@ -115,7 +114,7 @@ export default function Sidebar()
 						className="flex size-6 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors"
 						title="뒤로가기"
 					>
-						<FontAwesomeIcon icon={faArrowLeft} className="text-xs" />
+						<ArrowLeft className="size-3.5" />
 					</Link>
 					<span className="text-xs font-semibold text-sidebar-foreground/50">Studio</span>
 				</div>
@@ -148,7 +147,7 @@ export default function Sidebar()
 										{/* 미연동 경고 뱃지 */}
 										{channel !== null && !isConnected && (
 											<span className="absolute -right-1 -bottom-1 flex size-3.5 items-center justify-center rounded-full bg-background">
-												<FontAwesomeIcon icon={faCircleExclamation} className="text-[10px] text-amber-500" />
+												<CircleAlert className="size-2.5 text-amber-500" />
 											</span>
 										)}
 									</div>
@@ -172,7 +171,7 @@ export default function Sidebar()
 							</DropdownMenuTrigger>
 
 							<DropdownMenuContent
-								className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
+								className="w-[--anchor-width] min-w-56 rounded-lg"
 								align="start"
 								side={isMobile ? 'bottom' : 'right'}
 								sideOffset={4}
@@ -213,7 +212,7 @@ export default function Sidebar()
 							<SidebarMenuItem>
 								<SidebarMenuButton asChild isActive={isActive('/studio/dashboard')} tooltip="대시보드">
 									<Link href="/studio/dashboard">
-										<FontAwesomeIcon icon={faGauge} />
+										<Gauge />
 										<span>대시보드</span>
 									</Link>
 								</SidebarMenuButton>
@@ -224,11 +223,10 @@ export default function Sidebar()
 								<SidebarMenuItem>
 									<CollapsibleTrigger asChild>
 										<SidebarMenuButton isActive={isDonationPath} tooltip="후원 설정">
-											<FontAwesomeIcon icon={faHeart} />
+											<Heart />
 											<span>후원 설정</span>
-											<FontAwesomeIcon
-												icon={faChevronDown}
-												className={`ml-auto text-xs transition-transform duration-200${donationOpen ? ' rotate-180' : ''}`}
+											<ChevronDown
+												className={`ml-auto size-4 transition-transform duration-200${donationOpen ? ' rotate-180' : ''}`}
 											/>
 										</SidebarMenuButton>
 									</CollapsibleTrigger>
@@ -237,7 +235,7 @@ export default function Sidebar()
 											<SidebarMenuSubItem>
 												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/alert')}>
 													<Link href="/studio/donation/alert">
-														<FontAwesomeIcon icon={faBell} />
+														<Bell />
 														<span>알림</span>
 													</Link>
 												</SidebarMenuSubButton>
@@ -245,7 +243,7 @@ export default function Sidebar()
 											<SidebarMenuSubItem>
 												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/goal')}>
 													<Link href="/studio/donation/goal">
-														<FontAwesomeIcon icon={faBullseye} />
+														<Target />
 														<span>목표</span>
 													</Link>
 												</SidebarMenuSubButton>
@@ -253,7 +251,7 @@ export default function Sidebar()
 											<SidebarMenuSubItem>
 												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/rank')}>
 													<Link href="/studio/donation/rank">
-														<FontAwesomeIcon icon={faTrophy} />
+														<Trophy />
 														<span>순위</span>
 													</Link>
 												</SidebarMenuSubButton>
@@ -261,8 +259,8 @@ export default function Sidebar()
 											<SidebarMenuSubItem>
 												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/crew')}>
 													<Link href="/studio/donation/crew">
-														<FontAwesomeIcon icon={faUsers} />
-														<span>크루 후원</span>
+														<Users />
+														<span>크루</span>
 													</Link>
 												</SidebarMenuSubButton>
 											</SidebarMenuSubItem>
@@ -276,11 +274,10 @@ export default function Sidebar()
 								<SidebarMenuItem>
 									<CollapsibleTrigger asChild>
 										<SidebarMenuButton isActive={isWalletPath} tooltip="지갑">
-											<FontAwesomeIcon icon={faWallet} />
+											<Wallet />
 											<span>지갑</span>
-											<FontAwesomeIcon
-												icon={faChevronDown}
-												className={`ml-auto text-xs transition-transform duration-200${walletOpen ? ' rotate-180' : ''}`}
+											<ChevronDown
+												className={`ml-auto size-4 transition-transform duration-200${walletOpen ? ' rotate-180' : ''}`}
 											/>
 										</SidebarMenuButton>
 									</CollapsibleTrigger>
@@ -289,7 +286,7 @@ export default function Sidebar()
 											<SidebarMenuSubItem>
 												<SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/balance')}>
 													<Link href="/studio/wallet/balance">
-														<FontAwesomeIcon icon={faCoins} />
+														<Coins />
 														<span>잔액</span>
 													</Link>
 												</SidebarMenuSubButton>
@@ -297,7 +294,7 @@ export default function Sidebar()
 											<SidebarMenuSubItem>
 												<SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/revenue')}>
 													<Link href="/studio/wallet/revenue">
-														<FontAwesomeIcon icon={faChartLine} />
+														<ChartLine />
 														<span>수익</span>
 													</Link>
 												</SidebarMenuSubButton>
@@ -305,7 +302,7 @@ export default function Sidebar()
 											<SidebarMenuSubItem>
 												<SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/withdraw')}>
 													<Link href="/studio/wallet/withdraw">
-														<FontAwesomeIcon icon={faArrowRightFromBracket} />
+														<LogOut />
 														<span>출금</span>
 													</Link>
 												</SidebarMenuSubButton>
@@ -320,11 +317,10 @@ export default function Sidebar()
 								<SidebarMenuItem>
 									<CollapsibleTrigger asChild>
 										<SidebarMenuButton isActive={isSettlementPath} tooltip="정산">
-											<FontAwesomeIcon icon={faCalculator} />
+											<Calculator />
 											<span>정산</span>
-											<FontAwesomeIcon
-												icon={faChevronDown}
-												className={`ml-auto text-xs transition-transform duration-200${settlementOpen ? ' rotate-180' : ''}`}
+											<ChevronDown
+												className={`ml-auto size-4 transition-transform duration-200${settlementOpen ? ' rotate-180' : ''}`}
 											/>
 										</SidebarMenuButton>
 									</CollapsibleTrigger>
@@ -333,7 +329,7 @@ export default function Sidebar()
 											<SidebarMenuSubItem>
 												<SidebarMenuSubButton asChild isActive={isActive('/studio/settlement/account')}>
 													<Link href="/studio/settlement/account">
-														<FontAwesomeIcon icon={faBuildingColumns} />
+														<Landmark />
 														<span>계좌 관리</span>
 													</Link>
 												</SidebarMenuSubButton>
@@ -341,8 +337,8 @@ export default function Sidebar()
 											<SidebarMenuSubItem>
 												<SidebarMenuSubButton asChild isActive={isActive('/studio/settlement/tax')}>
 													<Link href="/studio/settlement/tax">
-														<FontAwesomeIcon icon={faFileLines} />
-														<span>세금계산서</span>
+														<FileText />
+														<span>원천징수 내역</span>
 													</Link>
 												</SidebarMenuSubButton>
 											</SidebarMenuSubItem>

+ 176 - 1
app/studio/dashboard/page.tsx

@@ -1,13 +1,188 @@
 'use client';
 
+import { useEffect, useState } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCopy, faArrowUpRightFromSquare, faWallet, faCalendarDay, faCalendar, faClock } from '@fortawesome/free-solid-svg-icons';
+import { fetchApi } from '@/lib/utils/client';
+import type { DashboardResponse, DashboardWidgetUrls } from '@/types/response/studio/dashboard';
 import './style.scss';
 
+const WIDGET_LABELS: { key: keyof Omit<DashboardWidgetUrls, 'widgetToken'>; label: string; description: string }[] = [
+	{ key: 'alert', label: '후원 알림', description: 'OBS에 후원 알림 표시' },
+	{ key: 'goal', label: '후원 목표', description: '목표 금액 진행률 표시' },
+	{ key: 'rank', label: '후원 순위', description: '후원자 순위 표시' },
+	{ key: 'crew', label: '크루 리더보드', description: '크루 순위 표시' },
+	{ key: 'remote', label: '리모콘', description: '후원 알림 제어' },
+];
+
 export default function DashboardPage() {
+	const [data, setData] = useState<DashboardResponse|null>(null);
+	const [loading, setLoading] = useState(true);
+	const [copiedKey, setCopiedKey] = useState<string|null>(null);
+
+	useEffect(() => {
+		loadDashboard();
+	}, []);
+
+	const loadDashboard = async () => {
+		try {
+			const res = await fetchApi<DashboardResponse>('/api/studio/dashboard', { silent: true });
+			if (res.data) {
+				setData(res.data);
+			}
+		} catch {} finally {
+			setLoading(false);
+		}
+	};
+
+	const getWidgetFullUrl = (path: string) => {
+		if (typeof window === 'undefined') {
+			return path;
+		}
+		return `${window.location.origin}${path}`;
+	};
+
+	const handleCopy = async (key: string, path: string) => {
+		try {
+			await navigator.clipboard.writeText(getWidgetFullUrl(path));
+			setCopiedKey(key);
+			setTimeout(() => setCopiedKey(null), 2000);
+		} catch {}
+	};
+
+	const handleOpen = (path: string) => {
+		window.open(getWidgetFullUrl(path), '_blank');
+	};
+
+	if (loading) {
+		return (
+			<div className="studio-page">
+				<div className="dashboard">
+					<h1 className="studio-page__title">대시보드</h1>
+					<p className="studio-page__empty">준비 중...</p>
+				</div>
+			</div>
+		);
+	}
+
+	const financial = data?.financial;
+	const widgets = data?.widgets;
+	const recentDonations = data?.recentDonations ?? [];
+
 	return (
 		<div className="studio-page">
 			<div className="dashboard">
 				<h1 className="studio-page__title">대시보드</h1>
-				<p className="dashboard__placeholder">채널 통계 및 요약 정보가 표시될 예정입니다.</p>
+
+				{/* 재무 요약 카드 */}
+				<div className="dashboard__cards">
+					<div className="dashboard__card">
+						<span className="dashboard__card-icon">
+							<FontAwesomeIcon icon={faWallet} />
+						</span>
+						<span className="dashboard__card-label">출금 가능 잔액</span>
+						<span className="dashboard__card-value dashboard__card-value--primary">
+							{(financial?.availableBalance ?? 0).toLocaleString()}원
+						</span>
+					</div>
+					<div className="dashboard__card">
+						<span className="dashboard__card-icon">
+							<FontAwesomeIcon icon={faCalendarDay} />
+						</span>
+						<span className="dashboard__card-label">오늘 후원</span>
+						<span className="dashboard__card-value">
+							{(financial?.todayDonations ?? 0).toLocaleString()}원
+						</span>
+					</div>
+					<div className="dashboard__card">
+						<span className="dashboard__card-icon">
+							<FontAwesomeIcon icon={faCalendar} />
+						</span>
+						<span className="dashboard__card-label">이번 달 후원</span>
+						<span className="dashboard__card-value">
+							{(financial?.monthDonations ?? 0).toLocaleString()}원
+						</span>
+					</div>
+					<div className="dashboard__card">
+						<span className="dashboard__card-icon">
+							<FontAwesomeIcon icon={faClock} />
+						</span>
+						<span className="dashboard__card-label">출금 대기</span>
+						<span className="dashboard__card-value dashboard__card-value--danger">
+							{(financial?.pendingWithdrawal ?? 0).toLocaleString()}원
+						</span>
+					</div>
+				</div>
+
+				{/* 위젯 URL */}
+				{widgets && (
+					<section className="dashboard__section">
+						<h2 className="dashboard__section-title">위젯 URL</h2>
+						<p className="dashboard__section-desc">OBS 브라우저 소스에 아래 URL을 등록하세요.</p>
+						<div className="dashboard__widget-list">
+							{WIDGET_LABELS.map(({ key, label, description }) => {
+								const path = widgets[key];
+								const isCopied = copiedKey === key;
+								return (
+									<div key={key} className="dashboard__widget-item">
+										<div className="dashboard__widget-info">
+											<span className="dashboard__widget-label">{label}</span>
+											<span className="dashboard__widget-desc">{description}</span>
+										</div>
+										<div className="dashboard__widget-url">
+											<code className="dashboard__widget-url-text">{getWidgetFullUrl(path)}</code>
+										</div>
+										<div className="dashboard__widget-actions">
+											<button
+												type="button"
+												className={`dashboard__widget-btn${isCopied ? ' dashboard__widget-btn--copied' : ''}`}
+												onClick={() => handleCopy(key, path)}
+												title="URL 복사"
+											>
+												<FontAwesomeIcon icon={faCopy} />
+												{isCopied ? '복사됨' : '복사'}
+											</button>
+											<button
+												type="button"
+												className="dashboard__widget-btn dashboard__widget-btn--open"
+												onClick={() => handleOpen(path)}
+												title="새 창에서 열기"
+											>
+												<FontAwesomeIcon icon={faArrowUpRightFromSquare} />
+												열기
+											</button>
+										</div>
+									</div>
+								);
+							})}
+						</div>
+					</section>
+				)}
+
+				{/* 최근 후원 */}
+				<section className="dashboard__section">
+					<h2 className="dashboard__section-title">최근 후원</h2>
+					{recentDonations.length === 0 ? (
+						<div className="dashboard__empty">아직 후원 내역이 없습니다.</div>
+					) : (
+						<div className="dashboard__donation-list">
+							{recentDonations.map(d => (
+								<div key={d.id} className="dashboard__donation-item">
+									<div className="dashboard__donation-info">
+										<span className="dashboard__donation-name">{d.sendName}</span>
+										<span className="dashboard__donation-amount">{d.amount.toLocaleString()}원</span>
+									</div>
+									{d.message && (
+										<div className="dashboard__donation-msg">{d.message}</div>
+									)}
+									<div className="dashboard__donation-time">
+										{new Date(d.createdAt).toLocaleString('ko-KR')}
+									</div>
+								</div>
+							))}
+						</div>
+					)}
+				</section>
 			</div>
 		</div>
 	);

+ 335 - 4
app/studio/dashboard/style.scss

@@ -1,8 +1,339 @@
+// ── 대시보드 ──────────────────────────────────────
 .dashboard {
-	&__placeholder {
-		font-size: 0.875rem;
+	width: 100%;
+	max-width: 960px;
+
+	// ── 재무 요약 카드 ──
+	&__cards {
+		display: grid;
+		grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+		gap: 16px;
+		margin-bottom: 32px;
+	}
+
+	&__card {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 20px;
+		background: hsl(var(--background));
+		display: flex;
+		flex-direction: column;
+		gap: 6px;
+	}
+
+	&__card-icon {
+		font-size: 1.25rem;
+		color: hsl(var(--muted-foreground));
+		margin-bottom: 4px;
+	}
+
+	&__card-label {
+		font-size: 0.8125rem;
+		color: hsl(var(--muted-foreground));
+	}
+
+	&__card-value {
+		font-size: 1.5rem;
+		font-weight: 700;
+		color: hsl(var(--foreground));
+
+		&--primary {
+			color: hsl(var(--primary));
+		}
+
+		&--danger {
+			color: var(--color-danger);
+		}
+	}
+
+	// ── 섹션 ──
+	&__section {
+		margin-bottom: 32px;
+	}
+
+	&__section-title {
+		font-size: 1.125rem;
+		font-weight: 600;
+		color: hsl(var(--foreground));
+		margin-bottom: 8px;
+	}
+
+	&__section-desc {
+		font-size: 0.8125rem;
+		color: hsl(var(--muted-foreground));
+		margin-bottom: 16px;
+	}
+
+	// ── 위젯 URL ──
+	&__widget-list {
+		display: flex;
+		flex-direction: column;
+		gap: 12px;
+	}
+
+	&__widget-item {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 16px 20px;
+		background: hsl(var(--background));
+		display: flex;
+		align-items: center;
+		gap: 16px;
+	}
+
+	&__widget-info {
+		display: flex;
+		flex-direction: column;
+		gap: 2px;
+		min-width: 120px;
+		flex-shrink: 0;
+	}
+
+	&__widget-label {
+		font-size: 0.9375rem;
+		font-weight: 600;
+		color: hsl(var(--foreground));
+	}
+
+	&__widget-desc {
+		font-size: 0.75rem;
+		color: hsl(var(--muted-foreground));
+	}
+
+	&__widget-url {
+		flex: 1;
+		width: 0;
+		min-width: 0;
+		overflow: hidden;
+	}
+
+	&__widget-url-text {
+		display: block;
+		font-size: 0.8125rem;
 		color: hsl(var(--muted-foreground));
-		line-height: 1.6;
-		margin-top: 8px;
+		background: hsl(var(--muted));
+		padding: 6px 10px;
+		border-radius: 4px;
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		font-family: 'Fira Code', 'Consolas', monospace;
+	}
+
+	&__widget-actions {
+		display: flex;
+		gap: 6px;
+		flex-shrink: 0;
+	}
+
+	&__widget-btn {
+		display: inline-flex;
+		align-items: center;
+		gap: 4px;
+		padding: 6px 12px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		font-size: 0.8125rem;
+		font-weight: 500;
+		cursor: pointer;
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		transition: background-color 0.15s;
+		white-space: nowrap;
+
+		&:hover {
+			background: hsl(var(--accent));
+		}
+
+		&--copied {
+			background: hsl(var(--primary));
+			color: hsl(var(--primary-foreground));
+			border-color: hsl(var(--primary));
+		}
+
+		&--open {
+			color: hsl(var(--primary));
+			border-color: hsl(var(--primary));
+
+			&:hover {
+				background: hsl(var(--primary) / 0.1);
+			}
+		}
+	}
+
+	// ── 최근 후원 ──
+	&__donation-list {
+		display: flex;
+		flex-direction: column;
+		gap: 8px;
+	}
+
+	&__donation-item {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 14px 20px;
+		background: hsl(var(--background));
+	}
+
+	&__donation-info {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		margin-bottom: 4px;
+	}
+
+	&__donation-name {
+		font-size: 0.9375rem;
+		font-weight: 600;
+		color: hsl(var(--foreground));
+	}
+
+	&__donation-amount {
+		font-size: 0.9375rem;
+		font-weight: 700;
+		color: hsl(var(--primary));
+	}
+
+	&__donation-msg {
+		font-size: 0.8125rem;
+		color: hsl(var(--foreground));
+		margin-bottom: 4px;
+		line-height: 1.4;
+	}
+
+	&__donation-time {
+		font-size: 0.75rem;
+		color: hsl(var(--muted-foreground));
+	}
+
+	&__empty {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 40px 24px;
+		text-align: center;
+		color: hsl(var(--muted-foreground));
+		font-size: 0.875rem;
+	}
+
+	// ── 반응형 ──
+	@media (max-width: 768px) {
+		&__cards {
+			grid-template-columns: repeat(2, 1fr);
+			gap: 10px;
+			margin-bottom: 24px;
+		}
+
+		&__card {
+			padding: 14px;
+		}
+
+		&__card-value {
+			font-size: 1.25rem;
+		}
+
+		&__section {
+			margin-bottom: 24px;
+		}
+
+		&__section-title {
+			font-size: 1rem;
+		}
+
+		&__widget-item {
+			flex-direction: column;
+			align-items: flex-start;
+			gap: 10px;
+			padding: 14px 16px;
+		}
+
+		&__widget-info {
+			min-width: 0;
+			width: 100%;
+		}
+
+		&__widget-url {
+			width: 100%;
+		}
+
+		&__widget-url-text {
+			font-size: 0.75rem;
+			padding: 8px 10px;
+		}
+
+		&__widget-actions {
+			width: 100%;
+
+			.dashboard__widget-btn {
+				flex: 1;
+				justify-content: center;
+				padding: 10px 12px;
+			}
+		}
+
+		&__donation-item {
+			padding: 12px 14px;
+		}
+
+		&__donation-name {
+			font-size: 0.875rem;
+		}
+
+		&__donation-amount {
+			font-size: 0.875rem;
+		}
+
+		&__donation-msg {
+			font-size: 0.75rem;
+		}
+
+		&__empty {
+			padding: 28px 16px;
+		}
+	}
+
+	@media (max-width: 480px) {
+		&__cards {
+			grid-template-columns: 1fr;
+		}
+
+		&__card-label {
+			font-size: 0.75rem;
+		}
+
+		&__card-value {
+			font-size: 1.125rem;
+		}
+
+		&__widget-list {
+			gap: 10px;
+		}
+
+		&__widget-item {
+			padding: 12px 14px;
+			gap: 8px;
+		}
+
+		&__widget-label {
+			font-size: 0.875rem;
+		}
+
+		&__widget-desc {
+			font-size: 0.6875rem;
+		}
+
+		&__donation-list {
+			gap: 6px;
+		}
+
+		&__donation-info {
+			flex-direction: column;
+			align-items: flex-start;
+			gap: 2px;
+			margin-bottom: 6px;
+		}
+
+		&__donation-time {
+			font-size: 0.6875rem;
+		}
 	}
 }

+ 47 - 11
app/studio/donation/alert/_components/AlertListPanel.tsx

@@ -1,9 +1,11 @@
 'use client';
 
 import { useEffect, useRef, useState } from 'react';
+import Image from 'next/image';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faPlus, faImage, faVolumeHigh } from '@fortawesome/free-solid-svg-icons';
+import { faPlus, faImage, faPlay, faStop } from '@fortawesome/free-solid-svg-icons';
 import { Checkbox } from '@/components/ui/checkbox';
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
 import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
 import { PER_PAGE_OPTIONS } from '@/constants/donation';
 
@@ -37,6 +39,8 @@ export default function AlertListPanel({
 	onBatchDelete
 }: Props) {
 	const [playingAudio, setPlayingAudio] = useState<number|null>(null);
+	const [previewImageUrl, setPreviewImageUrl] = useState<string|null>(null);
+	const [thumbErrors, setThumbErrors] = useState<Set<number>>(new Set());
 	const audioRef = useRef<HTMLAudioElement|null>(null);
 
 	// ── 페이징 ───────────────────────────────────────
@@ -161,7 +165,7 @@ export default function AlertListPanel({
 							<tr>
 								<th className="alert-config__th--check">
 									<Checkbox
-										checked={isIndeterminate ? 'indeterminate' : allChecked}
+										checked={allChecked} indeterminate={isIndeterminate}
 										onCheckedChange={handleSelectAll}
 										aria-label="전체선택"
 									/>
@@ -210,14 +214,33 @@ export default function AlertListPanel({
 										<td className="alert-config__td--media">
 											<div>
 												{hasImage && (
-													<button
-														type="button"
-														className="alert-config__media-btn alert-config__media-btn--image"
-														title="이미지 보기"
-														onClick={() => window.open(item.imageUrl!, '_blank')}
-													>
-														<FontAwesomeIcon icon={faImage} />
-													</button>
+													thumbErrors.has(item.id) ? (
+														<button
+															type="button"
+															className="alert-config__media-btn alert-config__media-btn--image"
+															title="이미지 보기"
+															onClick={() => setPreviewImageUrl(item.imageUrl!)}
+														>
+															<FontAwesomeIcon icon={faImage} />
+														</button>
+													) : (
+														<button
+															type="button"
+															className="alert-config__media-thumb"
+															title="이미지 크게 보기"
+															onClick={() => setPreviewImageUrl(item.imageUrl!)}
+														>
+															<Image
+																src={item.imageUrl!}
+																alt="알림 이미지"
+																width={40}
+																height={40}
+																className="alert-config__media-thumb-img"
+																loading="lazy"
+																onError={() => setThumbErrors(prev => new Set(prev).add(item.id))}
+															/>
+														</button>
+													)
 												)}
 												{hasSound && (
 													<button
@@ -226,7 +249,7 @@ export default function AlertListPanel({
 														title={playingAudio === item.id ? '사운드 정지' : '사운드 재생'}
 														onClick={() => handlePlaySound(item.id, item.soundUrl!)}
 													>
-														<FontAwesomeIcon icon={faVolumeHigh} />
+														<FontAwesomeIcon icon={playingAudio === item.id ? faStop : faPlay} />
 													</button>
 												)}
 												{!hasImage && !hasSound && (
@@ -282,6 +305,19 @@ export default function AlertListPanel({
 					</button>
 				</div>
 			)}
+		{/* 이미지 확대 모달 */}
+		<Dialog open={!!previewImageUrl} onOpenChange={open => { if (!open) { setPreviewImageUrl(null); } }}>
+			<DialogContent className="alert-config__media-dialog">
+				<DialogTitle className="sr-only">이미지 미리보기</DialogTitle>
+				{previewImageUrl && (
+					<img
+						src={previewImageUrl}
+						alt="알림 이미지"
+						className="alert-config__media-dialog-img"
+					/>
+				)}
+			</DialogContent>
+		</Dialog>
 		</div>
 	);
 }

+ 55 - 0
app/studio/donation/alert/style.scss

@@ -279,6 +279,31 @@
 		}
 	}
 
+	&__media-thumb {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 40px;
+		height: 40px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		cursor: pointer;
+		overflow: hidden;
+		padding: 0;
+		transition: border-color 0.15s;
+
+		&:hover {
+			border-color: hsl(var(--primary));
+		}
+	}
+
+	&__media-thumb-img {
+		width: 100%;
+		height: 100%;
+		object-fit: cover;
+	}
+
 	&__media-btn {
 		display: inline-flex;
 		align-items: center;
@@ -312,9 +337,25 @@
 			color: hsl(var(--primary));
 			border-color: hsl(var(--primary));
 			background: hsl(var(--primary) / 0.1);
+			animation: alert-pulse 1.5s ease-in-out infinite;
 		}
 	}
 
+	&__media-dialog {
+		max-width: 90vw;
+		width: auto;
+		padding: 16px;
+	}
+
+	&__media-dialog-img {
+		display: block;
+		max-width: 100%;
+		max-height: 80vh;
+		margin: 0 auto;
+		border-radius: calc(var(--radius) - 2px);
+		object-fit: contain;
+	}
+
 	&__row--checked {
 		opacity: 0.5;
 
@@ -694,5 +735,19 @@
 		&__table {
 			min-width: 892px;
 		}
+
+		&__media-thumb {
+			width: 32px;
+			height: 32px;
+		}
+	}
+}
+
+@keyframes alert-pulse {
+	0%, 100% {
+		opacity: 1;
+	}
+	50% {
+		opacity: 0.5;
 	}
 }

+ 307 - 0
app/studio/donation/crew/[id]/_components/CrewMembersTab.tsx

@@ -0,0 +1,307 @@
+'use client';
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import type { CrewMemberItem, CrewMemberListResponse, SearchResultItem, SearchMemberResponse } from '@/types/response/crew/member';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+
+function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
+	return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
+}
+
+const EMPTY_MEMBER_FORM = {
+	nickname: '',
+	role: '',
+	sortOrder: 0,
+	isActive: true
+};
+
+type Props = {
+	crewID: number;
+};
+
+export default function CrewMembersTab({ crewID }: Props)
+{
+	const { channelID } = useStudioContext();
+	const [members, setMembers] = useState<CrewMemberItem[]>([]);
+	const [loading, setLoading] = useState(true);
+
+	// 초대 코드
+	const [inviteCode, setInviteCode] = useState<string|null>(null);
+	const [generatingCode, setGeneratingCode] = useState(false);
+
+	// 회원 검색
+	const [searchQuery, setSearchQuery] = useState('');
+	const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
+	const [showSearch, setShowSearch] = useState(false);
+	const searchTimer = useRef<ReturnType<typeof setTimeout>|null>(null);
+
+	// 크루원 편집 모달
+	const [editModal, setEditModal] = useState<{ open: boolean; member: CrewMemberItem|null }>({ open: false, member: null });
+	const [editForm, setEditForm] = useState(EMPTY_MEMBER_FORM);
+	const [saving, setSaving] = useState(false);
+
+	const fetchMembers = useCallback(async () => {
+		try {
+
+			const res = await fetchApi<CrewMemberListResponse>(`/api/studio/crew/member/list/${crewID}`);
+			setMembers(res.data?.list ?? []);
+
+		} catch {
+
+		} finally {
+			setLoading(false);
+		}
+	}, [crewID]);
+
+	useEffect(() => { fetchMembers(); }, [fetchMembers]);
+
+	// 회원 검색 (디바운스)
+	useEffect(() => {
+		if (searchTimer.current) {
+			clearTimeout(searchTimer.current);
+		}
+
+		if (searchQuery.trim().length < 2) {
+			setSearchResults([]);
+			return;
+		}
+
+		searchTimer.current = setTimeout(async () => {
+			try {
+
+				const res = await fetchApi<SearchMemberResponse>(
+					`/api/studio/crew/member/search?channelID=${channelID}&q=${encodeURIComponent(searchQuery)}&crewID=${crewID}`
+				);
+
+				setSearchResults(res.data?.list ?? []);
+			} catch {}
+		}, 300);
+
+		return () => {
+			if (searchTimer.current) {
+				clearTimeout(searchTimer.current);
+			}
+		};
+	}, [searchQuery, channelID, crewID]);
+
+	const handleAddMember = async (item: SearchResultItem) => {
+		try {
+
+			await fetchApi('/api/studio/crew/member/add', {
+				method: 'POST',
+				body: { crewID, targetMemberID: item.memberID, nickname: item.name ?? item.email, sortOrder: members.length }
+			});
+
+			setSearchQuery('');
+			setSearchResults([]);
+			setShowSearch(false);
+			fetchMembers();
+
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '추가에 실패했습니다.');
+		}
+	};
+
+	const handleRemove = async (memberID: number) => {
+		if (!confirm('이 크루원을 삭제하시겠습니까?')) {
+			return;
+		}
+
+		try {
+
+			await fetchApi(`/api/studio/crew/member/${memberID}`, { method: 'DELETE' });
+			fetchMembers();
+
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '삭제에 실패했습니다.');
+		}
+	};
+
+	const openEdit = (member: CrewMemberItem) => {
+		setEditForm({ nickname: member.nickname, role: member.role ?? '', sortOrder: member.sortOrder, isActive: member.isActive });
+		setEditModal({ open: true, member });
+	};
+
+	const handleUpdate = async () => {
+		if (!editModal.member) {
+			return;
+		}
+
+		setSaving(true);
+
+		try {
+
+			await fetchApi(`/api/studio/crew/member/${editModal.member.id}`, {
+				method: 'PUT',
+				body: {
+					crewMemberID: editModal.member.id,
+					nickname: editForm.nickname,
+					role: editForm.role || null,
+					sortOrder: editForm.sortOrder,
+					isActive: editForm.isActive
+				}
+			});
+
+			setEditModal({ open: false, member: null });
+			fetchMembers();
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '수정에 실패했습니다.');
+		} finally {
+			setSaving(false);
+		}
+	};
+
+	const handleGenerateCode = async () => {
+		setGeneratingCode(true);
+
+		try {
+
+			const res = await fetchApi<{ inviteCode: string }>('/api/studio/crew/invite/generate', {
+				method: 'POST',
+				body: { crewID }
+			});
+
+			setInviteCode(res.data?.inviteCode ?? null);
+
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '코드 생성에 실패했습니다.');
+		} finally {
+			setGeneratingCode(false);
+		}
+	};
+
+	const copyCode = () => {
+		if (inviteCode) {
+			navigator.clipboard.writeText(inviteCode);
+			alert('복사되었습니다.');
+		}
+	};
+
+	if (loading) return <p className="studio-page__empty">준비 중...</p>;
+
+	return (
+		<>
+			{/* 초대 코드 */}
+			<fieldset className="crew-invite">
+				<legend className="crew-invite__legend">초대 코드</legend>
+				<div className="crew-invite__body">
+					<Input
+						readOnly
+						value={inviteCode ?? ''}
+						placeholder="코드를 생성해 주세요"
+						className="crew-invite__input"
+					/>
+					<div className="crew-invite__actions">
+						{inviteCode ? (
+							<>
+								<Button variant="outline" onClick={copyCode}>복사</Button>
+								<Button variant="outline" onClick={handleGenerateCode} disabled={generatingCode}>
+									{generatingCode ? '생성 중...' : '재생성'}
+								</Button>
+							</>
+						) : (
+							<Button onClick={handleGenerateCode} disabled={generatingCode}>
+								{generatingCode ? '생성 중...' : '코드 생성'}
+							</Button>
+						)}
+					</div>
+				</div>
+			</fieldset>
+
+			{/* 크루원 관리 */}
+			<div className="crew-members">
+				<div className="crew-members__toolbar pb-3">
+					<h2 className="crew-members__subtitle">크루원 ({members.length}명)</h2>
+					<Button size="sm" onClick={() => setShowSearch(!showSearch)}>
+						{showSearch ? '닫기' : '+ 크루원 추가'}
+					</Button>
+				</div>
+
+				{showSearch && (
+					<div className="member-search" style={{ marginBottom: 16 }}>
+						<Input className="member-search__input" placeholder="별명 또는 이메일로 검색 (2자 이상)" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
+						{searchResults.length > 0 && (
+							<div className="member-search__results">
+								{searchResults.map(item => (
+									<button type="button" key={item.memberID} className="member-search__item" onClick={() => handleAddMember(item)}>
+										{item.thumb ? <img src={item.thumb} alt="" className="member-search__thumb" /> : <div className="member-search__thumb" />}
+										<div className="member-search__info">
+											<div className="member-search__name">{item.name ?? '(이름 없음)'}</div>
+											<div className="member-search__email">{item.email}</div>
+											{item.channelName && <div className="member-search__channel">{item.channelName}</div>}
+										</div>
+									</button>
+								))}
+							</div>
+						)}
+					</div>
+				)}
+
+				<div className="studio-page__table-wrap">
+					<table className="studio-page__table">
+						<thead>
+							<tr><th>순서</th><th>닉네임</th><th>역할</th><th>채널</th><th>상태</th><th>가입일</th><th>작업</th></tr>
+						</thead>
+						<tbody>
+							{members.length === 0 ? (
+								<tr><td colSpan={7} className="studio-page__empty">등록된 크루원이 없습니다.</td></tr>
+							) : members.map(m => (
+								<tr key={m.id}>
+									<td>{m.sortOrder}</td>
+									<td>{m.thumb && <img src={m.thumb} alt="" className="member-row__thumb" />}{m.nickname}</td>
+									<td>{m.role ?? '-'}</td>
+									<td>{m.channelName ?? '-'}</td>
+									<td><span className={`studio-page__badge studio-page__badge--${m.isActive ? 'active' : 'inactive'}`}>{m.isActive ? '활성' : '비활성'}</span></td>
+									<td>{new Date(m.joinedAt).toLocaleDateString('ko-KR')}</td>
+									<td>
+										<div className="studio-page__actions">
+											<Button variant="outline" size="sm" onClick={() => openEdit(m)}>수정</Button>
+											<Button variant="destructive" size="sm" onClick={() => handleRemove(m.id)}>삭제</Button>
+										</div>
+									</td>
+								</tr>
+							))}
+						</tbody>
+					</table>
+				</div>
+			</div>
+
+			{/* 크루원 편집 모달 */}
+			<Dialog open={editModal.open} onOpenChange={open => { if (!open) setEditModal({ open: false, member: null }); }}>
+				<DialogContent>
+					<DialogHeader>
+						<DialogTitle>크루원 수정</DialogTitle>
+					</DialogHeader>
+					<div className="space-y-4">
+						<div className="space-y-2">
+							<RequiredLabel htmlFor="member-nickname">닉네임</RequiredLabel>
+							<Input id="member-nickname" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
+						</div>
+						<div className="space-y-2">
+							<Label htmlFor="member-role">역할</Label>
+							<Input id="member-role" value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))} placeholder="예: 게이머, MC" />
+						</div>
+						<div className="space-y-2">
+							<Label htmlFor="member-order">순서</Label>
+							<Input id="member-order" type="number" min={0} value={editForm.sortOrder} onChange={e => setEditForm(f => ({ ...f, sortOrder: Number(e.target.value) }))} />
+						</div>
+						<div className="flex items-center gap-2">
+							<Checkbox id="member-active" checked={editForm.isActive} onCheckedChange={v => setEditForm(f => ({ ...f, isActive: !!v }))} />
+							<Label htmlFor="member-active">활성화</Label>
+						</div>
+					</div>
+					<DialogFooter className="flex flex-row justify-center gap-2 sm:justify-center">
+						<Button variant="outline" className="flex-1 sm:flex-none" onClick={() => setEditModal({ open: false, member: null })}>취소</Button>
+						<Button className="flex-1 sm:flex-none" onClick={handleUpdate} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
+					</DialogFooter>
+				</DialogContent>
+			</Dialog>
+		</>
+	);
+}

+ 140 - 0
app/studio/donation/crew/[id]/_components/CrewSessionTab.tsx

@@ -0,0 +1,140 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import type { ActiveSessionResponse, SessionHistoryResponse, SessionHistoryItem } from '@/types/response/crew/session';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+
+type Props = { crewID: number };
+
+export default function CrewSessionTab({ crewID }: Props)
+{
+	const [activeSession, setActiveSession] = useState<ActiveSessionResponse>(null);
+	const [history, setHistory] = useState<SessionHistoryItem[]>([]);
+	const [historyTotal, setHistoryTotal] = useState(0);
+	const [loading, setLoading] = useState(true);
+	const [sessionTitle, setSessionTitle] = useState('');
+	const [starting, setStarting] = useState(false);
+	const [ending, setEnding] = useState(false);
+
+	const fetchActiveSession = useCallback(async () => {
+		try {
+			const res = await fetchApi<ActiveSessionResponse>(`/api/studio/crew/session/active/${crewID}`);
+			setActiveSession(res.data ?? null);
+		} catch {}
+	}, [crewID]);
+
+	const fetchHistory = useCallback(async () => {
+		try {
+			const res = await fetchApi<SessionHistoryResponse>(`/api/studio/crew/session/history/${crewID}?page=1&perPage=10`);
+			setHistory(res.data?.list ?? []);
+			setHistoryTotal(res.data?.total ?? 0);
+		} catch {}
+	}, [crewID]);
+
+	useEffect(() => {
+		setLoading(true);
+		Promise.all([fetchActiveSession(), fetchHistory()]).finally(() => setLoading(false));
+	}, [fetchActiveSession, fetchHistory]);
+
+	const handleStart = async () => {
+		if (!sessionTitle.trim()) { alert('방송 제목을 입력해 주세요.'); return; }
+		setStarting(true);
+		try {
+			await fetchApi('/api/crew/session/start', { method: 'POST', body: { crewID, title: sessionTitle.trim() } });
+			setSessionTitle('');
+			fetchActiveSession();
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '세션 시작에 실패했습니다.');
+		} finally { setStarting(false); }
+	};
+
+	const handleEnd = async () => {
+		if (!activeSession) return;
+		if (!confirm('크루 방송을 종료하시겠습니까?\n종료 시 크루원에게 정산 결과가 전송됩니다.')) return;
+		setEnding(true);
+		try {
+			await fetchApi('/api/crew/session/end', { method: 'POST', body: { crewSessionID: activeSession.crewSessionID } });
+			setActiveSession(null);
+			fetchHistory();
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '세션 종료에 실패했습니다.');
+		} finally { setEnding(false); }
+	};
+
+	if (loading) return <p className="studio-page__empty">준비 중...</p>;
+
+	return (
+		<>
+			{activeSession ? (
+				<div className="session-active">
+					<div className="session-active__card">
+						<div className="session-active__top">
+							<div className="session-active__info">
+								<span className="session-active__session-title">{activeSession.title}</span>
+								<span className={`studio-page__badge studio-page__badge--${activeSession.status === 'Active' ? 'active' : 'warning'}`}>
+									{activeSession.status === 'Inviting' ? '동의 대기 중' : '방송 중'}
+								</span>
+							</div>
+							<Button variant="destructive" size="sm" onClick={handleEnd} disabled={ending || activeSession.status === 'Inviting'}>
+								{ending ? '종료 중...' : '방송 종료'}
+							</Button>
+						</div>
+						<div className="session-active__stats">
+							<div className="session-active__stat"><div className="session-active__stat-value">{activeSession.totalAmount.toLocaleString()}원</div><div className="session-active__stat-label">총 후원액</div></div>
+							<div className="session-active__stat"><div className="session-active__stat-value">{activeSession.totalDonationCount}건</div><div className="session-active__stat-label">후원 건수</div></div>
+							<div className="session-active__stat"><div className="session-active__stat-value">{activeSession.consents.filter(c => c.isConsented).length}/{activeSession.consents.length}</div><div className="session-active__stat-label">동의 현황</div></div>
+						</div>
+						<div className="session-consents">
+							<div className="session-consents__title">크루원 동의 현황</div>
+							<div className="session-consents__list">
+								{activeSession.consents.map(c => (
+									<div key={c.crewMemberID} className={`session-consents__item session-consents__item--${c.isConsented ? 'consented' : 'pending'}`}>
+										<span className="session-consents__icon">{c.isConsented ? '✓' : '⏳'}</span>{c.nickname}
+									</div>
+								))}
+							</div>
+						</div>
+						{activeSession.status === 'Active' && activeSession.summaries.length > 0 && (
+							<div style={{ marginTop: 16 }}>
+								<table className="studio-page__table">
+									<thead><tr><th>순위</th><th>크루원</th><th>후원액</th><th>건수</th><th>기여율</th></tr></thead>
+									<tbody>
+										{activeSession.summaries.map(s => (
+											<tr key={s.crewMemberID}><td>{s.rank}위</td><td>{s.nickname}</td><td>{s.totalAmount.toLocaleString()}원</td><td>{s.donationCount}건</td><td>{s.contributionRate.toFixed(1)}%</td></tr>
+										))}
+									</tbody>
+								</table>
+							</div>
+						)}
+					</div>
+				</div>
+			) : (
+				<div className="session-start">
+					<div className="session-start__title">새 크루 방송 시작</div>
+					<div className="session-start__form">
+						<Input className="session-start__input" placeholder="방송 제목을 입력하세요" value={sessionTitle} onChange={e => setSessionTitle(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleStart()} />
+						<Button onClick={handleStart} disabled={starting}>{starting ? '시작 중...' : '방송 시작'}</Button>
+					</div>
+				</div>
+			)}
+
+			<div className="session-history">
+				<div className="session-history__title">지난 방송 ({historyTotal}건)</div>
+				<div className="studio-page__table-wrap">
+					<table className="studio-page__table">
+						<thead><tr><th>제목</th><th>총 후원액</th><th>건수</th><th>시작</th><th>종료</th></tr></thead>
+						<tbody>
+							{history.length === 0 ? (
+								<tr><td colSpan={5} className="studio-page__empty">아직 진행한 크루 방송이 없습니다.</td></tr>
+							) : history.map(h => (
+								<tr key={h.id}><td>{h.title}</td><td>{h.totalAmount.toLocaleString()}원</td><td>{h.totalDonationCount}건</td><td>{h.startedAt ? new Date(h.startedAt).toLocaleString('ko-KR') : '-'}</td><td>{h.endedAt ? new Date(h.endedAt).toLocaleString('ko-KR') : '-'}</td></tr>
+							))}
+						</tbody>
+					</table>
+				</div>
+			</div>
+		</>
+	);
+}

+ 105 - 0
app/studio/donation/crew/[id]/_components/CrewSettingsTab.tsx

@@ -0,0 +1,105 @@
+'use client';
+
+import { useState } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import type { CrewItem } from '@/types/response/crew/list';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+
+function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
+	return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
+}
+
+function MoneyInput({ id, value, onChange, placeholder }: { id: string; value: string|number; onChange: (v: string) => void; placeholder?: string }) {
+	const [focused, setFocused] = useState(false);
+	const raw = String(value);
+	const display = focused || !raw ? raw : (Number(raw) ? Number(raw).toLocaleString() : raw);
+
+	return (
+		<Input
+			id={id} type={focused ? 'number' : 'text'} min={0} placeholder={placeholder}
+			value={display}
+			onChange={e => onChange(e.target.value)}
+			onFocus={() => setFocused(true)}
+			onBlur={() => setFocused(false)}
+		/>
+	);
+}
+
+type Props = {
+	crew: CrewItem;
+	onUpdated: () => void;
+};
+
+export default function CrewSettingsTab({ crew, onUpdated }: Props)
+{
+	const { channelID } = useStudioContext();
+	const [form, setForm] = useState({
+		name: crew.name,
+		description: crew.description ?? '',
+		minAmount: crew.minAmount ?? ('' as string|number),
+		isActive: crew.isActive
+	});
+	const [saving, setSaving] = useState(false);
+
+	const handleSave = async () => {
+		if (!form.name.trim()) {
+			alert('크루명을 입력해 주세요.'); return;
+		}
+
+		setSaving(true);
+
+		try {
+
+			await fetchApi('/api/studio/crew/save', {
+				method: 'POST',
+				body: {
+					channelID,
+					id: crew.id,
+					name: form.name,
+					description: form.description || undefined,
+					minAmount: form.minAmount !== '' ? Number(form.minAmount) : undefined,
+					isActive: form.isActive
+				}
+			});
+
+			alert('저장되었습니다.');
+			onUpdated();
+
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '저장에 실패했습니다.');
+		} finally {
+			setSaving(false);
+		}
+	};
+
+	return (
+		<div className="crew-settings">
+			<div className="space-y-4 max-w-lg">
+				<div className="space-y-2">
+					<RequiredLabel htmlFor="crew-name">크루명</RequiredLabel>
+					<Input id="crew-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
+				</div>
+				<div className="space-y-2">
+					<Label htmlFor="crew-desc">설명</Label>
+					<Input id="crew-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
+				</div>
+				<div className="space-y-2">
+					<Label htmlFor="crew-min">최소 후원금 (원)</Label>
+					<MoneyInput id="crew-min" placeholder="미설정" value={form.minAmount} onChange={v => setForm(f => ({ ...f, minAmount: v }))} />
+					<p className="text-xs text-muted-foreground">이 금액 이상 후원한 회원만 크루에 가입할 수 있습니다.</p>
+				</div>
+				<div className="flex items-center gap-2">
+					<Checkbox id="crew-active" checked={form.isActive} onCheckedChange={v => setForm(f => ({ ...f, isActive: !!v }))} />
+					<Label htmlFor="crew-active">활성화</Label>
+				</div>
+				<div className="pt-4 border-t">
+					<Button onClick={handleSave} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
+				</div>
+			</div>
+		</div>
+	);
+}

+ 181 - 0
app/studio/donation/crew/[id]/_components/CrewWidgetTab.tsx

@@ -0,0 +1,181 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import type { CrewWidgetConfigResponse, CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
+import { CREW_PERIODS, CREW_WIDGET_THEMES } from '../../widget/constants';
+import { type FormState, createEmptyForm, formatDateTime } from '../../widget/types';
+import CrewWidgetFormPanel from '../../widget/_components/CrewWidgetFormPanel';
+import CrewWidgetPreviewPanel from '../../widget/_components/CrewWidgetPreviewPanel';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Separator } from '@/components/ui/separator';
+
+type Props = { crewID: number };
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export default function CrewWidgetTab({ crewID: _crewID }: Props)
+{
+	const { channelID } = useStudioContext();
+	const [items, setItems] = useState<CrewWidgetConfigItem[]>([]);
+	const [loading, setLoading] = useState(true);
+	const [saving, setSaving] = useState(false);
+	const [editItem, setEditItem] = useState<CrewWidgetConfigItem|null>(null);
+	const [showForm, setShowForm] = useState(false);
+	const [selected, setSelected] = useState<Set<number>>(new Set());
+	const [form, setForm] = useState<FormState>(createEmptyForm());
+
+	const fetchList = useCallback(() => {
+		if (!channelID) {
+			setLoading(false);
+			return;
+		}
+
+		setLoading(true);
+		fetchApi<CrewWidgetConfigResponse>(`/api/studio/crew/widget/config/${channelID}`)
+			.then(res => setItems(res.data?.list ?? []))
+			.catch(() => {})
+			.finally(() => setLoading(false));
+
+	}, [channelID]);
+
+	useEffect(() => {
+		fetchList();
+	}, [fetchList]);
+
+	const getPeriodLabel = (p: number) => CREW_PERIODS.find(x => x.value === p)?.label ?? '-';
+	const getThemeLabel = (t: number) => CREW_WIDGET_THEMES.find(x => x.value === t)?.label ?? '-';
+
+	const openAdd = () => {
+		setEditItem(null);
+		setForm(createEmptyForm());
+		setShowForm(true);
+	};
+
+	const openEdit = (item: CrewWidgetConfigItem) => {
+		setEditItem(item);
+		setShowForm(true);
+	};
+
+	const handleSaved = () => {
+		setShowForm(false);
+		setEditItem(null);
+		fetchList();
+	};
+
+	const toggleSelect = (id: number) => {
+		setSelected(prev => {
+			const next = new Set(prev);
+			if (next.has(id)) {
+				next.delete(id);
+			} else {
+				next.add(id);
+			}
+			return next;
+		});
+	};
+
+	const handleBatchDelete = async () => {
+		if (selected.size === 0) {
+			return;
+		}
+
+		if (!confirm(`${selected.size}개 설정을 삭제하시겠습니까?`)) {
+			return;
+		}
+
+		for (const id of selected) {
+			try {
+				await fetchApi(`/api/studio/crew/widget/config/${id}/${channelID}`, { method: 'DELETE' });
+			} catch {
+
+			}
+		}
+
+		setSelected(new Set());
+		fetchList();
+	};
+
+	if (loading) {
+		return <p className="studio-page__empty">준비 중...</p>;
+	}
+
+	if (showForm) {
+		return (
+			<>
+				<div className="crew-members__toolbar">
+					<h2 className="crew-members__subtitle">{editItem ? '위젯 수정' : '위젯 추가'}</h2>
+					<Button variant="outline" size="sm" type="button" onClick={() => setShowForm(false)}>< 뒤로가기</Button>
+				</div>
+				<div className="pt-3 pb-3">
+					<Separator orientation="horizontal" />
+				</div>
+				<div className="crew-widget-layout">
+					<CrewWidgetPreviewPanel form={form} />
+					<Separator orientation="vertical" />
+					<CrewWidgetFormPanel
+						editItem={editItem ?? undefined}
+						form={form}
+						onFormChange={setForm}
+						onSaved={handleSaved}
+						onCancel={() => setShowForm(false)}
+						channelID={channelID ?? undefined}
+						saving={saving}
+						setSaving={setSaving}
+					/>
+				</div>
+			</>
+		);
+	}
+
+	return (
+		<div className="crew-widget">
+			<div className="crew-members__toolbar pb-3">
+				<h2 className="crew-members__subtitle">위젯 설정 ({items.length}개)</h2>
+				<div className="studio-page__actions">
+					{selected.size > 0 && (
+						<Button variant="destructive" size="sm" type="button" onClick={handleBatchDelete}>{selected.size}개 삭제</Button>
+					)}
+					<Button size="sm" type="button" onClick={openAdd}>+ 추가</Button>
+				</div>
+			</div>
+
+			<div className="studio-page__table-wrap">
+				<table className="studio-page__table">
+					<thead>
+						<tr>
+							<th><Checkbox checked={items.length > 0 && selected.size === items.length} onCheckedChange={() => setSelected(items.length === selected.size ? new Set() : new Set(items.map(i => i.id)))} /></th>
+							<th>제목</th>
+							<th>기간</th>
+							<th>테마</th>
+							<th>최대 표시</th>
+							<th>활성</th>
+							<th>작업</th>
+						</tr>
+					</thead>
+					<tbody>
+						{items.length === 0 ? (
+							<tr><td colSpan={7} className="studio-page__empty">등록된 위젯 설정이 없습니다.</td></tr>
+						) : items.map(item => (
+							<tr key={item.id}>
+								<td><Checkbox checked={selected.has(item.id)} onCheckedChange={() => toggleSelect(item.id)} /></td>
+								<td>{item.title}</td>
+								<td>
+									{getPeriodLabel(item.period)}
+									{item.period === 5 && item.startAt && item.endAt && (
+										<div className="text-xs text-muted-foreground">{formatDateTime(item.startAt)} ~ {formatDateTime(item.endAt)}</div>
+									)}
+								</td>
+								<td>{getThemeLabel(item.theme)}</td>
+								<td>{item.maxDisplayCount}명</td>
+								<td><span className={`studio-page__badge studio-page__badge--${item.isActive ? 'active' : 'inactive'}`}>{item.isActive ? '활성' : '비활성'}</span></td>
+								<td><Button variant="outline" size="sm" type="button" onClick={() => openEdit(item)}>수정</Button></td>
+							</tr>
+						))}
+					</tbody>
+				</table>
+			</div>
+		</div>
+	);
+}

+ 96 - 0
app/studio/donation/crew/[id]/page.tsx

@@ -0,0 +1,96 @@
+'use client';
+
+import './style.scss';
+import '../../crew/widget/style.scss';
+import { useState, useEffect, useCallback } from 'react';
+import { useParams } from 'next/navigation';
+import Link from 'next/link';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import type { CrewItem, CrewListResponse } from '@/types/response/crew/list';
+import { Button } from '@/components/ui/button';
+import CrewSettingsTab from './_components/CrewSettingsTab';
+import CrewMembersTab from './_components/CrewMembersTab';
+import CrewWidgetTab from './_components/CrewWidgetTab';
+import CrewSessionTab from './_components/CrewSessionTab';
+
+const TABS = [
+	{ key: 'settings', label: '기본 설정' },
+	{ key: 'members', label: '크루원' },
+	{ key: 'widget', label: '위젯' },
+	{ key: 'session', label: '세션' }
+] as const;
+
+type TabKey = typeof TABS[number]['key'];
+
+export default function CrewDetailPage()
+{
+	const { id } = useParams<{ id: string }>();
+	const { channelID } = useStudioContext();
+	const crewID = Number(id);
+
+	const [crew, setCrew] = useState<CrewItem|null>(null);
+	const [loading, setLoading] = useState(true);
+	const [activeTab, setActiveTab] = useState<TabKey>('members');
+
+	const fetchCrew = useCallback(async () => {
+		if (!channelID) return;
+		try {
+			const res = await fetchApi<CrewListResponse>(`/api/studio/crew/list/${channelID}`);
+			const found = res.data?.list.find(c => c.id === crewID);
+			if (found) setCrew(found);
+		} catch {}
+	}, [channelID, crewID]);
+
+	useEffect(() => {
+		setLoading(true);
+		fetchCrew().finally(() => setLoading(false));
+	}, [fetchCrew]);
+
+	if (loading) {
+		return <div className="studio-page"><p className="studio-page__empty">준비 중...</p></div>;
+	}
+
+	if (!crew) {
+		return <div className="studio-page"><p className="studio-page__empty">크루를 찾을 수 없습니다.</p></div>;
+	}
+
+	return (
+		<div className="studio-page crew-detail">
+			{/* 헤더: 제목 + 액션 버튼 */}
+			<div className="crew-detail__header">
+				<div className="crew-detail__info">
+					<h1 className="crew-detail__title">{crew.name}</h1>
+					{crew.description && <p className="crew-detail__desc">{crew.description}</p>}
+				</div>
+				<div className="crew-detail__actions">
+					<Button variant="outline" size="sm" asChild>
+						<Link href="/studio/donation/crew">< 목록으로</Link>
+					</Button>
+				</div>
+			</div>
+
+			{/* 탭 네비게이션 */}
+			<nav className="crew-tabs">
+				{TABS.map(tab => (
+					<button
+						type="button"
+						key={tab.key}
+						className={`crew-tabs__item${activeTab === tab.key ? ' crew-tabs__item--active' : ''}`}
+						onClick={() => setActiveTab(tab.key)}
+					>
+						{tab.label}
+					</button>
+				))}
+			</nav>
+
+			{/* 탭 컨텐츠 */}
+			<div className="crew-tabs__content">
+				{activeTab === 'settings' && <CrewSettingsTab crew={crew} onUpdated={fetchCrew} />}
+				{activeTab === 'members' && <CrewMembersTab crewID={crewID} />}
+				{activeTab === 'widget' && <CrewWidgetTab crewID={crewID} />}
+				{activeTab === 'session' && <CrewSessionTab crewID={crewID} />}
+			</div>
+		</div>
+	);
+}

+ 489 - 0
app/studio/donation/crew/[id]/style.scss

@@ -0,0 +1,489 @@
+.crew-detail {
+	&__header {
+		display: flex;
+		align-items: flex-start;
+		justify-content: space-between;
+		gap: 16px;
+		margin-bottom: 14px;
+
+		@media (max-width: 768px) {
+			flex-direction: column;
+			gap: 12px;
+		}
+	}
+
+	&__info {
+		flex: 1;
+		min-width: 0;
+	}
+
+	&__title {
+		font-size: 1.5rem;
+		font-weight: 700;
+		margin: 0;
+		line-height: 1.3;
+	}
+
+	&__desc {
+		color: var(--muted-foreground);
+		font-size: 0.875rem;
+		margin-top: 4px;
+	}
+
+	&__actions {
+		display: flex;
+		gap: 8px;
+		flex-shrink: 0;
+		align-items: center;
+	}
+}
+
+.crew-tabs {
+	display: flex;
+	gap: 0;
+	border-bottom: 1px solid var(--border);
+	margin-bottom: 24px;
+
+	@media (max-width: 768px) {
+		overflow-x: auto;
+		-webkit-overflow-scrolling: touch;
+
+		&::-webkit-scrollbar {
+			display: none;
+		}
+	}
+
+	&__item {
+		padding: 14px 24px;
+		border: none;
+		background: none;
+		cursor: pointer;
+		font-size: 0.875rem;
+		font-weight: 500;
+		color: var(--muted-foreground);
+		border-bottom: 3px solid transparent;
+		margin-bottom: -1px;
+		transition: color 0.15s;
+		white-space: nowrap;
+		letter-spacing: 0.01em;
+
+		&:first-of-type {
+			padding-left: 0;
+		}
+
+		&:hover {
+			color: var(--foreground);
+		}
+
+		&--active {
+			color: var(--foreground);
+			font-weight: 600;
+			border-bottom-color: hsl(var(--foreground));
+		}
+	}
+
+	&__content {
+		min-height: 200px;
+	}
+}
+
+.crew-members {
+	&__toolbar {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		margin-bottom: 0;
+
+		@media (max-width: 768px) {
+			align-items: flex-start;
+			gap: 8px;
+		}
+	}
+
+	&__subtitle {
+		font-size: 1rem;
+		font-weight: 600;
+		margin: 0;
+	}
+
+	&__actions {
+		display: flex;
+		gap: 8px;
+	}
+
+	.studio-page__table-wrap {
+		@media (max-width: 768px) {
+			overflow-x: auto;
+		}
+	}
+
+	.studio-page__table {
+		@media (max-width: 768px) {
+			min-width: 640px;
+		}
+	}
+}
+
+.crew-invite {
+	margin-bottom: 24px;
+	padding: 16px;
+	border: 1px solid hsl(var(--border));
+	border-radius: 8px;
+
+	@media (min-width: 1024px) {
+		max-width: 420px;
+	}
+
+	&__legend {
+		font-size: 0.875rem;
+		font-weight: 600;
+		padding: 0 6px;
+	}
+
+	&__body {
+		display: flex;
+		gap: 8px;
+		align-items: center;
+
+		@media (max-width: 768px) {
+			flex-direction: column;
+		}
+	}
+
+	&__input {
+		flex: 1;
+		font-family: monospace;
+		font-size: 1rem;
+		font-weight: 600;
+		letter-spacing: -1px;
+
+		@media (max-width: 768px) {
+			width: 100%;
+		}
+	}
+
+	&__actions {
+		display: flex;
+		gap: 8px;
+		flex-shrink: 0;
+
+		@media (max-width: 768px) {
+			width: 100%;
+
+			> button {
+				flex: 1;
+			}
+		}
+	}
+}
+
+.member-search {
+	position: relative;
+
+	&__input {
+		width: 100%;
+		padding: 8px 12px;
+		border: 1px solid var(--border-default);
+		border-radius: 6px;
+		font-size: 0.875rem;
+		background: var(--background);
+		color: var(--foreground);
+	}
+
+	&__results {
+		position: absolute;
+		top: 100%;
+		left: 0;
+		right: 0;
+		z-index: 10;
+		background: var(--background);
+		border: 1px solid var(--border);
+		border-radius: 6px;
+		max-height: 240px;
+		overflow-y: auto;
+		margin-top: 4px;
+		box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+	}
+
+	&__item {
+		display: flex;
+		align-items: center;
+		gap: 10px;
+		padding: 8px 12px;
+		cursor: pointer;
+		border: none;
+		background: none;
+		width: 100%;
+		text-align: left;
+		font: inherit;
+		color: var(--foreground);
+
+		&:hover {
+			background: var(--accent);
+		}
+	}
+
+	&__thumb {
+		width: 32px;
+		height: 32px;
+		border-radius: 50%;
+		background: var(--muted);
+		object-fit: cover;
+	}
+
+	&__info {
+		flex: 1;
+		min-width: 0;
+	}
+
+	&__name {
+		font-weight: 500;
+		font-size: 0.875rem;
+	}
+
+	&__email {
+		font-size: 0.75rem;
+		color: var(--muted-foreground);
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+
+	&__channel {
+		font-size: 0.75rem;
+		color: var(--primary);
+	}
+}
+
+// ── 위젯 설정 탭 ──────────────────────────────────
+.crew-widget {
+	.studio-page__table-wrap {
+		@media (max-width: 768px) {
+			overflow-x: auto;
+		}
+	}
+
+	.studio-page__table {
+		@media (max-width: 768px) {
+			min-width: 640px;
+		}
+	}
+}
+
+// ── 세션 탭 ────────────────────────────────────
+.session-active {
+	margin-bottom: 32px;
+
+	&__card {
+		border: 1px solid var(--border);
+		border-radius: 8px;
+		padding: 20px;
+		background: var(--card);
+
+		@media (max-width: 768px) {
+			padding: 14px;
+		}
+	}
+
+	&__top {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		margin-bottom: 16px;
+
+		@media (max-width: 768px) {
+			flex-direction: column;
+			align-items: flex-start;
+			gap: 10px;
+		}
+	}
+
+	&__info {
+		display: flex;
+		align-items: center;
+		gap: 12px;
+
+		@media (max-width: 768px) {
+			flex-wrap: wrap;
+		}
+	}
+
+	&__session-title {
+		font-size: 1.1rem;
+		font-weight: 600;
+	}
+
+	&__stats {
+		display: flex;
+		gap: 24px;
+		margin-bottom: 20px;
+		padding: 12px 16px;
+		background: var(--accent);
+		border-radius: 6px;
+
+		@media (max-width: 768px) {
+			flex-direction: column;
+			gap: 12px;
+		}
+	}
+
+	&__stat {
+		text-align: center;
+
+		@media (max-width: 768px) {
+			display: flex;
+			align-items: center;
+			justify-content: space-between;
+			text-align: left;
+		}
+
+		&-value {
+			font-size: 1.5rem;
+			font-weight: 700;
+			color: var(--primary);
+
+			@media (max-width: 768px) {
+				font-size: 1.25rem;
+			}
+		}
+
+		&-label {
+			font-size: 0.75rem;
+			color: var(--muted-foreground);
+			margin-top: 2px;
+
+			@media (max-width: 768px) {
+				margin-top: 0;
+			}
+		}
+	}
+
+	.studio-page__table-wrap {
+		@media (max-width: 768px) {
+			overflow-x: auto;
+		}
+	}
+
+	.studio-page__table {
+		@media (max-width: 768px) {
+			min-width: 480px;
+		}
+	}
+}
+
+.session-consents {
+	margin-bottom: 16px;
+
+	&__title {
+		font-size: 0.875rem;
+		font-weight: 600;
+		margin-bottom: 8px;
+	}
+
+	&__list {
+		display: flex;
+		flex-wrap: wrap;
+		gap: 8px;
+
+		@media (max-width: 768px) {
+			gap: 6px;
+		}
+	}
+
+	&__item {
+		display: flex;
+		align-items: center;
+		gap: 6px;
+		padding: 6px 12px;
+		border-radius: 20px;
+		font-size: 0.875rem;
+		border: 1px solid var(--border);
+
+		&--consented {
+			background: hsl(var(--primary) / 0.1);
+			border-color: hsl(var(--primary) / 0.3);
+		}
+
+		&--pending {
+			background: var(--accent);
+		}
+	}
+
+	&__icon {
+		font-size: 0.875rem;
+	}
+}
+
+.session-start {
+	border: 2px dashed var(--border);
+	border-radius: 8px;
+	padding: 32px;
+	text-align: center;
+	margin-bottom: 0;
+
+	@media (max-width: 768px) {
+		padding: 20px;
+	}
+
+	&__title {
+		font-size: 1.1rem;
+		font-weight: 600;
+		margin-bottom: 16px;
+	}
+
+	&__form {
+		display: flex;
+		gap: 12px;
+		justify-content: center;
+		align-items: center;
+		max-width: 480px;
+		margin: 0 auto;
+
+		@media (max-width: 768px) {
+			flex-direction: column;
+		}
+	}
+
+	&__input {
+		flex: 1;
+		padding: 8px 12px;
+		font-size: 0.875rem;
+
+		@media (max-width: 768px) {
+			width: 100%;
+		}
+	}
+}
+
+.session-history {
+	&__title {
+		font-size: 1rem;
+		font-weight: 600;
+		margin-bottom: 16px;
+	}
+
+	.studio-page__table-wrap {
+		@media (max-width: 768px) {
+			overflow-x: auto;
+		}
+	}
+
+	.studio-page__table {
+		@media (max-width: 768px) {
+			min-width: 580px;
+		}
+	}
+}
+
+// ── 멤버 아바타 ─────────────────────────────────
+.member-row {
+	&__thumb {
+		width: 28px;
+		height: 28px;
+		border-radius: 50%;
+		background: var(--muted);
+		object-fit: cover;
+		vertical-align: middle;
+		margin-right: 8px;
+	}
+}

+ 75 - 49
app/studio/donation/crew/page.tsx

@@ -2,9 +2,37 @@
 
 import './style.scss';
 import { useState, useEffect, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
 import { fetchApi } from '@/lib/utils/client';
 import { useStudioContext } from '@/app/studio/context';
 import type { CrewListResponse, CrewItem } from '@/types/response/crew/list';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
+
+function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
+	return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
+}
+
+function MoneyInput({ id, value, onChange, placeholder }: { id: string; value: string|number; onChange: (v: string) => void; placeholder?: string }) {
+	const [focused, setFocused] = useState(false);
+	const raw = String(value);
+	const display = focused || !raw ? raw : (Number(raw) ? Number(raw).toLocaleString() : raw);
+
+	return (
+		<Input
+			id={id} type={focused ? 'number' : 'text'} min={0} placeholder={placeholder}
+			value={display}
+			onKeyDown={e => { if (!/^[0-9]$/.test(e.key) && !['Backspace','Delete','ArrowLeft','ArrowRight','Tab','Home','End'].includes(e.key)) e.preventDefault(); }}
+			onCompositionStart={e => e.preventDefault()}
+			onChange={e => onChange(e.target.value.replace(/[^0-9]/g, ''))}
+			onFocus={() => setFocused(true)}
+			onBlur={() => setFocused(false)}
+		/>
+	);
+}
 
 const EMPTY_FORM = {
 	name: '',
@@ -13,7 +41,9 @@ const EMPTY_FORM = {
 	isActive: true
 };
 
-export default function CrewConfigPage() {
+export default function CrewConfigPage()
+{
+	const router = useRouter();
 	const { channelID } = useStudioContext();
 	const [items, setItems] = useState<CrewItem[]>([]);
 	const [loading, setLoading] = useState(true);
@@ -70,19 +100,17 @@ export default function CrewConfigPage() {
 		setSaving(true);
 
 		try {
-			const body = {
-				channelID,
-				id: modal.editing?.id ?? undefined,
-				name: form.name,
-				description: form.description || undefined,
-				minAmount: form.minAmount !== '' ? Number(form.minAmount) : undefined,
-				isActive: form.isActive
-			};
-
-			const res = await fetchApi('/api/studio/crew/save', {
+
+			await fetchApi('/api/studio/crew/save', {
 				method: 'POST',
-				headers: { 'Content-Type': 'application/json' },
-				body: JSON.stringify(body)
+				body: {
+					channelID,
+					id: modal.editing?.id ?? undefined,
+					name: form.name,
+					description: form.description || undefined,
+					minAmount: form.minAmount !== '' ? Number(form.minAmount) : undefined,
+					isActive: form.isActive
+				}
 			});
 
 			closeModal();
@@ -95,12 +123,10 @@ export default function CrewConfigPage() {
 	};
 
 	return (
-		<div className="studio-page">
+		<div className="studio-page crew-config">
 			<div className="studio-page__header">
 				<h1 className="studio-page__title">크루 후원 설정</h1>
-				<button type="button" className="studio-page__btn studio-page__btn--primary" onClick={openAdd}>
-					+ 크루 추가
-				</button>
+				<Button onClick={openAdd}>+ 크루 추가</Button>
 			</div>
 
 			<div className="studio-page__table-wrap">
@@ -128,7 +154,11 @@ export default function CrewConfigPage() {
 							) : (
 								items.map(item => (
 									<tr key={item.id}>
-										<td className="crew-config__name">{item.name}</td>
+										<td className="crew-config__name">
+											<button type="button" className="crew-config__link" onClick={() => router.push(`/studio/donation/crew/${item.id}`)}>
+												{item.name}
+											</button>
+										</td>
 										<td className="crew-config__desc">{item.description ?? '-'}</td>
 										<td>{item.minAmount ? `${item.minAmount.toLocaleString()}원` : '-'}</td>
 										<td>{item.memberCount}명</td>
@@ -139,7 +169,8 @@ export default function CrewConfigPage() {
 										</td>
 										<td>
 											<div className="studio-page__actions">
-												<button type="button" className="studio-page__btn" onClick={() => openEdit(item)}>수정</button>
+												<Button variant="outline" size="sm" onClick={() => openEdit(item)}>수정</Button>
+												<Button variant="outline" size="sm" onClick={() => router.push(`/studio/donation/crew/${item.id}`)}>관리</Button>
 											</div>
 										</td>
 									</tr>
@@ -150,41 +181,36 @@ export default function CrewConfigPage() {
 				)}
 			</div>
 
-			{modal.open && (
-				<div className="studio-modal">
-					<div className="studio-modal__overlay" onClick={closeModal} />
-					<div className="studio-modal__box">
-						<h2 className="studio-modal__title">{modal.editing ? '크루 수정' : '크루 추가'}</h2>
-
-						<div className="studio-modal__field">
-							<label>크루명 *</label>
-							<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} title='크루명' />
+			<Dialog open={modal.open} onOpenChange={open => { if (!open) closeModal(); }}>
+				<DialogContent>
+					<DialogHeader>
+						<DialogTitle>{modal.editing ? '크루 수정' : '크루 추가'}</DialogTitle>
+					</DialogHeader>
+					<div className="space-y-4">
+						<div className="space-y-2">
+							<RequiredLabel htmlFor="crew-name">크루명</RequiredLabel>
+							<Input id="crew-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
 						</div>
-						<div className="studio-modal__field">
-							<label>설명</label>
-							<input type="text" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} title='설명' />
+						<div className="space-y-2">
+							<Label htmlFor="crew-desc">설명</Label>
+							<Input id="crew-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
 						</div>
-						<div className="studio-modal__field">
-							<label>최소 후원금 (원)</label>
-							<input type="number" min={0} placeholder="미설정" value={form.minAmount} onChange={e => setForm(f => ({ ...f, minAmount: e.target.value }))} />
-							<p className="studio-modal__hint">이 금액 이상 후원한 회원만 크루에 가입할 수 있습니다.</p>
+						<div className="space-y-2">
+							<Label htmlFor="crew-min">최소 후원금 (원)</Label>
+							<MoneyInput id="crew-min" placeholder="미설정" value={form.minAmount} onChange={v => setForm(f => ({ ...f, minAmount: v }))} />
+							<p className="text-xs text-muted-foreground">이 금액 이상 후원한 회원만 크루에 가입할 수 있습니다.</p>
 						</div>
-						<div className="studio-modal__field">
-							<label className="studio-modal__checkbox-label">
-								<input type="checkbox" checked={form.isActive} onChange={e => setForm(f => ({ ...f, isActive: e.target.checked }))} />
-								활성화
-							</label>
-						</div>
-
-						<div className="studio-modal__footer">
-							<button type="button" className="studio-page__btn" onClick={closeModal}>취소</button>
-							<button type="button" className="studio-page__btn studio-page__btn--primary" onClick={handleSave} disabled={saving}>
-								{saving ? '저장 중...' : '저장'}
-							</button>
+						<div className="flex items-center gap-2">
+							<Checkbox id="crew-active" checked={form.isActive} onCheckedChange={v => setForm(f => ({ ...f, isActive: !!v }))} />
+							<Label htmlFor="crew-active">활성화</Label>
 						</div>
 					</div>
-				</div>
-			)}
+					<DialogFooter className="flex flex-row justify-center gap-0 sm:justify-center">
+						<Button variant="outline" className="flex-1 sm:flex-none" onClick={closeModal}>취소</Button>
+						<Button className="flex-1 sm:flex-none" onClick={handleSave} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
+					</DialogFooter>
+				</DialogContent>
+			</Dialog>
 		</div>
 	);
 }

+ 28 - 0
app/studio/donation/crew/style.scss

@@ -3,6 +3,21 @@
 		font-weight: 500;
 	}
 
+	&__link {
+		color: var(--primary);
+		text-decoration: none;
+		cursor: pointer;
+		background: none;
+		border: none;
+		padding: 0;
+		font: inherit;
+		font-weight: 500;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
 	&__desc {
 		max-width: 180px;
 		overflow: hidden;
@@ -10,4 +25,17 @@
 		white-space: nowrap;
 		color: var(--muted-foreground);
 	}
+
+	// 목록 테이블 모바일 가로 스크롤
+	.studio-page__table-wrap {
+		@media (max-width: 768px) {
+			overflow-x: auto;
+		}
+	}
+
+	.studio-page__table {
+		@media (max-width: 768px) {
+			min-width: 580px;
+		}
+	}
 }

+ 371 - 0
app/studio/donation/crew/widget/_components/CrewWidgetFormPanel.tsx

@@ -0,0 +1,371 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import { useCrewWidgetConfigContext } from '../context';
+import { CREW_WIDGET_THEMES, CREW_PERIODS, FONT_FAMILIES } from '../constants';
+import { type FormState, createEmptyForm, formatInput, parseInput } from '../types';
+import type { CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
+import { Checkbox } from '@/components/ui/checkbox';
+
+/** 색상 입력 (color picker + hex text) */
+function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+	return (
+		<div className="crew-widget-form__color-field">
+			<input
+				type="color"
+				className="crew-widget-form__color-picker"
+				value={value}
+				onChange={e => onChange(e.target.value)}
+			/>
+			<input
+				type="text"
+				className="crew-widget-form__input crew-widget-form__input--color-text"
+				value={value}
+				onChange={e => onChange(e.target.value)}
+				maxLength={7}
+			/>
+		</div>
+	);
+}
+
+type Props = {
+	editItem?: CrewWidgetConfigItem;
+	form?: FormState;
+	onFormChange?: (form: FormState) => void;
+	onSaved?: () => void;
+	onCancel?: () => void;
+	channelID?: number;
+	saving?: boolean;
+	setSaving?: (v: boolean) => void;
+};
+
+export default function CrewWidgetFormPanel({ editItem, form: externalForm, onFormChange, onSaved, onCancel, channelID: directChannelID, saving: directSaving, setSaving: directSetSaving }: Props)
+{
+	const router = useRouter();
+	const studio = useStudioContext();
+	const channelID = directChannelID ?? studio.channelID;
+
+	// context 사용 가능 여부에 따라 분기
+	let ctxSaving = false;
+	let ctxSetSaving: (v: boolean) => void = () => {};
+	let ctxFetchList: () => void = () => {};
+
+	try {
+		const ctx = useCrewWidgetConfigContext();
+		ctxSaving = ctx.saving;
+		ctxSetSaving = ctx.setSaving;
+		ctxFetchList = ctx.fetchList;
+	} catch {
+
+	}
+
+	const saving = directSaving ?? ctxSaving;
+	const setSaving = directSetSaving ?? ctxSetSaving;
+	const fetchList = ctxFetchList;
+
+	const [internalForm, setInternalForm] = useState<FormState>(createEmptyForm());
+	const form = externalForm ?? internalForm;
+
+	useEffect(() => {
+		if (!editItem) {
+			return;
+		}
+
+		const data: FormState = {
+			title: editItem.title,
+			theme: editItem.theme,
+			period: editItem.period,
+			startAt: editItem.startAt,
+			endAt: editItem.endAt,
+			maxDisplayCount: editItem.maxDisplayCount,
+			isShowAmount: editItem.isShowAmount,
+			isShowDonationCount: editItem.isShowDonationCount,
+			isShowContributionRate: editItem.isShowContributionRate,
+			isShowMemberIcon: editItem.isShowMemberIcon,
+			isActive: editItem.isActive,
+			bgColor: editItem.bgColor,
+			titleFontFamily: editItem.titleFontFamily,
+			titleFontSizePx: editItem.titleFontSizePx,
+			titleFontColor: editItem.titleFontColor,
+			rank1FontFamily: editItem.rank1FontFamily,
+			rank1FontSizePx: editItem.rank1FontSizePx,
+			rank1FontColor: editItem.rank1FontColor,
+			rank2FontFamily: editItem.rank2FontFamily,
+			rank2FontSizePx: editItem.rank2FontSizePx,
+			rank2FontColor: editItem.rank2FontColor,
+			rank3FontFamily: editItem.rank3FontFamily,
+			rank3FontSizePx: editItem.rank3FontSizePx,
+			rank3FontColor: editItem.rank3FontColor,
+			rowFontFamily: editItem.rowFontFamily,
+			rowFontSizePx: editItem.rowFontSizePx,
+			rowFontColor: editItem.rowFontColor
+		};
+
+		if (onFormChange) {
+			onFormChange(data);
+		} else {
+			setInternalForm(data);
+		}
+	}, [editItem]);
+
+	const set = (key: keyof FormState, value: FormState[keyof FormState]) => {
+		const updater = (f: FormState): FormState => ({ ...f, [key]: value });
+		if (onFormChange) {
+			onFormChange(updater(form));
+		} else {
+			setInternalForm(updater);
+		}
+	};
+
+	const handleSave = async () => {
+		if (!form.title.trim()) {
+			alert('제목을 입력해 주세요.');
+			return;
+		}
+
+		if (form.period === 5) {
+			if (!form.startAt || !form.endAt) {
+				alert('사용자 지정 기간을 입력해 주세요.');
+				return;
+			}
+		}
+
+		setSaving(true);
+
+		try {
+
+			await fetchApi('/api/studio/crew/widget/config', {
+				method: 'POST',
+				body: { ...form, channelID, id: editItem?.id ?? undefined }
+			});
+
+			fetchList();
+
+			if (onSaved) {
+				onSaved();
+			} else {
+				router.push('/studio/donation/crew/widget/list');
+			}
+
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '저장에 실패했습니다.');
+		} finally {
+			setSaving(false);
+		}
+	};
+
+	const renderFontFields = (prefix: string) =>
+	{
+		const familyKey = `${prefix}FontFamily` as keyof FormState;
+		const sizeKey = `${prefix}FontSizePx` as keyof FormState;
+		const colorKey = `${prefix}FontColor` as keyof FormState;
+
+		return (
+			<>
+				<div className="crew-widget-form__field">
+					<label className="crew-widget-form__field-label">글꼴</label>
+					<select
+						className="crew-widget-form__select"
+						aria-label="글꼴"
+						value={(form[familyKey] as string) ?? ''}
+						onChange={e => set(familyKey, e.target.value || null)}
+					>
+						{FONT_FAMILIES.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
+					</select>
+				</div>
+				<div className="crew-widget-form__row">
+					<div className="crew-widget-form__field">
+						<label className="crew-widget-form__field-label">크기(px)</label>
+						<input
+							type="number"
+							className="crew-widget-form__input"
+							min={10}
+							max={48}
+							value={form[sizeKey] as number}
+							onChange={e => set(sizeKey, Number(e.target.value))}
+						/>
+					</div>
+					<div className="crew-widget-form__field">
+						<label className="crew-widget-form__field-label">색상</label>
+						<ColorInput
+							value={form[colorKey] as string}
+							onChange={v => set(colorKey, v)}
+						/>
+					</div>
+				</div>
+			</>
+		);
+	};
+
+	const renderFontDetails = (label: string, prefix: string) => {
+		return (
+			<details className="crew-widget-form__details">
+				<summary className="crew-widget-form__details-summary">{label} 폰트 설정</summary>
+				<div className="crew-widget-form__details-body">
+					{renderFontFields(prefix)}
+				</div>
+			</details>
+		);
+	};
+
+	return (
+		<main className="crew-widget-form">
+			{/* 기본 설정 */}
+			<details className="crew-widget-form__section" open>
+				<summary className="crew-widget-form__section-title">기본 설정</summary>
+				<div className="crew-widget-form__section-body">
+					<div className="crew-widget-form__field">
+						<label className="crew-widget-form__field-label"><span className="text-destructive mr-0.5">*</span> 제목</label>
+						<input
+							type="text"
+							className="crew-widget-form__input"
+							value={form.title}
+							onChange={e => set('title', e.target.value)}
+							maxLength={300}
+						/>
+					</div>
+
+					<div className="crew-widget-form__row">
+						<div className="crew-widget-form__field">
+							<label className="crew-widget-form__field-label">테마</label>
+							<select
+								className="crew-widget-form__select"
+								aria-label="테마"
+								value={form.theme}
+								onChange={e => set('theme', Number(e.target.value))}
+							>
+								{CREW_WIDGET_THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
+							</select>
+						</div>
+						<div className="crew-widget-form__field">
+							<label className="crew-widget-form__field-label">기간</label>
+							<select
+								className="crew-widget-form__select"
+								aria-label="기간"
+								value={form.period}
+								onChange={e => set('period', Number(e.target.value))}
+							>
+								{CREW_PERIODS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
+							</select>
+						</div>
+					</div>
+
+					{form.period === 5 && (
+						<div className="crew-widget-form__row">
+							<div className="crew-widget-form__field">
+								<label className="crew-widget-form__field-label">시작</label>
+								<input
+									type="text"
+									className="crew-widget-form__input"
+									placeholder="2026.04.12 14:00"
+									maxLength={16}
+									value={form.startAt ? formatInput(new Date(form.startAt)) : ''}
+									onChange={e => set('startAt', parseInput(e.target.value) || null)}
+								/>
+							</div>
+							<div className="crew-widget-form__field">
+								<label className="crew-widget-form__field-label">종료</label>
+								<input
+									type="text"
+									className="crew-widget-form__input"
+									placeholder="2026.04.13 14:00"
+									maxLength={16}
+									value={form.endAt ? formatInput(new Date(form.endAt)) : ''}
+									onChange={e => set('endAt', parseInput(e.target.value) || null)}
+								/>
+							</div>
+						</div>
+					)}
+
+					<div className="crew-widget-form__row">
+						<div className="crew-widget-form__field">
+							<label className="crew-widget-form__field-label">최대 표시 수</label>
+							<input
+								type="number"
+								className="crew-widget-form__input"
+								min={1}
+								max={20}
+								value={form.maxDisplayCount}
+								onChange={e => set('maxDisplayCount', Number(e.target.value))}
+							/>
+						</div>
+						<div className="crew-widget-form__field">
+							<label className="crew-widget-form__field-label">배경 색상</label>
+							<ColorInput
+								value={form.bgColor}
+								onChange={v => set('bgColor', v)}
+							/>
+						</div>
+					</div>
+				</div>
+			</details>
+
+			{/* 표시 옵션 */}
+			<details className="crew-widget-form__section" open>
+				<summary className="crew-widget-form__section-title">표시 옵션</summary>
+				<div className="crew-widget-form__section-body">
+					<div className="crew-widget-form__field">
+						<label className="crew-widget-form__checkbox-label">
+							<Checkbox checked={form.isShowAmount} onCheckedChange={v => set('isShowAmount', !!v)} />
+							후원 금액 표시
+						</label>
+					</div>
+					<div className="crew-widget-form__field">
+						<label className="crew-widget-form__checkbox-label">
+							<Checkbox checked={form.isShowDonationCount} onCheckedChange={v => set('isShowDonationCount', !!v)} />
+							후원 건수 표시
+						</label>
+					</div>
+					<div className="crew-widget-form__field">
+						<label className="crew-widget-form__checkbox-label">
+							<Checkbox checked={form.isShowContributionRate} onCheckedChange={v => set('isShowContributionRate', !!v)} />
+							기여율 표시
+						</label>
+					</div>
+					<div className="crew-widget-form__field">
+						<label className="crew-widget-form__checkbox-label">
+							<Checkbox checked={form.isShowMemberIcon} onCheckedChange={v => set('isShowMemberIcon', !!v)} />
+							크루원 아이콘 표시
+						</label>
+					</div>
+					<div className="crew-widget-form__field">
+						<label className="crew-widget-form__checkbox-label">
+							<Checkbox checked={form.isActive} onCheckedChange={v => set('isActive', !!v)} />
+							활성화
+						</label>
+					</div>
+				</div>
+			</details>
+
+			{/* 제목 폰트 */}
+			<details className="crew-widget-form__section" open>
+				<summary className="crew-widget-form__section-title">제목 폰트</summary>
+				<div className="crew-widget-form__section-body">
+					{renderFontFields('title')}
+				</div>
+			</details>
+
+			{/* 순위별 폰트 */}
+			<details className="crew-widget-form__section" open>
+				<summary className="crew-widget-form__section-title">순위별 폰트</summary>
+				<div className="crew-widget-form__section-body">
+					{renderFontDetails('1위', 'rank1')}
+					{renderFontDetails('2위', 'rank2')}
+					{renderFontDetails('3위', 'rank3')}
+					{renderFontDetails('일반', 'row')}
+				</div>
+			</details>
+
+			{/* 버튼 */}
+			<div className="crew-widget-form__footer flex-1 w-full sm:justify-end gap-2">
+				<button type="button" className="crew-widget-form__btn flex-1 sm:flex-none" onClick={() => onCancel ? onCancel() : router.push('/studio/donation/crew/widget/list')}>취소</button>
+				<button type="button" className="crew-widget-form__btn crew-widget-form__btn--primary flex-1 sm:flex-none" onClick={handleSave} disabled={saving}>
+					{saving ? '저장 중...' : '저장'}
+				</button>
+			</div>
+		</main>
+	);
+}

+ 127 - 0
app/studio/donation/crew/widget/_components/CrewWidgetListPanel.tsx

@@ -0,0 +1,127 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import { useCrewWidgetConfigContext } from '../context';
+import { CREW_PERIODS, CREW_WIDGET_THEMES } from '../constants';
+import { formatDateTime } from '../types';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+
+export default function CrewWidgetListPanel()
+{
+	const router = useRouter();
+	const { channelID } = useStudioContext();
+	const { items, loading, fetchList } = useCrewWidgetConfigContext();
+	const [selected, setSelected] = useState<Set<number>>(new Set());
+
+	const toggleSelect = (id: number) => {
+		setSelected(prev => {
+			const next = new Set(prev);
+			if (next.has(id)) next.delete(id); else next.add(id);
+			return next;
+		});
+	};
+
+	const toggleAll = () => {
+		if (selected.size === items.length) {
+			setSelected(new Set());
+		} else {
+			setSelected(new Set(items.map(i => i.id)));
+		}
+	};
+
+	const handleBatchDelete = async () => {
+		if (selected.size === 0) {
+			return;
+		}
+
+		if (!confirm(`${selected.size}개 설정을 삭제하시겠습니까?`)) {
+			return;
+		}
+
+		for (const id of selected) {
+			try {
+				await fetchApi(`/api/studio/crew/widget/config/${id}/${channelID}`, { method: 'DELETE' });
+			} catch {}
+		}
+
+		setSelected(new Set());
+		fetchList();
+	};
+
+	const getPeriodLabel = (p: number) => CREW_PERIODS.find(x => x.value === p)?.label ?? '-';
+	const getThemeLabel = (t: number) => CREW_WIDGET_THEMES.find(x => x.value === t)?.label ?? '-';
+
+	if (loading) return <p className="studio-page__empty">준비 중...</p>;
+
+	return (
+		<div className="studio-page">
+			<div className="studio-page__header">
+				<h1 className="studio-page__title">크루 위젯 설정</h1>
+				<div className="studio-page__header-actions">
+					{selected.size > 0 && (
+						<Button variant="destructive" size="sm" onClick={handleBatchDelete}>
+							{selected.size}개 삭제
+						</Button>
+					)}
+					<Button size="sm" onClick={() => router.push('/studio/donation/crew/widget/add')}>
+						+ 추가
+					</Button>
+				</div>
+			</div>
+
+			<div className="studio-page__table-wrap">
+				<table className="studio-page__table">
+					<thead>
+						<tr>
+							<th>
+								<Checkbox checked={items.length > 0 && selected.size === items.length} onCheckedChange={toggleAll} />
+							</th>
+							<th>제목</th>
+							<th>기간</th>
+							<th>테마</th>
+							<th>최대 표시</th>
+							<th>활성</th>
+							<th>작업</th>
+						</tr>
+					</thead>
+					<tbody>
+						{items.length === 0 ? (
+							<tr><td colSpan={7} className="studio-page__empty">등록된 위젯 설정이 없습니다.</td></tr>
+						) : items.map(item => (
+							<tr key={item.id}>
+								<td>
+									<Checkbox checked={selected.has(item.id)} onCheckedChange={() => toggleSelect(item.id)} />
+								</td>
+								<td>{item.title}</td>
+								<td>
+									{getPeriodLabel(item.period)}
+									{item.period === 5 && item.startAt && item.endAt && (
+										<div style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
+											{formatDateTime(item.startAt)} ~ {formatDateTime(item.endAt)}
+										</div>
+									)}
+								</td>
+								<td>{getThemeLabel(item.theme)}</td>
+								<td>{item.maxDisplayCount}명</td>
+								<td>
+									<span className={`studio-page__badge studio-page__badge--${item.isActive ? 'active' : 'inactive'}`}>
+										{item.isActive ? '활성' : '비활성'}
+									</span>
+								</td>
+								<td>
+									<Button variant="outline" size="sm" onClick={() => router.push(`/studio/donation/crew/widget/edit/${item.id}`)}>
+										수정
+									</Button>
+								</td>
+							</tr>
+						))}
+					</tbody>
+				</table>
+			</div>
+		</div>
+	);
+}

+ 112 - 0
app/studio/donation/crew/widget/_components/CrewWidgetPreviewPanel.tsx

@@ -0,0 +1,112 @@
+'use client';
+
+import { MOCK_CREW_RANKING } from '../constants';
+import type { FormState } from '../types';
+
+type Props = {
+	form: FormState;
+};
+
+const THEME_NAMES = ['basic', 'dark', 'minimal'];
+
+export default function CrewWidgetPreviewPanel({ form }: Props)
+{
+	const visibleItems = MOCK_CREW_RANKING.slice(0, form.maxDisplayCount);
+	const themeName = THEME_NAMES[form.theme] ?? 'basic';
+
+	const getFontStyle = (rank: number) => {
+		if (rank === 1) {
+			return {
+				fontFamily: form.rank1FontFamily || undefined,
+				fontSize: `${form.rank1FontSizePx}px`,
+				color: form.rank1FontColor
+			};
+		}
+		if (rank === 2) {
+			return {
+				fontFamily: form.rank2FontFamily || undefined,
+				fontSize: `${form.rank2FontSizePx}px`,
+				color: form.rank2FontColor
+			};
+		}
+		if (rank === 3) {
+			return {
+				fontFamily: form.rank3FontFamily || undefined,
+				fontSize: `${form.rank3FontSizePx}px`,
+				color: form.rank3FontColor
+			};
+		}
+		return {
+			fontFamily: form.rowFontFamily || undefined,
+			fontSize: `${form.rowFontSizePx}px`,
+			color: form.rowFontColor
+		};
+	};
+
+	const getBadgeClass = (rank: number) => {
+		if (rank <= 3) {
+			return `crew-widget-preview__badge--${rank}`;
+		}
+		return 'crew-widget-preview__badge--default';
+	};
+
+	return (
+		<aside className="crew-widget-preview">
+			<div className="crew-widget-preview__widget">
+				<div className="crew-widget-preview__widget-label">미리보기</div>
+				<div
+					className={`crew-widget-preview__widget-body crew-widget-preview__widget-body--${themeName}`}
+					style={{ backgroundColor: form.bgColor }}
+				>
+					{/* 위젯 제목 */}
+					<div className="crew-widget-preview__title" style={{
+						fontFamily: form.titleFontFamily || undefined,
+						fontSize: `${form.titleFontSizePx}px`,
+						color: form.titleFontColor
+					}}>
+						{form.title || '크루 순위'}
+					</div>
+
+					{/* 컬럼 헤더 */}
+					<div className="crew-widget-preview__columns">
+						<span className="crew-widget-preview__col-rank">순위</span>
+						{form.isShowMemberIcon && <span className="crew-widget-preview__col-icon" />}
+						<span className="crew-widget-preview__col-name">이름</span>
+						{form.isShowContributionRate && <span className="crew-widget-preview__col-rate">기여도</span>}
+						{form.isShowAmount && <span className="crew-widget-preview__col-amount">후원 점수</span>}
+						{form.isShowDonationCount && <span className="crew-widget-preview__col-count">건수</span>}
+					</div>
+
+					{/* 순위 리스트 */}
+					<div className="crew-widget-preview__list">
+						{visibleItems.map(item => {
+							const style = getFontStyle(item.rank);
+							return (
+								<div
+									key={item.rank}
+									className={`crew-widget-preview__item${item.rank <= 3 ? ` crew-widget-preview__item--${item.rank}` : ''}`}
+									style={style}
+								>
+									<span className={`crew-widget-preview__badge ${getBadgeClass(item.rank)}`}>
+										{item.rank}
+									</span>
+									{form.isShowMemberIcon && <span className="crew-widget-preview__member-icon" />}
+									<span className="crew-widget-preview__name">{item.nickname}</span>
+									{form.isShowContributionRate && (
+										<span className="crew-widget-preview__rate">{item.contributionRate.toFixed(1)}%</span>
+									)}
+									{form.isShowAmount && (
+										<span className="crew-widget-preview__amount">{item.totalAmount.toLocaleString()}원</span>
+									)}
+									{form.isShowDonationCount && (
+										<span className="crew-widget-preview__count">{item.donationCount}건</span>
+									)}
+								</div>
+							);
+						})}
+					</div>
+				</div>
+			</div>
+		</aside>
+	);
+}

+ 30 - 0
app/studio/donation/crew/widget/add/page.tsx

@@ -0,0 +1,30 @@
+'use client';
+
+import { useState } from 'react';
+import Link from 'next/link';
+import { type FormState, createEmptyForm } from '../types';
+import CrewWidgetFormPanel from '../_components/CrewWidgetFormPanel';
+import CrewWidgetPreviewPanel from '../_components/CrewWidgetPreviewPanel';
+import { Separator } from '@/components/ui/separator';
+
+export default function CrewWidgetAddPage()
+{
+	const [form, setForm] = useState<FormState>(createEmptyForm());
+
+	return (
+		<>
+			<div className="studio-page__title-row">
+				<h1 className="studio-page__title">크루 위젯 추가</h1>
+				<Link href="/studio/donation/crew/widget/list" className="text-sm text-muted-foreground hover:text-foreground">< 목록으로</Link>
+			</div>
+			<div className="pt-4 pb-4">
+				<Separator orientation="horizontal" />
+			</div>
+			<div className="crew-widget-layout">
+				<CrewWidgetPreviewPanel form={form} />
+				<Separator orientation="vertical" />
+				<CrewWidgetFormPanel form={form} onFormChange={setForm} />
+			</div>
+		</>
+	);
+}

+ 35 - 0
app/studio/donation/crew/widget/constants.ts

@@ -0,0 +1,35 @@
+export const CREW_WIDGET_THEMES = [
+	{ value: 0, label: 'Basic' },
+	{ value: 1, label: 'Dark' },
+	{ value: 2, label: 'Minimal' }
+];
+
+export const CREW_PERIODS = [
+	{ value: 1, label: '일간' },
+	{ value: 2, label: '주간' },
+	{ value: 3, label: '월간' },
+	{ value: 4, label: '연간' },
+	{ value: 5, label: '사용자 지정' },
+	{ value: 6, label: '전체' }
+];
+
+export const FONT_FAMILIES = [
+	{ value: '', label: '기본 (시스템)' },
+	{ value: 'Pretendard', label: 'Pretendard' },
+	{ value: 'Noto Sans KR', label: 'Noto Sans KR' },
+	{ value: 'Nanum Gothic', label: '나눔고딕' },
+	{ value: 'Nanum Myeongjo', label: '나눔명조' },
+	{ value: 'Black Han Sans', label: '블랙한산스' },
+	{ value: 'Jua', label: '주아' },
+	{ value: 'Do Hyeon', label: '도현' },
+	{ value: 'Gaegu', label: '개구' },
+	{ value: 'Gothic A1', label: 'Gothic A1' }
+];
+
+export const MOCK_CREW_RANKING = [
+	{ rank: 1, nickname: '크루원A', totalAmount: 50000, donationCount: 10, contributionRate: 40.0 },
+	{ rank: 2, nickname: '크루원B', totalAmount: 30000, donationCount: 7, contributionRate: 24.0 },
+	{ rank: 3, nickname: '크루원C', totalAmount: 20000, donationCount: 5, contributionRate: 16.0 },
+	{ rank: 4, nickname: '크루원D', totalAmount: 15000, donationCount: 4, contributionRate: 12.0 },
+	{ rank: 5, nickname: '크루원E', totalAmount: 10000, donationCount: 3, contributionRate: 8.0 }
+];

+ 24 - 0
app/studio/donation/crew/widget/context.tsx

@@ -0,0 +1,24 @@
+'use client';
+
+import { createContext, useContext, type Dispatch, type SetStateAction } from 'react';
+import type { CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
+
+export type CrewWidgetConfigContextValue = {
+	items: CrewWidgetConfigItem[];
+	loading: boolean;
+	saving: boolean;
+	setSaving: Dispatch<SetStateAction<boolean>>;
+	fetchList: () => void;
+};
+
+export const CrewWidgetConfigContext = createContext<CrewWidgetConfigContextValue>({
+	items: [],
+	loading: true,
+	saving: false,
+	setSaving: () => {},
+	fetchList: () => {}
+});
+
+export function useCrewWidgetConfigContext() {
+	return useContext(CrewWidgetConfigContext);
+}

+ 44 - 0
app/studio/donation/crew/widget/edit/[id]/page.tsx

@@ -0,0 +1,44 @@
+'use client';
+
+import { useState } from 'react';
+import { useParams } from 'next/navigation';
+import Link from 'next/link';
+import { useCrewWidgetConfigContext } from '../../context';
+import { type FormState, createEmptyForm } from '../../types';
+import CrewWidgetFormPanel from '../../_components/CrewWidgetFormPanel';
+import CrewWidgetPreviewPanel from '../../_components/CrewWidgetPreviewPanel';
+import { Separator } from '@/components/ui/separator';
+
+export default function CrewWidgetEditPage()
+{
+	const { id } = useParams<{ id: string }>();
+	const { items, loading } = useCrewWidgetConfigContext();
+	const [form, setForm] = useState<FormState>(createEmptyForm());
+
+	if (loading) {
+		return <p className="studio-page__empty">준비 중...</p>;
+	}
+
+	const item = items.find(i => i.id === Number(id));
+
+	if (!item) {
+		return <p className="studio-page__empty">설정을 찾을 수 없습니다.</p>;
+	}
+
+	return (
+		<>
+			<div className="studio-page__title-row">
+				<h1 className="studio-page__title">크루 위젯 수정</h1>
+				<Link href="/studio/donation/crew/widget/list" className="text-sm text-muted-foreground hover:text-foreground">< 목록으로</Link>
+			</div>
+			<div className="pt-4 pb-4">
+				<Separator orientation="horizontal" />
+			</div>
+			<div className="crew-widget-layout">
+				<CrewWidgetPreviewPanel form={form} />
+				<Separator orientation="vertical" />
+				<CrewWidgetFormPanel editItem={item} form={form} onFormChange={setForm} />
+			</div>
+		</>
+	);
+}

+ 42 - 0
app/studio/donation/crew/widget/layout.tsx

@@ -0,0 +1,42 @@
+'use client';
+
+import './style.scss';
+import { useState, useEffect, useCallback, type ReactNode } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import type { CrewWidgetConfigResponse, CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
+import { CrewWidgetConfigContext } from './context';
+
+export default function CrewWidgetLayout({ children }: { children: ReactNode })
+{
+	const { channelID } = useStudioContext();
+	const [items, setItems] = useState<CrewWidgetConfigItem[]>([]);
+	const [loading, setLoading] = useState(true);
+	const [saving, setSaving] = useState(false);
+
+	const fetchList = useCallback(() => {
+		if (!channelID) {
+			setLoading(false);
+			return;
+		}
+
+		setLoading(true);
+
+		fetchApi<CrewWidgetConfigResponse>(`/api/studio/crew/widget/config/${channelID}`)
+			.then(res => setItems(res.data?.list ?? []))
+			.catch(() => {})
+			.finally(() => setLoading(false)
+		);
+
+	}, [channelID]);
+
+	useEffect(() => {
+		fetchList();
+	}, [fetchList]);
+
+	return (
+		<CrewWidgetConfigContext.Provider value={{ items, loading, saving, setSaving, fetchList }}>
+			{children}
+		</CrewWidgetConfigContext.Provider>
+	);
+}

+ 8 - 0
app/studio/donation/crew/widget/list/page.tsx

@@ -0,0 +1,8 @@
+'use client';
+
+import CrewWidgetListPanel from '../_components/CrewWidgetListPanel';
+
+export default function CrewWidgetListPage()
+{
+	return <CrewWidgetListPanel />;
+}

+ 15 - 0
app/studio/donation/crew/widget/page.tsx

@@ -0,0 +1,15 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useEffect } from 'react';
+
+export default function CrewWidgetPage()
+{
+	const router = useRouter();
+
+	useEffect(() => {
+		router.replace('/studio/donation/crew/widget/list');
+	}, [router]);
+
+	return null;
+}

+ 467 - 0
app/studio/donation/crew/widget/style.scss

@@ -0,0 +1,467 @@
+// ── 편집 3-column 레이아웃 (rank-config__layout 패턴) ──
+.crew-widget-layout {
+	display: grid;
+	grid-template-columns: minmax(0, 500px) auto 1fr;
+	gap: 24px;
+	align-items: start;
+}
+
+// ── 좌측 패널 (미리보기, sticky) ──
+.crew-widget-preview {
+	position: sticky;
+	top: 16px;
+	display: flex;
+	flex-direction: column;
+	gap: 16px;
+	max-height: calc(100vh - 48px);
+	overflow-y: auto;
+
+	&__widget {
+		border: 1px solid hsl(var(--border));
+		border-radius: var(--radius);
+		overflow: hidden;
+	}
+
+	&__widget-label {
+		padding: 8px 14px;
+		font-size: 0.8125rem;
+		font-weight: 500;
+		color: hsl(var(--muted));
+		background: #0e0e1a;
+	}
+
+	&__widget-body {
+		padding: 16px;
+		min-height: 200px;
+
+		&--basic { background: #1a1a2e; }
+		&--dark { background: #0a0a14; }
+		&--minimal { background: #222; }
+	}
+
+	// ── 위젯 제목 ──
+	&__title {
+		text-align: center;
+		font-weight: 700;
+		font-size: 18px;
+		margin-bottom: 12px;
+		color: #fff;
+		text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.4);
+	}
+
+	// ── 컬럼 헤더 ──
+	&__columns {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		padding: 6px 12px;
+		margin-bottom: 6px;
+		border-bottom: 1px solid rgba(255, 255, 255, 0.15);
+		font-size: 0.6875rem;
+		font-weight: 500;
+		color: rgba(255, 255, 255, 0.5);
+		letter-spacing: 0.03em;
+	}
+
+	&__col-rank { width: 28px; text-align: center; flex-shrink: 0; }
+	&__col-icon { width: 28px; flex-shrink: 0; }
+	&__col-name { flex: 1; min-width: 0; }
+	&__col-rate { width: 50px; text-align: right; flex-shrink: 0; }
+	&__col-amount { width: 70px; text-align: right; flex-shrink: 0; }
+	&__col-count { width: 40px; text-align: right; flex-shrink: 0; }
+
+	// ── 순위 리스트 ──
+	&__list {
+		display: flex;
+		flex-direction: column;
+		gap: 6px;
+	}
+
+	&__item {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		padding: 8px 12px;
+		border-radius: 8px;
+		background: rgba(255, 255, 255, 0.08);
+
+		&--1 { background: linear-gradient(135deg, rgba(255, 215, 0, 0.2), rgba(255, 215, 0, 0.05)); }
+		&--2 { background: linear-gradient(135deg, rgba(192, 192, 192, 0.15), rgba(192, 192, 192, 0.05)); }
+		&--3 { background: linear-gradient(135deg, rgba(205, 127, 50, 0.15), rgba(205, 127, 50, 0.05)); }
+	}
+
+	// ── 순위 배지 ──
+	&__badge {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 28px;
+		height: 28px;
+		border-radius: 50%;
+		font-size: 14px;
+		font-weight: 800;
+		flex-shrink: 0;
+
+		&--1 { background: #FFD700; color: #000; }
+		&--2 { background: #C0C0C0; color: #000; }
+		&--3 { background: #CD7F32; color: #fff; }
+		&--default { background: #555; color: #fff; }
+	}
+
+	// ── 크루원 아이콘 ──
+	&__member-icon {
+		width: 28px;
+		height: 28px;
+		border-radius: 50%;
+		background: #444;
+		flex-shrink: 0;
+		border: 1px solid rgba(255, 255, 255, 0.15);
+	}
+
+	// ── 이름 ──
+	&__name {
+		flex: 1;
+		font-weight: 600;
+		color: #fff;
+		min-width: 0;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+
+	// ── 기여도 ──
+	&__rate {
+		width: 50px;
+		text-align: right;
+		flex-shrink: 0;
+		font-size: 0.85em;
+		color: #A0D2FF;
+		white-space: nowrap;
+	}
+
+	// ── 후원 금액 ──
+	&__amount {
+		width: 70px;
+		text-align: right;
+		flex-shrink: 0;
+		font-weight: 700;
+		color: #FF6B35;
+		font-size: 0.85em;
+		white-space: nowrap;
+	}
+
+	// ── 후원 건수 ──
+	&__count {
+		width: 40px;
+		text-align: right;
+		flex-shrink: 0;
+		font-size: 0.8em;
+		color: #aaa;
+		white-space: nowrap;
+	}
+}
+
+// ── 우측 패널 (폼) ──
+.crew-widget-form {
+	display: flex;
+	flex-direction: column;
+	gap: 0;
+
+	// ── 섹션 (details 기반, 좌측 제목 / 우측 입력 그리드) ──
+	&__section {
+		display: grid;
+		grid-template-columns: 140px 1fr;
+		gap: 24px;
+		padding: 24px 0;
+		border-bottom: 1px solid hsl(var(--border));
+
+		&:first-of-type {
+			padding-top: 0;
+		}
+
+		// details 마커 숨김
+		&::-webkit-details-marker {
+			display: none;
+		}
+	}
+
+	&__section-title {
+		font-size: 0.875rem;
+		font-weight: 600;
+		color: hsl(var(--foreground));
+		padding-top: 2px;
+		margin: 0;
+		cursor: pointer;
+		user-select: none;
+		list-style: none;
+
+		&::-webkit-details-marker {
+			display: none;
+		}
+
+		&::before {
+			content: '▸';
+			display: inline-block;
+			font-size: 0.7rem;
+			margin-right: 6px;
+			transition: transform 0.15s;
+		}
+
+		&:hover {
+			color: hsl(var(--foreground) / 0.7);
+		}
+	}
+
+	// 열린 상태: 화살표 회전
+	&__section[open] > .crew-widget-form__section-title::before {
+		transform: rotate(90deg);
+	}
+
+	&__section-body {
+		display: flex;
+		flex-direction: column;
+		gap: 17px;
+		max-width: 520px;
+	}
+
+	// ── 필드 행 (2-column 그리드) ──
+	&__row {
+		display: grid;
+		grid-template-columns: 1fr 1fr;
+		gap: 10px;
+
+		@media (max-width: 640px) {
+			grid-template-columns: 1fr;
+		}
+	}
+
+	// ── 개별 필드 ──
+	&__field {
+		display: flex;
+		flex-direction: column;
+		gap: 6px;
+	}
+
+	&__field-label {
+		font-size: 0.875rem;
+		font-weight: 500;
+		color: hsl(var(--foreground));
+	}
+
+	&__input {
+		padding: 8px 12px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.875rem;
+		width: 100%;
+
+		&::placeholder {
+			color: hsl(var(--muted-foreground));
+		}
+
+		&--color-text {
+			flex: 1;
+			min-width: 0;
+			font-family: monospace;
+		}
+	}
+
+	&__select {
+		padding: 8px 12px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.875rem;
+		width: 100%;
+		cursor: pointer;
+	}
+
+	// ── 색상 선택 필드 ──
+	&__color-field {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+	}
+
+	&__color-picker {
+		width: 36px;
+		height: 36px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: none;
+		cursor: pointer;
+		padding: 2px;
+		flex-shrink: 0;
+
+		&::-webkit-color-swatch-wrapper {
+			padding: 0;
+		}
+
+		&::-webkit-color-swatch {
+			border: none;
+			border-radius: 2px;
+		}
+
+		&::-moz-color-swatch {
+			border: none;
+			border-radius: 2px;
+		}
+	}
+
+	// ── 체크박스 행 ──
+	&__checkbox-label {
+		display: inline-flex;
+		align-items: center;
+		gap: 8px;
+		font-size: 0.875rem;
+		color: hsl(var(--foreground));
+		cursor: pointer;
+	}
+
+	// ── 순위별 폰트 접이식 (details 내부) ──
+	&__details {
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		overflow: hidden;
+
+		&[open] > .crew-widget-form__details-summary {
+			border-bottom: 1px solid hsl(var(--border));
+		}
+	}
+
+	&__details-summary {
+		padding: 10px 14px;
+		font-size: 0.8125rem;
+		font-weight: 500;
+		color: hsl(var(--foreground));
+		cursor: pointer;
+		user-select: none;
+		list-style: none;
+		display: flex;
+		align-items: center;
+		gap: 6px;
+		transition: background-color 0.15s;
+
+		&:hover {
+			background: hsl(var(--accent) / 0.5);
+		}
+
+		&::before {
+			content: '▸';
+			font-size: 0.75rem;
+			transition: transform 0.15s;
+		}
+
+		&::-webkit-details-marker {
+			display: none;
+		}
+	}
+
+	&__details[open] > .crew-widget-form__details-summary::before {
+		transform: rotate(90deg);
+	}
+
+	&__details-body {
+		display: flex;
+		flex-direction: column;
+		gap: 14px;
+		padding: 14px;
+	}
+
+	// ── 버튼 ──
+	&__btn {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		padding: 8px 20px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		font-size: 0.875rem;
+		font-weight: 500;
+		cursor: pointer;
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		transition: background-color 0.15s;
+
+		&:hover:not(:disabled) {
+			background: hsl(var(--accent));
+		}
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+
+		&--primary {
+			background: hsl(var(--primary));
+			color: hsl(var(--primary-foreground));
+			border-color: hsl(var(--primary));
+
+			&:hover:not(:disabled) {
+				background: hsl(var(--primary) / 0.9);
+			}
+		}
+	}
+
+	// ── 하단 버튼 ──
+	&__footer {
+		display: flex;
+		gap: 8px;
+		justify-content: center;
+		padding-top: 20px;
+		margin-top: 4px;
+	}
+}
+
+// ── 반응형 ──
+@media (max-width: 1380px) {
+	.crew-widget-form {
+		&__section {
+			grid-template-columns: 1fr;
+			gap: 5px;
+		}
+
+		&__section-title {
+			margin-bottom: 9px;
+		}
+	}
+}
+
+@media (max-width: 1120px) {
+	.crew-widget-layout {
+		grid-template-columns: minmax(130px, auto) auto 3fr;
+	}
+}
+
+@media (max-width: 768px) {
+	.crew-widget-layout {
+		grid-template-columns: 1fr;
+	}
+
+	.crew-widget-preview {
+		position: static;
+		max-height: none;
+	}
+
+	.crew-widget-form {
+		&__section {
+			grid-template-columns: 1fr;
+			gap: 8px;
+		}
+
+		&__section-title {
+			margin-bottom: 12px;
+		}
+
+		&__section-body {
+			max-width: none;
+		}
+
+		&__row {
+			grid-template-columns: 1fr;
+		}
+	}
+}

+ 61 - 0
app/studio/donation/crew/widget/types.ts

@@ -0,0 +1,61 @@
+import type { CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
+
+export type FormState = Omit<CrewWidgetConfigItem, 'id'>;
+
+export function createEmptyForm(): FormState {
+	return {
+		title: '크루 순위',
+		theme: 0,
+		period: 3,
+		startAt: null,
+		endAt: null,
+		maxDisplayCount: 5,
+		isShowAmount: true,
+		isShowDonationCount: false,
+		isShowContributionRate: true,
+		isShowMemberIcon: true,
+		isActive: true,
+		bgColor: '#1A1A2E',
+		titleFontFamily: null,
+		titleFontSizePx: 18,
+		titleFontColor: '#FFFFFF',
+		rank1FontFamily: null,
+		rank1FontSizePx: 15,
+		rank1FontColor: '#FFD700',
+		rank2FontFamily: null,
+		rank2FontSizePx: 15,
+		rank2FontColor: '#C0C0C0',
+		rank3FontFamily: null,
+		rank3FontSizePx: 15,
+		rank3FontColor: '#CD7F32',
+		rowFontFamily: null,
+		rowFontSizePx: 14,
+		rowFontColor: '#FFFFFF'
+	};
+}
+
+export function formatInput(date: Date): string {
+	const y = date.getFullYear();
+	const m = String(date.getMonth() + 1).padStart(2, '0');
+	const d = String(date.getDate()).padStart(2, '0');
+	const h = String(date.getHours()).padStart(2, '0');
+	const min = String(date.getMinutes()).padStart(2, '0');
+	return `${y}.${m}.${d} ${h}:${min}`;
+}
+
+export function parseInput(str: string): string {
+	if (!str || str.length < 16) return '';
+	const [datePart, timePart] = str.split(' ');
+	if (!datePart || !timePart) return '';
+	const [y, m, d] = datePart.split('.');
+	const [h, min] = timePart.split(':');
+	const date = new Date(Number(y), Number(m) - 1, Number(d), Number(h), Number(min));
+	return isNaN(date.getTime()) ? '' : date.toISOString();
+}
+
+export function formatDateTime(dateStr: string|null): string {
+	if (!dateStr) return '-';
+	const d = new Date(dateStr);
+	if (isNaN(d.getTime())) return '-';
+	return formatInput(d);
+}

+ 1 - 1
app/studio/donation/goal/_components/GoalListPanel.tsx

@@ -121,7 +121,7 @@ export default function GoalListPanel({
 							<tr>
 								<th className="goal-config__th--check">
 									<Checkbox
-										checked={isIndeterminate ? 'indeterminate' : allChecked}
+										checked={allChecked} indeterminate={isIndeterminate}
 										onCheckedChange={handleSelectAll}
 										aria-label="전체선택"
 									/>

+ 1 - 1
app/studio/donation/rank/_components/RankListPanel.tsx

@@ -121,7 +121,7 @@ export default function RankListPanel({
 							<tr>
 								<th className="rank-config__th--check">
 									<Checkbox
-										checked={isIndeterminate ? 'indeterminate' : allChecked}
+										checked={allChecked} indeterminate={isIndeterminate}
 										onCheckedChange={handleSelectAll}
 										aria-label="전체선택"
 									/>

+ 1 - 1
app/studio/settings/page.tsx

@@ -87,7 +87,7 @@ export default function StudioSettingsPage()
 		setDisconnecting(true);
 
 		try {
-			const res = await fetchApi('/api/studio/youtube-disconnect', { method: 'POST' });
+			await fetchApi('/api/studio/youtube-disconnect', { method: 'POST' });
 			alert('YouTube 연동이 해지되었습니다.');
 			window.location.replace('/studio/settings');
 		} catch (err) {

+ 273 - 0
app/studio/settlement/account/page.tsx

@@ -0,0 +1,273 @@
+'use client';
+
+import { useState, useEffect, useMemo } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCircleCheck, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
+import { fetchApi } from '@/lib/utils/client';
+import type { SettlementAccountResponse, SettlementAccountItem } from '@/types/response/settlement/account';
+import { BANK_LIST } from '../constants';
+import Loading from '@/app/component/Loading';
+
+const MAX_ACCOUNTS = 8;
+
+export default function SettlementAccountPage() {
+	const [loading, setLoading] = useState(true);
+	const [submitting, setSubmitting] = useState(false);
+	const [accounts, setAccounts] = useState<SettlementAccountItem[]>([]);
+	const [mode, setMode] = useState<'list'|'add'|'edit'>('list');
+	const [editTarget, setEditTarget] = useState<SettlementAccountItem|null>(null);
+
+	const [bankCode, setBankCode] = useState('');
+	const [accountNumber, setAccountNumber] = useState('');
+	const [accountHolder, setAccountHolder] = useState('');
+
+	const fetchAccounts = () => {
+		setLoading(true);
+		fetchApi<SettlementAccountResponse>('/api/studio/settlement/account')
+			.then(res => {
+				if (res.data) {
+					setAccounts(res.data.accounts);
+				}
+			})
+			.catch(() => {})
+			.finally(() => setLoading(false));
+	};
+
+	useEffect(() => {
+		fetchAccounts();
+	}, []);
+
+	const canSubmit = useMemo(() => {
+		return (
+			bankCode !== '' &&
+			/^\d{7,16}$/.test(accountNumber) &&
+			accountHolder.trim().length >= 2 &&
+			!submitting
+		);
+	}, [bankCode, accountNumber, accountHolder, submitting]);
+
+	const resetForm = () => {
+		setBankCode('');
+		setAccountNumber('');
+		setAccountHolder('');
+		setEditTarget(null);
+	};
+
+	const handleAdd = () => {
+		resetForm();
+		setMode('add');
+	};
+
+	const handleEdit = (item: SettlementAccountItem) => {
+		setEditTarget(item);
+		setBankCode(item.bankCode);
+		setAccountNumber('');
+		setAccountHolder(item.accountHolder);
+		setMode('edit');
+	};
+
+	const handleCancel = () => {
+		resetForm();
+		setMode('list');
+	};
+
+	const handleDelete = async (item: SettlementAccountItem) => {
+		if (!confirm(`${item.bankName} ${item.accountNumber} 계좌를 삭제하시겠습니까?`)) {
+			return;
+		}
+
+		try {
+			await fetchApi(`/api/studio/settlement/account/${item.id}`, { method: 'DELETE' });
+			fetchAccounts();
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '계좌 삭제에 실패했습니다.');
+		}
+	};
+
+	const handleSubmit = async () => {
+		if (!canSubmit) {
+			return;
+		}
+
+		setSubmitting(true);
+
+		try {
+			await fetchApi('/api/studio/settlement/account', {
+				method: 'POST',
+				body: {
+					accountID: editTarget?.id ?? null,
+					bankCode,
+					accountNumber,
+					accountHolder,
+				},
+			});
+
+			alert(editTarget ? '계좌가 수정되었습니다.' : '계좌가 등록되었습니다.');
+			resetForm();
+			setMode('list');
+			fetchAccounts();
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '계좌 등록에 실패했습니다.');
+		} finally {
+			setSubmitting(false);
+		}
+	};
+
+	if (loading) {
+		return <Loading />;
+	}
+
+	return (
+		<div className="studio-page settlement">
+			<div className="studio-page__header">
+				<h1 className="studio-page__title">계좌 관리</h1>
+			</div>
+
+			{/* 계좌 추가/수정 폼 */}
+			{mode !== 'list' && (
+				<div className="settlement__account-box">
+					<p className="settlement__account-form-title">
+						{mode === 'edit' ? '계좌 수정' : '계좌 추가'}
+					</p>
+
+					<div className="settlement__form">
+						<div className="settlement__field">
+							<label className="settlement__label" htmlFor="bank-select">은행</label>
+							<select
+								id="bank-select"
+								className="settlement__select"
+								value={bankCode}
+								onChange={e => setBankCode(e.target.value)}
+							>
+								<option value="">은행을 선택하세요</option>
+								{BANK_LIST.map(bank => (
+									<option key={bank.code} value={bank.code}>{bank.name}</option>
+								))}
+							</select>
+						</div>
+
+						<div className="settlement__field">
+							<label className="settlement__label" htmlFor="account-number">계좌번호</label>
+							<input
+								id="account-number"
+								type="text"
+								inputMode="numeric"
+								className="settlement__input"
+								placeholder="숫자만 입력 (7~16자리)"
+								value={accountNumber}
+								onChange={e => setAccountNumber(e.target.value.replace(/\D/g, ''))}
+								maxLength={16}
+							/>
+						</div>
+
+						<div className="settlement__field">
+							<label className="settlement__label" htmlFor="account-holder">예금주</label>
+							<input
+								id="account-holder"
+								type="text"
+								className="settlement__input"
+								placeholder="예금주명을 입력하세요"
+								value={accountHolder}
+								onChange={e => setAccountHolder(e.target.value)}
+							/>
+						</div>
+
+						<ul className="settlement__notice">
+							<li>본인 명의 계좌만 등록 가능합니다.</li>
+							<li>출금 시 등록된 계좌로 입금됩니다.</li>
+						</ul>
+
+						<div className="settlement__account-actions">
+							<button
+								type="button"
+								className="settlement__btn settlement__btn--primary"
+								disabled={!canSubmit}
+								onClick={handleSubmit}
+							>
+								{submitting ? '저장 중...' : (mode === 'edit' ? '수정하기' : '등록하기')}
+							</button>
+							<button
+								type="button"
+								className="settlement__btn settlement__btn--cancel"
+								onClick={handleCancel}
+							>
+								취소
+							</button>
+						</div>
+					</div>
+				</div>
+			)}
+
+			{/* 계좌 목록 */}
+			{mode === 'list' && (
+				<>
+					<div className="settlement__account-header">
+						<span className="settlement__account-count">등록 계좌 {accounts.length}/{MAX_ACCOUNTS}</span>
+						{accounts.length < MAX_ACCOUNTS && (
+							<button
+								type="button"
+								className="settlement__btn settlement__btn--primary"
+								onClick={handleAdd}
+							>
+								+ 계좌 추가
+							</button>
+						)}
+					</div>
+
+					{accounts.length === 0 ? (
+						<div className="settlement__account-empty">
+							등록된 계좌가 없습니다. 출금을 위해 계좌를 등록해 주세요.
+						</div>
+					) : (
+						<div className="settlement__account-list">
+							{accounts.map(item => (
+								<div key={item.id} className="settlement__account-card">
+									<div className="settlement__account-info">
+										<span className="settlement__account-bank">
+											{item.bankName} {item.accountNumber}
+										</span>
+										<span className="settlement__account-detail">
+											예금주: {item.accountHolder}
+										</span>
+										<span className={`settlement__account-status${item.isVerified ? ' settlement__account-status--verified' : ' settlement__account-status--unverified'}`}>
+											{item.isVerified ? (
+												<>
+													<FontAwesomeIcon icon={faCircleCheck} />
+													인증 완료
+												</>
+											) : (
+												<>
+													<FontAwesomeIcon icon={faTriangleExclamation} />
+													미인증
+												</>
+											)}
+											<span className="settlement__account-detail">
+												 · 등록일 {item.registeredAt.slice(0, 10).replace(/-/g, '.')}
+											</span>
+										</span>
+									</div>
+									<div className="settlement__account-actions">
+										<button
+											type="button"
+											className="settlement__btn"
+											onClick={() => handleEdit(item)}
+										>
+											수정
+										</button>
+										<button
+											type="button"
+											className="settlement__btn settlement__btn--danger"
+											onClick={() => handleDelete(item)}
+										>
+											삭제
+										</button>
+									</div>
+								</div>
+							))}
+						</div>
+					)}
+				</>
+			)}
+		</div>
+	);
+}

+ 37 - 0
app/studio/settlement/constants.ts

@@ -0,0 +1,37 @@
+export const BANK_LIST = [
+	{ code: '004', name: 'KB국민은행' },
+	{ code: '088', name: '신한은행' },
+	{ code: '020', name: '우리은행' },
+	{ code: '081', name: '하나은행' },
+	{ code: '011', name: 'NH농협은행' },
+	{ code: '003', name: 'IBK기업은행' },
+	{ code: '002', name: 'KDB산업은행' },
+	{ code: '023', name: 'SC제일은행' },
+	{ code: '027', name: '한국씨티은행' },
+	{ code: '071', name: '우체국' },
+	{ code: '031', name: 'DGB대구은행' },
+	{ code: '032', name: '부산은행' },
+	{ code: '039', name: '경남은행' },
+	{ code: '034', name: '광주은행' },
+	{ code: '035', name: '제주은행' },
+	{ code: '037', name: '전북은행' },
+	{ code: '007', name: '수협은행' },
+	{ code: '045', name: '새마을금고' },
+	{ code: '048', name: '신협' },
+	{ code: '090', name: '카카오뱅크' },
+	{ code: '092', name: '토스뱅크' },
+	{ code: '089', name: '케이뱅크' },
+];
+
+export const MONTH_NAMES = [
+	'1월', '2월', '3월', '4월', '5월', '6월',
+	'7월', '8월', '9월', '10월', '11월', '12월',
+];
+
+export const TAX_GUIDE_ITEMS = [
+	'본 소득은 사업소득(인적용역)으로 분류됩니다.',
+	'연말정산 대상이 아닙니다.',
+	'매년 5월 종합소득세를 직접 신고해야 합니다.',
+	'국세청 홈택스에서 지급명세서를 확인할 수 있습니다. (My홈택스 > 지급명세서 등 제출내역)',
+	'플랫폼은 매년 3월 10일까지 지급명세서를 국세청에 제출합니다.',
+];

+ 5 - 0
app/studio/settlement/layout.tsx

@@ -0,0 +1,5 @@
+import './style.scss';
+
+export default function SettlementLayout({ children }: { children: React.ReactNode }) {
+	return children;
+}

+ 0 - 8
app/studio/settlement/page.tsx

@@ -1,8 +0,0 @@
-export default function StudioSettlement() {
-	return (
-		<div className="studio-placeholder">
-			<h1>정산</h1>
-			<p>후원 정산 내역 및 출금 신청이 표시될 예정입니다.</p>
-		</div>
-	);
-}

+ 417 - 0
app/studio/settlement/style.scss

@@ -0,0 +1,417 @@
+// ── 정산 공통 ──────────────────────────────────────
+.settlement {
+	max-width: 960px;
+
+	// ── Summary Cards ──
+	&__cards {
+		display: grid;
+		grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+		gap: 16px;
+		margin-bottom: 28px;
+	}
+
+	&__card {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 20px;
+		background: hsl(var(--background));
+	}
+
+	&__card-label {
+		display: block;
+		font-size: 0.8125rem;
+		color: hsl(var(--muted-foreground));
+		margin-bottom: 6px;
+	}
+
+	&__card-value {
+		font-size: 1.5rem;
+		font-weight: 700;
+		color: hsl(var(--foreground));
+
+		&--primary {
+			color: hsl(var(--primary));
+		}
+
+		&--danger {
+			color: var(--color-danger);
+		}
+	}
+
+	// ── Account (계좌 관리) ──
+	&__account-header {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		margin-bottom: 16px;
+	}
+
+	&__account-count {
+		font-size: 0.875rem;
+		color: hsl(var(--muted-foreground));
+	}
+
+	&__account-list {
+		display: flex;
+		flex-direction: column;
+		gap: 12px;
+	}
+
+	&__account-card {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 20px 24px;
+		background: hsl(var(--background));
+		gap: 16px;
+	}
+
+	&__account-empty {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 48px 24px;
+		text-align: center;
+		color: hsl(var(--muted-foreground));
+		font-size: 0.875rem;
+	}
+
+	&__account-box {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 24px;
+		max-width: 560px;
+		margin-bottom: 24px;
+	}
+
+	&__account-form-title {
+		font-size: 1rem;
+		font-weight: 600;
+		margin-bottom: 16px;
+		color: hsl(var(--foreground));
+	}
+
+	&__account-info {
+		display: flex;
+		flex-direction: column;
+		gap: 8px;
+		min-width: 0;
+	}
+
+	&__account-bank {
+		font-size: 1.125rem;
+		font-weight: 600;
+		color: hsl(var(--foreground));
+	}
+
+	&__account-detail {
+		font-size: 0.875rem;
+		color: hsl(var(--muted-foreground));
+	}
+
+	&__account-status {
+		display: flex;
+		align-items: center;
+		gap: 6px;
+		margin-top: 4px;
+		font-size: 0.8125rem;
+
+		&--verified {
+			color: var(--color-success);
+		}
+
+		&--unverified {
+			color: var(--color-warning);
+		}
+	}
+
+	&__account-actions {
+		display: flex;
+		gap: 8px;
+		flex-shrink: 0;
+	}
+
+	// ── Account Form ──
+	&__form {
+		display: flex;
+		flex-direction: column;
+		gap: 16px;
+	}
+
+	&__field {
+		display: flex;
+		flex-direction: column;
+		gap: 6px;
+	}
+
+	&__label {
+		font-size: 0.875rem;
+		font-weight: 500;
+		color: hsl(var(--foreground));
+	}
+
+	&__select,
+	&__input {
+		width: 100%;
+		padding: 8px 12px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.875rem;
+
+		&::placeholder {
+			color: hsl(var(--muted-foreground));
+		}
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+	}
+
+	&__select {
+		appearance: none;
+		background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+		background-repeat: no-repeat;
+		background-position: right 12px center;
+		padding-right: 32px;
+	}
+
+	&__notice {
+		margin-top: 4px;
+
+		li {
+			font-size: 0.8125rem;
+			color: hsl(var(--muted-foreground));
+			padding: 2px 0;
+
+			&::before {
+				content: '※ ';
+			}
+		}
+	}
+
+	&__btn {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		padding: 10px 32px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		font-size: 0.875rem;
+		font-weight: 500;
+		cursor: pointer;
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		transition: background-color 0.15s;
+
+		&:hover:not(:disabled) {
+			background: hsl(var(--accent));
+		}
+
+		&--primary {
+			background: hsl(var(--primary));
+			color: hsl(var(--primary-foreground));
+			border-color: hsl(var(--primary));
+
+			&:hover:not(:disabled) {
+				background: hsl(var(--primary) / 0.9);
+			}
+		}
+
+		&--cancel {
+			background: transparent;
+		}
+
+		&--danger {
+			color: var(--color-danger);
+			border-color: var(--color-danger);
+
+			&:hover:not(:disabled) {
+				background: hsl(0 84% 60% / 0.1);
+			}
+		}
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+	}
+
+	// ── Tax (원천징수 내역) ──
+	&__year-select {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		margin-bottom: 20px;
+
+		label {
+			font-size: 0.875rem;
+			font-weight: 500;
+			color: hsl(var(--foreground));
+		}
+
+		select {
+			padding: 6px 32px 6px 12px;
+			border: 1px solid hsl(var(--border));
+			border-radius: calc(var(--radius) - 2px);
+			background: hsl(var(--background));
+			color: hsl(var(--foreground));
+			font-size: 0.875rem;
+			appearance: none;
+			background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+			background-repeat: no-repeat;
+			background-position: right 10px center;
+		}
+	}
+
+	&__section-title {
+		font-size: 1rem;
+		font-weight: 600;
+		margin-bottom: 12px;
+		color: hsl(var(--foreground));
+	}
+
+	// ── Table ──
+	&__table-wrap {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		overflow: hidden;
+		margin-bottom: 24px;
+	}
+
+	&__table {
+		width: 100%;
+		border-collapse: collapse;
+
+		th, td {
+			padding: 12px 16px;
+			text-align: left;
+			border-bottom: 1px solid hsl(var(--border));
+			font-size: 0.875rem;
+		}
+
+		th {
+			background-color: hsl(var(--muted));
+			font-weight: 600;
+			color: hsl(var(--muted-foreground));
+			white-space: nowrap;
+		}
+
+		tr:last-child td {
+			border-bottom: none;
+		}
+
+		tr:hover td {
+			background-color: hsl(var(--accent));
+		}
+
+		td[colspan] {
+			text-align: center;
+		}
+	}
+
+	&__amount {
+		&--plus {
+			color: var(--color-success);
+			font-weight: 600;
+		}
+
+		&--minus {
+			color: var(--color-danger);
+			font-weight: 600;
+		}
+	}
+
+	&__empty {
+		padding: 40px;
+		text-align: center;
+		color: hsl(var(--muted-foreground));
+	}
+
+	// ── Tax Guide ──
+	&__guide {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 20px 24px;
+		background: hsl(var(--muted) / 0.3);
+	}
+
+	&__guide-title {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		font-size: 0.9375rem;
+		font-weight: 600;
+		color: hsl(var(--foreground));
+		margin-bottom: 12px;
+	}
+
+	&__guide-list {
+		li {
+			font-size: 0.8125rem;
+			color: hsl(var(--muted-foreground));
+			padding: 3px 0;
+			padding-left: 14px;
+			position: relative;
+
+			&::before {
+				content: '·';
+				position: absolute;
+				left: 0;
+				font-weight: 700;
+			}
+		}
+	}
+
+	// ── Responsive ──
+	@media (max-width: 768px) {
+		&__cards {
+			grid-template-columns: 1fr 1fr;
+		}
+
+		&__card {
+			padding: 14px;
+		}
+
+		&__card-value {
+			font-size: 1.25rem;
+		}
+
+		&__account-box {
+			max-width: none;
+		}
+
+		&__account-card {
+			flex-direction: column;
+			align-items: flex-start;
+		}
+
+		&__account-actions {
+			width: 100%;
+
+			.settlement__btn {
+				flex: 1;
+			}
+		}
+
+		&__table-wrap {
+			overflow-x: auto;
+		}
+
+		&__table {
+			min-width: 640px;
+
+			th, td {
+				padding: 10px 12px;
+				font-size: 0.8125rem;
+			}
+		}
+
+		&__guide {
+			padding: 16px;
+		}
+	}
+}

+ 156 - 0
app/studio/settlement/tax/page.tsx

@@ -0,0 +1,156 @@
+'use client';
+
+import { useState, useEffect, useMemo } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCircleInfo } from '@fortawesome/free-solid-svg-icons';
+import { fetchApi } from '@/lib/utils/client';
+import type { WithholdingTaxSummaryResponse } from '@/types/response/settlement/tax';
+import { MONTH_NAMES, TAX_GUIDE_ITEMS } from '../constants';
+import Loading from '@/app/component/Loading';
+
+export default function SettlementTaxPage() {
+	const currentYear = new Date().getFullYear();
+	const [loading, setLoading] = useState(true);
+	const [year, setYear] = useState(currentYear);
+	const [data, setData] = useState<WithholdingTaxSummaryResponse|null>(null);
+
+	const yearOptions = useMemo(() => {
+		const years: number[] = [];
+		for (let y = currentYear; y >= currentYear - 4; y--) {
+			years.push(y);
+		}
+		return years;
+	}, [currentYear]);
+
+	useEffect(() => {
+		setLoading(true);
+		fetchApi<WithholdingTaxSummaryResponse>(`/api/studio/settlement/tax?year=${year}`)
+			.then(res => {
+				if (res.data) {
+					setData(res.data);
+				}
+			})
+			.catch(() => {})
+			.finally(() => setLoading(false));
+	}, [year]);
+
+	return (
+		<div className="studio-page settlement">
+			<div className="studio-page__header">
+				<h1 className="studio-page__title">원천징수 내역</h1>
+			</div>
+
+			{/* 연도 선택 */}
+			<div className="settlement__year-select">
+				<label htmlFor="year-select">연도</label>
+				<select
+					id="year-select"
+					value={year}
+					onChange={e => setYear(Number(e.target.value))}
+				>
+					{yearOptions.map(y => (
+						<option key={y} value={y}>{y}년</option>
+					))}
+				</select>
+			</div>
+
+			{loading && <Loading />}
+
+			{!loading && data && (
+				<>
+					{/* 연간 요약 카드 */}
+					<div className="settlement__cards">
+						<div className="settlement__card">
+							<span className="settlement__card-label">총 지급액</span>
+							<div className="settlement__card-value">
+								{data.annualSummary.totalGrossAmount.toLocaleString()}원
+							</div>
+						</div>
+						<div className="settlement__card">
+							<span className="settlement__card-label">소득세 (3%)</span>
+							<div className="settlement__card-value settlement__card-value--danger">
+								-{data.annualSummary.totalIncomeTax.toLocaleString()}원
+							</div>
+						</div>
+						<div className="settlement__card">
+							<span className="settlement__card-label">지방소득세 (0.3%)</span>
+							<div className="settlement__card-value settlement__card-value--danger">
+								-{data.annualSummary.totalLocalTax.toLocaleString()}원
+							</div>
+						</div>
+						<div className="settlement__card">
+							<span className="settlement__card-label">실수령액</span>
+							<div className="settlement__card-value settlement__card-value--primary">
+								{data.annualSummary.totalNetAmount.toLocaleString()}원
+							</div>
+						</div>
+					</div>
+
+					{/* 월별 상세 */}
+					<h2 className="settlement__section-title">월별 상세</h2>
+					<div className="settlement__table-wrap">
+						<table className="settlement__table">
+							<thead>
+								<tr>
+									<th>월</th>
+									<th style={{ textAlign: 'right' }}>지급액</th>
+									<th style={{ textAlign: 'right' }}>소득세</th>
+									<th style={{ textAlign: 'right' }}>지방소득세</th>
+									<th style={{ textAlign: 'right' }}>실수령액</th>
+									<th style={{ textAlign: 'right' }}>건수</th>
+								</tr>
+							</thead>
+							<tbody>
+								{data.monthlyList.length > 0 ? (
+									data.monthlyList.map(row => (
+										<tr key={row.month}>
+											<td>{MONTH_NAMES[row.month - 1]}</td>
+											<td style={{ textAlign: 'right' }}>
+												{row.grossAmount.toLocaleString()}원
+											</td>
+											<td style={{ textAlign: 'right' }}>
+												<span className="settlement__amount--minus">
+													-{row.incomeTax.toLocaleString()}원
+												</span>
+											</td>
+											<td style={{ textAlign: 'right' }}>
+												<span className="settlement__amount--minus">
+													-{row.localTax.toLocaleString()}원
+												</span>
+											</td>
+											<td style={{ textAlign: 'right' }}>
+												<span className="settlement__amount--plus">
+													{row.netAmount.toLocaleString()}원
+												</span>
+											</td>
+											<td style={{ textAlign: 'right' }}>{row.paymentCount}건</td>
+										</tr>
+									))
+								) : (
+									<tr>
+										<td colSpan={6} className="settlement__empty">
+											해당 연도의 원천징수 내역이 없습니다.
+										</td>
+									</tr>
+								)}
+							</tbody>
+						</table>
+					</div>
+
+					{/* 종합소득세 신고 안내 */}
+					<div className="settlement__guide">
+						<div className="settlement__guide-title">
+							<FontAwesomeIcon icon={faCircleInfo} />
+							종합소득세 신고 안내
+						</div>
+						<ul className="settlement__guide-list">
+							{TAX_GUIDE_ITEMS.map((item, i) => (
+								<li key={i}>{item}</li>
+							))}
+						</ul>
+					</div>
+				</>
+			)}
+		</div>
+	);
+}

+ 4 - 2
app/studio/style.scss

@@ -15,8 +15,6 @@
 
 // 스튜디오 공통 관리 페이지 스타일
 .studio-page {
-	max-width: 900px;
-
 	&__header {
 		display: flex;
 		align-items: center;
@@ -130,6 +128,10 @@
 		text-align: center;
 		color: hsl(var(--muted-foreground));
 	}
+
+	&__table &__empty {
+		text-align: center;
+	}
 }
 
 // 모달

+ 105 - 0
app/studio/wallet/balance/page.tsx

@@ -0,0 +1,105 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { LogOut } from 'lucide-react';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
+import type { WalletBalanceResponse } from '@/types/response/wallet/balance';
+import { TRANSACTION_TYPE_MAP } from '../constants';
+import Loading from '@/app/component/Loading';
+
+export default function WalletBalancePage() {
+	const [loading, setLoading] = useState(true);
+	const [data, setData] = useState<WalletBalanceResponse|null>(null);
+
+	useEffect(() => {
+		fetchApi<WalletBalanceResponse>('/api/studio/wallet/balance')
+			.then(res => {
+				if (res.data) {
+					setData(res.data);
+				}
+			})
+			.catch(() => {})
+			.finally(() => setLoading(false));
+	}, []);
+
+	if (loading || !data) {
+		return <Loading />;
+	}
+
+	return (
+		<div className="studio-page wallet">
+			<div className="studio-page__header">
+				<h1 className="studio-page__title">잔액 현황</h1>
+			</div>
+
+			{/* Summary Cards */}
+			<div className="wallet__cards">
+				<div className="wallet__card">
+					<span className="wallet__card-label">출금 가능 잔액 (M)</span>
+					<div className="wallet__card-value wallet__card-value--money">
+						{data.withdrawableBalance.toLocaleString()}원
+					</div>
+				</div>
+				<div className="wallet__card">
+					<span className="wallet__card-label">누적 수익</span>
+					<div className="wallet__card-value">
+						{data.totalEarned.toLocaleString()}원
+					</div>
+				</div>
+				<div className="wallet__card">
+					<span className="wallet__card-label">누적 출금</span>
+					<div className="wallet__card-value">
+						{data.totalWithdrawn.toLocaleString()}원
+					</div>
+				</div>
+			</div>
+
+			{/* Actions */}
+			<div className="wallet__actions">
+				<Link href="/studio/wallet/withdraw" className="wallet__action-btn wallet__action-btn--primary">
+					<LogOut className="size-4" />
+					출금하기
+				</Link>
+			</div>
+
+			{/* Recent Transactions */}
+			<h2 className="wallet__section-title">최근 거래 내역</h2>
+
+			<div className="wallet__table-wrap">
+				<table className="wallet__table">
+					<thead>
+						<tr>
+							<th>일시</th>
+							<th>유형</th>
+							<th>내용</th>
+							<th style={{ textAlign: 'right' }}>금액</th>
+							<th style={{ textAlign: 'right' }}>잔액</th>
+						</tr>
+					</thead>
+					<tbody>
+						{data.recentTransactions.length > 0 ? (
+							data.recentTransactions.map((tx) => (
+								<tr key={tx.id}>
+									<td>{getDateTime(tx.createdAt)}</td>
+									<td>{TRANSACTION_TYPE_MAP[tx.type] ?? tx.type}</td>
+									<td>{tx.description}</td>
+									<td style={{ textAlign: 'right' }}>
+										<span className={tx.amount >= 0 ? 'wallet__amount--plus' : 'wallet__amount--minus'}>
+											{tx.amount >= 0 ? '+' : ''}{tx.amount.toLocaleString()}원
+										</span>
+									</td>
+									<td style={{ textAlign: 'right' }}>{tx.balance.toLocaleString()}원</td>
+								</tr>
+							))
+						) : (
+							<tr>
+								<td colSpan={5} className="wallet__empty">거래 내역이 없습니다.</td>
+							</tr>
+						)}
+					</tbody>
+				</table>
+			</div>
+		</div>
+	);
+}

+ 31 - 0
app/studio/wallet/constants.ts

@@ -0,0 +1,31 @@
+import { LoginLogType } from '@/constants/common';
+
+export const PERIOD_TABS = [
+	{ label: '오늘', value: LoginLogType.Today },
+	{ label: '1주일', value: LoginLogType.Week },
+	{ label: '1개월', value: LoginLogType.Month },
+	{ label: '3개월', value: LoginLogType.QuarterYear },
+	{ label: '6개월', value: LoginLogType.HalfYear },
+];
+
+export const WITHDRAW_STATUS_MAP: Record<string, { label: string; cls: string }> = {
+	Pending:    { label: '대기',   cls: 'wallet__status--pending' },
+	Processing: { label: '처리중', cls: 'wallet__status--processing' },
+	Completed:  { label: '완료',   cls: 'wallet__status--completed' },
+	Rejected:   { label: '거절',   cls: 'wallet__status--rejected' },
+};
+
+export const TRANSACTION_TYPE_MAP: Record<string, string> = {
+	donation_received: '후원 수익',
+	withdrawal: '출금',
+	fee: '수수료',
+	adjustment: '조정',
+};
+
+export const REVENUE_TYPE_MAP: Record<string, string> = {
+	donation: '후원',
+	crew_donation: '크루 후원',
+};
+
+export const WITHHOLDING_TAX_RATE = 0.033;
+export const MIN_WITHDRAW_AMOUNT = 40000;

+ 5 - 0
app/studio/wallet/layout.tsx

@@ -0,0 +1,5 @@
+import './style.scss';
+
+export default function WalletLayout({ children }: { children: React.ReactNode }) {
+	return children;
+}

+ 209 - 0
app/studio/wallet/revenue/page.tsx

@@ -0,0 +1,209 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { LoginLogType } from '@/constants/common';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
+import type { WalletRevenueResponse, RevenueChartItem } from '@/types/response/wallet/revenue';
+import { PERIOD_TABS, REVENUE_TYPE_MAP } from '../constants';
+import Loading from '@/app/component/Loading';
+import Pagination from '@/app/component/Pagination';
+import {
+	ResponsiveContainer,
+	AreaChart,
+	Area,
+	XAxis,
+	YAxis,
+	CartesianGrid,
+	Tooltip,
+} from 'recharts';
+
+function RevenueChart({ chartData }: { chartData: RevenueChartItem[] }) {
+	if (chartData.length === 0) {
+		return <div className="wallet__chart-empty">데이터가 없습니다.</div>;
+	}
+
+	return (
+		<ResponsiveContainer width="100%" height={280}>
+			<AreaChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
+				<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
+				<XAxis
+					dataKey="date"
+					tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
+					axisLine={{ stroke: 'hsl(var(--border))' }}
+					tickLine={false}
+				/>
+				<YAxis
+					tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
+					axisLine={false}
+					tickLine={false}
+					tickFormatter={(v: number) => v >= 10000 ? `${(v / 10000).toFixed(0)}만` : v.toLocaleString()}
+					width={50}
+				/>
+				<Tooltip
+					formatter={(value, name) => [
+						`${Number(value).toLocaleString()}원`,
+						name === 'netAmount' ? '순수익' : '수수료',
+					]}
+					labelFormatter={(label) => String(label)}
+					contentStyle={{
+						background: 'hsl(var(--background))',
+						border: '1px solid hsl(var(--border))',
+						borderRadius: '6px',
+						fontSize: '0.8125rem',
+					}}
+				/>
+				<Area
+					type="monotone"
+					dataKey="netAmount"
+					name="netAmount"
+					stroke="hsl(var(--primary))"
+					fill="hsl(var(--primary))"
+					fillOpacity={0.15}
+					strokeWidth={2}
+				/>
+				<Area
+					type="monotone"
+					dataKey="platformFee"
+					name="platformFee"
+					stroke="hsl(var(--muted-foreground))"
+					fill="hsl(var(--muted-foreground))"
+					fillOpacity={0.08}
+					strokeWidth={1.5}
+				/>
+			</AreaChart>
+		</ResponsiveContainer>
+	);
+}
+
+export default function WalletRevenuePage() {
+	const [loading, setLoading] = useState(true);
+	const [page, setPage] = useState(1);
+	const [period, setPeriod] = useState<LoginLogType>(LoginLogType.Month);
+	const [data, setData] = useState<WalletRevenueResponse>({ total: 0, summary: { grossAmount: 0, platformFee: 0, netAmount: 0 }, list: [] });
+	const [chartData, setChartData] = useState<RevenueChartItem[]>([]);
+
+	useEffect(() => {
+		setLoading(true);
+		fetchApi<WalletRevenueResponse & { chartData?: RevenueChartItem[] }>(`/api/studio/wallet/revenue?period=${period}&page=${page}&perPage=20`)
+			.then(res => {
+				if (res.data) {
+					setData(res.data);
+					setChartData(res.data.chartData ?? []);
+				}
+			})
+			.catch(() => {})
+			.finally(() => setLoading(false));
+	}, [period, page]);
+
+	useEffect(() => {
+		setPage(1);
+	}, [period]);
+
+	return (
+		<div className="studio-page wallet">
+			<div className="studio-page__header">
+				<h1 className="studio-page__title">수익 내역</h1>
+			</div>
+
+			{loading && <Loading />}
+
+			{/* Summary Cards */}
+			<div className="wallet__cards">
+				<div className="wallet__card">
+					<span className="wallet__card-label">총 후원 금액</span>
+					<div className="wallet__card-value">
+						{data.summary.grossAmount.toLocaleString()}원
+					</div>
+				</div>
+				<div className="wallet__card">
+					<span className="wallet__card-label">플랫폼 수수료</span>
+					<div className="wallet__card-value wallet__card-value--danger">
+						-{data.summary.platformFee.toLocaleString()}원
+					</div>
+				</div>
+				<div className="wallet__card">
+					<span className="wallet__card-label">순수익</span>
+					<div className="wallet__card-value wallet__card-value--money">
+						{data.summary.netAmount.toLocaleString()}원
+					</div>
+				</div>
+			</div>
+
+			{/* Period Filter */}
+			<div className="wallet__header">
+				<div />
+				<div className="wallet__tabs">
+					{PERIOD_TABS.map((tab) => (
+						<button
+							type="button"
+							key={tab.value}
+							className={period === tab.value ? 'active' : ''}
+							onClick={() => setPeriod(tab.value)}
+						>
+							{tab.label}
+						</button>
+					))}
+				</div>
+			</div>
+
+			{/* Chart */}
+			<div className="wallet__chart-wrap">
+				<RevenueChart chartData={chartData} />
+				<div style={{ display: 'flex', justifyContent: 'center', gap: '20px', marginTop: '8px', fontSize: '0.8125rem' }}>
+					<span><span style={{ color: 'hsl(var(--primary))' }}>■</span> 순수익</span>
+					<span><span style={{ color: 'hsl(var(--muted-foreground))' }}>■</span> 수수료</span>
+				</div>
+			</div>
+
+			{/* Table Header */}
+			<div className="wallet__header">
+				<div className="wallet__summary">합계: {data.total}건</div>
+			</div>
+
+			{/* Table */}
+			<div className="wallet__table-wrap">
+				<table className="wallet__table">
+					<thead>
+						<tr>
+							<th>일시</th>
+							<th>후원자</th>
+							<th>유형</th>
+							<th style={{ textAlign: 'right' }}>후원 금액</th>
+							<th style={{ textAlign: 'right' }}>수수료</th>
+							<th style={{ textAlign: 'right' }}>순수익</th>
+						</tr>
+					</thead>
+					<tbody>
+						{data.list.length > 0 ? (
+							data.list.map((row) => (
+								<tr key={row.id}>
+									<td>{getDateTime(row.createdAt)}</td>
+									<td>{row.donorName}</td>
+									<td>
+										{REVENUE_TYPE_MAP[row.type] ?? row.type}
+										{row.crewName && <small style={{ marginLeft: '4px', color: 'hsl(var(--muted-foreground))' }}>({row.crewName})</small>}
+									</td>
+									<td style={{ textAlign: 'right' }}>{row.grossAmount.toLocaleString()}원</td>
+									<td style={{ textAlign: 'right' }}>
+										<span className="wallet__amount--minus">-{row.platformFee.toLocaleString()}원</span>
+									</td>
+									<td style={{ textAlign: 'right' }}>
+										<span className="wallet__amount--plus">{row.netAmount.toLocaleString()}원</span>
+									</td>
+								</tr>
+							))
+						) : (
+							<tr>
+								<td colSpan={6} className="wallet__empty">수익 내역이 없습니다.</td>
+							</tr>
+						)}
+					</tbody>
+				</table>
+			</div>
+
+			{data.total > 0 && (
+				<Pagination total={data.total} page={page} perPage={20} onChange={setPage} />
+			)}
+		</div>
+	);
+}

+ 482 - 0
app/studio/wallet/style.scss

@@ -0,0 +1,482 @@
+// ── 지갑 공통 ──────────────────────────────────────
+.wallet {
+	max-width: 960px;
+
+	// ── Summary Cards ──
+	&__cards {
+		display: grid;
+		grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+		gap: 16px;
+		margin-bottom: 28px;
+	}
+
+	&__card {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 20px;
+		background: hsl(var(--background));
+	}
+
+	&__card-label {
+		display: block;
+		font-size: 0.8125rem;
+		color: hsl(var(--muted-foreground));
+		margin-bottom: 6px;
+	}
+
+	&__card-value {
+		font-size: 1.5rem;
+		font-weight: 700;
+		color: hsl(var(--foreground));
+
+		&--money {
+			color: hsl(var(--primary));
+		}
+
+		&--danger {
+			color: var(--color-danger);
+		}
+	}
+
+	// ── Actions ──
+	&__actions {
+		display: flex;
+		gap: 8px;
+		margin-bottom: 32px;
+	}
+
+	&__action-btn {
+		display: inline-flex;
+		align-items: center;
+		gap: 6px;
+		padding: 8px 20px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		font-size: 0.875rem;
+		font-weight: 500;
+		cursor: pointer;
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		transition: background-color 0.15s;
+		text-decoration: none;
+
+		&:hover {
+			background: hsl(var(--accent));
+		}
+
+		&--primary {
+			background: hsl(var(--primary));
+			color: hsl(var(--primary-foreground));
+			border-color: hsl(var(--primary));
+
+			&:hover {
+				background: hsl(var(--primary) / 0.9);
+			}
+		}
+	}
+
+	// ── Section Title ──
+	&__section-title {
+		font-size: 1rem;
+		font-weight: 600;
+		margin-bottom: 12px;
+		color: hsl(var(--foreground));
+	}
+
+	// ── Header (filter area) ──
+	&__header {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		margin-bottom: 10px;
+		flex-wrap: wrap;
+		gap: 0.5rem;
+	}
+
+	&__summary {
+		font-size: 0.9375rem;
+		color: hsl(var(--muted-foreground));
+	}
+
+	&__tabs {
+		display: flex;
+
+		button {
+			padding: 0.5rem 1rem;
+			font-size: 0.875rem;
+			color: hsl(var(--muted-foreground));
+			border-bottom: 2px solid transparent;
+			transition: color 0.2s, border-color 0.2s;
+			background: none;
+			cursor: pointer;
+
+			&:hover {
+				color: hsl(var(--primary));
+				border-bottom-color: hsl(var(--primary));
+			}
+
+			&.active {
+				color: hsl(var(--primary));
+				font-weight: 600;
+				border-bottom-color: hsl(var(--primary));
+			}
+		}
+	}
+
+	// ── Chart ──
+	&__chart-wrap {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 20px 16px 12px;
+		margin-bottom: 24px;
+		background: hsl(var(--background));
+	}
+
+	&__chart-empty {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		height: 200px;
+		color: hsl(var(--muted-foreground));
+		font-size: 0.875rem;
+	}
+
+	// ── Table ──
+	&__table-wrap {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		overflow: hidden;
+	}
+
+	&__table {
+		width: 100%;
+		border-collapse: collapse;
+
+		th, td {
+			padding: 12px 16px;
+			text-align: left;
+			border-bottom: 1px solid hsl(var(--border));
+			font-size: 0.875rem;
+		}
+
+		th {
+			background-color: hsl(var(--muted));
+			font-weight: 600;
+			color: hsl(var(--muted-foreground));
+			white-space: nowrap;
+		}
+
+		tr:last-child td {
+			border-bottom: none;
+		}
+
+		tr:hover td {
+			background-color: hsl(var(--accent));
+		}
+
+		td[colspan] {
+			text-align: center;
+		}
+	}
+
+	&__amount {
+		&--plus {
+			color: var(--color-success);
+			font-weight: 600;
+		}
+
+		&--minus {
+			color: var(--color-danger);
+			font-weight: 600;
+		}
+	}
+
+	&__empty {
+		padding: 40px;
+		text-align: center;
+		color: hsl(var(--muted-foreground));
+	}
+
+	// ── Mobile row (dl) ──
+	&__mobile-row {
+		display: none;
+		padding: 12px 16px;
+		border-bottom: 1px solid hsl(var(--border));
+	}
+
+	&__mobile-title {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		font-size: 0.9375rem;
+		margin-bottom: 4px;
+	}
+
+	&__mobile-detail {
+		display: flex;
+		gap: 12px;
+		font-size: 0.8125rem;
+		color: hsl(var(--muted-foreground));
+	}
+
+	// ── Status Badges ──
+	&__status {
+		font-weight: 600;
+		font-size: 0.8125rem;
+
+		&--pending {
+			color: var(--color-warning);
+		}
+
+		&--processing {
+			color: hsl(var(--primary));
+		}
+
+		&--completed {
+			color: var(--color-success);
+		}
+
+		&--rejected {
+			color: var(--color-danger);
+		}
+	}
+
+	// ── Withdraw Form ──
+	&__withdraw-form {
+		border: 1px solid hsl(var(--border));
+		border-radius: 8px;
+		padding: 24px;
+		margin-bottom: 32px;
+		max-width: 560px;
+	}
+
+	&__withdraw-balance {
+		font-size: 1.25rem;
+		font-weight: 600;
+		color: hsl(var(--primary));
+		margin-bottom: 20px;
+
+		small {
+			font-size: 0.8125rem;
+			font-weight: 400;
+			color: hsl(var(--muted-foreground));
+			margin-left: 4px;
+		}
+	}
+
+	&__withdraw-field {
+		margin-bottom: 16px;
+
+		label {
+			display: block;
+			font-size: 0.875rem;
+			font-weight: 500;
+			margin-bottom: 6px;
+			color: hsl(var(--foreground));
+		}
+	}
+
+	&__withdraw-select {
+		width: 100%;
+		padding: 8px 32px 8px 12px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.875rem;
+		appearance: none;
+		background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
+		background-repeat: no-repeat;
+		background-position: right 12px center;
+		margin-bottom: 8px;
+	}
+
+	&__withdraw-account-link {
+		font-size: 0.8125rem;
+		color: hsl(var(--primary));
+		text-decoration: none;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	&__withdraw-input {
+		width: 100%;
+		padding: 8px 12px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.875rem;
+
+		&::placeholder {
+			color: hsl(var(--muted-foreground));
+		}
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+	}
+
+	&__withdraw-preview {
+		background: hsl(var(--muted));
+		border: 1px solid hsl(var(--border));
+		border-radius: 6px;
+		padding: 16px;
+		margin-bottom: 16px;
+	}
+
+	&__withdraw-row {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		padding: 4px 0;
+		font-size: 0.875rem;
+		color: hsl(var(--foreground));
+	}
+
+	&__withdraw-total {
+		display: flex;
+		justify-content: space-between;
+		align-items: center;
+		padding-top: 10px;
+		margin-top: 8px;
+		border-top: 1px solid hsl(var(--border));
+		font-size: 0.9375rem;
+		font-weight: 700;
+		color: hsl(var(--foreground));
+	}
+
+	&__withdraw-account {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		padding: 12px 16px;
+		border: 1px solid hsl(var(--border));
+		border-radius: 6px;
+		margin-bottom: 16px;
+		font-size: 0.875rem;
+
+		a {
+			margin-left: auto;
+			font-size: 0.8125rem;
+			color: hsl(var(--primary));
+			text-decoration: none;
+
+			&:hover {
+				text-decoration: underline;
+			}
+		}
+	}
+
+	&__withdraw-info {
+		margin-bottom: 20px;
+
+		li {
+			font-size: 0.8125rem;
+			color: hsl(var(--muted-foreground));
+			padding: 2px 0;
+
+			&::before {
+				content: '※ ';
+			}
+		}
+	}
+
+	&__withdraw-warning {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		padding: 12px 16px;
+		border: 1px solid var(--color-warning);
+		border-radius: 6px;
+		background: hsl(48 96% 89% / 0.3);
+		margin-bottom: 16px;
+		font-size: 0.875rem;
+		color: var(--color-warning);
+
+		a {
+			font-weight: 600;
+			color: hsl(var(--primary));
+			text-decoration: none;
+
+			&:hover {
+				text-decoration: underline;
+			}
+		}
+	}
+
+	&__withdraw-submit {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		padding: 10px 32px;
+		border: none;
+		border-radius: calc(var(--radius) - 2px);
+		font-size: 0.9375rem;
+		font-weight: 600;
+		cursor: pointer;
+		background: hsl(var(--primary));
+		color: hsl(var(--primary-foreground));
+		transition: background-color 0.15s;
+
+		&:hover:not(:disabled) {
+			background: hsl(var(--primary) / 0.9);
+		}
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+	}
+
+	// ── Responsive ──
+	@media (max-width: 768px) {
+		&__cards {
+			grid-template-columns: 1fr 1fr;
+		}
+
+		&__card {
+			padding: 14px;
+		}
+
+		&__card-value {
+			font-size: 1.25rem;
+		}
+
+		&__chart-wrap {
+			padding: 12px 8px 8px;
+		}
+
+		&__table-wrap {
+			overflow-x: auto;
+		}
+
+		&__table {
+			min-width: 640px;
+
+			th, td {
+				padding: 10px 12px;
+				font-size: 0.8125rem;
+			}
+		}
+
+		// PC table 숨기고 mobile row 표시
+		&__table--responsive {
+			thead,
+			tbody tr td {
+				display: none;
+			}
+		}
+
+		&__mobile-row {
+			display: block;
+		}
+
+		&__withdraw-form {
+			max-width: none;
+		}
+	}
+}

+ 265 - 0
app/studio/wallet/withdraw/page.tsx

@@ -0,0 +1,265 @@
+'use client';
+
+import { useState, useEffect, useMemo } from 'react';
+import Link from 'next/link';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
+import type { WalletWithdrawResponse } from '@/types/response/wallet/withdraw';
+import { LoginLogType } from '@/constants/common';
+import { PERIOD_TABS, WITHDRAW_STATUS_MAP, WITHHOLDING_TAX_RATE, MIN_WITHDRAW_AMOUNT } from '../constants';
+import Loading from '@/app/component/Loading';
+import Pagination from '@/app/component/Pagination';
+
+export default function WalletWithdrawPage() {
+	const [loading, setLoading] = useState(true);
+	const [submitting, setSubmitting] = useState(false);
+	const [page, setPage] = useState(1);
+	const [period, setPeriod] = useState<LoginLogType>(LoginLogType.Month);
+	const [data, setData] = useState<WalletWithdrawResponse>({
+		total: 0,
+		withdrawableBalance: 0,
+		accounts: [],
+		list: [],
+	});
+	const [amount, setAmount] = useState('');
+	const [selectedAccountID, setSelectedAccountID] = useState<number>(0);
+
+	const numericAmount = useMemo(() => {
+		const n = parseInt(amount, 10);
+		return isNaN(n) ? 0 : n;
+	}, [amount]);
+
+	const withholdingTax = useMemo(() => Math.floor(numericAmount * WITHHOLDING_TAX_RATE), [numericAmount]);
+	const netAmount = useMemo(() => numericAmount - withholdingTax, [numericAmount, withholdingTax]);
+
+	const canSubmit = useMemo(() => {
+		return (
+			selectedAccountID > 0 &&
+			numericAmount >= MIN_WITHDRAW_AMOUNT &&
+			numericAmount <= data.withdrawableBalance &&
+			!submitting
+		);
+	}, [selectedAccountID, numericAmount, data.withdrawableBalance, submitting]);
+
+	useEffect(() => {
+		setLoading(true);
+		fetchApi<WalletWithdrawResponse>(`/api/studio/wallet/withdraw?period=${period}&page=${page}&perPage=20`)
+			.then(res => {
+				if (res.data) {
+					setData(res.data);
+					// 계좌가 1개면 자동 선택
+					if (res.data.accounts.length === 1 && selectedAccountID === 0) {
+						setSelectedAccountID(res.data.accounts[0].id);
+					}
+				}
+			})
+			.catch(() => {})
+			.finally(() => setLoading(false));
+	}, [period, page]);
+
+	useEffect(() => {
+		setPage(1);
+	}, [period]);
+
+	const handleSubmit = async () => {
+		if (!canSubmit) {
+			return;
+		}
+
+		const selectedAccount = data.accounts.find(a => a.id === selectedAccountID);
+		const confirmed = confirm(
+			`출금 신청하시겠습니까?\n\n` +
+			`입금 계좌: ${selectedAccount?.bankName} ${selectedAccount?.accountNumber}\n` +
+			`신청 금액: ${numericAmount.toLocaleString()}원\n` +
+			`원천징수(3.3%): -${withholdingTax.toLocaleString()}원\n` +
+			`실수령액: ${netAmount.toLocaleString()}원`
+		);
+
+		if (!confirmed) {
+			return;
+		}
+
+		setSubmitting(true);
+		try {
+			await fetchApi('/api/studio/wallet/withdraw', {
+				method: 'POST',
+				body: { accountID: selectedAccountID, amount: numericAmount },
+			});
+			alert('출금 신청이 완료되었습니다.');
+			setAmount('');
+			// 목록 새로고침
+			const res = await fetchApi<WalletWithdrawResponse>(`/api/studio/wallet/withdraw?period=${period}&page=1&perPage=20`);
+			if (res.data) {
+				setData(res.data);
+			}
+			setPage(1);
+		} catch (err: unknown) {
+			alert(err instanceof Error ? err.message : '출금 신청에 실패했습니다.');
+		} finally {
+			setSubmitting(false);
+		}
+	};
+
+	const getStatus = (status: string) => WITHDRAW_STATUS_MAP[status] ?? { label: status, cls: '' };
+
+	return (
+		<div className="studio-page wallet">
+			<div className="studio-page__header">
+				<h1 className="studio-page__title">출금</h1>
+			</div>
+
+			{loading && <Loading />}
+
+			{/* Withdraw Form */}
+			<div className="wallet__withdraw-form">
+				<div className="wallet__withdraw-balance">
+					{data.withdrawableBalance.toLocaleString()}원
+					<small>(M) 출금 가능</small>
+				</div>
+
+				{/* 계좌 미등록 경고 */}
+				{data.accounts.length === 0 && (
+					<div className="wallet__withdraw-warning">
+						계좌가 등록되지 않았습니다.
+						<Link href="/studio/settlement/account">계좌 등록하기</Link>
+					</div>
+				)}
+
+				{/* 계좌 선택 */}
+				{data.accounts.length > 0 && (
+					<div className="wallet__withdraw-field">
+						<label htmlFor="withdraw-account">입금 계좌</label>
+						<select
+							id="withdraw-account"
+							className="wallet__withdraw-select"
+							value={selectedAccountID}
+							onChange={e => setSelectedAccountID(Number(e.target.value))}
+						>
+							{data.accounts.length > 1 && (
+								<option value={0}>계좌를 선택하세요</option>
+							)}
+							{data.accounts.map(acc => (
+								<option key={acc.id} value={acc.id}>
+									{acc.bankName} {acc.accountNumber} ({acc.accountHolder})
+								</option>
+							))}
+						</select>
+						<Link href="/studio/settlement/account" className="wallet__withdraw-account-link">계좌 관리</Link>
+					</div>
+				)}
+
+				{/* 금액 입력 */}
+				<div className="wallet__withdraw-field">
+					<label htmlFor="withdraw-amount">출금 금액</label>
+					<input
+						id="withdraw-amount"
+						type="text"
+						inputMode="numeric"
+						className="wallet__withdraw-input"
+						placeholder={`최소 ${MIN_WITHDRAW_AMOUNT.toLocaleString()}원`}
+						value={amount}
+						onChange={e => setAmount(e.target.value.replace(/\D/g, ''))}
+						disabled={data.accounts.length === 0}
+					/>
+				</div>
+
+				{/* 차감 프리뷰 */}
+				{numericAmount > 0 && (
+					<div className="wallet__withdraw-preview">
+						<div className="wallet__withdraw-row">
+							<span>신청 금액</span>
+							<span>{numericAmount.toLocaleString()}원</span>
+						</div>
+						<div className="wallet__withdraw-row">
+							<span>원천징수 (3.3%)</span>
+							<span className="wallet__amount--minus">-{withholdingTax.toLocaleString()}원</span>
+						</div>
+						<div className="wallet__withdraw-total">
+							<span>실수령액</span>
+							<span>{netAmount.toLocaleString()}원</span>
+						</div>
+					</div>
+				)}
+
+				{/* 안내사항 */}
+				<ul className="wallet__withdraw-info">
+					<li>최소 출금 금액: {MIN_WITHDRAW_AMOUNT.toLocaleString()}원</li>
+					<li>원천징수 3.3% (소득세 3% + 지방소득세 0.3%)</li>
+					<li>매월 10일까지 신청 시 당월 말 입금</li>
+				</ul>
+
+				{/* 제출 */}
+				<button
+					type="button"
+					className="wallet__withdraw-submit"
+					disabled={!canSubmit}
+					onClick={handleSubmit}
+				>
+					{submitting ? '신청 중...' : '출금 신청'}
+				</button>
+			</div>
+
+			{/* Withdraw History */}
+			<h2 className="wallet__section-title">출금 내역</h2>
+
+			<div className="wallet__header">
+				<div className="wallet__summary">합계: {data.total}건</div>
+				<div className="wallet__tabs">
+					{PERIOD_TABS.map((tab) => (
+						<button
+							type="button"
+							key={tab.value}
+							className={period === tab.value ? 'active' : ''}
+							onClick={() => setPeriod(tab.value)}
+						>
+							{tab.label}
+						</button>
+					))}
+				</div>
+			</div>
+
+			<div className="wallet__table-wrap">
+				<table className="wallet__table">
+					<thead>
+						<tr>
+							<th>신청일</th>
+							<th style={{ textAlign: 'right' }}>신청 금액</th>
+							<th style={{ textAlign: 'right' }}>원천징수</th>
+							<th style={{ textAlign: 'right' }}>실수령액</th>
+							<th>계좌</th>
+							<th>상태</th>
+						</tr>
+					</thead>
+					<tbody>
+						{data.list.length > 0 ? (
+							data.list.map((row) => {
+								const st = getStatus(row.status);
+								return (
+									<tr key={row.id}>
+										<td>{getDateTime(row.requestedAt)}</td>
+										<td style={{ textAlign: 'right' }}>{row.requestedAmount.toLocaleString()}원</td>
+										<td style={{ textAlign: 'right' }}>
+											<span className="wallet__amount--minus">-{row.withholdingTax.toLocaleString()}원</span>
+										</td>
+										<td style={{ textAlign: 'right' }}>
+											<span className="wallet__amount--plus">{row.netAmount.toLocaleString()}원</span>
+										</td>
+										<td>{row.bankName} {row.accountNumber}</td>
+										<td><span className={st.cls}>{st.label}</span></td>
+									</tr>
+								);
+							})
+						) : (
+							<tr>
+								<td colSpan={6} className="wallet__empty">출금 내역이 없습니다.</td>
+							</tr>
+						)}
+					</tbody>
+				</table>
+			</div>
+
+			{data.total > 0 && (
+				<Pagination total={data.total} page={page} perPage={20} onChange={setPage} />
+			)}
+		</div>
+	);
+}

+ 17 - 11
app/styles/profile.scss

@@ -130,22 +130,28 @@
 		}
 	}
 
-	// ── 비로그인 드롭다운 ─────────────────────────────
-	&__login-content {
-		width: auto;
-	}
-
-	&__login-panel {
-		padding: 0;
+	// ── 비로그인 링크 ────────────────────────────────
+	&__guest {
 		display: flex;
-		flex-direction: column;
 		align-items: center;
-		gap: 0;
+		gap: 8px;
+		white-space: nowrap;
 	}
 
-	&__login-title {
+	&__guest-link {
 		font-size: 0.875rem;
 		font-weight: 500;
-		color: var(--text-muted);
+		color: var(--text-head);
+		text-decoration: none;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	&__guest-divider {
+		font-size: 0.75rem;
+		color: var(--text-head);
+		opacity: 0.5;
 	}
 }

+ 0 - 67
app/styles/quill.scss

@@ -1,67 +0,0 @@
-.ql-editor {
-    .file-card {
-        position: relative;
-        display: block;
-        margin: 8px 0;
-		border: 1px solid var(--border-default);
-        border-radius: 8px;
-        padding: 8px;
-        background: var(--bg-elevated);
-		white-space: initial;
-		justify-self: start;
-		user-select: auto;
-        cursor: grab;
-
-        .file-card-content {
-            display: flex;
-            flex-direction: column;
-            gap: 4px;
-			pointer-events: auto;
-        }
-
-        .file-card-info {
-            font-size: 14px;
-            word-break: break-word;
-        }
-
-        .file-card-actions {
-			z-index: 1;
-			position: absolute;
-			display: none;
-			width: 154px;
-			top: 100%;
-			left: 50%;
-			transform: translate(-50%, -50%);
-			background: var(--bg-page);
-			border: 1px solid var(--border-strong);
-			padding: 4px;
-			border-radius: 4px;
-
-            button {
-                background: var(--border-default);
-                border: none;
-                border-radius: 4px;
-                padding: 4px 8px;
-                cursor: pointer;
-
-                &:hover, &.active {
-                    background: #d5d5d5;
-                }
-            }
-        }
-
-		&:hover {
-			cursor: grab;
-			outline: 1px solid var(--text-muted);
-		}
-
-		&.active {
-			border: 1px solid var(--color-success);
-
-			.file-card-actions {
-				display: flex;
-				gap: 0.313rem;
-			}
-		}
-    }
-}

+ 3 - 3
app/widget/crew/[channelSID]/page.tsx → app/widget/crew/[widgetToken]/page.tsx

@@ -5,15 +5,15 @@ import { useDonationHub } from '@/hooks/useDonationHub';
 import './style.scss';
 
 type Props = {
-	params: Promise<{ channelSID: string }>;
+	params: Promise<{ widgetToken: string }>;
 };
 
 const DEFAULT_ICON = '/images/default-avatar.png';
 
 export default function CrewLeaderboardPage({ params }: Props) {
-	const { channelSID } = use(params);
+	const { widgetToken } = use(params);
 	const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
-	const { crewRanking } = useDonationHub(channelSID, hubUrl);
+	const { crewRanking } = useDonationHub(widgetToken, hubUrl);
 
 	return (
 		<div className="crew-widget">

+ 0 - 0
app/widget/crew/[channelSID]/style.scss → app/widget/crew/[widgetToken]/style.scss


+ 3 - 3
app/widget/goal/[channelSID]/page.tsx → app/widget/goal/[widgetToken]/page.tsx

@@ -5,7 +5,7 @@ import { useDonationHub } from '@/hooks/useDonationHub';
 import './style.scss';
 
 type Props = {
-	params: Promise<{ channelSID: string }>;
+	params: Promise<{ widgetToken: string }>;
 };
 
 // 기본 스타일 설정 (TODO: API에서 DonationGoalConfig 조회)
@@ -21,9 +21,9 @@ const DEFAULT_CONFIG = {
 };
 
 export default function GoalPage({ params }: Props) {
-	const { channelSID } = use(params);
+	const { widgetToken } = use(params);
 	const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
-	const { goalProgress } = useDonationHub(channelSID, hubUrl);
+	const { goalProgress } = useDonationHub(widgetToken, hubUrl);
 	const widgetRef = useRef<HTMLDivElement>(null);
 
 	const cfg = DEFAULT_CONFIG;

+ 0 - 0
app/widget/goal/[channelSID]/style.scss → app/widget/goal/[widgetToken]/style.scss


+ 3 - 3
app/widget/rank/[channelSID]/page.tsx → app/widget/rank/[widgetToken]/page.tsx

@@ -5,13 +5,13 @@ import { useDonationHub } from '@/hooks/useDonationHub';
 import './style.scss';
 
 type Props = {
-	params: Promise<{ channelSID: string }>;
+	params: Promise<{ widgetToken: string }>;
 };
 
 export default function RankPage({ params }: Props) {
-	const { channelSID } = use(params);
+	const { widgetToken } = use(params);
 	const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
-	const { ranking } = useDonationHub(channelSID, hubUrl);
+	const { ranking } = useDonationHub(widgetToken, hubUrl);
 
 	return (
 		<div className="rank-widget">

+ 0 - 0
app/widget/rank/[channelSID]/style.scss → app/widget/rank/[widgetToken]/style.scss


+ 0 - 57
components/ui/accordion.tsx

@@ -1,57 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as AccordionPrimitive from "@radix-ui/react-accordion"
-import { ChevronDown } from "lucide-react"
-
-import { cn } from "@/lib/utils/client"
-
-const Accordion = AccordionPrimitive.Root
-
-const AccordionItem = React.forwardRef<
-  React.ElementRef<typeof AccordionPrimitive.Item>,
-  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
->(({ className, ...props }, ref) => (
-  <AccordionPrimitive.Item
-    ref={ref}
-    className={cn("border-b", className)}
-    {...props}
-  />
-))
-AccordionItem.displayName = "AccordionItem"
-
-const AccordionTrigger = React.forwardRef<
-  React.ElementRef<typeof AccordionPrimitive.Trigger>,
-  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
->(({ className, children, ...props }, ref) => (
-  <AccordionPrimitive.Header className="flex">
-    <AccordionPrimitive.Trigger
-      ref={ref}
-      className={cn(
-        "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
-        className
-      )}
-      {...props}
-    >
-      {children}
-      <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
-    </AccordionPrimitive.Trigger>
-  </AccordionPrimitive.Header>
-))
-AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
-
-const AccordionContent = React.forwardRef<
-  React.ElementRef<typeof AccordionPrimitive.Content>,
-  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
->(({ className, children, ...props }, ref) => (
-  <AccordionPrimitive.Content
-    ref={ref}
-    className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
-    {...props}
-  >
-    <div className={cn("pb-4 pt-0", className)}>{children}</div>
-  </AccordionPrimitive.Content>
-))
-AccordionContent.displayName = AccordionPrimitive.Content.displayName
-
-export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

+ 8 - 8
components/ui/avatar.tsx

@@ -1,12 +1,12 @@
 "use client"
 
 import * as React from "react"
-import * as AvatarPrimitive from "@radix-ui/react-avatar"
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
 
 import { cn } from "@/lib/utils/client"
 
 const Avatar = React.forwardRef<
-  React.ElementRef<typeof AvatarPrimitive.Root>,
+  HTMLSpanElement,
   React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
 >(({ className, ...props }, ref) => (
   <AvatarPrimitive.Root
@@ -18,10 +18,10 @@ const Avatar = React.forwardRef<
     {...props}
   />
 ))
-Avatar.displayName = AvatarPrimitive.Root.displayName
+Avatar.displayName = "Avatar"
 
 const AvatarImage = React.forwardRef<
-  React.ElementRef<typeof AvatarPrimitive.Image>,
+  HTMLImageElement,
   React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
 >(({ className, ...props }, ref) => (
   <AvatarPrimitive.Image
@@ -30,10 +30,10 @@ const AvatarImage = React.forwardRef<
     {...props}
   />
 ))
-AvatarImage.displayName = AvatarPrimitive.Image.displayName
+AvatarImage.displayName = "AvatarImage"
 
 const AvatarFallback = React.forwardRef<
-  React.ElementRef<typeof AvatarPrimitive.Fallback>,
+  HTMLSpanElement,
   React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
 >(({ className, ...props }, ref) => (
   <AvatarPrimitive.Fallback
@@ -45,6 +45,6 @@ const AvatarFallback = React.forwardRef<
     {...props}
   />
 ))
-AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+AvatarFallback.displayName = "AvatarFallback"
 
-export { Avatar, AvatarImage, AvatarFallback }
+export { Avatar, AvatarImage, AvatarFallback }

+ 11 - 4
components/ui/button.tsx

@@ -1,5 +1,4 @@
 import * as React from "react"
-import { Slot } from "@radix-ui/react-slot"
 import { cva, type VariantProps } from "class-variance-authority"
 
 import { cn } from "@/lib/utils/client"
@@ -42,9 +41,17 @@ export interface ButtonProps
 
 const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
   ({ className, variant, size, asChild = false, ...props }, ref) => {
-    const Comp = asChild ? Slot : "button"
+    if (asChild) {
+      const child = React.Children.only(props.children) as React.ReactElement<Record<string, unknown>>
+      return React.cloneElement(child, {
+        className: cn(buttonVariants({ variant, size, className }), child.props.className as string),
+        ref,
+        ...props,
+        children: child.props.children,
+      })
+    }
     return (
-      <Comp
+      <button
         className={cn(buttonVariants({ variant, size, className }))}
         ref={ref}
         {...props}
@@ -54,4 +61,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
 )
 Button.displayName = "Button"
 
-export { Button, buttonVariants }
+export { Button, buttonVariants }

+ 8 - 7
components/ui/checkbox.tsx

@@ -2,26 +2,27 @@
 
 import * as React from "react"
 import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
-import { Check } from "lucide-react"
+import { Check, Minus } from "lucide-react"
 
-import { cn } from "@/lib/utils/client"
+import { cn } from "@/lib/utils"
 
 const Checkbox = React.forwardRef<
   React.ElementRef<typeof CheckboxPrimitive.Root>,
-  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
->(({ className, ...props }, ref) => (
+  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & { indeterminate?: boolean }
+>(({ className, indeterminate, checked, ...props }, ref) => (
   <CheckboxPrimitive.Root
     ref={ref}
     className={cn(
-      "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground mr-2 relative top-px data-[state=checked]:relative data-[state=checked]:top-0.5",
+      "grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-input focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:text-primary-foreground",
       className
     )}
+    checked={indeterminate ? 'indeterminate' : checked}
     {...props}
   >
     <CheckboxPrimitive.Indicator
-      className={cn("flex items-center justify-center text-current")}
+      className={cn("grid place-content-center text-current")}
     >
-      <Check className="h-4 w-4 relative top-px" />
+      {indeterminate ? <Minus className="h-3.5 w-3.5" /> : <Check className="h-4 w-4" />}
     </CheckboxPrimitive.Indicator>
   </CheckboxPrimitive.Root>
 ))

+ 14 - 4
components/ui/collapsible.tsx

@@ -1,11 +1,21 @@
 "use client"
 
-import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+import * as React from "react"
+import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
 
 const Collapsible = CollapsiblePrimitive.Root
 
-const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
+const CollapsibleTrigger = React.forwardRef<
+  HTMLButtonElement,
+  React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Trigger> & { asChild?: boolean }
+>(({ asChild, children, ...props }, ref) => {
+  if (asChild) {
+    return <CollapsiblePrimitive.Trigger ref={ref} render={children as React.ReactElement} {...props} />
+  }
+  return <CollapsiblePrimitive.Trigger ref={ref} {...props}>{children}</CollapsiblePrimitive.Trigger>
+})
+CollapsibleTrigger.displayName = "CollapsibleTrigger"
 
-const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
+const CollapsibleContent = CollapsiblePrimitive.Panel
 
-export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }

+ 51 - 28
components/ui/dialog.tsx

@@ -1,57 +1,75 @@
 "use client"
 
 import * as React from "react"
-import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
 import { X } from "lucide-react"
 
 import { cn } from "@/lib/utils/client"
 
 const Dialog = DialogPrimitive.Root
 
-const DialogTrigger = DialogPrimitive.Trigger
+const DialogTrigger = React.forwardRef<
+  HTMLButtonElement,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger> & { asChild?: boolean }
+>(({ asChild, children, ...props }, ref) => {
+  if (asChild) {
+    return <DialogPrimitive.Trigger ref={ref} render={children as React.ReactElement} {...props} />
+  }
+  return <DialogPrimitive.Trigger ref={ref} {...props}>{children}</DialogPrimitive.Trigger>
+})
+DialogTrigger.displayName = "DialogTrigger"
 
 const DialogPortal = DialogPrimitive.Portal
 
-const DialogClose = DialogPrimitive.Close
+const DialogClose = React.forwardRef<
+  HTMLButtonElement,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close> & { asChild?: boolean }
+>(({ asChild, children, ...props }, ref) => {
+  if (asChild) {
+    return <DialogPrimitive.Close ref={ref} render={children as React.ReactElement} {...props} />
+  }
+  return <DialogPrimitive.Close ref={ref} {...props}>{children}</DialogPrimitive.Close>
+})
+DialogClose.displayName = "DialogClose"
 
 const DialogOverlay = React.forwardRef<
-  React.ElementRef<typeof DialogPrimitive.Overlay>,
-  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Backdrop>
 >(({ className, ...props }, ref) => (
-  <DialogPrimitive.Overlay
+  <DialogPrimitive.Backdrop
     ref={ref}
     className={cn(
-      "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      "fixed inset-0 z-50 bg-black/80 data-[open]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[open]:fade-in-0",
       className
     )}
     {...props}
   />
 ))
-DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+DialogOverlay.displayName = "DialogOverlay"
 
 const DialogContent = React.forwardRef<
-  React.ElementRef<typeof DialogPrimitive.Content>,
-  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Popup>
 >(({ className, children, ...props }, ref) => (
   <DialogPortal>
     <DialogOverlay />
-    <DialogPrimitive.Content
+    <DialogPrimitive.Popup
       ref={ref}
       className={cn(
-        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[open]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[open]:fade-in-0 data-[closed]:zoom-out-95 data-[open]:zoom-in-95 data-[closed]:slide-out-to-left-1/2 data-[closed]:slide-out-to-top-[48%] data-[open]:slide-in-from-left-1/2 data-[open]:slide-in-from-top-[48%] sm:rounded-lg",
         className
       )}
       {...props}
     >
       {children}
-      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[open]:bg-accent data-[open]:text-muted-foreground">
         <X className="h-4 w-4" />
         <span className="sr-only">Close</span>
       </DialogPrimitive.Close>
-    </DialogPrimitive.Content>
+    </DialogPrimitive.Popup>
   </DialogPortal>
 ))
-DialogContent.displayName = DialogPrimitive.Content.displayName
+DialogContent.displayName = "DialogContent"
 
 const DialogHeader = ({
   className,
@@ -82,7 +100,7 @@ const DialogFooter = ({
 DialogFooter.displayName = "DialogFooter"
 
 const DialogTitle = React.forwardRef<
-  React.ElementRef<typeof DialogPrimitive.Title>,
+  HTMLHeadingElement,
   React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
 >(({ className, ...props }, ref) => (
   <DialogPrimitive.Title
@@ -94,19 +112,24 @@ const DialogTitle = React.forwardRef<
     {...props}
   />
 ))
-DialogTitle.displayName = DialogPrimitive.Title.displayName
+DialogTitle.displayName = "DialogTitle"
 
 const DialogDescription = React.forwardRef<
-  React.ElementRef<typeof DialogPrimitive.Description>,
-  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
->(({ className, ...props }, ref) => (
-  <DialogPrimitive.Description
-    ref={ref}
-    className={cn("text-sm text-muted-foreground", className)}
-    {...props}
-  />
-))
-DialogDescription.displayName = DialogPrimitive.Description.displayName
+  HTMLParagraphElement,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & { asChild?: boolean }
+>(({ className, asChild, children, ...props }, ref) => {
+  if (asChild) {
+    return <DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} render={children as React.ReactElement} {...props} />
+  }
+  return (
+    <DialogPrimitive.Description
+      ref={ref}
+      className={cn("text-sm text-muted-foreground", className)}
+      {...props}
+    >{children}</DialogPrimitive.Description>
+  )
+})
+DialogDescription.displayName = "DialogDescription"
 
 export {
   Dialog,
@@ -119,4 +142,4 @@ export {
   DialogFooter,
   DialogTitle,
   DialogDescription,
-}
+}

+ 115 - 80
components/ui/dropdown-menu.tsx

@@ -1,33 +1,44 @@
 "use client"
 
 import * as React from "react"
-import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
 import { Check, ChevronRight, Circle } from "lucide-react"
 
 import { cn } from "@/lib/utils/client"
 
-const DropdownMenu = DropdownMenuPrimitive.Root
+const DropdownMenu = MenuPrimitive.Root
 
-const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+const DropdownMenuTrigger = React.forwardRef<
+  HTMLButtonElement,
+  React.ComponentPropsWithoutRef<typeof MenuPrimitive.Trigger> & { asChild?: boolean }
+>(({ asChild, children, ...props }, ref) => {
+  if (asChild) {
+    const child = children as React.ReactElement<Record<string, unknown>>
+    const isNonButton = child?.type === 'label' || child?.type === 'div' || child?.type === 'span' || child?.type === 'a'
+    return <MenuPrimitive.Trigger ref={ref} render={child} nativeButton={!isNonButton} {...props} />
+  }
+  return <MenuPrimitive.Trigger ref={ref} {...props}>{children}</MenuPrimitive.Trigger>
+})
+DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
 
-const DropdownMenuGroup = DropdownMenuPrimitive.Group
+const DropdownMenuGroup = MenuPrimitive.Group
 
-const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+const DropdownMenuPortal = MenuPrimitive.Portal
 
-const DropdownMenuSub = DropdownMenuPrimitive.Sub
+const DropdownMenuSub = MenuPrimitive.SubmenuRoot
 
-const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+const DropdownMenuRadioGroup = MenuPrimitive.RadioGroup
 
 const DropdownMenuSubTrigger = React.forwardRef<
-  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
-  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof MenuPrimitive.SubmenuTrigger> & {
     inset?: boolean
   }
 >(({ className, inset, children, ...props }, ref) => (
-  <DropdownMenuPrimitive.SubTrigger
+  <MenuPrimitive.SubmenuTrigger
     ref={ref}
     className={cn(
-      "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+      "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
       inset && "pl-8",
       className
     )}
@@ -35,69 +46,93 @@ const DropdownMenuSubTrigger = React.forwardRef<
   >
     {children}
     <ChevronRight className="ml-auto" />
-  </DropdownMenuPrimitive.SubTrigger>
+  </MenuPrimitive.SubmenuTrigger>
 ))
-DropdownMenuSubTrigger.displayName =
-  DropdownMenuPrimitive.SubTrigger.displayName
+DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"
 
 const DropdownMenuSubContent = React.forwardRef<
-  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
-  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof MenuPrimitive.Popup>
 >(({ className, ...props }, ref) => (
-  <DropdownMenuPrimitive.SubContent
-    ref={ref}
-    className={cn(
-      "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
-      className
-    )}
-    {...props}
-  />
+  <MenuPrimitive.Portal>
+    <MenuPrimitive.Positioner>
+      <MenuPrimitive.Popup
+        ref={ref}
+        className={cn(
+          "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[open]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[open]:fade-in-0 data-[closed]:zoom-out-95 data-[open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--transform-origin]",
+          className
+        )}
+        {...props}
+      />
+    </MenuPrimitive.Positioner>
+  </MenuPrimitive.Portal>
 ))
-DropdownMenuSubContent.displayName =
-  DropdownMenuPrimitive.SubContent.displayName
+DropdownMenuSubContent.displayName = "DropdownMenuSubContent"
 
 const DropdownMenuContent = React.forwardRef<
-  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
-  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
->(({ className, sideOffset = 4, ...props }, ref) => (
-  <DropdownMenuPrimitive.Portal>
-    <DropdownMenuPrimitive.Content
-      ref={ref}
-      sideOffset={sideOffset}
-      className={cn(
-        "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
-        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
-        className
-      )}
-      {...props}
-    />
-  </DropdownMenuPrimitive.Portal>
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof MenuPrimitive.Popup> & {
+    sideOffset?: number
+    side?: "top" | "bottom" | "left" | "right"
+    align?: "start" | "center" | "end"
+  }
+>(({ className, sideOffset = 4, side, align, ...props }, ref) => (
+  <MenuPrimitive.Portal>
+    <MenuPrimitive.Positioner sideOffset={sideOffset} side={side} align={align}>
+      <MenuPrimitive.Popup
+        ref={ref}
+        className={cn(
+          "z-50 max-h-[var(--available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
+          "data-[open]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[open]:fade-in-0 data-[closed]:zoom-out-95 data-[open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--transform-origin]",
+          className
+        )}
+        {...props}
+      />
+    </MenuPrimitive.Positioner>
+  </MenuPrimitive.Portal>
 ))
-DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+DropdownMenuContent.displayName = "DropdownMenuContent"
 
 const DropdownMenuItem = React.forwardRef<
-  React.ElementRef<typeof DropdownMenuPrimitive.Item>,
-  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof MenuPrimitive.Item> & {
     inset?: boolean
+    asChild?: boolean
   }
->(({ className, inset, ...props }, ref) => (
-  <DropdownMenuPrimitive.Item
-    ref={ref}
-    className={cn(
-      "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
-      inset && "pl-8",
-      className
-    )}
-    {...props}
-  />
-))
-DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+>(({ className, inset, asChild, children, ...props }, ref) => {
+  if (asChild) {
+    return (
+      <MenuPrimitive.Item
+        ref={ref}
+        className={cn(
+          "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+          inset && "pl-8",
+          className
+        )}
+        render={children as React.ReactElement}
+        {...props}
+      />
+    )
+  }
+  return (
+    <MenuPrimitive.Item
+      ref={ref}
+      className={cn(
+        "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+        inset && "pl-8",
+        className
+      )}
+      {...props}
+    >{children}</MenuPrimitive.Item>
+  )
+})
+DropdownMenuItem.displayName = "DropdownMenuItem"
 
 const DropdownMenuCheckboxItem = React.forwardRef<
-  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
-  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof MenuPrimitive.CheckboxItem>
 >(({ className, children, checked, ...props }, ref) => (
-  <DropdownMenuPrimitive.CheckboxItem
+  <MenuPrimitive.CheckboxItem
     ref={ref}
     className={cn(
       "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
@@ -107,21 +142,20 @@ const DropdownMenuCheckboxItem = React.forwardRef<
     {...props}
   >
     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
-      <DropdownMenuPrimitive.ItemIndicator>
+      <MenuPrimitive.CheckboxItemIndicator>
         <Check className="h-4 w-4" />
-      </DropdownMenuPrimitive.ItemIndicator>
+      </MenuPrimitive.CheckboxItemIndicator>
     </span>
     {children}
-  </DropdownMenuPrimitive.CheckboxItem>
+  </MenuPrimitive.CheckboxItem>
 ))
-DropdownMenuCheckboxItem.displayName =
-  DropdownMenuPrimitive.CheckboxItem.displayName
+DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem"
 
 const DropdownMenuRadioItem = React.forwardRef<
-  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
-  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioItem>
 >(({ className, children, ...props }, ref) => (
-  <DropdownMenuPrimitive.RadioItem
+  <MenuPrimitive.RadioItem
     ref={ref}
     className={cn(
       "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
@@ -130,22 +164,22 @@ const DropdownMenuRadioItem = React.forwardRef<
     {...props}
   >
     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
-      <DropdownMenuPrimitive.ItemIndicator>
+      <MenuPrimitive.RadioItemIndicator>
         <Circle className="h-2 w-2 fill-current" />
-      </DropdownMenuPrimitive.ItemIndicator>
+      </MenuPrimitive.RadioItemIndicator>
     </span>
     {children}
-  </DropdownMenuPrimitive.RadioItem>
+  </MenuPrimitive.RadioItem>
 ))
-DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem"
 
 const DropdownMenuLabel = React.forwardRef<
-  React.ElementRef<typeof DropdownMenuPrimitive.Label>,
-  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement> & {
     inset?: boolean
   }
 >(({ className, inset, ...props }, ref) => (
-  <DropdownMenuPrimitive.Label
+  <div
     ref={ref}
     className={cn(
       "px-2 py-1.5 text-sm font-semibold",
@@ -155,19 +189,20 @@ const DropdownMenuLabel = React.forwardRef<
     {...props}
   />
 ))
-DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+DropdownMenuLabel.displayName = "DropdownMenuLabel"
 
 const DropdownMenuSeparator = React.forwardRef<
-  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
-  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
 >(({ className, ...props }, ref) => (
-  <DropdownMenuPrimitive.Separator
+  <div
     ref={ref}
+    role="separator"
     className={cn("-mx-1 my-1 h-px bg-muted", className)}
     {...props}
   />
 ))
-DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
 
 const DropdownMenuShortcut = ({
   className,
@@ -198,4 +233,4 @@ export {
   DropdownMenuSubContent,
   DropdownMenuSubTrigger,
   DropdownMenuRadioGroup,
-}
+}

+ 1 - 1
components/ui/input.tsx

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
       <input
         type={type}
         className={cn(
-          "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+          "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
           className
         )}
         ref={ref}

+ 5 - 6
components/ui/label.tsx

@@ -1,7 +1,6 @@
 "use client"
 
 import * as React from "react"
-import * as LabelPrimitive from "@radix-ui/react-label"
 import { cva, type VariantProps } from "class-variance-authority"
 
 import { cn } from "@/lib/utils/client"
@@ -11,16 +10,16 @@ const labelVariants = cva(
 )
 
 const Label = React.forwardRef<
-  React.ElementRef<typeof LabelPrimitive.Root>,
-  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
+  HTMLLabelElement,
+  React.ComponentPropsWithoutRef<"label"> &
     VariantProps<typeof labelVariants>
 >(({ className, ...props }, ref) => (
-  <LabelPrimitive.Root
+  <label
     ref={ref}
     className={cn(labelVariants(), className)}
     {...props}
   />
 ))
-Label.displayName = LabelPrimitive.Root.displayName
+Label.displayName = "Label"
 
-export { Label }
+export { Label }

+ 32 - 78
components/ui/select.tsx

@@ -1,8 +1,8 @@
 "use client"
 
 import * as React from "react"
-import * as SelectPrimitive from "@radix-ui/react-select"
-import { Check, ChevronDown, ChevronUp } from "lucide-react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+import { Check, ChevronDown } from "lucide-react"
 
 import { cn } from "@/lib/utils/client"
 
@@ -13,106 +13,62 @@ const SelectGroup = SelectPrimitive.Group
 const SelectValue = SelectPrimitive.Value
 
 const SelectTrigger = React.forwardRef<
-  React.ElementRef<typeof SelectPrimitive.Trigger>,
+  HTMLButtonElement,
   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
 >(({ className, children, ...props }, ref) => (
   <SelectPrimitive.Trigger
     ref={ref}
     className={cn(
-      "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+      "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm data-[placeholder]:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
       className
     )}
     {...props}
   >
     {children}
-    <SelectPrimitive.Icon asChild>
+    <SelectPrimitive.Icon>
       <ChevronDown className="h-4 w-4 opacity-50" />
     </SelectPrimitive.Icon>
   </SelectPrimitive.Trigger>
 ))
-SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
-
-const SelectScrollUpButton = React.forwardRef<
-  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
-  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
->(({ className, ...props }, ref) => (
-  <SelectPrimitive.ScrollUpButton
-    ref={ref}
-    className={cn(
-      "flex cursor-default items-center justify-center py-1",
-      className
-    )}
-    {...props}
-  >
-    <ChevronUp className="h-4 w-4" />
-  </SelectPrimitive.ScrollUpButton>
-))
-SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
-
-const SelectScrollDownButton = React.forwardRef<
-  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
-  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
->(({ className, ...props }, ref) => (
-  <SelectPrimitive.ScrollDownButton
-    ref={ref}
-    className={cn(
-      "flex cursor-default items-center justify-center py-1",
-      className
-    )}
-    {...props}
-  >
-    <ChevronDown className="h-4 w-4" />
-  </SelectPrimitive.ScrollDownButton>
-))
-SelectScrollDownButton.displayName =
-  SelectPrimitive.ScrollDownButton.displayName
+SelectTrigger.displayName = "SelectTrigger"
 
 const SelectContent = React.forwardRef<
-  React.ElementRef<typeof SelectPrimitive.Content>,
-  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
->(({ className, children, position = "popper", ...props }, ref) => (
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Popup>
+>(({ className, children, ...props }, ref) => (
   <SelectPrimitive.Portal>
-    <SelectPrimitive.Content
-      ref={ref}
-      className={cn(
-        "relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
-        position === "popper" &&
-          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
-        className
-      )}
-      position={position}
-      {...props}
-    >
-      <SelectScrollUpButton />
-      <SelectPrimitive.Viewport
+    <SelectPrimitive.Positioner>
+      <SelectPrimitive.Popup
+        ref={ref}
         className={cn(
-          "p-1",
-          position === "popper" &&
-            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
+          "relative z-50 max-h-96 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[open]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[open]:fade-in-0 data-[closed]:zoom-out-95 data-[open]:zoom-in-95",
+          className
         )}
+        {...props}
       >
-        {children}
-      </SelectPrimitive.Viewport>
-      <SelectScrollDownButton />
-    </SelectPrimitive.Content>
+        <SelectPrimitive.List className="p-1">
+          {children}
+        </SelectPrimitive.List>
+      </SelectPrimitive.Popup>
+    </SelectPrimitive.Positioner>
   </SelectPrimitive.Portal>
 ))
-SelectContent.displayName = SelectPrimitive.Content.displayName
+SelectContent.displayName = "SelectContent"
 
 const SelectLabel = React.forwardRef<
-  React.ElementRef<typeof SelectPrimitive.Label>,
-  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof SelectPrimitive.GroupLabel>
 >(({ className, ...props }, ref) => (
-  <SelectPrimitive.Label
+  <SelectPrimitive.GroupLabel
     ref={ref}
     className={cn("px-2 py-1.5 text-sm font-semibold", className)}
     {...props}
   />
 ))
-SelectLabel.displayName = SelectPrimitive.Label.displayName
+SelectLabel.displayName = "SelectLabel"
 
 const SelectItem = React.forwardRef<
-  React.ElementRef<typeof SelectPrimitive.Item>,
+  HTMLDivElement,
   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
 >(({ className, children, ...props }, ref) => (
   <SelectPrimitive.Item
@@ -131,19 +87,19 @@ const SelectItem = React.forwardRef<
     <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
   </SelectPrimitive.Item>
 ))
-SelectItem.displayName = SelectPrimitive.Item.displayName
+SelectItem.displayName = "SelectItem"
 
 const SelectSeparator = React.forwardRef<
-  React.ElementRef<typeof SelectPrimitive.Separator>,
-  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
 >(({ className, ...props }, ref) => (
-  <SelectPrimitive.Separator
+  <div
     ref={ref}
     className={cn("-mx-1 my-1 h-px bg-muted", className)}
     {...props}
   />
 ))
-SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+SelectSeparator.displayName = "SelectSeparator"
 
 export {
   Select,
@@ -154,6 +110,4 @@ export {
   SelectLabel,
   SelectItem,
   SelectSeparator,
-  SelectScrollUpButton,
-  SelectScrollDownButton,
-}
+}

+ 11 - 10
components/ui/separator.tsx

@@ -1,22 +1,23 @@
 "use client"
 
 import * as React from "react"
-import * as SeparatorPrimitive from "@radix-ui/react-separator"
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
 
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils/client"
 
 const Separator = React.forwardRef<
-  React.ElementRef<typeof SeparatorPrimitive.Root>,
-  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive> & {
+    orientation?: "horizontal" | "vertical"
+    decorative?: boolean
+  }
 >(
   (
-    { className, orientation = "horizontal", decorative = true, ...props },
+    { className, orientation = "horizontal", decorative: _decorative, ...props },
     ref
   ) => (
-    <SeparatorPrimitive.Root
+    <SeparatorPrimitive
       ref={ref}
-      decorative={decorative}
-      orientation={orientation}
       className={cn(
         "shrink-0 bg-border",
         orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
@@ -26,6 +27,6 @@ const Separator = React.forwardRef<
     />
   )
 )
-Separator.displayName = SeparatorPrimitive.Root.displayName
+Separator.displayName = "Separator"
 
-export { Separator }
+export { Separator }

+ 23 - 23
components/ui/sheet.tsx

@@ -1,11 +1,11 @@
 "use client"
 
 import * as React from "react"
-import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
 import { cva, type VariantProps } from "class-variance-authority"
 import { X } from "lucide-react"
 
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils/client"
 
 const Sheet = SheetPrimitive.Root
 
@@ -16,31 +16,31 @@ const SheetClose = SheetPrimitive.Close
 const SheetPortal = SheetPrimitive.Portal
 
 const SheetOverlay = React.forwardRef<
-  React.ElementRef<typeof SheetPrimitive.Overlay>,
-  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Backdrop>
 >(({ className, ...props }, ref) => (
-  <SheetPrimitive.Overlay
+  <SheetPrimitive.Backdrop
     className={cn(
-      "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      "fixed inset-0 z-50 bg-black/80 data-[open]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[open]:fade-in-0",
       className
     )}
     {...props}
     ref={ref}
   />
 ))
-SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+SheetOverlay.displayName = "SheetOverlay"
 
 const sheetVariants = cva(
-  "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
+  "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[closed]:duration-300 data-[open]:duration-500 data-[open]:animate-in data-[closed]:animate-out",
   {
     variants: {
       side: {
-        top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+        top: "inset-x-0 top-0 border-b data-[closed]:slide-out-to-top data-[open]:slide-in-from-top",
         bottom:
-          "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
-        left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+          "inset-x-0 bottom-0 border-t data-[closed]:slide-out-to-bottom data-[open]:slide-in-from-bottom",
+        left: "inset-y-0 left-0 h-full w-3/4 border-r data-[closed]:slide-out-to-left data-[open]:slide-in-from-left sm:max-w-sm",
         right:
-          "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+          "inset-y-0 right-0 h-full w-3/4 border-l data-[closed]:slide-out-to-right data-[open]:slide-in-from-right sm:max-w-sm",
       },
     },
     defaultVariants: {
@@ -50,29 +50,29 @@ const sheetVariants = cva(
 )
 
 interface SheetContentProps
-  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
+  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Popup>,
     VariantProps<typeof sheetVariants> {}
 
 const SheetContent = React.forwardRef<
-  React.ElementRef<typeof SheetPrimitive.Content>,
+  HTMLDivElement,
   SheetContentProps
 >(({ side = "right", className, children, ...props }, ref) => (
   <SheetPortal>
     <SheetOverlay />
-    <SheetPrimitive.Content
+    <SheetPrimitive.Popup
       ref={ref}
       className={cn(sheetVariants({ side }), className)}
       {...props}
     >
-      <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
+      <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[open]:bg-secondary">
         <X className="h-4 w-4" />
         <span className="sr-only">Close</span>
       </SheetPrimitive.Close>
       {children}
-    </SheetPrimitive.Content>
+    </SheetPrimitive.Popup>
   </SheetPortal>
 ))
-SheetContent.displayName = SheetPrimitive.Content.displayName
+SheetContent.displayName = "SheetContent"
 
 const SheetHeader = ({
   className,
@@ -103,7 +103,7 @@ const SheetFooter = ({
 SheetFooter.displayName = "SheetFooter"
 
 const SheetTitle = React.forwardRef<
-  React.ElementRef<typeof SheetPrimitive.Title>,
+  HTMLHeadingElement,
   React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
 >(({ className, ...props }, ref) => (
   <SheetPrimitive.Title
@@ -112,10 +112,10 @@ const SheetTitle = React.forwardRef<
     {...props}
   />
 ))
-SheetTitle.displayName = SheetPrimitive.Title.displayName
+SheetTitle.displayName = "SheetTitle"
 
 const SheetDescription = React.forwardRef<
-  React.ElementRef<typeof SheetPrimitive.Description>,
+  HTMLParagraphElement,
   React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
 >(({ className, ...props }, ref) => (
   <SheetPrimitive.Description
@@ -124,7 +124,7 @@ const SheetDescription = React.forwardRef<
     {...props}
   />
 ))
-SheetDescription.displayName = SheetPrimitive.Description.displayName
+SheetDescription.displayName = "SheetDescription"
 
 export {
   Sheet,
@@ -137,4 +137,4 @@ export {
   SheetFooter,
   SheetTitle,
   SheetDescription,
-}
+}

+ 15 - 7
components/ui/sidebar.tsx

@@ -1,12 +1,22 @@
 "use client"
 
 import * as React from "react"
-import { Slot } from "@radix-ui/react-slot"
+// Slot 대체: asChild 패턴용 헬퍼
+const Slot = React.forwardRef<HTMLElement, Record<string, unknown> & { children: React.ReactNode }>(
+  ({ children, ...props }, ref) => {
+    const child = React.Children.only(children) as React.ReactElement<Record<string, unknown>>
+    return React.cloneElement(child, {
+      ...props,
+      ref,
+      className: cn(props.className as string, child.props.className as string),
+    })
+  }
+)
 import { cva, type VariantProps } from "class-variance-authority"
 import { PanelLeft } from "lucide-react"
 
 import { useIsMobile } from "@/hooks/use-mobile"
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils/client"
 import { Button } from "@/components/ui/button"
 import { Input } from "@/components/ui/input"
 import { Separator } from "@/components/ui/separator"
@@ -137,7 +147,7 @@ const SidebarProvider = React.forwardRef<
 
     return (
       <SidebarContext.Provider value={contextValue}>
-        <TooltipProvider delayDuration={0}>
+        <TooltipProvider delay={0}>
           <div
             style={
               {
@@ -244,7 +254,7 @@ const Sidebar = React.forwardRef<
         />
         <div
           className={cn(
-            "fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
+            "fixed inset-y-0 z-30 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
             side === "left"
               ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
               : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -589,10 +599,8 @@ const SidebarMenuButton = React.forwardRef<
 
     return (
       <Tooltip>
-        <TooltipTrigger asChild>{button}</TooltipTrigger>
+        <TooltipTrigger render={button as React.ReactElement} />
         <TooltipContent
-          side="right"
-          align="center"
           hidden={state !== "collapsed" || isMobile}
           {...tooltip}
         />

+ 1 - 1
components/ui/textarea.tsx

@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
   return (
     <textarea
       className={cn(
-        "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
         className
       )}
       ref={ref}

+ 21 - 16
components/ui/tooltip.tsx

@@ -1,9 +1,9 @@
 "use client"
 
 import * as React from "react"
-import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
 
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils/client"
 
 const TooltipProvider = TooltipPrimitive.Provider
 
@@ -12,21 +12,26 @@ const Tooltip = TooltipPrimitive.Root
 const TooltipTrigger = TooltipPrimitive.Trigger
 
 const TooltipContent = React.forwardRef<
-  React.ElementRef<typeof TooltipPrimitive.Content>,
-  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
->(({ className, sideOffset = 4, ...props }, ref) => (
+  HTMLDivElement,
+  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Popup> & {
+    sideOffset?: number
+    side?: "top" | "bottom" | "left" | "right"
+    align?: "start" | "center" | "end"
+  }
+>(({ className, sideOffset, side, align, ...props }, ref) => (
   <TooltipPrimitive.Portal>
-    <TooltipPrimitive.Content
-      ref={ref}
-      sideOffset={sideOffset}
-      className={cn(
-        "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
-        className
-      )}
-      {...props}
-    />
+    <TooltipPrimitive.Positioner className="z-50" sideOffset={sideOffset ?? 4} side={side} align={align}>
+      <TooltipPrimitive.Popup
+        ref={ref}
+        className={cn(
+          "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--transform-origin]",
+          className
+        )}
+        {...props}
+      />
+    </TooltipPrimitive.Positioner>
   </TooltipPrimitive.Portal>
 ))
-TooltipContent.displayName = TooltipPrimitive.Content.displayName
+TooltipContent.displayName = "TooltipContent"
 
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

+ 32 - 2
contexts/signalrProvider.tsx

@@ -4,16 +4,23 @@ import { createContext, useContext, useEffect, useState, useRef } from 'react';
 import * as signalR from '@microsoft/signalr';
 import { fetchApi } from '@/lib/utils/client';
 
+export type CrewEvent = {
+	type: 'invitation'|'consentUpdate'|'started'|'ended'|'toast';
+	data: any;
+};
+
 const SignalRContext = createContext<{
     chatConnection: signalR.HubConnection | null;
 	chatConnected: boolean;
 	stopConnections: () => Promise<void>;
 	reconnectChat: (accessToken?: string | null) => Promise<void>;
+	lastCrewEvent: CrewEvent|null;
 }>({
     chatConnection: null,
 	chatConnected: false,
 	stopConnections: async () => {},
-	reconnectChat: async () => {}
+	reconnectChat: async () => {},
+	lastCrewEvent: null
 });
 
 type Props = {
@@ -26,6 +33,7 @@ export function SignalRProvider({ children, accessToken, signalRChatUrl }: Props
 	const chatConnectionRef = useRef<signalR.HubConnection|null>(null);
 	const [chatConnection, setChatConnection] = useState<signalR.HubConnection|null>(null);
 	const [chatConnected, setChatConnected] = useState<boolean>(false);
+	const [lastCrewEvent, setLastCrewEvent] = useState<CrewEvent|null>(null);
 
 	// 초기 렌더 시에만 전달됨. 토큰 갱신 시에는 reconnectChat()을 통해 수동으로 재연결 처리
 	useEffect(() => {
@@ -60,6 +68,27 @@ export function SignalRProvider({ children, accessToken, signalRChatUrl }: Props
 				console.info(message);
 			});
 
+			// ── 크루 이벤트 ──
+			conn.on('ReceiveCrewInvitation', (data) => {
+				setLastCrewEvent({ type: 'invitation', data });
+			});
+
+			conn.on('ReceiveCrewConsentUpdate', (data) => {
+				setLastCrewEvent({ type: 'consentUpdate', data });
+			});
+
+			conn.on('ReceiveCrewStarted', (data) => {
+				setLastCrewEvent({ type: 'started', data });
+			});
+
+			conn.on('ReceiveCrewEnded', (data) => {
+				setLastCrewEvent({ type: 'ended', data });
+			});
+
+			conn.on('ReceiveCrewToast', (data) => {
+				setLastCrewEvent({ type: 'toast', data });
+			});
+
 			conn.on('Kick', async () => {
 				fetchApi('/api/auth/logout', {
 					method: 'POST'
@@ -100,7 +129,8 @@ export function SignalRProvider({ children, accessToken, signalRChatUrl }: Props
 			chatConnection,
 			chatConnected,
 			stopConnections,
-			reconnectChat
+			reconnectChat,
+			lastCrewEvent
 		}}>
 			{children}
 		</SignalRContext.Provider>

+ 4 - 4
hooks/useDonationHub.ts

@@ -7,7 +7,7 @@ import { GoalProgress, CrewRankItem } from '@/types/donation';
 /**
  * DonationHub 공통 Hook — 목표/순위/크루 위젯에서 공유
  */
-export function useDonationHub(channelSID: string, hubUrl: string) {
+export function useDonationHub(widgetToken: string, hubUrl: string) {
 	const connectionRef = useRef<signalR.HubConnection|null>(null);
 	const [connected, setConnected] = useState(false);
 
@@ -41,12 +41,12 @@ export function useDonationHub(channelSID: string, hubUrl: string) {
 		});
 
 		conn.start().then(() => {
-			conn.invoke('JoinChannel', channelSID);
+			conn.invoke('JoinChannel', widgetToken);
 			setConnected(true);
 		}).catch(err => console.error('[DonationHub]', err));
 
 		conn.onreconnected(() => {
-			conn.invoke('JoinChannel', channelSID);
+			conn.invoke('JoinChannel', widgetToken);
 			setConnected(true);
 		});
 
@@ -54,7 +54,7 @@ export function useDonationHub(channelSID: string, hubUrl: string) {
 		connectionRef.current = conn;
 
 		return () => { conn.stop(); };
-	}, [channelSID, hubUrl]);
+	}, [widgetToken, hubUrl]);
 
 	return { connected, goalProgress, setGoalProgress, ranking, setRanking, crewRanking, crewTotalAmount };
 }

File diff suppressed because it is too large
+ 172 - 922
package-lock.json


+ 3 - 11
package.json

@@ -9,6 +9,7 @@
         "lint": "next lint"
     },
     "dependencies": {
+        "@base-ui/react": "^1.4.0",
         "@ckeditor/ckeditor5-react": "^9.5.0",
         "@danalpay/javascript-sdk": "^1.1.1",
         "@fortawesome/fontawesome-svg-core": "^7.2.0",
@@ -17,17 +18,7 @@
         "@fortawesome/free-solid-svg-icons": "^7.2.0",
         "@fortawesome/react-fontawesome": "^3.2.0",
         "@microsoft/signalr": "^8.0.7",
-        "@radix-ui/react-accordion": "^1.2.4",
-        "@radix-ui/react-avatar": "^1.1.11",
-        "@radix-ui/react-checkbox": "^1.1.3",
-        "@radix-ui/react-collapsible": "^1.1.12",
-        "@radix-ui/react-dialog": "^1.1.15",
-        "@radix-ui/react-dropdown-menu": "^2.1.7",
-        "@radix-ui/react-label": "^2.1.5",
-        "@radix-ui/react-select": "^2.2.4",
-        "@radix-ui/react-separator": "^1.1.8",
-        "@radix-ui/react-slot": "^1.2.4",
-        "@radix-ui/react-tooltip": "^1.2.8",
+        "@radix-ui/react-checkbox": "^1.3.3",
         "@react-oauth/google": "^0.13.4",
         "animate.css": "^4.1.1",
         "axios": "^1.7.9",
@@ -43,6 +34,7 @@
         "react": "^19.1.0",
         "react-dom": "^19.1.0",
         "react-rnd": "^10.5.2",
+        "recharts": "^3.8.1",
         "sass": "^1.83.0",
         "swiper": "^12.1.2",
         "tailwind-merge": "^2.6.0",

+ 2 - 22
tailwind.config.ts

@@ -87,28 +87,8 @@ export default {
     			'6xl': '5120px',
     			'7xl': '7680px'
     		},
-    		keyframes: {
-    			'accordion-down': {
-    				from: {
-    					height: '0'
-    				},
-    				to: {
-    					height: 'var(--radix-accordion-content-height)'
-    				}
-    			},
-    			'accordion-up': {
-    				from: {
-    					height: 'var(--radix-accordion-content-height)'
-    				},
-    				to: {
-    					height: '0'
-    				}
-    			}
-    		},
-    		animation: {
-    			'accordion-down': 'accordion-down 0.2s ease-out',
-    			'accordion-up': 'accordion-up 0.2s ease-out'
-    		}
+    		keyframes: {},
+    		animation: {}
     	}
     },
     plugins: [

+ 28 - 0
types/response/crew/member.ts

@@ -0,0 +1,28 @@
+export type CrewMemberListResponse = {
+	list: CrewMemberItem[];
+};
+
+export type CrewMemberItem = {
+	id: number;
+	memberID: number;
+	channelID: number|null;
+	nickname: string;
+	role: string|null;
+	sortOrder: number;
+	isActive: boolean;
+	joinedAt: string;
+	thumb: string|null;
+	channelName: string|null;
+};
+
+export type SearchResultItem = {
+	memberID: number;
+	name: string|null;
+	email: string;
+	thumb: string|null;
+	channelName: string|null;
+};
+
+export type SearchMemberResponse = {
+	list: SearchResultItem[];
+};

+ 42 - 0
types/response/crew/session.ts

@@ -0,0 +1,42 @@
+export type ActiveSessionResponse = {
+	crewSessionID: number;
+	title: string;
+	status: string;
+	startedAt: string|null;
+	createdAt: string;
+	totalAmount: number;
+	totalDonationCount: number;
+	consents: ConsentItem[];
+	summaries: SummaryItem[];
+}|null;
+
+export type ConsentItem = {
+	crewMemberID: number;
+	nickname: string;
+	isConsented: boolean;
+	consentedAt: string|null;
+};
+
+export type SummaryItem = {
+	crewMemberID: number;
+	nickname: string;
+	totalAmount: number;
+	donationCount: number;
+	contributionRate: number;
+	rank: number;
+};
+
+export type SessionHistoryResponse = {
+	total: number;
+	list: SessionHistoryItem[];
+};
+
+export type SessionHistoryItem = {
+	id: number;
+	title: string;
+	totalAmount: number;
+	totalDonationCount: number;
+	startedAt: string|null;
+	endedAt: string|null;
+	createdAt: string;
+};

+ 34 - 0
types/response/crew/widgetConfig.ts

@@ -0,0 +1,34 @@
+export type CrewWidgetConfigResponse = {
+	list: CrewWidgetConfigItem[];
+};
+
+export type CrewWidgetConfigItem = {
+	id: number;
+	title: string;
+	theme: number;
+	period: number;
+	startAt: string|null;
+	endAt: string|null;
+	maxDisplayCount: number;
+	isShowAmount: boolean;
+	isShowDonationCount: boolean;
+	isShowContributionRate: boolean;
+	isShowMemberIcon: boolean;
+	isActive: boolean;
+	bgColor: string;
+	titleFontFamily: string|null;
+	titleFontSizePx: number;
+	titleFontColor: string;
+	rank1FontFamily: string|null;
+	rank1FontSizePx: number;
+	rank1FontColor: string;
+	rank2FontFamily: string|null;
+	rank2FontSizePx: number;
+	rank2FontColor: string;
+	rank3FontFamily: string|null;
+	rank3FontSizePx: number;
+	rank3FontColor: string;
+	rowFontFamily: string|null;
+	rowFontSizePx: number;
+	rowFontColor: string;
+};

+ 21 - 0
types/response/settlement/account.ts

@@ -0,0 +1,21 @@
+export interface SettlementAccountResponse {
+	accounts: SettlementAccountItem[];
+}
+
+export interface SettlementAccountItem {
+	id: number;
+	bankCode: string;
+	bankName: string;
+	accountNumber: string;
+	accountHolder: string;
+	isVerified: boolean;
+	registeredAt: string;
+	updatedAt: string;
+}
+
+export interface SaveAccountRequest {
+	accountID?: number;
+	bankCode: string;
+	accountNumber: string;
+	accountHolder: string;
+}

+ 19 - 0
types/response/settlement/tax.ts

@@ -0,0 +1,19 @@
+export interface WithholdingTaxSummaryResponse {
+	year: number;
+	annualSummary: {
+		totalGrossAmount: number;
+		totalIncomeTax: number;
+		totalLocalTax: number;
+		totalNetAmount: number;
+	};
+	monthlyList: WithholdingTaxMonthItem[];
+}
+
+export interface WithholdingTaxMonthItem {
+	month: number;
+	grossAmount: number;
+	incomeTax: number;
+	localTax: number;
+	netAmount: number;
+	paymentCount: number;
+}

+ 39 - 0
types/response/studio/dashboard.ts

@@ -0,0 +1,39 @@
+export type DashboardResponse = {
+	channel: DashboardChannelInfo|null;
+	widgets: DashboardWidgetUrls|null;
+	financial: DashboardFinancialSummary;
+	recentDonations: DashboardRecentDonation[];
+};
+
+export type DashboardChannelInfo = {
+	id: number;
+	sid: string;
+	name: string;
+	thumbnailUrl: string|null;
+	isVerified: boolean;
+	subscriberCount: number;
+};
+
+export type DashboardWidgetUrls = {
+	widgetToken: string;
+	alert: string;
+	goal: string;
+	rank: string;
+	crew: string;
+	remote: string;
+};
+
+export type DashboardFinancialSummary = {
+	availableBalance: number;
+	todayDonations: number;
+	monthDonations: number;
+	pendingWithdrawal: number;
+};
+
+export type DashboardRecentDonation = {
+	id: number;
+	sendName: string;
+	amount: number;
+	message: string|null;
+	createdAt: string;
+};

+ 16 - 0
types/response/wallet/balance.ts

@@ -0,0 +1,16 @@
+export interface WalletBalanceResponse {
+	withdrawableBalance: number;
+	totalEarned: number;
+	totalWithdrawn: number;
+	pendingWithdrawal: number;
+	recentTransactions: WalletTransaction[];
+}
+
+export interface WalletTransaction {
+	id: number;
+	type: 'donation_received'|'withdrawal'|'fee'|'adjustment';
+	amount: number;
+	balance: number;
+	description: string;
+	createdAt: string;
+}

+ 28 - 0
types/response/wallet/revenue.ts

@@ -0,0 +1,28 @@
+export interface WalletRevenueResponse {
+	total: number;
+	summary: {
+		grossAmount: number;
+		platformFee: number;
+		netAmount: number;
+	};
+	list: WalletRevenueItem[];
+}
+
+export interface WalletRevenueItem {
+	id: number;
+	donorName: string;
+	donorSID: string|null;
+	grossAmount: number;
+	platformFee: number;
+	netAmount: number;
+	type: 'donation'|'crew_donation';
+	crewName: string|null;
+	createdAt: string;
+}
+
+export interface RevenueChartItem {
+	date: string;
+	grossAmount: number;
+	platformFee: number;
+	netAmount: number;
+}

+ 31 - 0
types/response/wallet/withdraw.ts

@@ -0,0 +1,31 @@
+export interface WalletWithdrawResponse {
+	total: number;
+	withdrawableBalance: number;
+	accounts: WalletAccountInfo[];
+	list: WalletWithdrawItem[];
+}
+
+export interface WalletAccountInfo {
+	id: number;
+	bankName: string;
+	accountNumber: string;
+	accountHolder: string;
+}
+
+export interface WalletWithdrawItem {
+	id: number;
+	requestedAmount: number;
+	withholdingTax: number;
+	netAmount: number;
+	status: 'Pending'|'Processing'|'Completed'|'Rejected';
+	bankName: string;
+	accountNumber: string;
+	requestedAt: string;
+	completedAt: string|null;
+	rejectedReason: string|null;
+}
+
+export interface WithdrawRequest {
+	accountID: number;
+	amount: number;
+}

+ 4 - 0
types/scss.d.ts

@@ -0,0 +1,4 @@
+declare module '*.scss' {
+	const content: { [className: string]: string };
+	export default content;
+}

Some files were not shown because too many files changed in this diff