Compare commits

61 Commits

Author SHA1 Message Date
e6659a416a style(stock): 스크리너 모바일 적층 + 표 가로 스크롤 2026-05-12 14:23:16 +09:00
3abd46c0fd docs(stock): CLAUDE.md 스크리너 API 표 추가 + Stock 페이지 링크 2026-05-12 14:22:18 +09:00
c42d3fe8d4 feat(stock): ResultTable 본구현 + ScoreChips (노드 칩 + 70점 강조) 2026-05-12 14:21:05 +09:00
1e8542f6c7 feat(stock): GatePanel 자동 폼 + GlobalControls (TopN/ATR/RR + 3버튼) 2026-05-12 14:19:36 +09:00
a11475db57 feat(stock): NodeCard 자동 폼 (param_schema 기반) + NodePanel 통합 2026-05-12 14:18:22 +09:00
bc2c020f71 feat(stock): /stock/screener 페이지 골격 + hooks 4개 + 컴포넌트 stub 6개 2026-05-12 14:15:36 +09:00
cd6072727f feat(stock): /stock/screener 라우트 + 임시 placeholder 2026-05-12 14:13:26 +09:00
42ebd5a87c feat(stock): screener API 헬퍼 7개 2026-05-12 14:11:51 +09:00
3b66a47316 docs(plan): 데이터 소스 pykrx → FDR + 네이버 스크래핑 (Task 0.1/0.3)
실측 결과 pykrx의 시장 전체 함수 (get_market_ticker_list,
get_market_cap, get_market_ohlcv_by_ticker)가 모두 KRX 인증
요구로 깨짐. Task 0.1 의존성을 finance-datareader + bs4 + lxml
로 교체하고 Task 0.3 snapshot.py는 FDR + 네이버 frgn 스크래핑
방식으로 재작성 (implementer dispatch 시 인라인 안내).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 04:03:31 +09:00
f7323a5b72 docs(plan): Stock Screener Board MVP 구현 plan
6 Phase × 35 task. Phase 0(백엔드 기반)·Phase 1(노드 8개 TDD)·
Phase 2(엔진/사이저/텔레그램)·Phase 3(라우터)·Phase 4(프론트)·
Phase 5(agent-office 통합)·Phase 6(백필·검증·배포).
모든 task에 TDD step + 코드 + 명령 명시. 로컬 venv 기반
실행으로 메모리 규약(로컬 docker 금지) 준수.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:48:20 +09:00
ccf6d4e551 docs(spec): Stock Screener Board MVP 설계 문서
KRX 강세주 발굴 노드 기반 분석 보드의 첫 슬라이스 설계.
pykrx 일봉·수급 캐시 + 위생 게이트 1 + 점수 노드 7
(외국인 누적 매수·거래량 급증·20일 모멘텀·52주 신고가 근접도·
RS Rating·이평선 정배열·VCP-lite) + 가중합 + ATR 포지션 사이징.
평일 16:30 KST agent-office 자동 잡으로 텔레그램 전송.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 03:33:35 +09:00
a20315ce34 feat(stock): 포트폴리오 현재가에 NXT 시간외 거래 뱃지
백엔드 응답의 price_session에 따라 NXT 프리마켓/애프터마켓 거래 중인
종목에 작은 'NXT' / 'NXT 프리' 뱃지를 표시. 툴팁에 거래 시각 노출.
정규장 마감 후에도 평가금액이 자연스럽게 이어지는 흐름을 시각적으로 보강.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:32:26 +09:00
3fa4dbda3c Merge feature/lotto-curator-evolution: Lotto Curator Evolution (frontend)
7 commits for Phase F + G:
- api.js: getLatestReview / getReviewHistory / bulkPurchase 헬퍼
- useReview 훅 + useBriefing 4계층 normalize
- DecisionCard + RetrospectiveBox + TierModeToggle + TierSection + PickCard + BulkPurchaseButton
- BriefingTab 단일 화면 재구성
- 분석탭 → 자료실 라벨 + 9개 패널 details 접힘
- PurchasePanel 자동 채점 일치수 배지 + 4등↑ 플래그
- 4주 추세 차트(너 vs 큐레이터 평균 일치)

자세한 컨셉/계획: docs/superpowers/{specs,plans}/2026-05-11-*.md
2026-05-11 09:39:08 +09:00
baf34dd7aa feat(lotto): 구매탭 4주 추세 차트(너 vs 큐레이터 평균 일치) 2026-05-11 09:06:54 +09:00
4ef76f6cce feat(lotto): 구매탭에 자동 채점 일치수 배지 + 4등↑ 플래그 2026-05-11 09:05:22 +09:00
0bf1233e96 feat(lotto): 분석탭 → 자료실 라벨 + 첫 진입 모든 패널 접힘 2026-05-11 09:03:10 +09:00
ff7ac48c6b feat(lotto): DecisionCard + BulkPurchaseButton, BriefingTab 단일 화면 재구성 2026-05-11 09:00:59 +09:00
329141c732 feat(lotto): DecisionCard 하위 컴포넌트(Pick/Tier/Toggle/Retro) + 스타일 2026-05-11 08:59:00 +09:00
cd3c538eb7 feat(lotto): useReview 훅 + useBriefing 4계층 정규화 2026-05-11 08:57:14 +09:00
9d2dfad512 feat(api): review + bulkPurchase 헬퍼 2026-05-11 08:56:10 +09:00
42073a5bf3 docs(plan): Lotto Curator Evolution 구현 plan
23 task로 분해 (TDD 사이클 + 빈번한 commit):
- Phase A (1-2): weekly_review 테이블 + 4계층 마이그레이션
- Phase B (3-5): 채점 보조 함수 + 통합 잡 + cron
- Phase C (6-8): review/bulk/briefing 라우터
- Phase D (9-12): 큐레이터 4계층 스키마 + 회고 + pipeline
- Phase E (13-15): 텔레그램 알림 + webhook + cron 변경
- Phase F (16-19): api 헬퍼 + 훅 + DecisionCard
- Phase G (20-22): 자료실 강등 + 자동채점 표시 + 추세 차트
- Phase H (23): 1주차 운영 점검

스펙→코드베이스 보정 사항(테이블명/기존 컬럼/기존 자동채점) plan 상단에 명시
2026-05-11 04:26:00 +09:00
6b2fcda2af docs(spec): Lotto Curator Evolution 설계 문서
매주 같은 시간에 큐레이터가 한 번 더 똑똑해지는 컨셉으로
- 회고 컨텍스트(weekly_review + 자동 채점 잡)
- 4계층 위계(코어/보너스/확장/풀, 5~20세트)
- 결정 카드 단일 화면(브리핑 탭 재구성)
- 분석 탭은 자료실로 강등
- 월요일 09:00 큐레이션 + 텔레그램 푸시
2026-05-11 03:19:58 +09:00
acac2cd20e chore: ignore .superpowers/ (visual companion mockup files) 2026-05-11 03:19:57 +09:00
95edc9d232 feat(web-ui): 배치 장르 목록 동적 fetch (POOLS 추가 시 자동 반영) 2026-05-10 23:53:49 +09:00
ec22321d56 fix(deploy): NAS_FRONTEND_DEST_WIN env로 Z: 매핑 변경 대응 2026-05-10 19:02:49 +09:00
a80b869878 feat(web-ui): Create 탭 배치 생성 섹션 + BatchProgress 폴링 2026-05-10 19:00:42 +09:00
93d5f49cdb feat(web-ui): PipelineStartModal '원하는 이미지 분위기' 메인 필드로 노출 2026-05-10 16:17:36 +09:00
3f5cd32c77 feat(web-ui): SetupTab visual_defaults 6옵션 확장 2026-05-09 13:36:26 +09:00
120c39a3ef feat(web-ui): PipelineDetailModal + 카드 mini 미리보기 2026-05-09 13:34:54 +09:00
08fce2d4f6 feat(web-ui): PipelineStartModal Mix 입력 라디오 + 고급 옵션 2026-05-09 13:32:23 +09:00
9c12de4593 feat(web-ui): CompileTab '영상 만들기' 버튼 + createPipeline payload 시그니처 2026-05-09 13:30:31 +09:00
53e9938903 fix(web-ui): PipelineStartModal에 initialTrackId 전달 2026-05-07 17:44:00 +09:00
522b7695aa feat(web-ui): YouTube 6 서브탭 + Library 영상 파이프라인 트리거 2026-05-07 17:31:37 +09:00
9ffd7889e7 feat(web-ui): PipelineTab — 진행 중 파이프라인 카드 보드 2026-05-07 17:28:14 +09:00
5bba880c23 feat(web-ui): SetupTab — YouTube 자동화 구성 허브 2026-05-07 17:25:53 +09:00
4498124514 feat(web-ui): pipeline/setup/youtube API 헬퍼 2026-05-07 17:23:51 +09:00
b6748ecd27 chore: 하네스 settings.json + CLAUDE.md 깨진 spec 참조 제거
- .claude/settings.json: git/npm/npx/node/ls allowlist + 민감파일 deny.
- CLAUDE.md: 삭제된 realestate-targeting-enhancement-design.md 참조 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:06:15 +09:00
397257cf3b docs: STATUS.md — 구현 현황 + 향후 계획 정리
페이지·서비스별 완료/예정 항목 인덱스. CLAUDE.md를 보완.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:57:12 +09:00
d38ee553c3 fix(stock): 포트폴리오 카드 모바일 금액 줄바꿈 방지
천만원 단위 이상에서 '원'이 다음 줄로 넘어가던 문제 해결.
값 길이별 폰트 단계 축소(is-fit-sm/xs) + nowrap 적용.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 03:54:20 +09:00
4acdc451c0 feat(music): YouTube 탭 + 컴파일 기능 통합
- YouTube 탭 (영상 제작, 수익 추적, 시장 트렌드, 컴파일) 연결
- Create 탭 트랙 제목 직접 입력
- TrendsTab 히스토리 상세 + 메타데이터 수정
- 다중 트랙 FFmpeg concat 컴파일 서브탭 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:01:12 +09:00
f3b0b2c109 feat(music): YouTube 탭 컴파일 서브탭 추가 (다중 트랙 FFmpeg concat)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 16:58:40 +09:00
4281c1873f feat(music): Create 탭 트랙 제목 직접 입력 추가 2026-05-01 15:49:56 +09:00
8a7b5e8a38 fix(music): setTimeout 정리 + useCallback 폴링 deps
- TrendsTab: useRef로 타이머 ID 추적 후 언마운트 시 clearTimeout 호출 (stale setState 방지)
- VideoProjectsTab: loadProjects를 useCallback으로 감싸고 폴링 useEffect deps에 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:13:04 +09:00
08981a292a feat(youtube-tab): MusicStudio YouTube 탭 연결 + CSS + Library 버튼
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:08:15 +09:00
ed95f6678f fix(music): TrendsTab 리포트 이력 메타데이터 장르/추천수 표시
리포트 목록 행의 메타 정보를 insights 미리보기에서 장르/추천 개수로 교체.
이제 list 응답에 top_genres·recommended_styles가 포함되므로 클릭 시
장르 차트와 Suno 프롬프트가 정상 표시됨.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:03:34 +09:00
1847771ad2 fix(music): TrendsTab 로딩상태·에러피드백·메타데이터 수정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 15:01:38 +09:00
0f0ca8610d fix(music): TrendsTab 리포트 selected_styles 표시 + created_at 시간 포맷
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 14:56:47 +09:00
3f2fdb095c feat(youtube-tab): TrendsTab 시장 트렌드 서브탭 2026-05-01 14:51:10 +09:00
3e54b2c98d feat(youtube-tab): RevenueTab 수익 추적 서브탭 2026-05-01 14:48:47 +09:00
16b8cc59ae feat(youtube-tab): VideoProjectsTab 영상 제작 서브탭 2026-05-01 14:46:27 +09:00
a89de57b79 feat(youtube-tab): YoutubeTab 서브탭 shell 컴포넌트 + 스텁 탭 추가 2026-05-01 14:44:21 +09:00
413dccb655 feat(api): video-project / revenue / market-trends API 함수 추가 2026-05-01 14:42:15 +09:00
d1526af32c feat(subscription): 청약 일정 캘린더 뷰 추가
공고 목록 탭에 📅 캘린더 토글 버튼 추가.
캘린더 모드: 월간 그리드, 접수 시작일 기준 도트 표시 (상태별 색상).
날짜 클릭 시 해당일 공고 목록 패널 표시, 항목 클릭 시 상세 뷰로 전환.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:35:44 +09:00
abd8762b5c feat(subscription): 프로필 완성도 힌트 배너 추가
소득·면적·예산·자치구 티어 중 미입력 항목이 있으면
프로필 패널 상단에 입력 권장 안내 배너 표시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:31:37 +09:00
8514232775 feat(subscription): 소득 기준 힌트 표시 + input 범위 제한
income_level 입력 필드에 특별공급별 소득 기준(%)'을 안내 텍스트로 표시.
미입력 시 검증 생략됨을 명시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:12:34 +09:00
6c1f19e690 fix(deploy): rsync 대신 tar|ssh 방식으로 전환 (Synology rsync --server 차단 우회)
Synology가 rsync 바이너리를 패치하여 --server 모드도 데몬 인증을 요구함.
SSH는 정상 동작하므로 tar czf | ssh로 대체하여 배포 가능하도록 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:48:37 +09:00
35ce362d20 fix(deploy): -e 인자 단따옴표 변경 + 키파일 존재 검증
- -e 'ssh ...' 단따옴표 사용으로 -i 경로의 따옴표 충돌 방지
- 키 파일 없을 때 명확한 에러 메시지 출력 후 종료

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:39:27 +09:00
11e4f00ae6 fix(deploy): rsync SSH에 -i 키파일 명시 (macOS Keychain 우회)
macOS에서 rsync 서브프로세스는 Keychain 키를 자동 로드하지 못해
비밀번호 프롬프트로 fallback됨. -i ~/.ssh/id_rsa 명시로 해결.
- BatchMode=yes: 비밀번호 프롬프트 차단 (명확한 에러 반환)
- StrictHostKeyChecking=accept-new: 최초 연결 host key 자동 수락
- NAS_SSH_KEY 환경변수로 다른 키 파일 지정 가능

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:35:56 +09:00
b11d1c421d fix(deploy): SSH env값 제어문자 sanitize + 포트 검증
- NAS_SSH_TARGET/PORT/PATH에서 \r\n\t 제거 (잘못된 export·copy-paste 대비)
- NAS_SSH_PORT는 숫자만 허용, 잘못된 값이면 명확한 오류 메시지 출력
- SSH deploy 실행 시 cleanTarget/sshCmd 값 콘솔에 출력 (디버그용)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:27:11 +09:00
f6d95264c3 fix(deploy): Mac SSH 배포 지원 + .env.local 자동 로드
- .env.local 파일에서 NAS_SSH_TARGET 등 SSH 설정 자동 로드
- NAS_SSH_TARGET 설정 시 SMB 마운트보다 SSH 우선 사용
- SMB 쓰기 실패(EIO) 시 스택트레이스 대신 SSH 설정 안내 메시지 출력
- .env.local을 .gitignore에 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 09:21:32 +09:00
7cbdbe6e8b feat(subscription): 5축 점수 breakdown 시각화 + 알림 대상 카운트
- AnnouncementDetail: 5축(지역/유형/면적/가격/자격) progress bar 추가
- MatchesTab: 카드마다 미니 5축 비례 바 추가 (색상 구분)
- ProfileTab: 마운트 시 dashboard도 함께 fetch → pass_count 취득
- NotificationSettings: passCount prop → "현재 N건 대상" 인라인 표시
- Subscription.css: .ns-pass-count 스타일 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 08:56:36 +09:00
62 changed files with 13008 additions and 121 deletions

34
.claude/settings.json Normal file
View File

@@ -0,0 +1,34 @@
{
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(git branch:*)",
"Bash(git stash list:*)",
"Bash(git remote -v)",
"Bash(npm run:*)",
"Bash(npm test:*)",
"Bash(npm install:*)",
"Bash(npm ci:*)",
"Bash(npm list:*)",
"Bash(npm view:*)",
"Bash(npm outdated:*)",
"Bash(npx eslint:*)",
"Bash(npx vite:*)",
"Bash(node -v)",
"Bash(ls:*)"
],
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/credentials*)",
"Read(**/secrets*)",
"Read(**/*.pem)",
"Read(**/*.key)"
]
}
}

4
.gitignore vendored
View File

@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env.local
# Superpowers visual companion (mockup files)
.superpowers/

View File

@@ -17,7 +17,8 @@
| `/lotto` | `Lotto` | 로또 추천/통계 |
| `/stock` | `Stock` | 주식 뉴스/지수 |
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격)<br>• 백엔드 스펙: `web-backend/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md` |
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
@@ -85,6 +86,12 @@ proxy: {
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
| 트레이딩 | GET | `/api/trade/balance` |
| 트레이딩 | POST | `/api/trade/order` |
| 스크리너 | GET | `/api/stock/screener/nodes` |
| 스크리너 | GET/PUT | `/api/stock/screener/settings` |
| 스크리너 | POST | `/api/stock/screener/run` — body: `{ mode, asof?, weights?, ... }` |
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
| 스크리너 | GET | `/api/stock/screener/runs/:id` |
| 포트폴리오 | GET/POST | `/api/portfolio` |
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |

126
STATUS.md Normal file
View File

@@ -0,0 +1,126 @@
# web-ui — 구현 현황 & 로드맵
> 최종 갱신: 2026-05-07
> 자세한 페이지·API 표는 [CLAUDE.md](./CLAUDE.md) 참조.
---
## 1. 구현 완료
### 1-1. 메인 페이지
| 경로 | 상태 | 비고 |
|------|------|------|
| `/` Home | ✅ | 메인 허브 |
| `/blog` Blog | ✅ | 마크다운 기반 |
| `/portfolio` Portfolio | ✅ | 프로필·경력·프로젝트·자기소개 |
| `/todo` Todo | ✅ | 태스크 보드 |
### 1-2. 로또 (`/lotto`)
| 영역 | 상태 |
|------|------|
| 3탭 구조 (브리핑 / 분석·통계 / 구매·성과) | ✅ |
| AI 큐레이터 브리핑 탭 | ✅ |
| 성과 배너 + ReportPanel + ConfidenceRing | ✅ |
| 개인 분석 패널 | ✅ |
| 구매 내역 CRUD + 성과 통계 | ✅ |
### 1-3. 주식 (`/stock`, `/stock/trade`)
| 영역 | 상태 |
|------|------|
| 뉴스·지수 | ✅ |
| 트레이딩 + 잔고 | ✅ |
| 포트폴리오 (수동 입력 종목 + 예수금 + 자산 추이) | ✅ |
| 자산 스냅샷 + 7/30/90일 차트 | ✅ |
| 실현손익(매도이력) Drawer | ✅ |
| 포트폴리오 카드 모바일 금액 줄바꿈 대응 | ✅ (2026-05-06) |
### 1-4. 청약 (`/realestate`, `/realestate/property`)
| 영역 | 상태 |
|------|------|
| 자치구 5티어 (S/A/B/C/D) 드래그&드롭 + 슬라이더 + 토글 | ✅ |
| 카드/매칭 결과에 district 뱃지 + 5티어 뱃지 | ✅ |
| AnnouncementDetail 매칭 분석 섹션 | ✅ |
| 5축 점수 breakdown 시각화 + 알림 대상 카운트 | ✅ |
| 청약 일정 캘린더 뷰 | ✅ |
| 프로필 완성도 힌트 배너 + 소득 기준 힌트 | ✅ |
### 1-5. 여행 (`/travel`)
| 영역 | 상태 |
|------|------|
| Dark Room 테마 갤러리 | ✅ |
| 앨범 카드 + Masonry + Lightbox + MiniMap | ✅ |
| 지역 변경 + 핀 좌표 지정 | ✅ |
| 영상(VideoTab) | 🚧 준비 중 |
### 1-6. 음악 스튜디오 (`/lab/music` — Sonic Forge)
| 영역 | 상태 |
|------|------|
| Create 탭 (장르/무드/악기/BPM/Key) + 트랙 제목 직접 입력 | ✅ |
| Library 탭 + 트랙 카드 + 삭제/재생 | ✅ |
| YouTube 탭 (서브탭 4개: VideoProjects / Trends / Revenue / Compile) | ✅ (2026-05-01~05-06) |
| 다중 트랙 컴파일 (FFmpeg concat → MP4) | ✅ |
| 시장 트렌드 리포트 (장르/추천수/이력) | ✅ |
### 1-7. 기타 Lab
| 경로 | 상태 |
|------|------|
| `/lab/sword-stream` Three.js 파티클 | ✅ |
| `/lab/day-calc` 날짜 계산기 | ✅ |
| `/agent-office` 에이전트 가상 오피스 (WebSocket) | ✅ |
| `/blog-lab` 블로그 마케팅 수익화 대시보드 | ✅ |
### 1-8. 인프라 / DX
| 항목 | 상태 |
|------|------|
| Vite 개발 서버 프록시 (`/api`, `/media`, `/ext`) | ✅ |
| Windows robocopy + macOS SSH/SMB 배포 (`scripts/deploy-nas.cjs`) | ✅ |
| Mac SSH 배포 + tar\|ssh 전환 (Synology rsync 우회) | ✅ |
| 반응형 웹 디자인 패스 | ✅ |
---
## 2. 진행 중 / 향후 계획
### 2-1. Travel 영상 탭 완성
- 현재 "준비 중" 플레이스홀더 → 실제 영상 업로드/재생 UI 구현
- 백엔드 `travel-proxy`에 영상 메타·썸네일 API 필요
### 2-2. 로또 프리미엄 구독 UI (백엔드 Phase 3 연동)
- 회원 가입/로그인 UI (JWT)
- 구독 플랜 선택 + Toss/Stripe 결제 플로우
- 구독자 전용 리포트·알림 영역
- 백엔드 로드맵: `web-backend/docs/lotto-premium-roadmap.md`
### 2-3. Music YouTube 탭 후속
- VideoProjects 실제 렌더링 진행률 시각화 강화
- Compile 탭에 트랙 트림/페이드 옵션
- Revenue 대시보드 차트 강화
### 2-4. 청약 후속
- 알림 dry-run 미리보기 UI (어떤 공고가 매칭됐을지 사전 확인)
- 모바일 5티어 편집 모드 (현재 PC 전용)
### 2-5. 포트폴리오/주식 후속
- 종목별 평균 매입가 분할 입력 UI
- 매도 시뮬레이터 (수익률 시나리오 비교)
### 2-6. 일반
- 다크/라이트 테마 토글 (현재 다크 단일)
- PWA 설치 + 홈화면 단축 (모바일 사용 빈도 증가)
---
## 3. 참고 문서
- 페이지·라우트·API 전체 표: [CLAUDE.md](./CLAUDE.md)
- 워크스페이스 통합 가이드: `../CLAUDE.md`
- 백엔드 상태: `../web-backend/STATUS.md`
- 백엔드 Spec/Plan 디렉토리: `../web-backend/docs/superpowers/`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
# Lotto Curator Evolution — Design Spec
- 일자: 2026-05-11
- 범위: `web-ui` (브리핑 탭 재구성), `web-backend/lotto` (스키마·잡), `web-backend/agent-office` (큐레이터·텔레그램)
- 컨셉 한 줄: **매주 같은 시간에 큐레이터가 한 번 더 똑똑해진다**
## 1. 동기와 문제
현재 `/lotto`는 3탭(브리핑·분석·구매)으로 구성되어 정보가 풍부하지만, 사용자가 5천~1만원 어치를 즐기며 구매하기에 다음 페인이 있다.
- 분석·통계·브리핑이 모두 *결정용 화면*처럼 노출되어 정보 과다.
- 큐레이터가 매주 5세트를 추천하지만, 5세트의 *역할*과 *왜 이 분배인지*가 와닿지 않는다.
- 큐레이터·시스템에 시간축이 없다. 매주 동일 알고리즘을 새로 도는 느낌.
- 1만원어치 구매 시 5세트로는 부족하다. 추가 게임에 대한 설계가 없다.
## 2. 컨셉
다음 두 축으로 강화한다.
- **서사적 진화**: 큐레이터가 매주 *지난 주를 회고*하고 이번 주 전략으로 이어간다. 자기 추천 결과 + 사용자 실제 구매 결과를 둘 다 회고 데이터로 사용한다.
- **포트폴리오 명료성**: 5게임이 단순 5장이 아니라 안정/균형/공격 분배가 그 주 데이터에 따라 동적으로 바뀌고, 그 이유가 한 줄로 와닿는다. 5~20세트로 위계적으로 확장된다.
## 3. 주간 사이클
```
토 20:35 추첨
일 03:00 추첨결과 sync (기존)
채점 잡 (신규) → weekly_review INSERT
lotto_purchase auto_graded UPDATE
월 09:00 큐레이션 트리거 (lotto_agent.on_schedule)
├─ build_retrospective(target_draw)
├─ collect_candidates(N=30)
├─ build_context (+retrospective)
├─ Claude 호출 (회고+계층 규칙)
└─ briefings INSERT (4계층 picks)
월 09:05 텔레그램 헤드라인 푸시
월~토 사용자: 사이트 결정 카드 → 모드 선택(5/10/15/20) → 1탭 구매 기록
토 20:35 추첨 → 다음 사이클
```
cron 시간(일 03:00 / 월 09:00)은 운영하며 조정 가능한 기본값.
## 4. 결정 카드 (브리핑 탭 메인)
브리핑 탭을 단일 `DecisionCard`로 재구성한다. 정보 위계는 위→아래로:
1. **헤더** — 회차 + 한 줄 헤드라인 + 신뢰도(0~100, 큐레이터 자기 평가)
2. **회고 박스** (▸ 보라색 라벨) — 지난 주 너 + 큐레이터 한 줄 회고. *시간축*의 핵심.
3. **헤드라인 + 3줄** — 이번 주 전망 + 근거 3줄(기존 narrative 유지).
4. **분배 칩** — 선택 모드까지의 안정/균형/공격 합산 + "왜 이 분배인지" 한 줄.
5. **모드 토글** — 4단계 칩(코어 5 / +보너스 5 / +확장 5 / +풀 5).
6. **계층 섹션 × 4** — 각 계층마다 타이틀 + 사유 한 줄 + 5장 PickCard. 코어는 항상 펼침, 그 외는 모드에 따라.
7. **하단 액션** — "이대로 N세트 구매했음" 한 클릭 → 자동 기록.
### 4계층 위계
| 계층 | 누적 게임 | 비용 | 큐레이터의 의도 |
|---|---|---|---|
| 코어(필수) | 5 | 5천 | 안정 2 / 균형 2 / 공격 1, 그 주 주축 |
| + 보너스 | 10 | 1만 | 코어 분배의 공백 보완 |
| + 확장 | 15 | 1.5만 | 코어·보너스에 없던 시각(합계 극단·콜드 누적·4주 미등장) |
| + 풀 | 20 | 2만 | 한 번도 누르지 않은 패턴(연속·동끝·5수 균등) |
각 5세트는 *큐레이터가 의도한 한 묶음*이며, 늘어날수록 *서사가 더해지는 구조*. 마지막 모드 선택은 브라우저 `localStorage``lotto.tier_mode` 키로 저장하여 다음 주 진입 시 디폴트로 사용한다(서버 저장 X — 사용자 디바이스 단위 기억).
### 분석 탭은 "Deep Dive" 자료실로 강등
- 라벨 변경: `📊 분석·통계``📚 자료실 / Deep Dive`
- 첫 진입 시 모든 패널 접힘
- 기존 패널 모두 보존 (CombinedRecommendPanel, ReportPanel, 시뮬레이션, 통계, 빈도, PersonalAnalysisPanel, 수동 추천, 히스토리)
- PerformanceBanner는 결정 카드 헤더와 역할 중복 없도록 자료실에만 둠
## 5. 데이터 모델
### 신규 테이블 — `weekly_review`
```sql
CREATE TABLE weekly_review (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_no INTEGER NOT NULL UNIQUE,
-- 큐레이터 자기 평가 (briefings.picks vs 추첨)
curator_avg_match REAL,
curator_best_tier TEXT, -- 안정 | 균형 | 공격
curator_best_match INTEGER,
curator_5plus_prizes INTEGER, -- 3개↑ 일치 카운트(5등 이상)
-- 사용자 구매 평가 (lotto_purchase vs 추첨)
user_avg_match REAL,
user_best_match INTEGER,
user_5plus_prizes INTEGER,
-- 패턴 갭 (서사 재료)
user_pattern_summary TEXT,
draw_pattern_summary TEXT,
pattern_delta TEXT, -- "너 저번호 편향 +1.2 / 합계 -18"
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### `lotto_purchase` 컬럼 추가
```sql
ALTER TABLE lotto_purchase ADD COLUMN numbers TEXT; -- JSON [3,11,17,25,33,41]
ALTER TABLE lotto_purchase ADD COLUMN match_count INTEGER;
ALTER TABLE lotto_purchase ADD COLUMN auto_graded INTEGER DEFAULT 0;
ALTER TABLE lotto_purchase ADD COLUMN curator_tier TEXT; -- core | bonus | extended | pool
ALTER TABLE lotto_purchase ADD COLUMN curator_role TEXT; -- 안정 | 균형 | 공격
```
### `briefings.picks` 구조 변경
JSON 컬럼을 4계층 구조로 마이그레이션:
```json
{
"core": [/* 5 */],
"bonus": [/* 5 */],
"extended": [/* 5 */],
"pool": [/* 5 */]
}
```
기존 단일 배열 데이터는 `core` 키에만 매핑하고 나머지 키는 빈 배열로 채우는 1회 마이그레이션 스크립트.
## 6. 큐레이터 변경
### 출력 스키마 (`agent-office/curator/schema.py`)
```python
class CuratorOutput(BaseModel):
core_picks: List[Pick] = Field(min_length=5, max_length=5)
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
tier_rationale: TierRationale # bonus / extended / pool 각 30자 이내
narrative: Narrative # retrospective(60자 이내) 필드 추가
confidence: int # 0~100
```
### SYSTEM_PROMPT 추가 규칙
```
회고 규칙:
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내).
- 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
- 이번 주 코어 분배는 회고에 근거해 조정. 사유는 narrative.headline 에 한 줄로.
계층별 큐레이션 규칙:
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축.
- bonus_picks (5): 코어 분배의 공백을 메움. 코어와 상보적.
- extended_picks (5): 코어·보너스에 없는 시각(합계 극단 / 콜드 누적 / 4주 미등장).
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴(연속·동끝·5수 균등).
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 사유.
- 후보에 없는 번호 조합은 절대 사용 금지(기존 규칙 유지).
```
### 회고 컨텍스트 — `agent-office/curator/retrospective.py` (신규)
```python
def build_retrospective(target_draw_no: int) -> dict | None:
last = lotto_get_review(target_draw_no - 1)
prev3 = lotto_get_reviews(target_draw_no - 4, target_draw_no - 2)
if not last:
return None
return {
"last_draw": {
"draw_no": last["draw_no"],
"curator_avg": last["curator_avg_match"],
"curator_best_tier": last["curator_best_tier"],
"user_avg": last["user_avg_match"],
"user_5plus": last["user_5plus_prizes"],
"pattern_delta": last["pattern_delta"],
},
"trend_4w": {
"curator_avg_4w": mean(curator_avg_match for r in [last, *prev3]),
"user_avg_4w": mean(user_avg_match for r in [last, *prev3] if user_avg_match is not None),
"user_persistent_bias": _detect_bias([last, *prev3]), # 3주↑ 유지된 패턴 편향(예: "저번호 편향")
}
}
```
### 후보 풀 N=30
`collect_candidates(n=30)` — 20세트 선별 + 다양성 여유. 기존 4개 소스(simulation/heatmap/statistics/meta) 추출량을 비례 확대.
## 7. 자동 채점 잡 — `lotto/app/jobs/grade_weekly_review.py`
```
실행: 매주 일요일 03:00 KST (cron)
입력: 가장 최근 sync된 추첨 회차
처리:
1) briefings 에서 해당 회차의 4계층 picks 로드 (없으면 curator_* NULL)
2) lotto_purchase 에서 해당 회차의 사용자 구매 로드 (없으면 user_* NULL)
3) 각 세트별 일치 수 계산 → 큐레이터/사용자 집계
4) 패턴 요약(저번호·홀짝·합계 평균) → user/draw_pattern_summary
5) 패턴 갭 한 줄(가장 큰 격차 1~2개) → pattern_delta
6) weekly_review UPSERT (draw_no 유니크)
7) lotto_purchase 채점:
- 일치 3개 → prize=5000, auto_graded=1
- 일치 4개 → prize=NULL, note 에 "4등 가능성 — 동행복권 확인" 플래그
- 일치 5+ → prize=NULL, note 에 "🚨 큰 당첨 가능성 — 즉시 확인" 플래그
+ agent-office HTTP webhook(`POST /api/agent-office/notify/lotto-prize`)
호출하여 텔레그램 별도 알림 트리거
- numbers NULL 인 행은 스킵
```
## 8. 텔레그램 알림 — `agent-office/notifiers/telegram_lotto.py` (신규)
큐레이션 성공 후 `lotto_agent` 가 호출. 발송 실패는 try/except 로 흡수(briefing 저장과 분리).
4등 이상 당첨 알림은 lotto-backend 채점 잡이 `POST /api/agent-office/notify/lotto-prize` webhook 으로 트리거(agent-office 측 라우터 신규 추가).
```
🎟 1154회 · 큐레이션 떴음
"이번 주는 안정 +1, 콜드 누적 보강."
신뢰도 72 · 분배 안정 3·균형 1·공격 1
▸ 회고: 너 2.0 / 나 1.8
너 저번호 편향 → 보너스 고번호 보강
👉 결정 카드 보러가기 (https://gahusb.synology.me/lotto)
```
회고 단락은 retrospective 가 있을 때만(첫 주 생략).
## 9. 프론트 변경
### 파일 변경 맵
| 파일 | 종류 | 내용 |
|------|------|------|
| `pages/lotto/Functions.jsx` | 수정 | 분석탭 라벨 변경 |
| `pages/lotto/tabs/BriefingTab.jsx` | 수정 | DecisionCard 단일로 재구성 |
| `pages/lotto/components/decision/DecisionCard.jsx` | 신규 | 결정 카드 메인 |
| `pages/lotto/components/decision/RetrospectiveBox.jsx` | 신규 | 회고 박스 |
| `pages/lotto/components/decision/TierModeToggle.jsx` | 신규 | 4단계 칩 토글 |
| `pages/lotto/components/decision/TierSection.jsx` | 신규 | 한 계층 영역(타이틀+사유+5장) |
| `pages/lotto/components/decision/PickCard.jsx` | 신규 | 한 세트 카드(역할+번호+사유) |
| `pages/lotto/components/decision/BulkPurchaseButton.jsx` | 신규 | 원클릭 구매 |
| `pages/lotto/components/briefing/*` | 삭제·이동 | DecisionCard 하위로 흡수, CuratorUsageFooter 는 자료실 이동 |
| `pages/lotto/components/PurchasePanel.jsx` | 수정 | auto_graded 표시 + 4등 이상 플래그 |
| `pages/lotto/components/PurchaseTrendChart.jsx` | 신규 | 4주 추세 라인(너 vs 큐레이터 평균 일치) |
| `pages/lotto/hooks/useBriefing.js` | 수정 | 4계층 + retrospective 수용 |
| `pages/lotto/hooks/useReview.js` | 신규 | weekly_review 로드 |
| `pages/lotto/hooks/usePurchases.js` | 수정 | bulkPurchase 추가 |
| `api.js` | 수정 | getLatestReview, getReviewHistory, bulkPurchase 헬퍼 |
### 컴포넌트 격리 원칙
- `DecisionCard``briefing` + `review` 두 객체만 props 로 받음(내부 hook 호출 X).
- `TierSection``tier`, `picks`, `rationale` 만 받아 4번 재사용.
- `BulkPurchaseButton``draw_no`, `tier_mode`, `sets`, `amount` 4개로 작동.
## 10. 백엔드 변경
### `web-backend/lotto/`
| 파일 | 종류 | 내용 |
|------|------|------|
| `app/db/migrations/00X_weekly_review.sql` | 신규 | 테이블 생성 |
| `app/db/migrations/00X_purchase_grading.sql` | 신규 | lotto_purchase 컬럼 추가 |
| `app/db/migrations/00X_briefings_tiers.sql` | 신규 | briefings.picks 4계층 마이그레이션 |
| `app/jobs/grade_weekly_review.py` | 신규 | 채점 잡 |
| `app/curator_helpers.py` | 수정 | collect_candidates(N=30) 기본값, build_context 에 retrospective 합치기 |
| `app/routers/briefing.py` | 수정 | BriefingRequest 4계층 + narrative.retrospective 수용 |
| `app/routers/review.py` | 신규 | GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N |
| `app/routers/purchase.py` | 수정 | POST /api/lotto/purchase/bulk |
| `app/cron.py` (또는 compose 스케줄러) | 수정 | 채점 잡 일 03:00 등록 |
### `web-backend/agent-office/`
| 파일 | 종류 | 내용 |
|------|------|------|
| `app/curator/retrospective.py` | 신규 | build_retrospective |
| `app/curator/schema.py` | 수정 | 4계층 + tier_rationale + narrative.retrospective |
| `app/curator/prompt.py` | 수정 | 회고·계층 규칙 추가 |
| `app/curator/pipeline.py` | 수정 | retrospective 빌드 호출, 4계층 직렬화 |
| `app/agents/lotto.py` | 수정 | on_schedule 월 09:00, 성공 시 텔레그램 호출 |
| `app/notifiers/telegram_lotto.py` | 신규 | 알림 포맷·발송(큐레이션 완료, 4등 이상 당첨 알림 둘 다) |
| `app/routers/notify.py` | 신규 | `POST /api/agent-office/notify/lotto-prize` — lotto-backend 채점 잡이 호출 |
| `app/service_proxy.py` | 수정 | review 헬퍼 추가 |
## 11. API 추가·변경
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/lotto/review/latest` | 최신 weekly_review 1건 |
| GET | `/api/lotto/review/history?limit=N` | 최근 N건 (4주 추세 차트용) |
| POST | `/api/lotto/purchase/bulk` | 결정 카드 원클릭 — body: `{ draw_no, tier_mode, sets, amount }` |
| POST | `/api/agent-office/notify/lotto-prize` | 4등 이상 당첨 시 lotto-backend 가 트리거 — body: `{ draw_no, match_count, numbers, purchase_id }` |
기존 엔드포인트는 그대로 유지(스키마 호환).
## 12. 에러 처리 / 격리
| 단계 | 실패 | 처리 |
|------|------|------|
| 추첨결과 sync | 동행복권 API down | 기존 정책(재시도). 채점 잡은 자동 지연만. |
| 채점 — 큐레이터 picks 없음 | 첫 주, 큐레이션 실패 회차 | curator_* NULL 로 INSERT |
| 채점 — 사용자 구매 없음 | 그 주 미구매 | user_* NULL |
| 채점 — numbers NULL 행 | 마이그레이션 이전 데이터 | 스킵, auto_graded=0 유지 |
| build_retrospective — review 없음 | 첫 주 | None 반환 → 프롬프트 분기 자연 처리 |
| Claude 스키마 실패 | 4계층 미준수 등 | 기존 1회 retry, 2회 실패 시 텔레그램 에러 알림 |
| 텔레그램 발송 실패 | 봇/네트워크 | try/except, 로그만. briefing 저장은 영향 없음 |
| bulk purchase — briefing 없음 | 큐레이션 실패 회차 | 400 + 토스트 |
| bulk purchase — 중복 호출 | 더블클릭 | (draw_no, tier_mode) 유니크 → idempotent |
| 자동채점 — 4등 이상 | 큰 당첨 | prize NULL + 메모 플래그 + 텔레그램 별도 알림 |
## 13. 테스트
### 백엔드 (`lotto/`)
- `grade_weekly_review`: (a) 정상 (b) user 구매 없음 (c) numbers NULL 스킵 (d) 일치 3개 → prize 5000 (e) 일치 4개 → 메모 플래그
- 마이그레이션: 빈 DB → 더미 → 잡 실행 → 행 정확
- briefings 마이그레이션: 구 단일 picks → core 매핑, 나머지 빈 배열
- `POST /purchase/bulk`: 정상 / 잘못된 tier_mode / briefing 없음 / 중복 호출
- `GET /review/latest`: 데이터 있음 / 빈 DB → 404
### 큐레이터 (`agent-office/curator/`)
- `build_retrospective`: review 1건 / 4건 / 0건
- `validate_response`: 정상 / 계층 누락 / 후보 외 번호 / tier_rationale 누락
- `curate_weekly` (Claude API mock): retrospective 있음·없음 / 1차 실패 → 2차 성공 / 2회 실패
- `telegram_lotto.format`: retrospective 있음·없음
### 프론트
- `DecisionCard` 수동: retrospective 있음·없음 / 모드 토글 5/10/15/20 / confidence 색
- `TierModeToggle` 단위: onChange 콜백 정확
- `BulkPurchaseButton` 수동 E2E: 클릭 → POST → 토스트 → 구매탭 갱신
- 자료실 탭 수동: 첫 진입 모두 접힘
- 모바일: DecisionCard 좁은 화면에서 깨짐 없음
## 14. 운영 점검 (배포 후 1주차)
수동으로 확인:
1. 일 03:00 채점 잡 1회 실행(`weekly_review` 1행 추가)
2. 월 09:00 큐레이션 실행(`briefings` 1행, 4계층 5×4=20개)
3. 텔레그램 알림 도착(회고 단락 정확 포함/생략)
4. 결정 카드 렌더링 정상(모바일 + PC)
5. 원클릭 구매 정확 N건 INSERT
6. cron 시간(03:00 / 09:00) 운영 패턴에 맞게 조정
## 15. Out of Scope
- 4등 이상 당첨금 자동 입력(회차별 변동, 사용자 PUT 으로 갱신)
- 큐레이터 호출 재무 비용 모니터링 강화(기존 `curator_usage` 그대로)
- 분석 탭 패널 자체의 리팩토링(라벨·디폴트 접힘만 변경)
- 1만원 외 임의 분량(7세트 등) 토글(4계층 5단위로 고정)

View File

@@ -0,0 +1,822 @@
# Stock Screener Board — 설계 문서 (MVP 슬라이스 1)
- **상태**: 설계 (Draft)
- **작성일**: 2026-05-12
- **대상 프로젝트**: `web-ui` (프론트엔드) + `web-backend/stock-lab` (백엔드) + `web-backend/agent-office` (스케줄러/텔레그램)
- **저자**: 개인 웹 플랫폼 CEO + Claude (brainstorming)
---
## 1. 배경 & 목표
현재 `/stock`은 뉴스·지수·공포탐욕, `/stock/trade`는 포트폴리오·매매·AI 코치까지 다룹니다. **시장 전체에서 강세주를 발굴하는 기능은 없습니다.**
이 작업은 KRX 전체 종목을 매일 분석해 강세주 후보를 점수화·순위화하고, 평일 장 마감 후 텔레그램으로 자동 전송하는 **노드 기반 분석 보드**를 만듭니다. 노드 인터페이스를 일관되게 정의해 후속 슬라이스에서 노드 캔버스 UI·AI 뉴스 노드·백테스트로 자연스럽게 확장 가능한 구조를 둡니다.
### 비전 (장기)
n8n 같은 노드 캔버스에서 시그널 노드를 연결·점수화하고, 결과를 표·텔레그램으로 받는 개인용 스크리닝/분석 워크벤치.
### 본 슬라이스 (MVP)
| 요소 | 범위 |
|------|------|
| 데이터 | pykrx로 매일 KRX 전종목 일봉 + 외국인/기관 수급 → SQLite 캐시 |
| 분석 노드 | 점수 7개 + 위생 게이트 1개 = 총 8개 |
| 결합 | 가중합 (게이트 통과군 내 백분위 정규화 기반) |
| 출력 | Top N(기본 20) 결과 표 + 진입가/손절/익절 + 텔레그램 |
| 실행 | 평일 16:30 KST 자동 + 사용자 수동 미리보기 |
| UI | `/stock/screener` 별도 페이지, 좌(설정)-중(표)-우(히스토리) |
| 자동 잡 | `agent-office`가 트리거, 텔레그램 전송 책임 |
### 비목표 (후속 슬라이스에 명시 예약)
1. AI 뉴스 호재/악재 노드
2. 노드 캔버스 UI (react-flow)
3. 주간 자가학습 (가중치 자동 조정 제안)
4. DART 공시·재무제표 노드
5. 분봉 기반 노드 (한투 API)
6. 진짜 미너비니 VCP (베이스 카운트·피벗 포인트)
7. 멀티 프리셋 ("공격형"/"안정형")
8. 백테스트 화면
9. KRX 호가단위 적용
10. 메트릭/대시보드 (Prometheus 등)
---
## 2. 전체 아키텍처
```
[agent-office 평일 16:30 KST] [사용자: Stock 스크리너 페이지]
│ │
▼ ▼
POST /api/stock/screener/snapshot/refresh POST /api/stock/screener/run
POST /api/stock/screener/run {mode:"auto"} {mode:"preview"|"manual_save"}
│ │
└──────────► Screener.run() ◄──────────────────┘
ScreenContext.load(asof)
(KRX 마스터·일봉·수급 SQLite 캐시)
HygieneGate.filter() ← Survivors ~500-800종
[ScoreNode.compute() × 7 활성 노드]
combine + rank Top N
position_sizer (entry/stop/target)
┌─────────────┴───────────────┐
▼ ▼
screener_runs + screener_results 응답 JSON (results, telegram_payload)
(mode='auto'·'manual_save') │
agent-office가 telegram_payload 전송
(mode='auto')
```
데이터 신선도 가정: pykrx의 외국인/기관 수급은 KRX 마감 후 30-60분 뒤 갱신. **16:30 KST 트리거는 안전 마진**.
---
## 3. 백엔드 컴포넌트 구조 (stock-lab)
### 3.1 디렉토리
```
web-backend/stock-lab/app/
├─ main.py # router.include_router(screener_router) 1줄 추가
├─ db.py
├─ price_fetcher.py
├─ scraper.py
├─ ai_summarizer.py
├─ holidays.json
├─ test_*.py # 기존
├─ test_screener_*.py # 신규 (각 노드/엔진/라우터)
└─ screener/ # ← NEW
├─ __init__.py
├─ router.py # FastAPI: /api/stock/screener/*
├─ schemas.py # Pydantic 요청/응답
├─ engine.py # Screener / ScreenContext / ScreenerResult / combine()
├─ snapshot.py # pykrx 일봉·수급 갱신
├─ position_sizer.py # ATR 기반 진입/손절/익절
├─ registry.py # NODE_REGISTRY, GATE_REGISTRY
├─ telegram.py # agent-office payload 빌더 (전송 책임은 agent-office)
├─ _test_fixtures.py # 합성 ScreenContext 헬퍼
└─ nodes/
├─ __init__.py
├─ base.py # ScoreNode, GateNode 추상
├─ hygiene.py
├─ foreign_buy.py
├─ volume_surge.py
├─ momentum.py
├─ high52w.py
├─ rs_rating.py
├─ ma_alignment.py
└─ vcp_lite.py
```
### 3.2 핵심 추상
```python
# nodes/base.py
class ScoreNode(ABC):
name: ClassVar[str] # "foreign_buy"
label: ClassVar[str] # "외국인 누적 순매수"
default_params: ClassVar[dict]
param_schema: ClassVar[dict] # 프론트 폼 자동 생성용 JSON Schema
@abstractmethod
def compute(self, ctx: "ScreenContext", params: dict) -> "pd.Series":
"""index=ticker, dtype=float, range 0..100."""
class GateNode(ABC):
name: ClassVar[str]
label: ClassVar[str]
default_params: ClassVar[dict]
param_schema: ClassVar[dict]
@abstractmethod
def filter(self, ctx: "ScreenContext", params: dict) -> "pd.Index":
"""returns surviving tickers."""
# engine.py
@dataclass(frozen=True)
class ScreenContext:
prices: pd.DataFrame # long form: date·ticker·open·high·low·close·volume·value
flow: pd.DataFrame # date·ticker·foreign_net·institution_net
master: pd.DataFrame # ticker·name·market·market_cap·is_managed·listed_date·is_preferred·is_spac
kospi: pd.Series # date → close (시장 비교용)
asof: datetime.date
@classmethod
def load(cls, asof: datetime.date) -> "ScreenContext": ...
def restrict(self, tickers) -> "ScreenContext": ...
class Screener:
def __init__(self, gate: GateNode, score_nodes: list[ScoreNode], weights: dict[str, float],
node_params: dict[str, dict], gate_params: dict, top_n: int,
sizer_params: dict):
...
def run(self, ctx: ScreenContext) -> "ScreenerResult":
survivors = self.gate.filter(ctx, self.gate_params)
scoped = ctx.restrict(survivors)
active = [n for n in self.score_nodes if self.weights.get(n.name, 0) > 0]
scores = {n.name: n.compute(scoped, self.node_params.get(n.name, {})) for n in active}
total = combine(scores, self.weights)
ranked = total.sort_values(ascending=False).head(self.top_n)
rows = position_sizer.expand(ranked, scoped, self.sizer_params)
return ScreenerResult(rows=rows, scores=scores, weights=self.weights,
survivors_count=len(survivors), warnings=[...])
```
### 3.3 registry
```python
# registry.py
from .nodes import (foreign_buy, volume_surge, momentum, high52w,
rs_rating, ma_alignment, vcp_lite, hygiene)
NODE_REGISTRY: dict[str, type[ScoreNode]] = {
"foreign_buy": foreign_buy.ForeignBuy,
"volume_surge": volume_surge.VolumeSurge,
"momentum": momentum.Momentum20,
"high52w": high52w.High52WProximity,
"rs_rating": rs_rating.RsRating,
"ma_alignment": ma_alignment.MaAlignment,
"vcp_lite": vcp_lite.VcpLite,
}
GATE_REGISTRY: dict[str, type[GateNode]] = {
"hygiene": hygiene.HygieneGate,
}
```
---
## 4. 데이터 모델 (stock.db 신규 7테이블)
### 4.1 KRX 캐시 (3테이블)
```sql
CREATE TABLE IF NOT EXISTS krx_master (
ticker TEXT PRIMARY KEY,
name TEXT NOT NULL,
market TEXT NOT NULL, -- 'KOSPI'|'KOSDAQ'
market_cap INTEGER, -- 원, nullable (pykrx 누락 케이스)
is_managed INTEGER NOT NULL DEFAULT 0,
is_preferred INTEGER NOT NULL DEFAULT 0,
is_spac INTEGER NOT NULL DEFAULT 0,
listed_date TEXT, -- 'YYYY-MM-DD'
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS krx_daily_prices (
ticker TEXT NOT NULL,
date TEXT NOT NULL,
open INTEGER, high INTEGER, low INTEGER, close INTEGER,
volume INTEGER,
value INTEGER, -- 거래대금(원)
PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_prices_date ON krx_daily_prices(date);
CREATE TABLE IF NOT EXISTS krx_flow (
ticker TEXT NOT NULL,
date TEXT NOT NULL,
foreign_net INTEGER, -- 원
institution_net INTEGER,
PRIMARY KEY (ticker, date)
);
CREATE INDEX IF NOT EXISTS idx_flow_date ON krx_flow(date);
```
**용량**: KRX 2,700종목 × 252거래일 × 5년 ≈ 340만 행. SQLite 충분 (수십 MB).
**갱신**: 마스터는 매일 전체 재기록, 일봉·수급은 당일 행 upsert.
**초기 백필 (최초 배포 시 1회)**: 백분위 정규화·52주 신고가·RS Rating(1년 수익률)·MA200 계산을 위해 **최소 1년(252거래일), 권장 2년**의 일봉·수급을 시드 데이터로 백필. `snapshot.py``backfill(start_date, end_date)` 함수를 두고 첫 배포·이전 캐시 손실 시 수동 호출. 자동 잡은 일일 증분만.
### 4.2 사용자 설정 (싱글톤 1테이블)
```sql
CREATE TABLE IF NOT EXISTS screener_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
weights_json TEXT NOT NULL, -- {"foreign_buy":1.0, ...}
node_params_json TEXT NOT NULL, -- {"foreign_buy":{"window_days":5}, ...}
gate_params_json TEXT NOT NULL, -- {"min_market_cap_won":50_000_000_000, ...}
top_n INTEGER NOT NULL DEFAULT 20,
rr_ratio REAL NOT NULL DEFAULT 2.0,
atr_window INTEGER NOT NULL DEFAULT 14,
atr_stop_mult REAL NOT NULL DEFAULT 2.0,
updated_at TEXT NOT NULL
);
```
`ensure_schema()` 시 초기 row 삽입 (디폴트 가중치 §6 참조).
### 4.3 실행 스냅샷 (2테이블)
```sql
CREATE TABLE IF NOT EXISTS screener_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asof TEXT NOT NULL,
mode TEXT NOT NULL, -- 'auto' | 'manual_save'
status TEXT NOT NULL, -- 'success' | 'failed' | 'skipped_holiday'
error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
weights_json TEXT NOT NULL,
node_params_json TEXT NOT NULL,
gate_params_json TEXT NOT NULL,
top_n INTEGER NOT NULL,
survivors_count INTEGER,
telegram_sent INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_runs_asof ON screener_runs(asof DESC);
CREATE TABLE IF NOT EXISTS screener_results (
run_id INTEGER NOT NULL,
rank INTEGER NOT NULL,
ticker TEXT NOT NULL,
name TEXT NOT NULL,
total_score REAL NOT NULL,
scores_json TEXT NOT NULL,
close INTEGER,
market_cap INTEGER,
entry_price INTEGER,
stop_price INTEGER,
target_price INTEGER,
atr14 REAL,
PRIMARY KEY (run_id, ticker),
FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_results_run_rank ON screener_results(run_id, rank);
```
**`mode='preview'`는 저장하지 않습니다.** `auto`·`manual_save`만 행을 만듭니다.
보관 기간 정책 없음 (디스크 부담 미미). 후속에서 cleanup 잡 필요시 추가.
### 4.4 마이그레이션 방식
stock-lab의 기존 `db.py` 패턴(`CREATE TABLE IF NOT EXISTS`)을 그대로 따릅니다. `screener/snapshot.py`·`screener/engine.py` import 시점에 1회 `ensure_screener_schema()` 호출. 별도 alembic 도입은 본 작업 스코프 밖.
---
## 5. 노드 8개 알고리즘
모든 점수 노드는 0~100 정수로 정규화. 표준 정규화는 **게이트 통과군 내 백분위(percentile)**, 룰 기반이 더 자연스러운 노드(이평선·52주 근접도)는 룰을 사용.
### 5.1 위생 게이트 — `HygieneGate` (점수 ❌)
```text
params:
min_market_cap_won = 50_000_000_000 # 500억 이상
min_avg_value_won = 500_000_000 # 20일 평균 거래대금 5억 이상
min_listed_days = 60 # 신규 상장 60일 미만 제외
skip_managed = true
skip_preferred = true
skip_spac = true
skip_halted_days = 3 # 최근 3일 거래정지(close 또는 volume=0)
통과 조건: 위 AND market_cap NOT NULL AND close NOT NULL
출력: 통과 종목 Index (보통 500~800종)
```
### 5.2 외국인 누적 순매수 — `ForeignBuy`
```text
params: window_days = 5
raw = sum(foreign_net[-5:]) / market_cap # 시총 대비 비율
score = percentile_rank(raw, 통과군) × 100
debug: foreign_net_sum, market_cap, raw_ratio_pct
```
### 5.3 거래량 급증 — `VolumeSurge`
```text
params: baseline_days = 20, eval_days = 3
baseline = mean(volume[-23:-3])
recent = mean(volume[-3:])
raw = log1p(recent / baseline) # 극값 평탄화
score = percentile_rank(raw, 통과군) × 100
debug: baseline, recent, ratio
```
### 5.4 20일 모멘텀 — `Momentum20`
```text
params: window_days = 20
raw = close[today] / close[today - 20] - 1
score = percentile_rank(raw, 통과군) × 100
debug: return_20d_pct
```
### 5.5 52주 신고가 근접도 — `High52WProximity` (룰 기반)
```text
params: window_days = 252
high_52w = max(high[-252:])
proximity = close / high_52w # 0..1
score = clip((proximity - 0.7) / 0.3, 0, 1) × 100
# 70% 미만 = 0, 100% 도달 = 100, 선형
debug: high_52w, proximity_pct
```
### 5.6 RS Rating — `RsRating`
```text
params: weights = {3m:2, 6m:1, 9m:1, 12m:1} # IBD 표준 가중
for k in [63, 126, 189, 252] 거래일:
r_stock = close[t]/close[t-k] - 1
r_kospi = kospi[t]/kospi[t-k] - 1
excess_k = r_stock - r_kospi
raw = Σ w_k × excess_k
score = percentile_rank(raw, 통과군) × 100 # IBD RS Rating 정의
debug: excess_1y, excess_3m, raw
```
### 5.7 이평선 정배열 — `MaAlignment` (룰 기반)
```text
params: ma_periods = [50, 150, 200]
5개 조건의 만족 개수 / 5 × 100:
① close > MA50
② MA50 > MA150
③ MA150 > MA200
④ close > MA200
⑤ close ≥ min(close[-252:]) × 1.25 # Stage 2 진입
debug: 각 조건 boolean
```
### 5.8 VCP-lite (변동성 수축률) — `VcpLite`
```text
params: short_window = 40, long_window = 252 # 8주 / 52주
daily_range_pct = (high - low) / close
short_vol = mean(daily_range_pct[-40:])
long_vol = mean(daily_range_pct[-252:])
raw = 1 - (short_vol / long_vol) # 양수면 수축
score = percentile_rank(raw, 통과군) × 100
debug: short_vol, long_vol, contraction_ratio
주: 진짜 미너비니 VCP(베이스 카운트·피벗 포인트)는 후속 슬라이스
```
### 5.9 결합 (`engine.combine`)
```python
total = Σ(w[n] * scores[n]) / Σ(w[n]) # active 노드만
# 가중치 0 → 노드 실행 스킵. 모든 가중치 0이면 422 에러.
```
### 5.10 디폴트 가중치
| 노드 | w | 근거 |
|------|----|------|
| foreign_buy | 1.0 | 한국 시장 강한 시그널 |
| volume_surge | 1.0 | 표준 |
| momentum | 1.0 | 표준 |
| high52w | **1.2** | 미너비니 SEPA 핵심 |
| rs_rating | **1.2** | 미너비니 + IBD 핵심 |
| ma_alignment | 1.0 | Stage 2 확인용 |
| vcp_lite | 0.8 | 단순 버전이라 보수적 가중 |
### 5.11 포지션 사이징 — `position_sizer.py`
```text
params (settings):
atr_window = 14
atr_stop_mult = 2.0 # 2 × ATR 손절
rr_ratio = 2.0 # 익절 = 진입가 + 2R
atr14 = ATR_Wilder(high, low, close, 14) # Wilder's smoothing (RMA), Pandas .ewm(alpha=1/14)
entry = round_won(close × 1.005) # 다음날 시초 0.5% 위
stop = round_won(close - 2.0 × atr14)
target = round_won(entry + 2.0 × (entry - stop))
r_pct = (entry - stop) / entry × 100 # 손실 위험 %
# round_won(x) = int(round(x)) — 1원 단위 반올림 (Python builtin)
```
ATR은 **Wilder's smoothing** (RMA). 일반 SMA보다 트레이딩 표준. MVP는 1원 단위 라운딩. KRX 호가단위(1·5·10·50·100·500·1000원)는 후속.
### 5.12 정규화 시 주의점
- 게이트 통과군이 100종목 미만이면 백분위 의미 ↓. 응답 `warnings`에 경고.
- 데이터 부족(상장 60일 미만 등)으로 NaN 발생 시 자동 0점 처리 (게이트가 이미 걸러줄 것).
---
## 6. API 명세 (prefix `/api/stock/screener/*`)
### 6.1 엔드포인트 표
| 메서드 | 경로 | 호출 주체 | 책임 |
|--------|------|----------|------|
| GET | `/nodes` | 프론트 | 노드 메타데이터 (label, default_params, param_schema) |
| GET | `/settings` | 프론트 | 현재 설정 조회 |
| PUT | `/settings` | 프론트 | 설정 업서트 (id=1 싱글톤) |
| POST | `/run` | 프론트 · agent-office | 분석 1회 실행. mode 매트릭스로 분기 |
| POST | `/snapshot/refresh` | agent-office | KRX 캐시 강제 갱신 |
| GET | `/runs?limit=30` | 프론트 | 최근 실행 메타 리스트 |
| GET | `/runs/{id}` | 프론트 | 특정 실행 결과 전체 |
### 6.2 `/run` 시맨틱
```jsonc
// REQUEST
POST /api/stock/screener/run
{
"mode": "preview" | "manual_save" | "auto",
"asof": "2026-05-12", // 생략 시 직전 거래일
"weights": { ... }, // optional override
"node_params": { ... }, // optional override
"gate_params": { ... }, // optional override
"top_n": 20 // optional override
}
// RESPONSE
{
"asof": "2026-05-12",
"mode": "preview",
"status": "success",
"run_id": null, // manual_save·auto만
"survivors_count": 612,
"weights": { ... }, // 실제 사용된 값
"top_n": 20,
"results": [
{
"rank": 1,
"ticker": "005930",
"name": "삼성전자",
"total_score": 84.3,
"scores": {
"foreign_buy": 92, "volume_surge": 78, "momentum": 73,
"high52w": 88, "rs_rating": 95, "ma_alignment": 80, "vcp_lite": 70
},
"close": 74500,
"market_cap": 444800000000000,
"entry_price": 74872,
"stop_price": 71200,
"target_price": 82216,
"atr14": 1835.5,
"r_pct": 4.9
}
],
"telegram_payload": null, // auto · manual_save만
"warnings": []
}
```
### 6.3 mode 매트릭스
| mode | settings_override | DB 저장 | telegram_payload 반환 | telegram 실전송 |
|------|------------------|---------|----------------------|----------------|
| `preview` | 허용 (DB 미반영) | ❌ | ✅ (미리보기 표시용) | ❌ |
| `manual_save` | 허용 (DB 미반영) | ✅ | ✅ | ❌ |
| `auto` | 무시 (DB settings만) | ✅ | ✅ | ✅ (호출자=agent-office) |
`telegram_payload``status='success'`일 때 항상 빌드해 반환 (페이로드 1회 생성 비용 매우 작음). **실전송은 mode='auto' 시 호출자(agent-office) 책임**. `status='failed'`·`'skipped_holiday'`이면 `null`.
### 6.4 `asof` 처리
- 요청에 `asof` 없으면: stock-lab이 `holidays.json` 참조해 **직전 거래일**로 자동 설정
- 요청한 `asof`가 공휴일·주말이거나 캐시에 없으면: 503 + message "no snapshot for {asof}"
- `agent-office` 자동 잡이 공휴일에 호출하는 경우 stock-lab은 status='skipped_holiday'로 success 응답 (텔레그램 전송 안 함)
### 6.5 에러 응답
응답 body의 `status` 필드와 HTTP status 코드의 매핑:
| HTTP | body.status | 발생 |
|------|-------------|------|
| 200 | `success` | 정상 분석 완료 |
| 200 | `skipped_holiday` | 공휴일·주말 asof로 자동 잡이 호출됨 |
| 422 | `failed` | 가중치 합 0, 게이트 통과 0, 잘못된 asof 형식 |
| 503 | `failed` | 캐시 미존재 (snapshot 미실행) |
| 500 | `failed` | 예기치 못한 예외 (응답 body는 일반 메시지) |
---
## 7. 프론트엔드 구조 (web-ui)
### 7.1 라우팅 & 내비게이션
- `src/routes.jsx`: `/stock/screener` 등록, 라벨 "스크리너"
- `src/Router.jsx`: 라우트 추가
- Stock·StockTrade 페이지 상단에 "스크리너" 링크
- 홈(`/`) 허브 카드에 항목 추가
### 7.2 디렉토리
```
src/pages/stock/screener/
├─ Screener.jsx # 페이지 루트
├─ Screener.css
├─ components/
│ ├─ NodePanel.jsx # 점수 노드 7개 카드
│ ├─ NodeCard.jsx # param_schema 기반 자동 폼
│ ├─ GatePanel.jsx # 위생 게이트 1개
│ ├─ GlobalControls.jsx # Top N, ATR, RR, "지금 실행", "스냅샷 저장"
│ ├─ ResultTable.jsx
│ ├─ ScoreChips.jsx # 각 노드 점수 칩
│ ├─ RunHistoryList.jsx
│ └─ TelegramPreview.jsx
└─ hooks/
├─ useScreenerMeta.js
├─ useScreenerSettings.js
├─ useScreenerRun.js
└─ useScreenerHistory.js
```
### 7.3 `src/api.js` 신규 헬퍼
```js
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
```
### 7.4 레이아웃
```
PC (≥1024px)
┌─────────────────────────────────────────────────────────────┐
│ 헤더 — 분석 기준일 · 직전 자동 잡 시각 · "스냅샷 저장" │
├──────────────┬──────────────────────────────┬───────────────┤
│ NodePanel │ ResultTable │ RunHistoryList │
│ + GlobalControls │ TelegramPreview │ │
│ [지금 실행] │ │ │
└──────────────┴──────────────────────────────┴───────────────┘
모바일 (<768px) — 세로 적층
[헤더] → [NodePanel 접기] → [GlobalControls+실행] → [ResultTable 가로 스크롤]
→ [TelegramPreview 접기] → [RunHistoryList]
```
### 7.5 상태 관리 패턴
- `useScreenerMeta`: 마운트 시 1회, 정적
- `useScreenerSettings`: GET → 사용자 슬라이더 조작 시 로컬 dirty state. **명시적 "설정 저장" 버튼**에서만 PUT
- "지금 실행" → `runScreener({mode:'preview', ...override})`. **DB는 건드리지 않음**
- "스냅샷 저장" → 같은 override를 `mode:'manual_save'`로 재호출
- 히스토리 클릭 → `getScreenerRun(id)`로 결과 표 교체
---
## 8. 텔레그램 메시지 포맷
자동 잡과 manual_save 모두 동일. **Top 20 중 본문 1-10**까지 표시, 11-20은 페이지 링크. MarkdownV2.
```
🎯 *KRX 강세주 스크리너* — 2026-05-12 (자동)
통과 612종 / Top 20 / 본문 1-10
1. *삼성전자* `005930` ⭐ 84.3
👤외 ⚡거 🚀모 🆙고 💪RS 📈MA
진입 74,872 손절 71,200 익절 82,216 (R 4.9%)
2. *NAVER* `035420` ⭐ 81.7
👤외 ⚡거 🆙고 💪RS 📈MA
진입 215,400 손절 207,800 익절 230,600 (R 3.5%)
⋯ (3-10)
🔗 전체 결과·11~20위:
https://gahusb.synology.me/stock/screener?run_id=42
```
### 노드 아이콘 (점수 ≥70인 노드만 표시)
| 노드 | 아이콘 |
|------|--------|
| foreign_buy | 👤외 |
| volume_surge | ⚡거 |
| momentum | 🚀모 |
| high52w | 🆙고 |
| rs_rating | 💪RS |
| ma_alignment | 📈MA |
| vcp_lite | 🌀VCP |
빌더(`screener/telegram.py`)는 payload만 반환:
```jsonc
{
"chat_target": "default",
"parse_mode": "MarkdownV2",
"text": "..." // 위 메시지
}
```
agent-office가 받아서 자체 텔레그램 채널로 발신. stock-lab은 텔레그램 SDK 의존성 없음.
---
## 9. agent-office 통합
agent-office 측에 새 잡(또는 stock_agent 액션) 추가:
```text
Trigger: 평일 16:30 KST (Asia/Seoul)
Steps:
1. POST /api/stock/screener/snapshot/refresh
실패해도 다음 단계 진행 (이전 캐시로 분석)
2. POST /api/stock/screener/run { "mode": "auto" }
3. 응답에서 status 확인:
- status == 'skipped_holiday': 종료, 텔레그램 미발신
- status == 'success': telegram_payload 추출 → 발신
- status == 'failed': agent-office 자체 알림(기존 패턴)으로 운영자에게
4. 텔레그램 발신은 agent-office의 기존 채널 사용
```
**공휴일 판정은 stock-lab 책임** (`holidays.json`이 stock-lab에 있으므로). agent-office는 매 평일 16:30에 호출하고 응답 status로 분기. agent-office에 공휴일 데이터를 복제할 필요 없음.
stock-lab은 agent-office의 인증을 신뢰 (내부 Docker 네트워크). MVP에서 헤더 토큰 검증 없음. 후속에서 필요해지면 시크릿 헤더 추가.
---
## 10. 에러 처리
| 발생 지점 | 정책 |
|----------|------|
| pykrx 종목 단위 실패 | retry ×3 → 실패해도 다음 종목 계속. 전체 실패율 >20%면 snapshot 작업 자체 실패 |
| 캐시 미존재 (`asof` 데이터 없음) | 503 + message "snapshot not available for {asof}" |
| 노드 1개 compute 실패 | 해당 노드 점수 0 처리, 다른 노드 정상. 응답 `warnings`에 사유 |
| 게이트 통과 종목 0 | 422 + message "no survivors after hygiene gate" |
| 모든 가중치 0 | 422 + message "no active score nodes" |
| 텔레그램 전송 실패 | `/run` 응답 status는 success. agent-office 측 로그·재시도 |
| 예기치 못한 예외 | 500. 스택트레이스는 stock-lab stdout 로그에만. 응답은 일반 메시지 |
`/run``warnings` 필드는 치명적이지 않은 이상을 모음. 프론트는 결과 표 위에 노란 배너로 노출.
---
## 11. 테스트 전략
stock-lab의 평탄 pytest 컨벤션을 따름. `app/test_screener_*.py`로 통합.
### 11.1 단위 테스트 (노드별)
```
app/test_screener_nodes_foreign_buy.py
app/test_screener_nodes_volume_surge.py
app/test_screener_nodes_momentum.py
app/test_screener_nodes_high52w.py
app/test_screener_nodes_rs_rating.py
app/test_screener_nodes_ma_alignment.py
app/test_screener_nodes_vcp_lite.py
app/test_screener_nodes_hygiene.py
app/test_screener_position_sizer.py
```
**공통 케이스**:
1. 알려진 입력 → 알려진 출력 (회귀 방지)
2. 데이터 부족(상장 30일짜리) → 게이트 탈락 또는 NaN 안전
3. 모든 종목 동일 값 → 백분위 정규화가 50점으로 평탄화
4. 극값 1개 → 다른 종목 점수가 무너지지 않음 (특히 volume_surge의 log1p)
### 11.2 통합 테스트
```
app/test_screener_engine.py # combine, Screener.run, ScreenContext.restrict
app/test_screener_router.py # /run mode 매트릭스, /settings round-trip, /nodes, /runs
app/test_screener_telegram.py # 메시지 텍스트 생성
```
### 11.3 픽스쳐
`app/screener/_test_fixtures.py`:
- 5종목 × 60거래일 합성 DataFrame 빌더
- 시나리오: "강세주 1종", "위생 게이트 탈락 1종(시총 부족)", "데이터 부족 1종", "약세주 2종"
- `StubScreenContext`: DB 거치지 않고 메모리 DataFrame 주입
### 11.4 수동 검증 (verification-before-completion)
- 실 KRX 데이터로 1회 돌려 Top 20이 합리적인 강세주 후보인지 사용자가 눈으로 확인
- 자동 잡 1회 실행 후 텔레그램에 메시지 도착 확인
- 모바일 화면에서 결과 표 가로 스크롤 OK 확인
---
## 12. 운영
- 로그: stock-lab stdout (Docker logs)
- 알림: agent-office가 `/run` failed 응답을 받으면 텔레그램 자체 알림
- 백업: stock.db는 NAS Synology 자체 백업 정책에 의존
- 메트릭 대시보드: MVP 범위 밖 (후속 슬라이스)
---
## 13. 양쪽 동시 수정 체크리스트 (workspace CLAUDE.md 규약)
- [ ] 백엔드: `web-backend/stock-lab/app/screener/` 패키지 신규
- [ ] 백엔드: `app/main.py`에 router include
- [ ] 백엔드: stock.db에 신규 테이블 7개 `ensure_*_schema()` 함수
- [ ] 백엔드: `requirements.txt``pykrx` 추가
- [ ] 프론트: `src/api.js`에 7개 헬퍼 추가
- [ ] 프론트: `src/routes.jsx` + `src/Router.jsx``/stock/screener` 등록
- [ ] 프론트: `src/pages/stock/screener/` 디렉토리 신규
- [ ] 프론트: `web-ui/CLAUDE.md` API 테이블에 7개 엔드포인트 추가
- [ ] agent-office: 평일 16:30 KST `stock_agent screener` 잡 추가
- [ ] 배포: `scripts/deploy.bat` 또는 개별
---
## 14. 후속 슬라이스 예약
| # | 슬라이스 | 의존 |
|---|---------|------|
| 2 | AI 뉴스 호재/악재 노드 | agent-office LLM 사용량 설계 |
| 3 | 노드 캔버스 UI (react-flow) | MVP 노드 인터페이스 안정화 후 |
| 4 | 주간 자가학습 (가중치 자동 조정 제안) | screener_runs 누적 4주 이상 |
| 5 | DART 공시·재무제표 노드 | DART 수집 파이프라인 별도 spec |
| 6 | 분봉 기반 노드 | 한투 API 분봉 캐싱 |
| 7 | 진짜 미너비니 VCP | 베이스 카운트·피벗 포인트 정의 |
| 8 | 멀티 프리셋 | settings 테이블 확장 |
| 9 | 백테스트 화면 | screener_runs + krx_daily_prices join |
| 10 | KRX 호가단위 적용 | 포지션 사이저 후처리 |
---
## 부록 A — 노드 메타데이터 응답 예시 (`GET /nodes`)
```jsonc
{
"score_nodes": [
{
"name": "foreign_buy",
"label": "외국인 누적 순매수",
"default_params": { "window_days": 5 },
"param_schema": {
"type": "object",
"properties": {
"window_days": { "type": "integer", "minimum": 1, "maximum": 60, "default": 5 }
}
}
}
// … 7개
],
"gate_nodes": [
{
"name": "hygiene",
"label": "위생 게이트",
"default_params": {
"min_market_cap_won": 50000000000,
"min_avg_value_won": 500000000,
"min_listed_days": 60,
"skip_managed": true,
"skip_preferred": true,
"skip_spac": true,
"skip_halted_days": 3
},
"param_schema": { ... }
}
]
}
```
이 응답으로 프론트는 `NodeCard`를 자동 생성합니다. 새 노드 추가 시 백엔드 클래스 1개 + registry 등록 1줄만으로 UI에 자동 노출.

View File

@@ -1,58 +1,122 @@
const { execSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
// Load .env.local from project root if present (persists NAS_SSH_TARGET etc.)
const envLocalPath = path.join(__dirname, "..", ".env.local");
if (fs.existsSync(envLocalPath)) {
for (const line of fs.readFileSync(envLocalPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx < 0) continue;
const k = trimmed.slice(0, idx).trim();
const v = trimmed.slice(idx + 1).trim();
if (!(k in process.env)) process.env[k] = v;
}
}
const isWin = process.platform === "win32";
const isMac = process.platform === "darwin";
const src = "dist";
const dstWin = "Z:\\docker\\webpage\\frontend\\";
const dstMac = "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
// Windows 배포 경로 — Z: 매핑이 NAS 루트(/volume1/)인 경우 docker\webpage\frontend,
// /volume1/docker/만 매핑된 경우 webpage\frontend, /volume1/docker/webpage 매핑이면 frontend.
// NAS_FRONTEND_DEST_WIN env로 override (예: "Z:\\webpage\\frontend\\")
const dstWin = process.env.NAS_FRONTEND_DEST_WIN || "Z:\\docker\\webpage\\frontend\\";
const dstMac = process.env.NAS_FRONTEND_DEST_MAC || "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
const dst = isWin ? dstWin : dstMac;
if (!fs.existsSync(src)) {
console.error("dist not found. Run build first.");
process.exit(1);
}
if (!fs.existsSync(dst)) {
console.error("NAS path not found. Check mount: " + dst);
process.exit(1);
}
if (isWin) {
// dstWin을 PowerShell 문자열로 안전하게 escape
const dstPs = dstWin.replace(/\\/g, "\\\\");
const cmd =
'powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"Z:\\\\docker\\\\webpage\\\\frontend\\\\\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS drive not found. Check Z: mapping.\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"';
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"${dstPs}\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS 경로를 찾을 수 없음: $dst — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"`;
execSync(cmd, { stdio: "inherit" });
} else if (isMac) {
const sshTarget = process.env.NAS_SSH_TARGET;
const sshPath =
process.env.NAS_SSH_PATH || "/volume1/docker/webpage/frontend/";
const sshPort = process.env.NAS_SSH_PORT;
// SSH 경로: NAS_SSH_TARGET이 설정된 경우 항상 우선
if (sshTarget) {
const sshCmd = sshPort ? `ssh -p ${sshPort}` : "ssh";
// 제어문자·줄바꿈 제거 (잘못된 export/copy-paste 대비)
const cleanTarget = sshTarget.replace(/[\r\n\t]/g, "").trim();
const cleanPath = sshPath.replace(/[\r\n\t]/g, "").trim();
const cleanPort = sshPort ? sshPort.replace(/\D/g, "").trim() : "";
if (!cleanTarget) {
console.error("NAS_SSH_TARGET 값이 비어있습니다. .env.local 또는 환경변수를 확인하세요.");
printSshHint();
process.exit(1);
}
if (cleanPort && !/^\d{1,5}$/.test(cleanPort)) {
console.error(`NAS_SSH_PORT 값이 잘못됐습니다: "${sshPort}" → 숫자만 입력하세요.`);
process.exit(1);
}
// macOS Keychain은 서브프로세스(rsync)에서 SSH 키를 자동 로드하지 못함 → -i 명시
const keyFile = (process.env.NAS_SSH_KEY || path.join(os.homedir(), ".ssh", "id_rsa"))
.replace(/[\r\n]/g, "").trim();
if (!fs.existsSync(keyFile)) {
console.error(`SSH 키 파일을 찾을 수 없습니다: ${keyFile}`);
console.error("NAS_SSH_KEY 환경변수를 올바른 키 경로로 설정하거나, ~/.ssh/id_rsa 가 있는지 확인하세요.");
process.exit(1);
}
const portOpt = cleanPort ? `-p ${cleanPort}` : "";
// Synology는 rsync --server 모드를 별도 인증으로 막음 → tar | ssh 방식 사용
const sshBase = `ssh ${portOpt} -i ${keyFile} -o StrictHostKeyChecking=accept-new -o PreferredAuthentications=publickey`
.replace(/\s+/g, " ").trim();
console.log(`Deploying via tar|ssh → ${cleanTarget}:${cleanPath}`);
// 1단계: 원격 디렉토리 초기화
execSync(
`rsync -r --delete --delete-delay -e \"${sshCmd}\" ${src}/ ${sshTarget}:${sshPath}`,
`${sshBase} ${cleanTarget} "rm -rf '${cleanPath}'/* 2>/dev/null; mkdir -p '${cleanPath}'"`,
{ stdio: "inherit" }
);
// 2단계: 빌드 산출물 tar로 전송 → 원격에서 압축 해제
execSync(
`cd ${src} && tar czf - . | ${sshBase} ${cleanTarget} "cd '${cleanPath}' && tar xzf -"`,
{ stdio: "inherit" }
);
console.log("Deploy complete.");
process.exit(0);
}
// rsync on macOS + SMB/NAS can be flaky; use ditto after a safe clean.
// SMB 마운트 경로 fallback
if (!fs.existsSync(dst)) {
console.error("NAS path not found: " + dst);
printSshHint();
process.exit(1);
}
if (!dst.includes("docker/webpage/frontend")) {
console.error("Safety check failed: unexpected dst path: " + dst);
process.exit(1);
}
try {
const testPath = `${dst}.deploy-write-test`;
fs.writeFileSync(testPath, "ok");
fs.unlinkSync(testPath);
} catch (err) {
console.error("NAS write test failed (EIO / permission error).");
console.error(
"NAS write test failed. Files may be locked or permissions are read-only."
"macOS SMB → Synology 쓰기 실패는 흔한 이슈입니다. SSH 배포를 사용하세요.\n"
);
console.error(
"Try stopping services using the folder, remounting the share with write access,",
"or set NAS_SSH_TARGET to deploy over SSH instead."
);
throw err;
printSshHint();
process.exit(1);
}
const sleep = (ms) =>
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
const retry = (fn, attempts = 6) => {
@@ -96,3 +160,15 @@ if (isWin) {
const cmd = `${baseArgs.join(" ")} ${src}/ ${dst}`;
execSync(cmd, { stdio: "inherit" });
}
function printSshHint() {
console.error("──────────────────────────────────────────────────");
console.error("SSH 배포 설정 방법:");
console.error(" 프로젝트 루트에 .env.local 파일을 만들고 아래 내용을 입력하세요:");
console.error("");
console.error(" NAS_SSH_TARGET=<NAS_유저명>@gahusb.synology.me");
console.error(" NAS_SSH_PORT=<SSH_포트> # 기본 22, DSM에서 확인");
console.error("");
console.error(" 이후 npm run release:nas 를 다시 실행하면 rsync over SSH로 배포됩니다.");
console.error("──────────────────────────────────────────────────");
}

View File

@@ -626,3 +626,81 @@ export async function triggerLottoCurate() {
return r.json();
}
// ── Music Lab — Video Projects ────────────────────
export const createVideoProject = (data) => apiPost('/api/music/video-project', data);
export const getVideoProjects = () => apiGet('/api/music/video-projects');
export const renderVideoProject = (id) => apiPost(`/api/music/video-project/${id}/render`);
export const exportVideoProject = (id) => apiGet(`/api/music/video-project/${id}/export`);
export const deleteVideoProject = (id) => apiDelete(`/api/music/video-project/${id}`);
// ── Music Lab — Revenue ───────────────────────────
export const getRevenueDashboard = () => apiGet('/api/music/revenue/dashboard');
export const getRevenueRecords = () => apiGet('/api/music/revenue');
export const addRevenueRecord = (data) => apiPost('/api/music/revenue', data);
export const updateRevenueRecord = (id, data) => apiPut(`/api/music/revenue/${id}`, data);
export const deleteRevenueRecord = (id) => apiDelete(`/api/music/revenue/${id}`);
// ── Music Lab — Market Trends ─────────────────────
export const getLatestTrendReport = () => apiGet('/api/music/market/report/latest');
export const getTrendReports = () => apiGet('/api/music/market/report');
export const getMarketSuggestions = () => apiGet('/api/music/market/suggest');
export const triggerYoutubeResearch = () => apiPost('/api/agent-office/youtube/research', {});
// ── Music Lab — Compile ──────────────────────────────────
export const createCompileJob = (data) => apiPost('/api/music/compile', data);
export const getCompileJobs = () => apiGet('/api/music/compiles');
export const getCompileJob = (id) => apiGet(`/api/music/compile/${id}`);
export const deleteCompileJob = (id) => apiDelete(`/api/music/compile/${id}`);
export const exportCompileJob = (id) => apiGet(`/api/music/compile/${id}/export`);
// --- Music Pipeline ---
export const listPipelines = (status='all') => apiGet(`/api/music/pipeline?status=${status}`);
export const getPipeline = (id) => apiGet(`/api/music/pipeline/${id}`);
export const createPipeline = (payload) => {
// 옛 호출 호환: createPipeline(13) → { track_id: 13 }
if (typeof payload === 'number') payload = { track_id: payload };
return apiPost('/api/music/pipeline', payload);
};
export const startPipeline = (id) => apiPost(`/api/music/pipeline/${id}/start`);
export const cancelPipeline = (id) => apiPost(`/api/music/pipeline/${id}/cancel`);
export const publishPipeline = (id) => apiPost(`/api/music/pipeline/${id}/publish`);
// --- Music Setup ---
export const getMusicSetup = () => apiGet('/api/music/setup');
export const updateMusicSetup = (payload) => apiPut('/api/music/setup', payload);
// --- YouTube OAuth ---
export const getYoutubeAuthUrl = () => apiGet('/api/music/youtube/auth-url');
export const getYoutubeStatus = () => apiGet('/api/music/youtube/status');
export const disconnectYoutube = () => apiPost('/api/music/youtube/disconnect');
// === Batch generation ===
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
export const listGenres = () => apiGet('/api/music/genres');
// === 주간 회고 (weekly_review) ===
// apiGet은 비-2xx 응답에서 `HTTP <status> ...` 메시지로 Error를 throw 하므로
// 404 케이스는 메시지를 파싱하여 null로 변환한다.
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
if (e?.status === 404 || /^HTTP 404\b/.test(e?.message || '')) return null;
throw e;
});
export const getReviewHistory = (limit = 4) =>
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
// === 큐레이터 4계층 원클릭 구매 ===
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
// ---- Stock Screener ----
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);

View File

@@ -7,7 +7,7 @@ import SwipeableView from '../../components/SwipeableView';
const TABS = [
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
{ id: 'analysis', label: '📊 분석·통계' },
{ id: 'analysis', label: '📚 자료실 / Deep Dive' },
{ id: 'purchase', label: '💰 구매·성과' },
];

View File

@@ -1020,7 +1020,7 @@
.lotto-purchase-list__head {
display: grid;
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
gap: 8px;
padding: 10px 14px;
font-size: 11px;
@@ -1033,7 +1033,7 @@
.lotto-purchase-row {
display: grid;
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
gap: 8px;
align-items: center;
padding: 12px 14px;
@@ -1068,6 +1068,21 @@
justify-content: flex-end;
}
.lotto-purchase-row__hits {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
overflow: hidden;
}
.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
.is-pos { color: #97c9aa; }
.is-neg { color: #f7a8a5; }
.is-prize { color: #fdd4b1; }
@@ -1098,8 +1113,8 @@
gap: 8px;
}
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+5),
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+5) {
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+6),
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+6) {
display: none;
}
@@ -1143,7 +1158,7 @@
.lotto-purchase-list__head,
.lotto-purchase-row {
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
grid-template-columns: 56px 90px 90px minmax(0, 120px) minmax(0, 1fr) 100px;
}
.lotto-purchase-list__head span:nth-child(4),
@@ -1526,3 +1541,14 @@
font-size: 13px;
}
}
.lotto-section-fold { margin-bottom: 14px; }
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
.trend-chart { display: block; margin: 0 auto; }
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
.dot--curator { background: #b8a8ff; }
.dot--user { background: #76e09a; }

View File

@@ -137,6 +137,7 @@ const PurchasePanel = ({
<span>투자금</span>
<span>당첨금</span>
<span>손익</span>
<span>채점</span>
<span>메모</span>
<span />
</div>
@@ -152,6 +153,14 @@ const PurchasePanel = ({
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
{net >= 0 ? '+' : ''}{fmtWon(net)}
</span>
<span className="lotto-purchase-row__hits">
{(rec.results || []).map((r, i) => (
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
))}
{(rec.results || []).some((r) => r.correct >= 4) && (
<span className="prize-flag">🚨 4 확인 필요</span>
)}
</span>
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
<div className="lotto-purchase-row__actions">
<button className="button ghost small" onClick={() => onEditStart(rec)}>

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { getReviewHistory } from '../../../api';
export default function PurchaseTrendChart() {
const [reviews, setReviews] = useState([]);
useEffect(() => {
getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
}, []);
if (reviews.length === 0) return null;
const maxAvg = Math.max(
...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
2.5
);
const w = 320, h = 80, pad = 16;
const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);
const line = (key) => reviews
.map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
.filter(p => p.y != null)
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
.join(' ');
return (
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
<h3> vs 큐레이터 평균 일치 </h3>
</div>
</div>
<svg width={w} height={h} className="trend-chart">
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
</svg>
<div className="trend-legend">
<span><span className="dot dot--curator" /> 큐레이터</span>
<span><span className="dot dot--user" /> </span>
</div>
</section>
);
}

View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import { bulkPurchase } from '../../../../api';
import { MODES } from './TierModeToggle';
export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
const [busy, setBusy] = useState(false);
const mode = MODES.find(m => m.key === tierMode) || MODES[0];
const onClick = async () => {
if (busy) return;
setBusy(true);
try {
await bulkPurchase({
draw_no: drawNo,
tier_mode: tierMode,
sets: mode.sets,
amount: mode.amount,
});
onSuccess?.();
alert(`${mode.sets}세트 구매 기록 완료!`);
} catch (e) {
alert(`구매 기록 실패: ${e?.message || e}`);
} finally {
setBusy(false);
}
};
return (
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
</button>
);
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useMemo, useState } from 'react';
import RetrospectiveBox from './RetrospectiveBox';
import TierModeToggle, { MODES } from './TierModeToggle';
import TierSection from './TierSection';
import BulkPurchaseButton from './BulkPurchaseButton';
import './decision.css';
const TIER_CHAIN = {
core: ['core'],
core_bonus: ['core', 'bonus'],
core_bonus_extended: ['core', 'bonus', 'extended'],
full: ['core', 'bonus', 'extended', 'pool'],
};
const STORAGE_KEY = 'lotto.tier_mode';
export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
const [tierMode, setTierMode] = useState(() =>
localStorage.getItem(STORAGE_KEY) || 'core'
);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, tierMode);
}, [tierMode]);
const visibleTiers = TIER_CHAIN[tierMode];
const totalSets = useMemo(
() => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
[briefing, visibleTiers]
);
// 분배 칩 — 보이는 계층의 risk_tag 합산
const balance = useMemo(() => {
const acc = { '안정': 0, '균형': 0, '공격': 0 };
for (const t of visibleTiers) {
for (const p of (briefing?.picks?.[t] || [])) {
if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
}
}
return acc;
}, [briefing, visibleTiers]);
if (!briefing) return null;
let cursor = 0;
return (
<div className="lc-card">
<header className="lc-head">
<div>
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}</p>
<h3 className="lc-title">{briefing.narrative.headline}</h3>
</div>
<div className="lc-conf">
<div className="lc-conf__num">{briefing.confidence}</div>
<div className="lc-conf__lbl">CONFIDENCE</div>
</div>
</header>
<RetrospectiveBox briefing={briefing} review={review} />
<p className="lc-headline-3">
{(briefing.narrative.summary_3lines || []).join(' · ')}
</p>
<div className="lc-balance">
<div className="lc-balance__chips">
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
</div>
</div>
<TierModeToggle value={tierMode} onChange={setTierMode} />
{visibleTiers.map(tier => {
const picks = briefing.picks?.[tier] || [];
const idxBase = cursor;
cursor += picks.length;
return (
<TierSection
key={tier}
tier={tier}
picks={picks}
rationale={briefing.tier_rationale?.[tier]}
indexBase={idxBase}
totalSets={totalSets}
/>
);
})}
<div className="lc-actions">
<BulkPurchaseButton
drawNo={briefing.draw_no}
tierMode={tierMode}
onSuccess={onPurchaseSuccess}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };
export default function PickCard({ pick, index, total }) {
const role = pick.risk_tag;
return (
<div className="lc-set">
<div className="lc-set__head">
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}> {role}</span>
<span className="lc-set__idx">Set {index + 1} / {total}</span>
</div>
<div className="lc-balls">
{pick.numbers.map(n => (
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
))}
</div>
<p className="lc-set__reason">{pick.reason}</p>
</div>
);
}

View File

@@ -0,0 +1,11 @@
export default function RetrospectiveBox({ briefing, review }) {
const retro = briefing?.narrative?.retrospective;
if (!retro) return null;
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
return (
<aside className="lc-retro">
<p className="lc-retro__time"> 지난 {drawNo ? `${drawNo}` : ''} 회고</p>
<p className="lc-retro__body">{retro}</p>
</aside>
);
}

View File

@@ -0,0 +1,28 @@
const MODES = [
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
];
export default function TierModeToggle({ value, onChange }) {
return (
<div className="lc-toggle" role="tablist">
{MODES.map((m, i) => (
<button
key={m.key}
role="tab"
aria-selected={value === m.key}
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
onClick={() => onChange(m.key)}
>
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
<span className="lc-toggle__lbl">{m.label}</span>
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}</span>
</button>
))}
</div>
);
}
export { MODES };

View File

@@ -0,0 +1,25 @@
import PickCard from './PickCard';
const TIER_TITLE = {
core: '코어 (필수, 5세트)',
bonus: '보너스 (+5)',
extended: '확장 (+5)',
pool: '풀 (+5)',
};
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
if (!picks?.length) return null;
return (
<section className={`lc-tier lc-tier--${tier}`}>
<header className="lc-tier__head">
<h4>{TIER_TITLE[tier]}</h4>
{rationale && tier !== 'core' && (
<p className="lc-tier__rationale">{rationale}</p>
)}
</header>
{picks.map((p, i) => (
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
))}
</section>
);
}

View File

@@ -0,0 +1,52 @@
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
.lc-balance__chips { display: flex; gap: 8px; }
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
.lc-tier { margin-bottom: 14px; }
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
padding: 14px; margin-bottom: 10px; }
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
.lc-set__role--stable { color: #76e09a; }
.lc-set__role--balance { color: #ffce6e; }
.lc-set__role--aggro { color: #ff8aa0; }
.lc-set__idx { font-size: 10px; opacity: 0.4; }
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
.lc-btn { padding: 12px 16px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer;
font-size: 14px; min-width: 160px; }
.lc-btn--prim { background: linear-gradient(135deg, #b8a8ff, #8a78db); color: #14101e; }
.lc-btn--prim:disabled { opacity: 0.5; cursor: not-allowed; }
.lc-btn--ghost { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #ece6f7; }
@media (max-width: 480px) {
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
}

View File

@@ -1,6 +1,18 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
const normalizePicks = (picks) => {
if (Array.isArray(picks)) {
return { core: picks, bonus: [], extended: [], pool: [] };
}
return {
core: picks?.core || [],
bonus: picks?.bonus || [],
extended: picks?.extended || [],
pool: picks?.pool || [],
};
};
export default function useBriefing() {
const [briefing, setBriefing] = useState(null);
const [loading, setLoading] = useState(true);
@@ -12,7 +24,7 @@ export default function useBriefing() {
setLoading(true); setError('');
try {
const data = await getLatestBriefing();
setBriefing(data);
setBriefing(data ? { ...data, picks: normalizePicks(data.picks) } : data);
} catch (e) {
setError(e.message);
} finally {
@@ -33,7 +45,7 @@ export default function useBriefing() {
try {
const data = await getLatestBriefing();
if (data && data.generated_at !== prevGen) {
setBriefing(data);
setBriefing({ ...data, picks: normalizePicks(data.picks) });
setRegenerating(false);
clearInterval(pollingRef.current);
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import {
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
bulkPurchase as apiBulkPurchase,
} from '../../../api';
import { emptyPurchaseForm } from '../lottoUtils';
@@ -94,6 +95,12 @@ export default function usePurchases() {
} catch { refreshPurchases(); }
}, [refreshPurchases]);
const handleBulkPurchase = useCallback(async (params) => {
const result = await apiBulkPurchase(params);
await refreshPurchases();
return result;
}, [refreshPurchases]);
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
return {
@@ -101,5 +108,6 @@ export default function usePurchases() {
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
handleBulkPurchase,
};
}

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
import { getLatestReview, getReviewHistory } from '../../../api';
export default function useReview() {
const [latest, setLatest] = useState(null);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancel = false;
Promise.all([getLatestReview(), getReviewHistory(4)])
.then(([l, h]) => {
if (cancel) return;
setLatest(l);
setHistory(h);
})
.catch(() => {})
.finally(() => !cancel && setLoading(false));
return () => { cancel = true; };
}, []);
return { latest, history, loading };
}

View File

@@ -40,18 +40,21 @@ export default function AnalysisTab() {
<PerformanceBanner perf={ld.perfStats} />
{/* 종합 추론 번호 추천 */}
<CombinedRecommendPanel
combined={ld.combined}
history={ld.combinedHistory}
loading={ld.combinedLoading}
histLoading={ld.combinedHistLoading}
onRun={ld.runCombinedRecommend}
onCopy={copyNumbers}
/>
<details className="lotto-section-fold">
<summary>종합 추론 추천</summary>
<CombinedRecommendPanel
combined={ld.combined}
history={ld.combinedHistory}
loading={ld.combinedLoading}
histLoading={ld.combinedHistLoading}
onRun={ld.runCombinedRecommend}
onCopy={copyNumbers}
/>
</details>
{/* 최신 회차 + 시뮬레이션 추천 */}
<div className="lotto-grid">
{/* Latest Draw */}
{/* 최신 회차 */}
<details className="lotto-section-fold">
<summary>최신 회차</summary>
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
@@ -87,8 +90,11 @@ export default function AnalysisTab() {
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
)}
</section>
</details>
{/* Simulation Picks */}
{/* Simulation Picks */}
<details className="lotto-section-fold">
<summary>시뮬레이션 추천</summary>
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
@@ -163,19 +169,24 @@ export default function AnalysisTab() {
</>
)}
</section>
</div>
</details>
{/* 이번 주 공략 리포트 */}
<ReportPanel
report={ld.report}
history={ld.reportHistory}
loading={ld.reportLoading}
onRefresh={ld.refreshReport}
onSelectDrw={ld.loadSpecificReport}
/>
<details className="lotto-section-fold">
<summary>이번 공략 리포트</summary>
<ReportPanel
report={ld.report}
history={ld.reportHistory}
loading={ld.reportLoading}
onRefresh={ld.refreshReport}
onSelectDrw={ld.loadSpecificReport}
/>
</details>
{/* 통계 분석 */}
<section className="lotto-panel lotto-panel--wide">
<details className="lotto-section-fold">
<summary>통계 분석</summary>
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Analysis</p>
@@ -237,9 +248,12 @@ export default function AnalysisTab() {
</p>
)}
</section>
</details>
{/* 전체 번호 분포 */}
<section className="lotto-panel lotto-panel--wide">
<details className="lotto-section-fold">
<summary>전체 회차 번호 분포</summary>
<section className="lotto-panel lotto-panel--wide">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Distribution</p>
@@ -263,12 +277,18 @@ export default function AnalysisTab() {
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
)}
</section>
</details>
{/* 내 번호 패턴 */}
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
<details className="lotto-section-fold">
<summary> 번호 패턴</summary>
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
</details>
{/* 수동 추천 */}
<section className="lotto-panel">
<details className="lotto-section-fold">
<summary>수동 추천</summary>
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
@@ -365,9 +385,12 @@ export default function AnalysisTab() {
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
)}
</section>
</details>
{/* 추천 히스토리 */}
<section className="lotto-panel">
<details className="lotto-section-fold">
<summary>추천 히스토리</summary>
<section className="lotto-panel">
<div className="lotto-panel__head">
<div>
<p className="lotto-panel__eyebrow">History</p>
@@ -423,6 +446,7 @@ export default function AnalysisTab() {
</div>
)}
</section>
</details>
</>
);
}

View File

@@ -1,25 +1,18 @@
import useBriefing from '../hooks/useBriefing';
import BriefingHeader from '../components/briefing/BriefingHeader';
import BriefingSummary from '../components/briefing/BriefingSummary';
import PickSetCard from '../components/briefing/PickSetCard';
import useReview from '../hooks/useReview';
import DecisionCard from '../components/decision/DecisionCard';
import BriefingEmpty from '../components/briefing/BriefingEmpty';
import CuratorUsageFooter from '../components/briefing/CuratorUsageFooter';
export default function BriefingTab() {
const { briefing, loading, error, regenerating, regenerate } = useBriefing();
const { latest: review } = useReview();
if (loading) return <div className="briefing-empty"><p>로딩 ...</p></div>;
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
return (
<div className="briefing-tab">
<BriefingHeader briefing={briefing} regenerating={regenerating} onRegenerate={regenerate} />
<BriefingSummary narrative={briefing.narrative} />
<div className="briefing-picks">
<h3>이번 5세트</h3>
{briefing.picks.map((p, i) => <PickSetCard key={i} pick={p} index={i} />)}
</div>
<CuratorUsageFooter />
<DecisionCard briefing={briefing} review={review} />
</div>
);
}

View File

@@ -1,25 +1,29 @@
import usePurchases from '../hooks/usePurchases';
import PurchasePanel from '../components/PurchasePanel';
import PurchaseTrendChart from '../components/PurchaseTrendChart';
export default function PurchaseTab() {
const pur = usePurchases();
return (
<PurchasePanel
records={pur.purchases}
stats={pur.purchaseStats}
loading={pur.purchaseLoading}
formOpen={pur.purchaseFormOpen}
form={pur.purchaseForm}
formSaving={pur.purchaseFormSaving}
formError={pur.purchaseFormError}
editId={pur.purchaseEditId}
onFormOpen={pur.handlePurchaseFormOpen}
onFormClose={pur.handlePurchaseFormClose}
onFormChange={pur.handlePurchaseFormChange}
onFormSubmit={pur.handlePurchaseFormSubmit}
onEditStart={pur.handlePurchaseEditStart}
onDelete={pur.handlePurchaseDelete}
/>
<>
<PurchaseTrendChart />
<PurchasePanel
records={pur.purchases}
stats={pur.purchaseStats}
loading={pur.purchaseLoading}
formOpen={pur.purchaseFormOpen}
form={pur.purchaseForm}
formSaving={pur.purchaseFormSaving}
formError={pur.purchaseFormError}
editId={pur.purchaseEditId}
onFormOpen={pur.handlePurchaseFormOpen}
onFormClose={pur.handlePurchaseFormClose}
onFormChange={pur.handlePurchaseFormChange}
onFormSubmit={pur.handlePurchaseFormSubmit}
onEditStart={pur.handlePurchaseEditStart}
onDelete={pur.handlePurchaseDelete}
/>
</>
);
}

View File

@@ -932,6 +932,27 @@
margin: 0;
}
/* ── Track title input ── */
.ms-title-input-wrap {
padding: 0 24px;
margin-bottom: 12px;
}
.ms-title-input {
width: 100%;
box-sizing: border-box;
background: #1f2937;
border: 1px solid #374151;
border-radius: 8px;
padding: 9px 14px;
color: #ccc;
font-size: 13px;
text-align: center;
}
.ms-title-input::placeholder { color: #4b5563; }
.ms-title-input:focus { outline: none; border-color: var(--ms-accent, #22c55e); }
/* ═══════════════════════════════════════════════════
GENERATE BUTTON
═══════════════════════════════════════════════════ */
@@ -2608,3 +2629,904 @@
background: var(--ms-surface); border: 1px solid var(--ms-line);
}
.ms-remix-submit { align-self: flex-start; margin-top: 8px; }
/* ══════════════════════════════════════════
YouTube Tab — yt-* classes
══════════════════════════════════════════ */
.ms-tab--youtube.is-active {
color: #f59e0b;
border-bottom-color: #f59e0b;
}
.yt-container {
display: flex;
flex-direction: column;
gap: 0;
}
.yt-subtabs {
display: flex;
border-bottom: 1px solid #1f2937;
background: #0d1117;
padding: 0 16px;
}
.yt-subtab {
padding: 10px 18px;
font-size: 12px;
color: #6b7280;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.yt-subtab:hover { color: #9ca3af; }
.yt-subtab.is-active {
color: #22c55e;
border-bottom-color: #22c55e;
font-weight: 600;
}
.yt-content {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.yt-card {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 14px;
}
.yt-card--create { border-color: #22c55e33; }
.yt-card--export { border-color: #3b82f633; border-style: dashed; }
.yt-card__title {
font-size: 12px;
font-weight: 700;
color: #ccc;
margin: 0 0 12px;
}
.yt-card--create .yt-card__title { color: #86efac; }
.yt-card--export .yt-card__title { color: #93c5fd; }
.yt-row {
display: flex;
gap: 8px;
margin-bottom: 10px;
align-items: center;
}
.yt-row--bottom { margin-bottom: 0; margin-top: 8px; }
.yt-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 0;
}
.yt-field { display: flex; flex-direction: column; gap: 4px; }
.yt-field__label { font-size: 10px; color: #6b7280; }
.yt-input {
background: #1f2937;
border: 1px solid #374151;
border-radius: 6px;
padding: 7px 10px;
color: #ccc;
font-size: 12px;
width: 100%;
box-sizing: border-box;
}
.yt-input:focus { outline: none; border-color: #22c55e; }
.yt-input--sm { padding: 4px 8px; font-size: 11px; }
.yt-select {
flex: 1;
background: #1f2937;
border: 1px solid #374151;
border-radius: 6px;
padding: 8px 10px;
color: #9ca3af;
font-size: 12px;
}
.yt-format-toggle { display: flex; gap: 4px; }
.yt-format-btn {
background: #1f2937;
border: 1px solid #374151;
border-radius: 6px;
padding: 8px 10px;
color: #9ca3af;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
}
.yt-format-btn.is-active {
background: #1a2e1a;
border-color: #22c55e;
color: #86efac;
}
.yt-country-label { font-size: 11px; color: #6b7280; margin-bottom: 6px; }
.yt-country-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.yt-chip {
background: #1f2937;
border: 1px solid #374151;
border-radius: 4px;
padding: 3px 10px;
color: #6b7280;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.yt-chip.is-active {
background: #1e3a2a;
border-color: #22c55e;
color: #86efac;
}
.yt-create-btn { width: 100%; margin-top: 2px; }
.yt-project-list { display: flex; flex-direction: column; gap: 8px; }
.yt-project-card {
background: #1f2937;
border-radius: 8px;
padding: 10px 12px;
display: flex;
align-items: center;
gap: 10px;
}
.yt-project-card__icon {
width: 40px;
height: 40px;
background: #111827;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.yt-project-card__info { flex: 1; min-width: 0; }
.yt-project-card__title {
font-size: 12px;
font-weight: 600;
color: #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yt-project-card__meta { font-size: 10px; color: #6b7280; margin-top: 2px; }
.yt-status {
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.yt-status--pending { background: #1f2937; color: #9ca3af; }
.yt-status--rendering { background: #1a1500; color: #f59e0b; }
.yt-status--done { background: #0a3d1a; color: #22c55e; }
.yt-status--failed { background: #2d0a0a; color: #f87171; }
.yt-progress-bar {
height: 3px;
background: #374151;
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.yt-progress-bar__fill {
height: 100%;
width: 65%;
background: linear-gradient(90deg, #f59e0b, #fbbf24);
border-radius: 2px;
animation: yt-progress-pulse 2s ease-in-out infinite;
}
@keyframes yt-progress-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.yt-export-links { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.yt-meta-preview { background: #111827; border-radius: 6px; padding: 8px; }
.yt-meta-preview__label { font-size: 10px; color: #6b7280; margin-bottom: 4px; }
.yt-meta-preview__content {
font-size: 11px;
color: #9ca3af;
font-family: monospace;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
.yt-empty {
text-align: center;
color: #6b7280;
font-size: 11px;
padding: 8px 0;
margin: 0;
}
.yt-dash-cards {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
.yt-dash-card {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 8px;
padding: 12px;
text-align: center;
}
.yt-dash-card__label { font-size: 10px; color: #6b7280; margin-bottom: 4px; }
.yt-dash-card__sub { font-size: 9px; color: #6b7280; margin-top: 2px; }
.yt-dash-card__value { font-size: 18px; font-weight: 700; }
.yt-dash-card__value--green { color: #22c55e; }
.yt-dash-card__value--blue { color: #60a5fa; }
.yt-dash-card__value--amber { color: #f59e0b; }
.yt-bar-chart { display: flex; flex-direction: column; gap: 8px; }
.yt-bar-row { display: flex; align-items: center; gap: 8px; }
.yt-bar-row__label {
width: 80px;
font-size: 11px;
color: #9ca3af;
text-align: right;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.yt-bar-row__rank {
width: 24px;
font-size: 11px;
font-weight: 700;
color: #f59e0b;
text-align: center;
flex-shrink: 0;
}
.yt-bar-row__info { flex: 1; }
.yt-bar-row__genre-header {
display: flex;
justify-content: space-between;
margin-bottom: 3px;
}
.yt-bar-row__genre-name { font-size: 12px; color: #ccc; }
.yt-bar-row__flags { font-size: 10px; color: #9ca3af; }
.yt-bar-row__track {
flex: 1;
height: 6px;
background: #1f2937;
border-radius: 3px;
overflow: hidden;
}
.yt-bar-row__fill {
height: 100%;
background: linear-gradient(90deg, #22c55e, #4ade80);
border-radius: 3px;
transition: width 0.4s ease;
}
.yt-bar-row__fill--genre { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.yt-bar-row__value {
width: 44px;
font-size: 11px;
color: #22c55e;
text-align: right;
flex-shrink: 0;
}
.yt-table { display: flex; flex-direction: column; gap: 2px; }
.yt-table__header {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 28px;
gap: 4px;
padding: 0 4px 6px;
border-bottom: 1px solid #1f2937;
font-size: 10px;
color: #6b7280;
}
.yt-table__row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 28px;
gap: 4px;
padding: 7px 4px;
border-bottom: 1px solid #111827;
align-items: center;
}
.yt-table__row--editing {
background: #111827;
border-radius: 6px;
padding: 8px;
}
.yt-table__row:last-child { border-bottom: none; }
.yt-table__cell { font-size: 11px; color: #9ca3af; }
.yt-table__cell--mono { font-family: monospace; }
.yt-table__cell--green { color: #22c55e; }
.yt-table__cell--amber { color: #f59e0b; }
.yt-table__actions { display: flex; gap: 4px; grid-column: span 2; }
.yt-status-bar {
background: #0d1117;
border: 1px solid #1f2937;
border-radius: 8px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.yt-status-bar__left { display: flex; align-items: center; gap: 8px; }
.yt-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 6px #22c55e;
flex-shrink: 0;
}
.yt-status-bar__text { font-size: 11px; color: #9ca3af; }
.yt-prompt-list { display: flex; flex-direction: column; gap: 8px; }
.yt-prompt-card {
background: #1a0d2e;
border-radius: 8px;
padding: 10px 12px;
}
.yt-prompt-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.yt-prompt-card__genre { font-size: 11px; font-weight: 700; color: #c084fc; }
.yt-prompt-card__countries { font-size: 10px; color: #6b7280; }
.yt-prompt-card__text {
display: block;
width: 100%;
text-align: left;
background: #110820;
border: none;
border-radius: 4px;
padding: 6px 8px;
font-size: 11px;
font-family: monospace;
color: #e9d5ff;
line-height: 1.6;
cursor: pointer;
transition: background 0.15s;
}
.yt-prompt-card__text:hover { background: #1a0d30; }
.yt-prompt-card__copied { font-size: 10px; color: #22c55e; margin-top: 4px; display: block; }
.yt-prompt-card__reason { font-size: 10px; color: #6b7280; margin-top: 5px; }
.yt-report-list { display: flex; flex-direction: column; gap: 4px; }
.yt-report-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.yt-report-row:hover { background: #1f2937; }
.yt-report-row.is-selected { background: #1f2937; }
.yt-report-row__date { font-size: 11px; color: #ccc; }
.yt-report-row__today { font-size: 10px; color: #22c55e; margin-left: 4px; }
.yt-report-row__meta { font-size: 10px; color: #9ca3af; }
.yt-report-row__action { font-size: 11px; color: #60a5fa; }
@media (max-width: 600px) {
.yt-dash-cards { grid-template-columns: 1fr 1fr; }
.yt-form-grid { grid-template-columns: 1fr; }
.yt-table__header,
.yt-table__row { grid-template-columns: 2fr 1fr 1fr 28px; }
.yt-table__header span:nth-child(4),
.yt-table__header span:nth-child(5),
.yt-table__row span:nth-child(4),
.yt-table__row span:nth-child(5) { display: none; }
}
/* ── Compile subtab ── */
.yt-compile-tracklist {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 280px;
overflow-y: auto;
}
.yt-compile-track {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
}
.yt-compile-track:hover { background: #1f2937; }
.yt-compile-track.is-selected { background: #0a2e18; }
.yt-compile-track__check {
width: 16px;
font-size: 11px;
color: #22c55e;
flex-shrink: 0;
}
.yt-compile-track__title {
flex: 1;
font-size: 12px;
color: #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yt-compile-track__dur {
font-size: 10px;
color: #6b7280;
flex-shrink: 0;
}
.yt-compile-order {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 14px;
}
.yt-compile-order__row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: #1f2937;
border-radius: 6px;
}
.yt-compile-order__num {
width: 20px;
font-size: 11px;
color: #6b7280;
text-align: center;
flex-shrink: 0;
}
.yt-compile-order__title {
flex: 1;
font-size: 12px;
color: #ccc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.yt-compile-order__btns {
display: flex;
gap: 3px;
flex-shrink: 0;
}
.yt-compile-settings {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.yt-compile-slider {
width: 100%;
accent-color: #22c55e;
}
/* === SetupTab === */
.setup-container { display:flex; flex-direction:column; gap:16px; padding:16px; }
.setup-card {
background: rgba(0,0,0,.3);
border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 14px;
padding: 16px;
}
.setup-card h3 {
margin: 0 0 12px;
font-size: 15px;
color: var(--ms-text, #f0f0f5);
font-family: var(--ms-ff-disp, inherit);
letter-spacing: 0.04em;
}
.setup-card label {
display: block;
margin: 8px 0;
font-size: 12px;
color: var(--ms-muted, #a0a0b0);
}
.setup-card input,
.setup-card textarea,
.setup-card select {
width: 100%;
padding: 8px;
margin-top: 4px;
background: rgba(255,255,255,.04);
border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 8px;
color: var(--ms-text, #f0f0f5);
font-size: 13px;
font-family: inherit;
box-sizing: border-box;
}
.setup-card input[type="range"] {
padding: 0;
background: transparent;
border: none;
accent-color: var(--ms-accent, #f5a623);
}
.setup-card button {
padding: 6px 14px;
margin-top: 8px;
background: rgba(245, 166, 35, 0.15);
color: var(--ms-accent, #bae6fd);
border: 1px solid rgba(245, 166, 35, 0.4);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
}
.setup-card button:hover {
background: rgba(245, 166, 35, 0.25);
}
.setup-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.setup-checkbox input[type="checkbox"] {
width: auto;
}
.setup-channel {
display: flex;
align-items: center;
gap: 12px;
}
.setup-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
}
.setup-prompt-row {
display: flex;
gap: 8px;
margin: 6px 0;
align-items: center;
}
.setup-prompt-genre {
width: 80px;
font-size: 12px;
color: var(--ms-muted, #a0a0b0);
flex-shrink: 0;
}
.setup-saving {
position: fixed;
bottom: 16px;
right: 16px;
background: #222;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,.1);
font-size: 12px;
color: var(--ms-text, #f0f0f5);
z-index: 100;
}
.ms-loading,
.ms-error {
padding: 24px;
text-align: center;
color: var(--ms-muted, #a0a0b0);
}
.ms-error {
color: #f87171;
}
/* === PipelineTab === */
.pipeline-container { padding:16px; }
.pipeline-toolbar { display:flex; gap:12px; margin-bottom:16px; align-items:center; }
.pipeline-toolbar select { padding:6px 10px; background:rgba(255,255,255,.04);
border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; color:var(--ms-text, #f0f0f5); font-size:13px; }
.pipeline-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(320px, 1fr)); gap:16px; }
.pipeline-card { background:rgba(0,0,0,.3); border:1px solid var(--ms-line, #2a2a3a);
border-radius:14px; padding:16px; display:flex; flex-direction:column; gap:8px; }
.pipeline-card__head { display:flex; justify-content:space-between; align-items:center; }
.pipeline-card__head h4 { margin:0; font-size:14px; color:var(--ms-text, #f0f0f5); }
.pipeline-card__head button { padding:4px 10px; background:rgba(248,113,113,.15); color:#fca5a5;
border:1px solid rgba(248,113,113,.3); border-radius:6px; cursor:pointer; font-size:11px; }
.pipeline-progress { display:flex; gap:6px; margin:8px 0; }
.pipeline-dot { flex:1; text-align:center; padding:6px 0; border-radius:8px;
background:rgba(255,255,255,.05); font-size:11px; color:var(--ms-muted, #a0a0b0); }
.pipeline-dot.is-done { background:rgba(56,189,248,.2); color:#bae6fd; }
.pipeline-dot.is-current { box-shadow:0 0 8px rgba(56,189,248,.6); }
.pipeline-state { font-size:13px; color:var(--ms-text, #f0f0f5); }
.pipeline-review { font-size:12px; color:var(--ms-muted, #a0a0b0); }
.pipeline-review strong { color:#bae6fd; }
.pipeline-feedback { margin-top:8px; font-size:12px; color:var(--ms-muted, #a0a0b0); }
.pipeline-feedback summary { cursor:pointer; }
.pipeline-card a { color:#bae6fd; font-size:12px; }
.ms-empty { padding:32px; text-align:center; color:var(--ms-muted, #a0a0b0); grid-column:1/-1; }
/* Modal — shared */
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6);
display:flex; align-items:center; justify-content:center; z-index:1000; }
.modal-body { background:#1a1a2e; padding:24px; border-radius:14px; min-width:320px;
border:1px solid var(--ms-line, #2a2a3a); }
.modal-body h3 { margin:0 0 12px; font-size:15px; color:var(--ms-text, #f0f0f5); }
.modal-body select { width:100%; padding:8px; background:rgba(255,255,255,.04);
border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; color:var(--ms-text, #f0f0f5); font-size:13px; }
.modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
.modal-actions button { padding:6px 14px; background:rgba(255,255,255,.05); color:var(--ms-text, #f0f0f5);
border:1px solid var(--ms-line, #2a2a3a); border-radius:8px; cursor:pointer; font-size:13px; }
.modal-actions .button.primary { background:rgba(56,189,248,.2); color:#bae6fd; border-color:rgba(56,189,248,.4); }
/* ── CompileTab → Pipeline 영상 만들기 버튼 ─────────────────────── */
.cmp-btn-video {
padding: 6px 12px;
background: rgba(56, 189, 248, 0.15);
color: #bae6fd;
border: 1px solid rgba(56, 189, 248, 0.4);
border-radius: 6px;
cursor: pointer;
font-size: 12px;
margin-left: 6px;
}
.cmp-btn-video:hover { background: rgba(56, 189, 248, 0.25); }
.psm-input-radio { border: 1px solid var(--ms-line, #2a2a3a); padding: 8px 12px;
border-radius: 8px; margin-bottom: 12px; }
.psm-input-radio legend { padding: 0 6px; font-size: 11px; color: var(--ms-muted, #a0a0b0); }
.psm-input-radio label { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; }
.psm-advanced { margin-top: 12px; padding: 8px 0; }
.psm-advanced summary { cursor: pointer; font-size: 12px; color: var(--ms-muted, #a0a0b0); user-select: none; }
.psm-advanced label { display: block; margin: 8px 0; font-size: 12px; }
.psm-advanced input, .psm-advanced select { width: 100%; padding: 6px 8px;
background: rgba(255,255,255,.04); border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 6px; color: var(--ms-text, #f0f0f5); font-size: 12px; }
/* === Pipeline Detail Modal === */
.modal-body--lg { max-width: 720px; max-height: 90vh; overflow-y: auto; }
.pdm-header { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
.pdm-header h3 { flex:1; margin:0; }
.pdm-badge { padding:2px 8px; background:rgba(56,189,248,.2); color:#bae6fd;
border-radius:6px; font-size:11px; }
.pdm-close { background:none; border:none; font-size:24px; cursor:pointer;
color:var(--ms-muted, #a0a0b0); padding:0 6px; }
.pdm-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px; }
.pdm-figure { margin:0; }
.pdm-figure img { width:100%; border-radius:8px; display:block; }
.pdm-figure figcaption { font-size:11px; color:var(--ms-muted, #a0a0b0); text-align:center; margin-top:4px; }
.pdm-video { margin-bottom:16px; }
.pdm-video video { border-radius:8px; }
.pdm-section { margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
.pdm-section:last-of-type { border-bottom:none; }
.pdm-section h4 { margin:0 0 8px; font-size:14px; }
.pdm-pre { background:rgba(0,0,0,.3); padding:8px; border-radius:6px; font-size:12px;
white-space:pre-wrap; overflow-x:auto; max-height:400px; }
.pdm-verdict { padding:2px 8px; margin-left:8px; border-radius:6px; font-size:12px; font-weight:bold; }
.pdm-verdict--pass { background:rgba(34,197,94,.2); color:#86efac; }
.pdm-verdict--fail { background:rgba(248,113,113,.2); color:#fca5a5; }
.pdm-score { color:var(--ms-muted, #a0a0b0); font-size:12px; margin-left:8px; font-weight:normal; }
.pdm-review-table { width:100%; border-collapse:collapse; font-size:13px; }
.pdm-review-table td { padding:4px 8px; border-bottom:1px solid var(--ms-line, #2a2a3a); }
.pdm-review-table td:nth-child(2) { text-align:right; font-weight:bold; }
.pdm-summary { font-size:12px; color:var(--ms-muted, #a0a0b0); margin-top:8px; }
.pdm-tracks { padding-left:24px; }
.pdm-tracks li { margin-bottom:4px; font-size:13px; }
.pdm-track-time { color:var(--ms-accent, #38bdf8); font-family:monospace; }
.pdm-track-dur { color:var(--ms-muted, #a0a0b0); font-size:11px; }
.pdm-feedback { padding-left:0; list-style:none; }
.pdm-feedback li { padding:6px 8px; background:rgba(0,0,0,.2); border-radius:6px;
margin-bottom:4px; font-size:12px; }
.pdm-feedback code { color:#fb923c; font-size:11px; }
.pdm-feedback small { display:block; color:var(--ms-muted, #a0a0b0); margin-top:2px; }
.pdm-youtube { display:inline-block; padding:8px 16px; background:#ff0000; color:white;
border-radius:8px; text-decoration:none; font-weight:bold; }
/* PipelineCard mini previews + style badge */
.pipeline-previews { display:flex; gap:8px; margin:8px 0; align-items:center; }
.pipeline-preview-mini { width:64px; height:64px; object-fit:cover; border-radius:6px;
border:1px solid var(--ms-line, #2a2a3a); }
.pipeline-video-icon { font-size:24px; color:var(--ms-accent, #38bdf8); margin-left:4px; }
.pipeline-style-badge { padding:1px 6px; background:rgba(56,189,248,.15); color:#bae6fd;
border-radius:4px; font-size:10px; }
.pipeline-card { cursor:pointer; }
.pipeline-card:hover { background:rgba(255,255,255,.02); }
.psm-keyword-main { display: block; margin: 12px 0; font-size: 13px; }
.psm-keyword-main input {
display: block; width: 100%; margin-top: 4px; padding: 8px;
background: rgba(255,255,255,.04);
border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 8px; color: var(--ms-text, #f0f0f5); font-size: 13px;
}
.psm-keyword-main small {
display: block; color: var(--ms-muted, #a0a0b0); font-size: 11px; margin-top: 4px;
}
/* === Batch Generation Section === */
.ms-batch-section {
margin: 16px 0;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 12px;
}
.ms-batch-section summary {
cursor: pointer;
font-weight: 600;
color: var(--ms-text, #f0f0f5);
user-select: none;
padding: 4px 0;
}
.ms-batch-section[open] summary {
margin-bottom: 8px;
}
.ms-batch-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0;
}
.ms-batch-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--ms-text, #f0f0f5);
}
.ms-batch-form select,
.ms-batch-form input[type="range"] {
padding: 6px 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--ms-line, #2a2a3a);
border-radius: 6px;
color: var(--ms-text, #f0f0f5);
}
.ms-batch-form input[type="range"] {
padding: 0;
background: transparent;
}
.ms-batch-checkbox {
flex-direction: row !important;
align-items: center;
gap: 8px;
}
.ms-batch-checkbox input {
width: auto;
margin: 0;
}
.ms-batch-estimate {
font-size: 12px;
color: var(--ms-muted, #a0a0b0);
margin: 4px 0;
}
.ms-batch-form button {
padding: 8px 16px;
background: rgba(56, 189, 248, 0.2);
color: #bae6fd;
border: 1px solid rgba(56, 189, 248, 0.4);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
align-self: flex-start;
}
.ms-batch-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ms-batch-progress {
margin-top: 12px;
padding: 12px;
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
border: 1px solid var(--ms-line, #2a2a3a);
}
.ms-batch-header {
font-size: 13px;
margin-bottom: 8px;
}
.ms-batch-status--generating { color: var(--ms-accent, #38bdf8); }
.ms-batch-status--compiling { color: #fb923c; }
.ms-batch-status--piped { color: #86efac; }
.ms-batch-status--failed { color: #fca5a5; }
.ms-batch-status--cancelled { color: var(--ms-muted, #a0a0b0); }
.ms-batch-tracks {
padding-left: 24px;
font-size: 12px;
margin: 8px 0;
}
.ms-batch-tracks li {
margin: 2px 0;
}
.ms-batch-tracks li.done {
color: #86efac;
}
.ms-batch-tracks li.current {
color: var(--ms-accent, #38bdf8);
font-weight: bold;
}
.ms-batch-tracks li.pending {
color: var(--ms-muted, #a0a0b0);
}
.ms-batch-link {
margin-top: 6px;
font-size: 12px;
color: var(--ms-muted, #a0a0b0);
}

View File

@@ -15,6 +15,9 @@ import {
getTimestampedLyrics,
generateStyleBoost,
generateVideo,
startBatchGen,
getBatchJob,
listGenres,
} from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
@@ -27,6 +30,8 @@ import LyricsTab from './components/LyricsTab';
import StemModal from './components/StemModal';
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
import RemixTab from './components/RemixTab';
import YoutubeTab from './components/YoutubeTab';
import BatchProgress from './components/BatchProgress';
/* ─────────────────────────────────────────────
데이터 상수
@@ -337,7 +342,7 @@ const TrackResult = ({ track, onDownload, onNew }) => {
/* ─────────────────────────────────────────────
Library Card
───────────────────────────────────────────── */
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating }) => {
const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating }) => {
const [menuOpen, setMenuOpen] = useState(false);
const genre = GENRES.find((g) => g.id === track.genre);
const totalSec = track.duration_sec ?? null;
@@ -425,6 +430,12 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
disabled={isGenerating || !track.lyrics}>📝 Synced Lyrics</button>
<button type="button" onClick={() => { onVideoGenerate(track); setMenuOpen(false); }}
disabled={isGenerating}>🎬 Music Video</button>
<button type="button" onClick={() => { onVideoProject(track); setMenuOpen(false); }}>
🎯 YouTube 프로젝트
</button>
<button type="button" onClick={() => { onVideoPipeline(track); setMenuOpen(false); }}>
🎬 영상 파이프라인
</button>
</div>
)}
</div>
@@ -435,6 +446,10 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
<a href={track.audio_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
Download
</a>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onVideoPipeline(track)}>
🎬 영상 파이프라인
</button>
</div>
)}
<p className="ms-lib-card__date">
@@ -447,7 +462,7 @@ const LibraryCard = ({ track, onDelete, onPlay, isPlaying, onExtend, onVocalRemo
/* ─────────────────────────────────────────────
Library Section
───────────────────────────────────────────── */
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, isGenerating, loading }) => {
const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCoverArt, onWavConvert, onStemSplit, onSyncedLyrics, onVideoGenerate, onVideoProject, onVideoPipeline, isGenerating, loading }) => {
const [playingId, setPlayingId] = useState(null);
const handlePlay = (track) => {
@@ -501,6 +516,8 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
onStemSplit={onStemSplit}
onSyncedLyrics={onSyncedLyrics}
onVideoGenerate={onVideoGenerate}
onVideoProject={onVideoProject}
onVideoPipeline={onVideoPipeline}
isGenerating={isGenerating}
/>
))}
@@ -515,6 +532,8 @@ const Library = ({ tracks, onDelete, onRefresh, onExtend, onVocalRemoval, onCove
export default function MusicStudio() {
/* ── 탭 ── */
const [tab, setTab] = useState('create');
const [initialTrackId, setInitialTrackId] = useState(null);
const [openPipelineFor, setOpenPipelineFor] = useState(null);
/* ── Provider 상태 ── */
const [providers, setProviders] = useState([]);
@@ -530,6 +549,7 @@ export default function MusicStudio() {
const [musicalKey, setMusicalKey] = useState('C');
const [scale, setScale] = useState('Major');
const [prompt, setPrompt] = useState('');
const [customTitle, setCustomTitle] = useState('');
/* ── Suno 전용 상태 ── */
const [lyrics, setLyrics] = useState('');
@@ -576,6 +596,17 @@ export default function MusicStudio() {
const pollRef = useRef(null);
const taskIdRef = useRef(null);
/* ── 배치 생성 상태 ── */
const [batchOpen, setBatchOpen] = useState(false);
const [batchGenre, setBatchGenre] = useState('lo-fi');
const [batchCount, setBatchCount] = useState(10);
const [batchDuration, setBatchDuration] = useState(180);
const [batchAutoPipe, setBatchAutoPipe] = useState(true);
const [currentBatch, setCurrentBatch] = useState(null);
const [batchPolling, setBatchPolling] = useState(false);
const [batchGenresList, setBatchGenresList] = useState(['lo-fi', 'phonk', 'ambient', 'pop']);
const batchPollRef = useRef(null);
const activeGenre = GENRES.find((g) => g.id === genre);
const accentColor = activeGenre?.color ?? '#f5a623';
@@ -635,6 +666,56 @@ export default function MusicStudio() {
/* ── 언마운트 시 폴링 정리 ── */
useEffect(() => () => clearInterval(pollRef.current), []);
/* ── 배치 생성 시작 ── */
const startBatch = async () => {
try {
const res = await startBatchGen({
genre: batchGenre,
count: batchCount,
target_duration_sec: batchDuration,
auto_pipeline: batchAutoPipe,
});
setCurrentBatch(res);
setBatchPolling(true);
} catch (e) {
alert(`배치 시작 실패: ${e.message || e}`);
}
};
/* ── 배치: 지원 장르 목록 fetch (mount 시 1회) ── */
useEffect(() => {
listGenres()
.then((r) => {
if (Array.isArray(r?.genres) && r.genres.length) {
setBatchGenresList(r.genres);
if (!r.genres.includes(batchGenre)) setBatchGenre(r.genres[0]);
}
})
.catch(() => { /* fallback hardcoded list 유지 */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/* ── 배치 폴링 ── */
useEffect(() => {
if (!batchPolling || !currentBatch?.id) return;
const tick = async () => {
try {
const j = await getBatchJob(currentBatch.id);
if (j) {
setCurrentBatch(j);
if (['piped', 'failed', 'cancelled'].includes(j.status)) {
setBatchPolling(false);
// library 갱신 (새 트랙들 표시되도록)
if (typeof loadLibrary === 'function') loadLibrary();
}
}
} catch { /* swallow */ }
};
batchPollRef.current = setInterval(tick, 5000);
return () => clearInterval(batchPollRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchPolling, currentBatch?.id]);
/* ── helpers ── */
const toggleMood = (id) =>
setMoods((prev) =>
@@ -730,7 +811,7 @@ export default function MusicStudio() {
const durSec = DURATIONS.find((d) => d.id === duration)?.sec ?? 60;
const moodLabel = moods[0] ? MOODS.find((m) => m.id === moods[0])?.label : 'Original';
const title = `${activeGenre?.label}${moodLabel} Mix`;
const title = customTitle.trim() || `${activeGenre?.label}${moodLabel} Mix`;
const instList = instruments.length > 0 ? instruments : ['piano', 'synth', 'bass'];
const payload = {
@@ -1058,10 +1139,21 @@ export default function MusicStudio() {
}
};
const handleVideoProject = (track) => {
setInitialTrackId(track.id);
setTab('youtube');
};
const handleVideoPipeline = (track) => {
setOpenPipelineFor(track.id);
setTab('youtube');
};
const handleNewTrack = () => {
setTrack(null);
setGenProgress(0);
setGenError(null);
setCustomTitle('');
clearInterval(pollRef.current);
};
@@ -1121,6 +1213,13 @@ export default function MusicStudio() {
>
<span className="ms-tab__icon">🔄</span> Remix
</button>
<button
type="button"
className={`ms-tab ms-tab--youtube ${tab === 'youtube' ? 'is-active' : ''}`}
onClick={() => setTab('youtube')}
>
<span className="ms-tab__icon">🎯</span> YouTube
</button>
</nav>
{/* ═══ LIBRARY TAB ═══ */}
@@ -1138,6 +1237,8 @@ export default function MusicStudio() {
onStemSplit={handleStemSplit}
onSyncedLyrics={handleSyncedLyrics}
onVideoGenerate={handleVideoGenerate}
onVideoProject={handleVideoProject}
onVideoPipeline={handleVideoPipeline}
isGenerating={isGenerating}
/>
</PullToRefresh>
@@ -1166,6 +1267,16 @@ export default function MusicStudio() {
/>
)}
{/* ═══ YOUTUBE TAB ═══ */}
{tab === 'youtube' && (
<YoutubeTab
library={library}
initialTrackId={initialTrackId}
onClearInitialTrack={() => setInitialTrackId(null)}
openPipelineFor={openPipelineFor}
/>
)}
{/* ═══ CREATE TAB ═══ */}
{tab === 'create' && (
<div className="ms-layout">
@@ -1230,6 +1341,44 @@ export default function MusicStudio() {
</div>
)}
{/* Batch Generation Section */}
<details className="ms-batch-section" open={batchOpen} onToggle={(e) => setBatchOpen(e.currentTarget.open)}>
<summary>🎲 배치 생성 (장르 1-10트랙 + 자동 영상)</summary>
<div className="ms-batch-form">
<label>장르
<select value={batchGenre} onChange={e => setBatchGenre(e.target.value)}>
{batchGenresList.map(g => (
<option key={g} value={g}>
{g.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('-')}
</option>
))}
</select>
</label>
<label>트랙 : <strong>{batchCount}</strong>
<input type="range" min={1} max={10} value={batchCount}
onChange={e => setBatchCount(parseInt(e.target.value))} />
</label>
<label>트랙당 길이: <strong>{batchDuration}</strong>
<input type="range" min={60} max={300} step={10} value={batchDuration}
onChange={e => setBatchDuration(parseInt(e.target.value))} />
</label>
<label className="ms-batch-checkbox">
<input type="checkbox" checked={batchAutoPipe}
onChange={e => setBatchAutoPipe(e.target.checked)} />
모든 트랙 생성 자동 영상 파이프라인 시작
</label>
<p className="ms-batch-estimate">
예상: {Math.ceil(batchCount * 1.5)}{batchCount * 2} ·
{' '}비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}
</p>
<button className="button primary" onClick={startBatch}
disabled={batchPolling}>
🎵 배치 생성 시작
</button>
</div>
{currentBatch && <BatchProgress batch={currentBatch} />}
</details>
{/* Step 1: Genre */}
<section className="ms-section">
<div className="ms-section__head">
@@ -1661,6 +1810,20 @@ export default function MusicStudio() {
</div>
</div>
{/* Track title input */}
{!track && (
<div className="ms-title-input-wrap">
<input
type="text"
className="ms-title-input"
placeholder="트랙 제목 (비워두면 자동 생성)"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
maxLength={80}
/>
</div>
)}
{/* Generate button */}
{!track && (
<button

View File

@@ -0,0 +1,48 @@
const STATUS_LABELS = {
queued: '대기 중',
generating: '음악 생성 중',
generated: '음악 완료, 컴파일 대기',
compiling: '컴파일 중',
piped: '영상 파이프라인 시작됨 — YouTube 탭 진행 탭에서 확인',
failed: '실패',
cancelled: '취소',
};
export default function BatchProgress({ batch }) {
if (!batch) return null;
const trackList = Array.from({ length: batch.count }, (_, i) => i + 1);
return (
<div className="ms-batch-progress">
<div className="ms-batch-header">
배치 #{batch.id} <strong>{batch.genre}</strong> ·{' '}
{batch.completed}/{batch.count} 완료 ·{' '}
상태: <strong className={`ms-batch-status ms-batch-status--${batch.status}`}>
{STATUS_LABELS[batch.status] || batch.status}
</strong>
</div>
{batch.error && <div className="ms-error">에러: {batch.error}</div>}
<ol className="ms-batch-tracks">
{trackList.map(n => {
const completed = n <= batch.completed;
const current = n === batch.current_track_index && batch.status === 'generating';
const tr = (batch.tracks || [])[n - 1];
return (
<li key={n} className={completed ? 'done' : current ? 'current' : 'pending'}>
{completed ? '✓' : current ? '⏳' : '○'}
{' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')}
</li>
);
})}
</ol>
{batch.compile_job_id && (
<div className="ms-batch-link">📀 컴파일 #{batch.compile_job_id}</div>
)}
{batch.pipeline_id && (
<div className="ms-batch-link">
🎬 영상 파이프라인 #{batch.pipeline_id} {' '}
<em>YouTube 진행 탭에서 확인</em>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,281 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
createCompileJob, getCompileJobs, deleteCompileJob, exportCompileJob,
createPipeline, startPipeline,
} from '../../../api';
const fmtDuration = (sec) => {
if (!sec) return '';
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return `${m}${s}`;
};
export default function CompileTab({ library, onSwitchToPipeline }) {
const [jobs, setJobs] = useState([]);
const [selected, setSelected] = useState([]); // [{id, title, audio_url}] in order
const [crossfade, setCrossfade] = useState(3);
const [title, setTitle] = useState('');
const [creating, setCreating] = useState(false);
const [exportData, setExportData] = useState(null); // {mp4_url, duration_sec, title}
const [exportingId, setExportingId] = useState(null);
const pollRef = useRef(null);
const loadJobs = useCallback(async () => {
const res = await getCompileJobs().catch(() => ({ jobs: [] }));
setJobs(Array.isArray(res.jobs) ? res.jobs : []);
}, []);
useEffect(() => { loadJobs(); }, [loadJobs]);
// Poll while any job is rendering
useEffect(() => {
const hasRendering = jobs.some(j => j.status === 'rendering');
if (hasRendering && !pollRef.current) {
pollRef.current = setInterval(loadJobs, 5000);
} else if (!hasRendering && pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return () => { clearInterval(pollRef.current); pollRef.current = null; };
}, [jobs, loadJobs]);
const toggleTrack = (track) => {
setSelected(prev => {
const exists = prev.find(t => t.id === track.id);
if (exists) return prev.filter(t => t.id !== track.id);
return [...prev, { id: track.id, title: track.title, audio_url: track.audio_url }];
});
};
const moveUp = (idx) => setSelected(prev => { const a = [...prev]; [a[idx-1], a[idx]] = [a[idx], a[idx-1]]; return a; });
const moveDown = (idx) => setSelected(prev => { const a = [...prev]; [a[idx], a[idx+1]] = [a[idx+1], a[idx]]; return a; });
const remove = (idx) => setSelected(prev => prev.filter((_, i) => i !== idx));
const handleCreate = async () => {
if (selected.length < 2 || creating) return;
setCreating(true);
try {
await createCompileJob({
title: title.trim() || `컴파일 ${new Date().toLocaleDateString('ko-KR')}`,
track_ids: selected.map(t => t.id),
crossfade_sec: crossfade,
});
setSelected([]);
setTitle('');
await loadJobs();
} catch (e) {
console.error('createCompileJob:', e);
} finally {
setCreating(false);
}
};
const handleExport = async (jobId) => {
setExportingId(jobId);
try {
const data = await exportCompileJob(jobId);
setExportData(data);
} catch (e) {
console.error('exportCompileJob:', e);
} finally {
setExportingId(null);
}
};
const handleDelete = async (jobId) => {
if (!window.confirm('컴파일 영상을 삭제할까요?')) return;
await deleteCompileJob(jobId).catch(() => {});
setJobs(prev => prev.filter(j => j.id !== jobId));
if (exportData && exportData.id === jobId) setExportData(null);
};
const handleVideoFromCompile = async (jobId) => {
if (!window.confirm('이 mix로 영상 파이프라인을 시작할까요?')) return;
try {
const p = await createPipeline({ compile_job_id: jobId });
await startPipeline(p.id);
if (onSwitchToPipeline) {
onSwitchToPipeline(p.id);
}
} catch (e) {
alert(`파이프라인 시작 실패: ${e.message || e}`);
}
};
const totalMin = selected.length > 0
? Math.round(selected.reduce((acc, t) => {
const match = library.find(l => l.id === t.id);
return acc + (match?.duration_sec ?? 180);
}, 0) / 60)
: 0;
return (
<div className="yt-content">
{/* 트랙 선택 패널 */}
<div className="yt-card yt-card--create">
<h3 className="yt-card__title">🎵 트랙 선택 (2 이상)</h3>
{library.length === 0 ? (
<p className="yt-empty">라이브러리에 트랙이 없습니다</p>
) : (
<div className="yt-compile-tracklist">
{library.map(t => {
const isSelected = !!selected.find(s => s.id === t.id);
return (
<div
key={t.id}
className={`yt-compile-track ${isSelected ? 'is-selected' : ''}`}
onClick={() => toggleTrack(t)}
>
<span className="yt-compile-track__check">{isSelected ? '✓' : ''}</span>
<span className="yt-compile-track__title">{t.title}</span>
{t.duration_sec && (
<span className="yt-compile-track__dur">{fmtDuration(t.duration_sec)}</span>
)}
</div>
);
})}
</div>
)}
</div>
{/* 순서 조정 + 설정 */}
{selected.length > 0 && (
<div className="yt-card">
<h3 className="yt-card__title">
📋 선택된 트랙 순서 ({selected.length}
{totalMin > 0 ? ` · 약 ${totalMin}` : ''})
</h3>
<div className="yt-compile-order">
{selected.map((t, i) => (
<div key={t.id} className="yt-compile-order__row">
<span className="yt-compile-order__num">{i + 1}</span>
<span className="yt-compile-order__title">{t.title}</span>
<div className="yt-compile-order__btns">
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => moveUp(i)} disabled={i === 0}></button>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => moveDown(i)} disabled={i === selected.length - 1}></button>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => remove(i)}></button>
</div>
</div>
))}
</div>
{/* 설정 */}
<div className="yt-compile-settings">
<div className="yt-field">
<label className="yt-field__label">크로스페이드 {crossfade}</label>
<input type="range" min="1" max="10" step="0.5"
value={crossfade}
onChange={e => setCrossfade(Number(e.target.value))}
className="yt-compile-slider"
/>
</div>
<div className="yt-field">
<label className="yt-field__label">컴파일 제목 (선택)</label>
<input type="text" className="yt-input"
placeholder={`컴파일 ${new Date().toLocaleDateString('ko-KR')}`}
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={80}
/>
</div>
</div>
<button
type="button"
className="ms-btn ms-btn--primary yt-create-btn"
onClick={handleCreate}
disabled={selected.length < 2 || creating}
>
{creating ? '생성 중...' : `🎬 컴파일 생성 (${selected.length}곡)`}
</button>
</div>
)}
{/* 컴파일 작업 목록 */}
{jobs.length > 0 && (
<div className="yt-card">
<h3 className="yt-card__title">컴파일 작업</h3>
<div className="yt-project-list">
{jobs.map(j => (
<div key={j.id} className="yt-project-card">
<div className="yt-project-card__icon">🎵</div>
<div className="yt-project-card__info">
<div className="yt-project-card__title">{j.title || `컴파일 #${j.id}`}</div>
<div className="yt-project-card__meta">
{j.track_ids?.length ?? 0}
{j.duration_sec ? ` · ${fmtDuration(j.duration_sec)}` : ''}
{' · '}크로스페이드 {j.crossfade_sec}
</div>
</div>
{j.status === 'rendering' && (
<>
<span className="yt-status yt-status--rendering">처리중</span>
<div className="yt-progress-bar" style={{position:'absolute',bottom:0,left:0,right:0}}>
<div className="yt-progress-bar__fill" />
</div>
</>
)}
{j.status === 'done' && (
<>
<span className="yt-status yt-status--done"> 완료</span>
<button type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => handleExport(j.id)}
disabled={exportingId === j.id}
>
{exportingId === j.id ? '...' : '↓ 내보내기'}
</button>
<button type="button"
className="cmp-btn-video"
onClick={() => handleVideoFromCompile(j.id)}
>
🎬 영상 만들기
</button>
</>
)}
{j.status === 'failed' && (
<span className="yt-status yt-status--failed">실패</span>
)}
{j.status === 'pending' && (
<span className="yt-status yt-status--pending">대기</span>
)}
<button type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => handleDelete(j.id)}
style={{marginLeft: 4}}
>🗑</button>
</div>
))}
</div>
</div>
)}
{/* 내보내기 패키지 */}
{exportData && (
<div className="yt-card yt-card--export">
<h3 className="yt-card__title"> 내보내기</h3>
<div className="yt-export-links">
<a href={exportData.mp4_url} download
className="ms-btn ms-btn--primary ms-btn--sm">
MP4 다운로드
</a>
</div>
<div className="yt-meta-preview">
<div className="yt-meta-preview__label">파일 정보</div>
<pre className="yt-meta-preview__content">
{JSON.stringify({
title: exportData.title,
duration: fmtDuration(exportData.duration_sec),
mp4_url: exportData.mp4_url,
}, null, 2)}
</pre>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useState } from 'react';
import { cancelPipeline, publishPipeline } from '../../../api';
import PipelineDetailModal from './PipelineDetailModal';
const STEP_LABELS = ['커버','영상','썸네','메타','검토','발행'];
function stepIndex(state) {
if (!state) return -1;
if (state.startsWith('cover')) return 0;
if (state.startsWith('video')) return 1;
if (state.startsWith('thumb')) return 2;
if (state.startsWith('meta')) return 3;
if (state.startsWith('ai_review') || state === 'publish_pending') return 4;
if (state.startsWith('publish')) return 5;
if (state === 'published') return 6;
return -1;
}
export default function PipelineCard({ pipeline, onChanged }) {
const [showDetail, setShowDetail] = useState(false);
const i = stepIndex(pipeline.state);
const title = pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`;
const handleCardClick = (e) => {
if (e.target.closest('button') || e.target.closest('a')) return;
setShowDetail(true);
};
return (
<>
<div className="pipeline-card" onClick={handleCardClick}>
<div className="pipeline-card__head">
<h4>{title}</h4>
{pipeline.visual_style && (
<span className="pipeline-style-badge">{pipeline.visual_style}</span>
)}
{!['published','cancelled','failed'].includes(pipeline.state) && (
<button onClick={async () => { await cancelPipeline(pipeline.id); onChanged(); }}>
취소
</button>
)}
</div>
<div className="pipeline-previews">
{pipeline.cover_url && (
<img src={pipeline.cover_url} alt="" className="pipeline-preview-mini" />
)}
{pipeline.thumbnail_url && (
<img src={pipeline.thumbnail_url} alt="" className="pipeline-preview-mini" />
)}
{pipeline.video_url && <span className="pipeline-video-icon"></span>}
</div>
<div className="pipeline-progress">
{STEP_LABELS.map((lbl, idx) => (
<div key={lbl}
className={`pipeline-dot ${idx <= i ? 'is-done' : ''} ${idx === i ? 'is-current' : ''}`}>
<span>{lbl}</span>
</div>
))}
</div>
<div className="pipeline-state">현재: {pipeline.state}</div>
{pipeline.review && (
<div className="pipeline-review">
AI 검토: <strong>{pipeline.review.verdict}</strong>
({pipeline.review.weighted_total}/100)
</div>
)}
{pipeline.state === 'publish_pending' && (
<button className="button primary"
onClick={async () => { await publishPipeline(pipeline.id); onChanged(); }}>
YouTube 업로드
</button>
)}
{pipeline.youtube_video_id && (
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
target="_blank" rel="noreferrer">
유튜브에서 보기
</a>
)}
</div>
{showDetail && <PipelineDetailModal pipeline={pipeline} onClose={() => setShowDetail(false)} />}
</>
);
}

View File

@@ -0,0 +1,117 @@
const fmtTimestamp = (sec) => {
if (sec == null) return '';
const total = Math.floor(sec);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
return `${m}:${String(s).padStart(2,'0')}`;
};
export default function PipelineDetailModal({ pipeline, onClose }) {
if (!pipeline) return null;
const meta = pipeline.metadata || {};
const review = pipeline.review || {};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-body modal-body--lg" onClick={e => e.stopPropagation()}>
<header className="pdm-header">
<h3>{pipeline.compile_title || pipeline.track_title || `Pipeline #${pipeline.id}`}</h3>
<span className="pdm-badge">{pipeline.visual_style || 'essential'}</span>
<button onClick={onClose} className="pdm-close" aria-label="close">×</button>
</header>
<div className="pdm-grid">
{pipeline.cover_url && (
<figure className="pdm-figure">
<img src={pipeline.cover_url} alt="cover" />
<figcaption>커버 (배경)</figcaption>
</figure>
)}
{pipeline.thumbnail_url && (
<figure className="pdm-figure">
<img src={pipeline.thumbnail_url} alt="thumbnail" />
<figcaption>썸네일</figcaption>
</figure>
)}
</div>
{pipeline.video_url && (
<div className="pdm-video">
<video src={pipeline.video_url} controls preload="metadata" width="100%" />
</div>
)}
{meta.title && (
<section className="pdm-section">
<h4>메타데이터</h4>
<p><strong>제목:</strong> {meta.title}</p>
<details>
<summary>설명 ({(meta.description || '').length})</summary>
<pre className="pdm-pre">{meta.description}</pre>
</details>
<p><strong>태그:</strong> {(meta.tags || []).join(', ')}</p>
</section>
)}
{review.weighted_total != null && (
<section className="pdm-section">
<h4>
AI 검토
<span className={`pdm-verdict pdm-verdict--${review.verdict}`}>
{review.verdict}
</span>
<span className="pdm-score">({review.weighted_total}/100)</span>
</h4>
<table className="pdm-review-table">
<tbody>
<tr><td>메타데이터 품질</td><td>{review.metadata_quality?.score}</td></tr>
<tr><td>콘텐츠 정책</td><td>{review.policy_compliance?.score}</td></tr>
<tr><td>시청 경험</td><td>{review.viewer_experience?.score}</td></tr>
<tr><td>트렌드 정렬</td><td>{review.trend_alignment?.score}</td></tr>
</tbody>
</table>
{review.summary && <p className="pdm-summary"><em>{review.summary}</em></p>}
</section>
)}
{pipeline.tracks && pipeline.tracks.length > 1 && (
<section className="pdm-section">
<h4>트랙 리스트 ({pipeline.tracks.length})</h4>
<ol className="pdm-tracks">
{pipeline.tracks.map(t => (
<li key={t.id}>
<span className="pdm-track-time">[{fmtTimestamp(t.start_offset_sec)}]</span>
{' '}{t.title}
<span className="pdm-track-dur"> ({fmtTimestamp(t.duration_sec)})</span>
</li>
))}
</ol>
</section>
)}
{pipeline.feedback && pipeline.feedback.length > 0 && (
<section className="pdm-section">
<h4>피드백 히스토리 ({pipeline.feedback.length})</h4>
<ul className="pdm-feedback">
{pipeline.feedback.map(f => (
<li key={f.id}>
<code>[{f.step}]</code> {f.feedback_text}
<small> {(f.received_at || '').replace('T', ' ')}</small>
</li>
))}
</ul>
</section>
)}
{pipeline.youtube_video_id && (
<a href={`https://youtu.be/${pipeline.youtube_video_id}`}
target="_blank" rel="noreferrer" className="pdm-youtube">
🎬 YouTube에서 보기
</a>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { useState, useEffect } from 'react';
import { createPipeline, startPipeline, getCompileJobs } from '../../../api';
const fmtDur = (s) => {
if (!s) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.round(s % 60);
return `${m}:${String(sec).padStart(2, '0')}`;
};
export default function PipelineStartModal({ library, initialTrackId, onClose, onCreated }) {
const [inputType, setInputType] = useState('track'); // 'track' | 'compile'
const [tid, setTid] = useState(initialTrackId || library?.[0]?.id || '');
const [cid, setCid] = useState('');
const [compileJobs, setCompileJobs] = useState([]);
const [advanced, setAdvanced] = useState(false);
const [visualStyle, setVisualStyle] = useState('');
const [bgMode, setBgMode] = useState('');
const [bgKeyword, setBgKeyword] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (inputType === 'compile') {
getCompileJobs()
.then(r => {
const list = (r.jobs || r || []);
const completed = list.filter(j => j.status === 'done' || j.status === 'succeeded');
setCompileJobs(completed);
if (completed.length && !cid) setCid(completed[0].id);
})
.catch(e => setError(String(e)));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputType]);
const submit = async () => {
try {
const payload = {};
if (inputType === 'track') {
payload.track_id = parseInt(tid);
} else {
if (!cid) {
setError('완료된 Mix를 선택해주세요');
return;
}
payload.compile_job_id = parseInt(cid);
}
if (visualStyle) payload.visual_style = visualStyle;
if (bgMode) payload.background_mode = bgMode;
if (bgKeyword) payload.background_keyword = bgKeyword;
const p = await createPipeline(payload);
await startPipeline(p.id);
onCreated(p);
} catch (e) {
setError(e.message || String(e));
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-body" onClick={e => e.stopPropagation()}>
<h3> 파이프라인 시작</h3>
<fieldset className="psm-input-radio">
<legend>입력</legend>
<label>
<input type="radio" checked={inputType === 'track'}
onChange={() => setInputType('track')} />
{' '}단일 트랙
</label>
<label style={{ marginLeft: 12 }}>
<input type="radio" checked={inputType === 'compile'}
onChange={() => setInputType('compile')} />
{' '}Mix (컴파일 결과)
</label>
</fieldset>
{inputType === 'track' ? (
<select value={tid} onChange={e => setTid(e.target.value)}>
{(library || []).map(t => (
<option key={t.id} value={t.id}>{t.title} ({t.genre})</option>
))}
</select>
) : (
<select value={cid} onChange={e => setCid(e.target.value)}>
{compileJobs.length === 0 && <option value="">완료된 Mix가 없습니다</option>}
{compileJobs.map(j => (
<option key={j.id} value={j.id}>
{j.title || `Mix #${j.id}`}
{' '}({fmtDur(j.duration_sec || 0)},{' '}
{j.tracks_count || (j.track_ids && j.track_ids.length) || '?'})
</option>
))}
</select>
)}
<label className="psm-keyword-main">
원하는 이미지 분위기 (선택)
<input
value={bgKeyword}
onChange={e => setBgKeyword(e.target.value)}
placeholder="예: 스케이트보드 파크 밝은 오후, 비 오는 카페 창가, 산 정상 일출 ..."
/>
<small>처음부터 cover 이미지 prompt에 반영됩니다. 비우면 장르 기본값 사용.</small>
</label>
<details className="psm-advanced" open={advanced}>
<summary onClick={(e) => { e.preventDefault(); setAdvanced(!advanced); }}>
고급 옵션
</summary>
<label>
시각 스타일
<select value={visualStyle} onChange={e => setVisualStyle(e.target.value)}>
<option value="">기본 (구성 default)</option>
<option value="essential">essential (배경 + 중앙 비주얼)</option>
<option value="single">single (커버 + 가장자리 파형)</option>
</select>
</label>
<label>
배경 모드
<select value={bgMode} onChange={e => setBgMode(e.target.value)}>
<option value="">기본 (구성 default)</option>
<option value="static">정적 사진</option>
<option value="video_loop">영상 루프 (Pexels)</option>
</select>
</label>
</details>
{error && <div className="ms-error">{error}</div>}
<div className="modal-actions">
<button onClick={onClose}>취소</button>
<button className="button primary" onClick={submit}
disabled={inputType === 'compile' && !cid}>
시작
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useState, useRef } from 'react';
import { listPipelines } from '../../../api';
import PipelineCard from './PipelineCard';
import PipelineStartModal from './PipelineStartModal';
export default function PipelineTab({ library, initialTrackId }) {
const [pipelines, setPipelines] = useState([]);
const [filter, setFilter] = useState('active');
const [modalOpen, setModalOpen] = useState(false);
const timer = useRef(null);
const load = async () => {
try {
const r = await listPipelines(filter);
setPipelines(r.pipelines || []);
} catch { /* swallow */ }
};
useEffect(() => {
load();
timer.current = setInterval(load, 5000);
return () => clearInterval(timer.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter]);
useEffect(() => {
if (initialTrackId) setModalOpen(true);
}, [initialTrackId]);
return (
<div className="pipeline-container">
<div className="pipeline-toolbar">
<button className="button primary" onClick={() => setModalOpen(true)}>+ 파이프라인</button>
<select value={filter} onChange={e => setFilter(e.target.value)}>
<option value="active">진행 </option>
<option value="all">전체</option>
</select>
</div>
<div className="pipeline-grid">
{pipelines.map(p => (
<PipelineCard key={p.id} pipeline={p} onChanged={load} />
))}
{pipelines.length === 0 && <p className="ms-empty">진행 중인 파이프라인이 없습니다</p>}
</div>
{modalOpen && (
<PipelineStartModal
library={library}
initialTrackId={initialTrackId}
onClose={() => setModalOpen(false)}
onCreated={() => { setModalOpen(false); load(); }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,276 @@
// src/pages/music/components/RevenueTab.jsx
import { useState, useEffect } from 'react';
import {
getRevenueDashboard, getRevenueRecords,
addRevenueRecord, updateRevenueRecord, deleteRevenueRecord,
} from '../../../api';
const COUNTRIES = ['BR', 'US', 'ID', 'MX', 'KR'];
const currentMonth = () => new Date().toISOString().slice(0, 7);
export default function RevenueTab() {
const [dashboard, setDashboard] = useState(null);
const [records, setRecords] = useState([]);
const [form, setForm] = useState({
yt_video_id: '', record_month: currentMonth(),
revenue_usd: '', views: '', country: 'BR',
});
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({});
const loadAll = async () => {
const [dash, recs] = await Promise.all([
getRevenueDashboard().catch(() => null),
getRevenueRecords().catch(() => []),
]);
setDashboard(dash);
setRecords(Array.isArray(recs) ? recs : recs.records ?? []);
};
useEffect(() => { loadAll(); }, []);
const handleAdd = async () => {
if (!form.yt_video_id || !form.revenue_usd || !form.views) return;
setSaving(true);
try {
await addRevenueRecord({
yt_video_id: form.yt_video_id,
record_month: form.record_month,
revenue_usd: parseFloat(form.revenue_usd),
views: parseInt(form.views, 10),
country: form.country,
});
setForm({ yt_video_id: '', record_month: currentMonth(), revenue_usd: '', views: '', country: 'BR' });
await loadAll();
} catch (e) {
console.error('addRevenueRecord:', e);
} finally {
setSaving(false);
}
};
const handleEditSave = async () => {
try {
await updateRevenueRecord(editingId, {
revenue_usd: parseFloat(editForm.revenue_usd),
views: parseInt(editForm.views, 10),
});
setEditingId(null);
await loadAll();
} catch (e) {
console.error('updateRevenueRecord:', e);
}
};
const handleDelete = async (id) => {
if (!window.confirm('이 기록을 삭제할까요?')) return;
try {
await deleteRevenueRecord(id);
await loadAll();
} catch (e) {
console.error('deleteRevenueRecord:', e);
}
};
// 영상별 RPM 상위 5개 (bar chart 용)
const chartData = records
.filter(r => r.views > 0)
.map(r => ({
label: r.yt_video_id,
rpm: (r.revenue_usd / r.views) * 1000,
}))
.sort((a, b) => b.rpm - a.rpm)
.slice(0, 5);
const maxRpm = chartData.length > 0 ? Math.max(...chartData.map(d => d.rpm)) : 1;
return (
<div className="yt-content">
{/* 대시보드 카드 3개 */}
<div className="yt-dash-cards">
<div className="yt-dash-card">
<div className="yt-dash-card__label"> 수익</div>
<div className="yt-dash-card__value yt-dash-card__value--green">
${dashboard?.total_revenue_usd?.toFixed(2) ?? '—'}
</div>
<div className="yt-dash-card__sub">누적</div>
</div>
<div className="yt-dash-card">
<div className="yt-dash-card__label"> 조회수</div>
<div className="yt-dash-card__value yt-dash-card__value--blue">
{dashboard?.total_views != null
? (dashboard.total_views >= 1000
? `${(dashboard.total_views / 1000).toFixed(1)}K`
: String(dashboard.total_views))
: '—'}
</div>
<div className="yt-dash-card__sub">누적</div>
</div>
<div className="yt-dash-card">
<div className="yt-dash-card__label">평균 RPM</div>
<div className="yt-dash-card__value yt-dash-card__value--amber">
${dashboard?.avg_rpm?.toFixed(2) ?? '—'}
</div>
<div className="yt-dash-card__sub">가중평균</div>
</div>
</div>
{/* 영상별 RPM 바 차트 */}
{chartData.length > 0 && (
<div className="yt-card">
<h3 className="yt-card__title">영상별 RPM 비교</h3>
<div className="yt-bar-chart">
{chartData.map((d, i) => (
<div key={i} className="yt-bar-row">
<div className="yt-bar-row__label" title={d.label}>
{d.label.slice(0, 11)}
</div>
<div className="yt-bar-row__track">
<div
className="yt-bar-row__fill"
style={{ width: `${(d.rpm / maxRpm) * 100}%` }}
/>
</div>
<div className="yt-bar-row__value">${d.rpm.toFixed(2)}</div>
</div>
))}
</div>
</div>
)}
{/* 수익 기록 추가 폼 */}
<div className="yt-card yt-card--create">
<h3 className="yt-card__title">+ 수익 기록 추가</h3>
<div className="yt-form-grid">
<div className="yt-field">
<label className="yt-field__label">YouTube 영상 ID</label>
<input
className="yt-input"
value={form.yt_video_id}
onChange={e => setForm(f => ({ ...f, yt_video_id: e.target.value }))}
placeholder="dQw4w9WgXcQ"
/>
</div>
<div className="yt-field">
<label className="yt-field__label">기록 </label>
<input
className="yt-input"
type="month"
value={form.record_month}
onChange={e => setForm(f => ({ ...f, record_month: e.target.value }))}
/>
</div>
<div className="yt-field">
<label className="yt-field__label">수익 (USD)</label>
<input
className="yt-input"
type="number"
step="0.01"
value={form.revenue_usd}
onChange={e => setForm(f => ({ ...f, revenue_usd: e.target.value }))}
placeholder="3.45"
/>
</div>
<div className="yt-field">
<label className="yt-field__label">조회수</label>
<input
className="yt-input"
type="number"
value={form.views}
onChange={e => setForm(f => ({ ...f, views: e.target.value }))}
placeholder="1200"
/>
</div>
</div>
<div className="yt-row yt-row--bottom">
<select
className="yt-select"
value={form.country}
onChange={e => setForm(f => ({ ...f, country: e.target.value }))}
>
{COUNTRIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<button
type="button"
className="ms-btn ms-btn--primary"
onClick={handleAdd}
disabled={saving}
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
{/* 수익 기록 테이블 */}
<div className="yt-card">
<h3 className="yt-card__title">수익 기록</h3>
{records.length === 0 ? (
<p className="yt-empty">수익 기록이 없습니다. 폼으로 추가해보세요.</p>
) : (
<div className="yt-table">
<div className="yt-table__header">
<span>영상 ID</span>
<span></span>
<span>수익</span>
<span>조회수</span>
<span>RPM</span>
<span />
</div>
{records.map(rec => (
editingId === rec.id ? (
<div key={rec.id} className="yt-table__row yt-table__row--editing">
<span className="yt-table__cell">{rec.yt_video_id.slice(0, 11)}</span>
<span className="yt-table__cell">{rec.record_month}</span>
<input
className="yt-input yt-input--sm"
type="number"
step="0.01"
value={editForm.revenue_usd}
onChange={e => setEditForm(f => ({ ...f, revenue_usd: e.target.value }))}
/>
<input
className="yt-input yt-input--sm"
type="number"
value={editForm.views}
onChange={e => setEditForm(f => ({ ...f, views: e.target.value }))}
/>
<span className="yt-table__cell"></span>
<div className="yt-table__actions">
<button type="button" className="ms-btn ms-btn--primary ms-btn--sm" onClick={handleEditSave}>저장</button>
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={() => setEditingId(null)}>취소</button>
</div>
</div>
) : (
<div
key={rec.id}
className="yt-table__row"
onClick={() => {
setEditingId(rec.id);
setEditForm({ revenue_usd: rec.revenue_usd, views: rec.views });
}}
style={{ cursor: 'pointer' }}
>
<span className="yt-table__cell yt-table__cell--mono">{rec.yt_video_id.slice(0, 11)}</span>
<span className="yt-table__cell">{rec.record_month}</span>
<span className="yt-table__cell yt-table__cell--green">${rec.revenue_usd?.toFixed(2)}</span>
<span className="yt-table__cell">{rec.views?.toLocaleString()}</span>
<span className="yt-table__cell yt-table__cell--amber">
{rec.views > 0
? `$${((rec.revenue_usd / rec.views) * 1000).toFixed(2)}`
: '—'}
</span>
<button
type="button"
className="ms-btn--icon ms-btn--danger"
onClick={e => { e.stopPropagation(); handleDelete(rec.id); }}
aria-label="삭제"
></button>
</div>
)
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import {
getMusicSetup, updateMusicSetup,
getYoutubeAuthUrl, getYoutubeStatus, disconnectYoutube,
} from '../../../api';
export default function SetupTab() {
const [setup, setSetup] = useState(null);
const [yt, setYt] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([getMusicSetup(), getYoutubeStatus()])
.then(([s, y]) => { setSetup(s); setYt(y); })
.catch(e => setError(String(e)));
}, []);
if (!setup) return <p className="ms-loading">Loading</p>;
const save = async (patch) => {
setSaving(true);
try {
const next = await updateMusicSetup(patch);
setSetup(next);
} catch (e) { setError(String(e)); }
finally { setSaving(false); }
};
const connectYoutube = async () => {
const { url } = await getYoutubeAuthUrl();
window.location.href = url;
};
return (
<div className="setup-container">
{error && <div className="ms-error">{error}</div>}
<section className="setup-card">
<h3>YouTube 채널 연동</h3>
{yt && yt.channel_id ? (
<div className="setup-channel">
{yt.avatar_url && <img src={yt.avatar_url} alt="" className="setup-avatar" />}
<span>{yt.channel_title}</span>
<button onClick={async () => { await disconnectYoutube(); setYt({}); }}>
연결 해제
</button>
</div>
) : (
<button className="button primary" onClick={connectYoutube}>
Google 계정 연결
</button>
)}
</section>
<section className="setup-card">
<h3>메타데이터 템플릿</h3>
<label>제목 패턴
<input
value={setup.metadata_template.title}
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template, title: e.target.value}}))}
/>
</label>
<label>설명 템플릿
<textarea
rows={6}
value={setup.metadata_template.description}
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template, description: e.target.value}}))}
/>
</label>
<label>기본 태그 (쉼표 구분)
<input
value={(setup.metadata_template.tags || []).join(', ')}
onChange={e => setSetup(s => ({...s, metadata_template: {...s.metadata_template,
tags: e.target.value.split(',').map(t => t.trim()).filter(Boolean)}}))}
/>
</label>
<button onClick={() => save({ metadata_template: setup.metadata_template })}>저장</button>
</section>
<section className="setup-card">
<h3>AI 커버 prompt (장르별)</h3>
{Object.entries(setup.cover_prompts).map(([g, p]) => (
<div key={g} className="setup-prompt-row">
<span className="setup-prompt-genre">{g}</span>
<input
value={p}
onChange={e => setSetup(s => ({...s, cover_prompts: {...s.cover_prompts, [g]: e.target.value}}))}
/>
</div>
))}
<button onClick={() => save({ cover_prompts: setup.cover_prompts })}>저장</button>
</section>
<section className="setup-card">
<h3>AI 최종 검토 기준</h3>
{['meta','policy','viewer','trend'].map(k => (
<label key={k}>
{k} 가중치 ({setup.review_weights[k]})
<input type="range" min="0" max="100"
value={setup.review_weights[k]}
onChange={e => setSetup(s => ({...s, review_weights: {...s.review_weights, [k]: parseInt(e.target.value)}}))}
/>
</label>
))}
<label>임계값 ({setup.review_threshold})
<input type="range" min="0" max="100" value={setup.review_threshold}
onChange={e => setSetup(s => ({...s, review_threshold: parseInt(e.target.value)}))}
/>
</label>
<button onClick={() => save({ review_weights: setup.review_weights, review_threshold: setup.review_threshold })}>저장</button>
</section>
<section className="setup-card">
<h3>영상 비주얼 기본값</h3>
<label>해상도
<select value={setup.visual_defaults.resolution || '1920x1080'}
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, resolution: e.target.value}}))}>
<option value="1920x1080">1920×1080 (가로)</option>
<option value="1080x1920">1080×1920 (세로/Shorts)</option>
</select>
</label>
<label>기본 시각 스타일
<select value={setup.visual_defaults.default_visual_style || 'essential'}
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_visual_style: e.target.value}}))}>
<option value="essential">essential (배경 + 중앙 비주얼)</option>
<option value="single">single (커버 + 가장자리 파형)</option>
</select>
</label>
<label>기본 배경 모드
<select value={setup.visual_defaults.default_background_mode || 'static'}
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_background_mode: e.target.value}}))}>
<option value="static">정적 사진</option>
<option value="video_loop">영상 루프 (Pexels)</option>
</select>
</label>
<label>기본 배경 키워드 (비우면 장르 기반 자동)
<input value={setup.visual_defaults.default_background_keyword || ''}
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, default_background_keyword: e.target.value}}))}
placeholder="lofi cafe, rainy window, mountain ..." />
</label>
<label>배경 이미지 소스 (정적 모드)
<select value={setup.visual_defaults.background_image_source || 'ai'}
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, background_image_source: e.target.value}}))}>
<option value="ai">AI 생성 (DALL·E)</option>
<option value="pexels">Pexels 스톡 사진</option>
</select>
</label>
<label className="setup-checkbox">
<input type="checkbox"
checked={setup.visual_defaults.subtitle_track_titles ?? true}
onChange={e => setSetup(s => ({...s, visual_defaults: {...s.visual_defaults, subtitle_track_titles: e.target.checked}}))}/>
Mix에서 곡명 자막 표시 (트랙 시작 5)
</label>
<button onClick={() => save({ visual_defaults: setup.visual_defaults })}>저장</button>
</section>
<section className="setup-card">
<h3>발행 정책</h3>
<label>privacy
<select value={setup.publish_policy.privacy}
onChange={e => setSetup(s => ({...s, publish_policy: {...s.publish_policy, privacy: e.target.value}}))}>
<option value="private">Private (비공개)</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</option>
</select>
</label>
<button onClick={() => save({ publish_policy: setup.publish_policy })}>저장</button>
</section>
{saving && <div className="setup-saving">저장 ...</div>}
</div>
);
}

View File

@@ -0,0 +1,220 @@
// src/pages/music/components/TrendsTab.jsx
import { useState, useEffect, useRef } from 'react';
import {
getLatestTrendReport, getTrendReports,
getMarketSuggestions, triggerYoutubeResearch,
} from '../../../api';
const FLAG = { BR: '🇧🇷', US: '🇺🇸', ID: '🇮🇩', MX: '🇲🇽', KR: '🇰🇷' };
function fmtDateTime(iso) {
if (!iso) return null;
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(0, 10);
const today = new Date().toDateString();
if (d.toDateString() === today) {
return `오늘 ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
return iso.slice(0, 10); // YYYY-MM-DD
}
export default function TrendsTab() {
const [latestReport, setLatestReport] = useState(null);
const [reports, setReports] = useState([]);
const [suggestions, setSuggestions] = useState([]);
const [selectedReport, setSelectedReport] = useState(null);
const [researching, setResearching] = useState(false);
const [copiedIdx, setCopiedIdx] = useState(null);
const [loading, setLoading] = useState(true);
const [researchMsg, setResearchMsg] = useState('');
const researchTimerRef = useRef(null);
const copyTimerRef = useRef(null);
const loadAll = async () => {
setLoading(true);
try {
const [latest, rpts, sugg] = await Promise.all([
getLatestTrendReport().catch(() => null),
getTrendReports().catch(() => []),
getMarketSuggestions().catch(() => []),
]);
setLatestReport(latest);
setReports(Array.isArray(rpts) ? rpts : rpts.reports ?? []);
setSuggestions(Array.isArray(sugg) ? sugg : sugg.suggestions ?? []);
} catch (e) {
console.error('loadAll:', e);
} finally {
setLoading(false);
}
};
useEffect(() => { loadAll(); }, []);
useEffect(() => {
return () => {
clearTimeout(researchTimerRef.current);
clearTimeout(copyTimerRef.current);
};
}, []);
const handleResearch = async () => {
setResearching(true);
try {
await triggerYoutubeResearch();
setResearchMsg('수집이 시작되었습니다. 잠시 후 새로고침하세요.');
clearTimeout(researchTimerRef.current);
researchTimerRef.current = setTimeout(() => setResearchMsg(''), 4000);
} catch (e) {
console.error('triggerYoutubeResearch:', e);
} finally {
setResearching(false);
}
};
const handleCopy = (text, idx) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedIdx(idx);
clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopiedIdx(null), 2000);
});
};
// 선택된 리포트가 있으면 그것, 없으면 최신 리포트의 장르 표시
const displayReport = selectedReport ?? latestReport;
const topGenres = displayReport?.top_genres?.slice(0, 5) ?? [];
const maxScore = topGenres.length > 0 ? Math.max(...topGenres.map(g => g.score)) : 1;
// Suno 프롬프트: 선택된 리포트가 있으면 그것의 recommended_styles, 없으면 라이브 suggestions
const displaySuggestions = selectedReport
? (selectedReport.recommended_styles ?? [])
: suggestions;
if (loading) return <div className="yt-content"><p className="yt-empty">데이터 로딩 ...</p></div>;
return (
<div className="yt-content">
{/* 수집 상태 바 */}
<div className="yt-status-bar">
<div className="yt-status-bar__left">
<span className="yt-status-dot" />
<span className="yt-status-bar__text">
마지막 수집 일시: <strong>{fmtDateTime(latestReport?.created_at) ?? latestReport?.report_date ?? '없음'}</strong>
{latestReport && ` · ${latestReport.top_genres?.length ?? 0}개 장르`}
</span>
</div>
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={handleResearch}
disabled={researching}
>
{researching ? '수집 중...' : '↻ 수동 수집'}
</button>
{researchMsg && <p className="yt-empty" style={{ color: '#22c55e', marginTop: 4 }}>{researchMsg}</p>}
</div>
{/* 인기 장르 Top 5 */}
<div className="yt-card">
<h3 className="yt-card__title">🔥 오늘의 인기 장르 Top 5</h3>
{topGenres.length === 0 ? (
<p className="yt-empty">
트렌드 데이터가 없습니다. 수동 수집을 실행하거나 agent-office가 내일 09:00 자동 수집합니다.
</p>
) : (
<div className="yt-bar-chart yt-bar-chart--genre">
{topGenres.map((g, i) => (
<div key={i} className="yt-bar-row">
<div className="yt-bar-row__rank">#{i + 1}</div>
<div className="yt-bar-row__info">
<div className="yt-bar-row__genre-header">
<span className="yt-bar-row__genre-name">{g.genre}</span>
<span className="yt-bar-row__flags">
{(g.countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
</span>
</div>
<div className="yt-bar-row__track">
<div
className="yt-bar-row__fill yt-bar-row__fill--genre"
style={{ width: `${(g.score / maxScore) * 100}%` }}
/>
</div>
</div>
<div className="yt-bar-row__value">{g.score}</div>
</div>
))}
</div>
)}
</div>
{/* Suno 프롬프트 추천 */}
{displaySuggestions.length > 0 && (
<div className="yt-card">
<h3 className="yt-card__title">
{selectedReport
? `${selectedReport.report_date} 추천 프롬프트`
: '✨ AI 추천 Suno 프롬프트'}
</h3>
<div className="yt-prompt-list">
{displaySuggestions.map((s, i) => (
<div key={i} className="yt-prompt-card">
<div className="yt-prompt-card__header">
<span className="yt-prompt-card__genre">{s.genre}</span>
<span className="yt-prompt-card__countries">
{(s.target_countries ?? []).map(c => FLAG[c] ?? c).join(' ')}
</span>
</div>
<button
type="button"
className="yt-prompt-card__text"
onClick={() => handleCopy(s.suno_prompt, i)}
title="클릭해서 복사"
>
{s.suno_prompt}
</button>
{copiedIdx === i && (
<span className="yt-prompt-card__copied"> 복사됨</span>
)}
{s.reason && (
<div className="yt-prompt-card__reason">{s.reason}</div>
)}
</div>
))}
</div>
</div>
)}
{/* 트렌드 리포트 이력 */}
<div className="yt-card">
<h3 className="yt-card__title">📋 트렌드 리포트 이력</h3>
{reports.length === 0 ? (
<p className="yt-empty">리포트 이력이 없습니다</p>
) : (
<div className="yt-report-list">
{reports.map(r => (
<div
key={r.id ?? r.report_date}
className={`yt-report-row ${selectedReport?.report_date === r.report_date ? 'is-selected' : ''}`}
onClick={() => {
setSelectedReport(selectedReport?.report_date === r.report_date ? null : r);
setCopiedIdx(null);
}}
>
<span className="yt-report-row__date">
{r.report_date}
{r.report_date === latestReport?.report_date && (
<span className="yt-report-row__today"> 오늘</span>
)}
</span>
<span className="yt-report-row__meta">
{r.top_genres?.length ?? 0} 장르 · {r.recommended_styles?.length ?? 0} 추천
</span>
<span className="yt-report-row__action">보기 </span>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,269 @@
// src/pages/music/components/VideoProjectsTab.jsx
import { useState, useEffect, useRef, useCallback } from 'react';
import {
createVideoProject, getVideoProjects,
renderVideoProject, exportVideoProject, deleteVideoProject,
} from '../../../api';
const COUNTRY_OPTIONS = ['BR', 'US', 'ID', 'MX', 'KR'];
const COUNTRY_FLAGS = { BR: '🇧🇷', US: '🇺🇸', ID: '🇮🇩', MX: '🇲🇽', KR: '🇰🇷' };
export default function VideoProjectsTab({ library, initialTrackId, onClearInitialTrack }) {
const [projects, setProjects] = useState([]);
const [selectedTrackId, setSelectedTrackId] = useState(initialTrackId ?? '');
const [format, setFormat] = useState('visualizer');
const [countries, setCountries] = useState(['BR']);
const [creating, setCreating] = useState(false);
const [exportData, setExportData] = useState(null);
const [exportingId, setExportingId] = useState(null);
const pollRef = useRef(null);
// initialTrackId prop 반영
useEffect(() => {
if (initialTrackId) {
setSelectedTrackId(String(initialTrackId));
onClearInitialTrack?.();
}
}, [initialTrackId]);
const loadProjects = useCallback(async () => {
try {
const data = await getVideoProjects();
setProjects(Array.isArray(data) ? data : data.projects ?? []);
} catch (e) {
console.error('getVideoProjects:', e);
}
}, []);
useEffect(() => { loadProjects(); }, []);
// 렌더링 중인 프로젝트가 있으면 5초마다 폴링
useEffect(() => {
const hasRendering = projects.some(p => p.status === 'rendering');
if (hasRendering && !pollRef.current) {
pollRef.current = setInterval(loadProjects, 5000);
} else if (!hasRendering && pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
return () => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
};
}, [projects, loadProjects]);
const toggleCountry = (c) => {
setCountries(prev =>
prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]
);
};
const handleCreate = async () => {
if (!selectedTrackId || countries.length === 0) return;
setCreating(true);
try {
await createVideoProject({
track_id: Number(selectedTrackId),
format,
target_countries: countries,
});
await loadProjects();
} catch (e) {
console.error('createVideoProject:', e);
} finally {
setCreating(false);
}
};
const handleRender = async (id) => {
try {
await renderVideoProject(id);
await loadProjects();
} catch (e) {
console.error('renderVideoProject:', e);
}
};
const handleExport = async (id) => {
setExportingId(id);
try {
const data = await exportVideoProject(id);
setExportData({ id, ...data });
} catch (e) {
console.error('exportVideoProject:', e);
} finally {
setExportingId(null);
}
};
const handleDelete = async (id) => {
if (!window.confirm('이 프로젝트를 삭제할까요?')) return;
try {
await deleteVideoProject(id);
setProjects(prev => prev.filter(p => p.id !== id));
if (exportData?.id === id) setExportData(null);
} catch (e) {
console.error('deleteVideoProject:', e);
}
};
return (
<div className="yt-content">
{/* ① 새 영상 만들기 */}
<div className="yt-card yt-card--create">
<h3 className="yt-card__title"> 영상 만들기</h3>
<div className="yt-row">
<select
className="yt-select"
value={selectedTrackId}
onChange={e => setSelectedTrackId(e.target.value)}
>
<option value="">📚 트랙 선택...</option>
{(library ?? []).map(t => (
<option key={t.id} value={String(t.id)}>{t.title}</option>
))}
</select>
<div className="yt-format-toggle">
{['visualizer', 'slideshow'].map(f => (
<button
key={f}
type="button"
className={`yt-format-btn ${format === f ? 'is-active' : ''}`}
onClick={() => setFormat(f)}
>
{f === 'visualizer' ? '비주얼라이저' : '슬라이드쇼'}
</button>
))}
</div>
</div>
<div className="yt-country-label">타겟 국가 (복수 선택)</div>
<div className="yt-country-chips">
{COUNTRY_OPTIONS.map(c => (
<button
key={c}
type="button"
className={`yt-chip ${countries.includes(c) ? 'is-active' : ''}`}
onClick={() => toggleCountry(c)}
>
{COUNTRY_FLAGS[c]} {c}
</button>
))}
</div>
<button
type="button"
className="ms-btn ms-btn--primary yt-create-btn"
onClick={handleCreate}
disabled={creating || !selectedTrackId || countries.length === 0}
>
{creating ? '생성 중...' : '프로젝트 생성'}
</button>
</div>
{/* ② 프로젝트 목록 */}
<div className="yt-card">
<h3 className="yt-card__title"> 영상 프로젝트</h3>
{projects.length === 0 ? (
<p className="yt-empty">트랙을 선택해 영상을 만들어보세요</p>
) : (
<div className="yt-project-list">
{projects.map(p => (
<ProjectCard
key={p.id}
project={p}
onRender={handleRender}
onExport={handleExport}
onDelete={handleDelete}
isExporting={exportingId === p.id}
/>
))}
</div>
)}
</div>
{/* ③ 내보내기 패키지 */}
{exportData && (
<div className="yt-card yt-card--export">
<h3 className="yt-card__title"> 내보내기 패키지</h3>
<div className="yt-export-links">
{exportData.mp4_url && (
<a href={exportData.mp4_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
📹 output.mp4 다운로드
</a>
)}
{exportData.thumbnail_url && (
<a href={exportData.thumbnail_url} download className="ms-btn ms-btn--ghost ms-btn--sm">
🖼 thumbnail.jpg
</a>
)}
</div>
{exportData.metadata && (
<div className="yt-meta-preview">
<div className="yt-meta-preview__label">metadata.json 미리보기</div>
<pre className="yt-meta-preview__content">
{JSON.stringify(exportData.metadata, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
function ProjectCard({ project, onRender, onExport, onDelete, isExporting }) {
const STATUS_MAP = {
pending: { text: '대기', cls: 'yt-status--pending' },
rendering: { text: '⚙ 처리중', cls: 'yt-status--rendering' },
done: { text: '✓ 완료', cls: 'yt-status--done' },
failed: { text: '실패', cls: 'yt-status--failed' },
};
const s = STATUS_MAP[project.status] ?? { text: project.status, cls: '' };
return (
<div className="yt-project-card">
<div className="yt-project-card__icon">
{project.status === 'rendering' ? '⚙️' : project.status === 'done' ? '🎬' : '🎵'}
</div>
<div className="yt-project-card__info">
<div className="yt-project-card__title">
{project.title ?? `프로젝트 #${project.id}`}
</div>
<div className="yt-project-card__meta">
{project.format} · {(project.target_countries ?? []).join(' ')}
</div>
{project.status === 'rendering' && (
<div className="yt-progress-bar">
<div className="yt-progress-bar__fill" />
</div>
)}
</div>
<span className={`yt-status ${s.cls}`}>{s.text}</span>
{project.status === 'pending' && (
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onRender(project.id)}
>
렌더
</button>
)}
{project.status === 'done' && (
<button
type="button"
className="ms-btn ms-btn--ghost ms-btn--sm"
onClick={() => onExport(project.id)}
disabled={isExporting}
>
{isExporting ? '...' : '↓ 내보내기'}
</button>
)}
<button
type="button"
className="ms-btn--icon ms-btn--danger"
onClick={() => onDelete(project.id)}
aria-label="삭제"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import VideoProjectsTab from './VideoProjectsTab';
import RevenueTab from './RevenueTab';
import TrendsTab from './TrendsTab';
import CompileTab from './CompileTab';
import PipelineTab from './PipelineTab';
import SetupTab from './SetupTab';
export default function YoutubeTab({ library, initialTrackId, onClearInitialTrack, openPipelineFor }) {
const [subtab, setSubtab] = useState('pipeline');
useEffect(() => {
if (initialTrackId) setSubtab('video');
}, [initialTrackId]);
useEffect(() => {
if (openPipelineFor) setSubtab('pipeline');
}, [openPipelineFor]);
const tabs = [
['pipeline', '🚀 진행'],
['video', '🎬 영상 제작'],
['compile', '🎵 컴파일'],
['trends', '📊 시장 트렌드'],
['revenue', '💰 수익 추적'],
['setup', '⚙️ 구성'],
];
return (
<div className="yt-container">
<nav className="yt-subtabs">
{tabs.map(([key, label]) => (
<button
key={key}
type="button"
className={`yt-subtab ${subtab === key ? 'is-active' : ''}`}
onClick={() => setSubtab(key)}
>
{label}
</button>
))}
</nav>
{subtab === 'pipeline' && <PipelineTab library={library} initialTrackId={openPipelineFor} />}
{subtab === 'video' && (
<VideoProjectsTab
library={library}
initialTrackId={initialTrackId}
onClearInitialTrack={onClearInitialTrack}
/>
)}
{subtab === 'compile' && (
<CompileTab
library={library}
onSwitchToPipeline={() => setSubtab('pipeline')}
/>
)}
{subtab === 'trends' && <TrendsTab />}
{subtab === 'revenue' && <RevenueTab />}
{subtab === 'setup' && <SetupTab />}
</div>
);
}

View File

@@ -833,6 +833,17 @@
.pf-total-summary__card strong {
font-size: 16px;
color: var(--text);
white-space: nowrap;
}
.pf-total-summary__card strong.is-fit-sm {
font-size: 13px;
letter-spacing: -0.01em;
}
.pf-total-summary__card strong.is-fit-xs {
font-size: 11px;
letter-spacing: -0.02em;
}
.pf-item-actions {
@@ -890,6 +901,22 @@
font-style: italic;
}
.pf-nxt-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid rgba(139, 92, 246, 0.45);
background: rgba(139, 92, 246, 0.12);
color: #c4b5fd;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
vertical-align: middle;
cursor: help;
white-space: nowrap;
}
.pf-edit-row {
grid-column: 1 / -1;
display: grid;
@@ -955,6 +982,14 @@
.pf-total-summary__card strong {
font-size: 14px;
}
.pf-total-summary__card strong.is-fit-sm {
font-size: 12px;
}
.pf-total-summary__card strong.is-fit-xs {
font-size: 10px;
}
}
/* ── Cash Panel (예수금) ─────────────────────────────────────────── */

View File

@@ -245,6 +245,9 @@ const Stock = () => {
<Link className="button ghost" to="/stock/trade">
거래 데스크
</Link>
<Link className="button ghost" to="/stock/screener">
스크리너
</Link>
</div>
</div>
<div className="stock-card">

View File

@@ -4,7 +4,27 @@ import {
ResponsiveContainer, AreaChart, Area, XAxis, YAxis,
Tooltip as ChartTooltip,
} from 'recharts';
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../stockUtils';
import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../stockUtils';
const formatPriceTime = (iso) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const PriceSessionBadge = ({ session, asOf }) => {
if (session !== 'NXT_AFTER' && session !== 'NXT_PRE') return null;
const isPre = session === 'NXT_PRE';
const label = isPre ? 'NXT 프리' : 'NXT';
const desc = isPre ? 'NXT 프리마켓 거래가' : 'NXT 야간거래 (15:30~20:00)';
const time = formatPriceTime(asOf);
return (
<span className="pf-nxt-badge" title={time ? `${desc} · ${time}` : desc}>
{label}
</span>
);
};
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
<>
@@ -140,32 +160,38 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
{ label: '총 평가', value: pf.portfolioSummary.total_eval },
{ label: '총 손익', value: pf.portfolioSummary.total_profit, isProfit: true },
{ label: '수익률', value: pf.portfolioSummary.total_profit_rate, isRate: true },
].map((s) => (
<div key={s.label} className="pf-total-summary__card">
<span>{s.label}</span>
<strong
className={
s.isProfit || s.isRate
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
: ''
}
>
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
</strong>
</div>
))}
{pf.totalCash != null && (
<div className="pf-total-summary__card is-cash">
<span>예수금 합계</span>
<strong>{formatNumber(pf.totalCash)}</strong>
</div>
)}
{pf.totalAssets != null && (
<div className="pf-total-summary__card is-assets">
<span> 자산</span>
<strong>{formatNumber(pf.totalAssets)}</strong>
</div>
)}
].map((s) => {
const display = s.isRate ? formatPercent(s.value) : formatNumber(s.value);
const profitCls = s.isProfit || s.isRate
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
: '';
return (
<div key={s.label} className="pf-total-summary__card">
<span>{s.label}</span>
<strong className={`${profitCls} ${numFitClass(display)}`.trim()}>
{display}
</strong>
</div>
);
})}
{pf.totalCash != null && (() => {
const display = `${formatNumber(pf.totalCash)}`;
return (
<div className="pf-total-summary__card is-cash">
<span>예수금 합계</span>
<strong className={numFitClass(display)}>{display}</strong>
</div>
);
})()}
{pf.totalAssets != null && (() => {
const display = `${formatNumber(pf.totalAssets)}`;
return (
<div className="pf-total-summary__card is-assets">
<span> 자산</span>
<strong className={numFitClass(display)}>{display}</strong>
</div>
);
})()}
</div>
)}
@@ -521,6 +547,10 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
{item.current_price != null
? formatNumber(item.current_price)
: '조회 실패'}
<PriceSessionBadge
session={item.price_session}
asOf={item.price_as_of}
/>
</strong>
</div>
<div className="stock-holdings__metric">

View File

@@ -0,0 +1,82 @@
.screener-page {
padding: 24px;
color: var(--text, #e5e7eb);
background: var(--bg, #0b0f17);
min-height: 100vh;
}
.screener-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 24px;
}
.screener-header h1 {
font-size: 28px;
margin: 0 0 4px 0;
}
.screener-header .meta {
color: #9ca3af;
font-size: 13px;
margin: 0;
}
.screener-header nav a {
margin-left: 12px;
color: #9ca3af;
text-decoration: none;
}
.screener-grid {
display: grid;
grid-template-columns: 320px 1fr 280px;
gap: 24px;
}
@media (max-width: 1023px) {
.screener-page { padding: 16px; }
.screener-header { flex-direction: column; align-items: flex-start; gap: 8px; }
.screener-grid { grid-template-columns: 1fr; gap: 16px; }
.screener-left { order: 1; }
.screener-center { order: 2; }
.screener-right { order: 3; }
.screener-table { font-size: 12px; }
.screener-table th, .screener-table td { padding: 6px 4px; }
}
@media (max-width: 640px) {
.screener-page { padding: 12px; }
.screener-card { padding: 12px; }
}
.screener-loading { padding: 80px; text-align: center; color: #9ca3af; }
.screener-card {
background: #0f1623;
border: 1px solid #1f2937;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.screener-card h3 { margin: 0 0 12px 0; font-size: 15px; }
.node-card {
background: #0a0f1a;
border: 1px solid #1f2937;
border-radius: 6px;
padding: 10px;
font-size: 13px;
}
.node-card-header { font-weight: 500; margin-bottom: 6px; }
.weight-row, .param-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.screener-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
}
.screener-table th { text-align: left; padding: 8px; background: #0a0f1a; color: #9ca3af; font-weight: 500; border-bottom: 1px solid #1f2937; }
.screener-table td { padding: 8px; border-bottom: 1px solid #1a2230; vertical-align: middle; }
.screener-table tr:hover { background: #0a0f1a; }

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { Link } from 'react-router-dom';
import './Screener.css';
import { useScreenerMeta } from './hooks/useScreenerMeta';
import { useScreenerSettings } from './hooks/useScreenerSettings';
import { useScreenerRun } from './hooks/useScreenerRun';
import { useScreenerHistory } from './hooks/useScreenerHistory';
import GatePanel from './components/GatePanel';
import NodePanel from './components/NodePanel';
import GlobalControls from './components/GlobalControls';
import ResultTable from './components/ResultTable';
import TelegramPreview from './components/TelegramPreview';
import RunHistoryList from './components/RunHistoryList';
export default function Screener() {
const { meta, loading: metaLoading } = useScreenerMeta();
const { settings, dirty, setLocal, save } = useScreenerSettings();
const { result, running, runPreview, runSave } = useScreenerRun();
const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory();
const activeResult = selectedRun || result;
if (metaLoading || !meta || !settings) {
return <div className="screener-loading">로딩 </div>;
}
return (
<div className="screener-page">
<header className="screener-header">
<div>
<h1>스크리너</h1>
<p className="meta">
최근 자동 : {runs?.find(r => r.mode === 'auto')?.asof ?? '-'}
· 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'}
</p>
</div>
<nav>
<Link to="/stock">시장</Link>
<Link to="/stock/trade">트레이드</Link>
</nav>
</header>
<div className="screener-grid">
<aside className="screener-left">
<GatePanel meta={meta.gate_nodes[0]} value={settings.gate_params} onChange={(p) => setLocal({...settings, gate_params: p})} />
<NodePanel meta={meta.score_nodes} weights={settings.weights} params={settings.node_params}
onWeights={(w) => setLocal({...settings, weights: w})}
onParams={(p) => setLocal({...settings, node_params: p})} />
<GlobalControls settings={settings} setSettings={setLocal}
onRun={() => runPreview(settings)}
onSave={() => runSave(settings)}
onPersist={save}
dirty={dirty}
running={running} />
</aside>
<main className="screener-center">
<ResultTable result={activeResult} />
<TelegramPreview payload={activeResult?.telegram_payload} />
</main>
<aside className="screener-right">
<RunHistoryList runs={runs} loading={runs_loading} onSelect={selectRun}
selectedId={selectedRun?.meta?.id} />
</aside>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
export default function GatePanel({ meta, value, onChange }) {
if (!meta) return null;
const props = meta.param_schema?.properties || {};
return (
<section className="screener-card">
<h3>{meta.label}</h3>
<p style={{ fontSize: 11, color: '#9ca3af', marginTop: 0 }}>
통과 조건 통과한 종목만 점수 노드에 전달
</p>
{Object.entries(props).map(([key, prop]) => (
<GateField key={key} paramKey={key} prop={prop}
value={value?.[key] ?? meta.default_params?.[key]}
onChange={(v) => onChange({ ...value, [key]: v })} />
))}
</section>
);
}
function GateField({ paramKey, prop, value, onChange }) {
if (prop.type === 'integer') {
return (
<div className="param-row">
<label style={{ width: 160, fontSize: 12 }}>{paramKey}</label>
<input type="number" value={value ?? ''}
min={prop.minimum} onChange={(e) => onChange(parseInt(e.target.value, 10))}
style={{ flex: 1 }} />
</div>
);
}
if (prop.type === 'boolean') {
return (
<div className="param-row">
<label>
<input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
<span style={{ marginLeft: 6, fontSize: 12 }}>{paramKey}</span>
</label>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,43 @@
export default function GlobalControls({ settings, setSettings, onRun, onSave, onPersist, dirty, running }) {
return (
<section className="screener-card">
<h3>실행 옵션</h3>
<div className="param-row">
<label style={{ width: 80, fontSize: 12 }}>Top N</label>
<input type="number" value={settings.top_n}
onChange={(e) => setSettings({ ...settings, top_n: parseInt(e.target.value, 10) })}
min={5} max={100} style={{ width: 80 }} />
</div>
<div className="param-row">
<label style={{ width: 80, fontSize: 12 }}>ATR window</label>
<input type="number" value={settings.atr_window}
onChange={(e) => setSettings({ ...settings, atr_window: parseInt(e.target.value, 10) })}
min={5} max={50} style={{ width: 80 }} />
</div>
<div className="param-row">
<label style={{ width: 80, fontSize: 12 }}>손절 ×ATR</label>
<input type="number" value={settings.atr_stop_mult} step={0.1}
onChange={(e) => setSettings({ ...settings, atr_stop_mult: parseFloat(e.target.value) })}
min={0.5} max={5} style={{ width: 80 }} />
</div>
<div className="param-row">
<label style={{ width: 80, fontSize: 12 }}>R:R 비율</label>
<input type="number" value={settings.rr_ratio} step={0.1}
onChange={(e) => setSettings({ ...settings, rr_ratio: parseFloat(e.target.value) })}
min={1} max={10} style={{ width: 80 }} />
</div>
<button onClick={onRun} disabled={running}
style={{ marginTop: 16, width: '100%', padding: 10, background: '#fbbf24', color: '#0b0f17', border: 'none', borderRadius: 6, fontWeight: 600 }}>
{running ? '실행 중…' : '지금 실행 (미리보기)'}
</button>
<button onClick={onSave} disabled={running}
style={{ marginTop: 8, width: '100%', padding: 8 }}>
스냅샷 저장
</button>
<button onClick={onPersist} disabled={!dirty}
style={{ marginTop: 8, width: '100%', padding: 8, opacity: dirty ? 1 : 0.5 }}>
설정 저장 (디폴트 갱신)
</button>
</section>
);
}

View File

@@ -0,0 +1,80 @@
import React from 'react';
export default function NodeCard({ meta, weight, params, onWeightChange, onParamsChange }) {
const enabled = (weight ?? 0) > 0;
return (
<div className="node-card" style={{ opacity: enabled ? 1 : 0.6 }}>
<div className="node-card-header">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={enabled}
onChange={(e) => onWeightChange(e.target.checked ? (weight || 1) : 0)}
/>
<span>{meta.label}</span>
</label>
</div>
<div className="node-card-body">
<div className="weight-row">
<span style={{ width: 50, fontSize: 12, color: '#9ca3af' }}>가중치</span>
<input
type="range" min="0" max="3" step="0.1"
value={weight ?? 0}
disabled={!enabled}
onChange={(e) => onWeightChange(parseFloat(e.target.value))}
style={{ flex: 1 }}
/>
<span style={{ width: 32, textAlign: 'right', fontSize: 12 }}>{(weight ?? 0).toFixed(1)}</span>
</div>
{Object.entries(meta.param_schema?.properties || {}).map(([key, prop]) => (
<ParamRow
key={key}
paramKey={key}
prop={prop}
value={params?.[key] ?? meta.default_params?.[key]}
disabled={!enabled}
onChange={(v) => onParamsChange({ ...params, [key]: v })}
/>
))}
</div>
</div>
);
}
function ParamRow({ paramKey, prop, value, disabled, onChange }) {
const type = prop.type;
if (type === 'integer' || type === 'number') {
return (
<div className="param-row">
<span style={{ width: 100, fontSize: 12 }}>{paramKey}</span>
<input
type="number"
min={prop.minimum} max={prop.maximum}
step={type === 'integer' ? 1 : 0.1}
value={value ?? ''}
disabled={disabled}
onChange={(e) => onChange(type === 'integer' ? parseInt(e.target.value, 10) : parseFloat(e.target.value))}
style={{ width: 80 }}
/>
</div>
);
}
if (type === 'boolean') {
return (
<div className="param-row">
<label>
<input type="checkbox" checked={!!value} disabled={disabled}
onChange={(e) => onChange(e.target.checked)} />
<span style={{ marginLeft: 6, fontSize: 12 }}>{paramKey}</span>
</label>
</div>
);
}
// object/array는 MVP에서 read-only JSON 표시 (RsRating의 weights 등)
return (
<div className="param-row" style={{ fontSize: 11, color: '#9ca3af' }}>
{paramKey}: <code>{JSON.stringify(value)}</code>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import NodeCard from './NodeCard';
export default function NodePanel({ meta, weights, params, onWeights, onParams }) {
return (
<section className="screener-card">
<h3>점수 노드 ({meta.length})</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{meta.map((m) => (
<NodeCard
key={m.name}
meta={m}
weight={weights[m.name]}
params={params[m.name]}
onWeightChange={(w) => onWeights({ ...weights, [m.name]: w })}
onParamsChange={(p) => onParams({ ...params, [m.name]: p })}
/>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,54 @@
import ScoreChips from './ScoreChips';
export default function ResultTable({ result }) {
if (!result) {
return (
<section className="screener-card">
<p style={{ color: '#9ca3af' }}>아직 결과 없음. "지금 실행" 눌러보세요.</p>
</section>
);
}
return (
<section className="screener-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}>
Top {result.top_n} · 통과 {result.survivors_count} · {result.asof}
</h3>
{result.warnings?.length > 0 && (
<div style={{
background: '#7c2d12', color: '#fde68a', padding: '4px 10px',
borderRadius: 4, fontSize: 12,
}}>
{result.warnings.join(' · ')}
</div>
)}
</div>
<div style={{ overflowX: 'auto', marginTop: 12 }}>
<table className="screener-table">
<thead>
<tr>
<th>#</th><th>종목</th><th>총점</th><th>노드</th>
<th>진입</th><th>손절</th><th>익절</th><th>R%</th>
</tr>
</thead>
<tbody>
{(result.results || []).map((r) => (
<tr key={r.ticker}>
<td>{r.rank}</td>
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
<td><ScoreChips scores={r.scores} /></td>
<td>{r.entry_price?.toLocaleString?.()}</td>
<td>{r.stop_price?.toLocaleString?.()}</td>
<td>{r.target_price?.toLocaleString?.()}</td>
<td>{r.r_pct?.toFixed?.(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

View File

@@ -0,0 +1,17 @@
export default function RunHistoryList({ runs, loading, onSelect, selectedId }) {
if (loading) return <section className="screener-card"><p>로딩</p></section>;
return (
<section className="screener-card">
<h3>최근 실행</h3>
<ul style={{listStyle:'none', padding:0, margin:0, fontSize:13}}>
{(runs || []).map((r) => (
<li key={r.id} style={{padding:'6px 0', borderBottom:'1px solid #1f2937', cursor:'pointer',
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb'}}
onClick={() => onSelect(r.id)}>
{r.asof} · {r.mode}
</li>
))}
</ul>
</section>
);
}

View File

@@ -0,0 +1,32 @@
const NODE_ICONS = {
foreign_buy: { icon: '👤', label: '외국인' },
volume_surge: { icon: '⚡', label: '거래량' },
momentum: { icon: '🚀', label: '모멘텀' },
high52w: { icon: '🆙', label: '52w고' },
rs_rating: { icon: '💪', label: 'RS' },
ma_alignment: { icon: '📈', label: '정배열' },
vcp_lite: { icon: '🌀', label: 'VCP' },
};
export default function ScoreChips({ scores }) {
return (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{Object.entries(scores || {}).map(([name, s]) => {
const meta = NODE_ICONS[name];
if (!meta) return null;
const active = s >= 70;
return (
<span key={name}
title={`${meta.label}: ${s.toFixed?.(0) ?? s}`}
style={{
padding: '2px 6px', borderRadius: 4, fontSize: 11,
background: active ? '#fbbf24' : '#1f2937',
color: active ? '#0b0f17' : '#9ca3af',
}}>
{meta.icon}{Math.round(s)}
</span>
);
})}
</div>
);
}

View File

@@ -0,0 +1,9 @@
export default function TelegramPreview({ payload }) {
if (!payload) return null;
return (
<section className="screener-card">
<h3>텔레그램 미리보기</h3>
<pre style={{whiteSpace:'pre-wrap', fontFamily:'monospace', fontSize:12}}>{payload.text}</pre>
</section>
);
}

View File

@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import { listScreenerRuns, getScreenerRun } from '../../../../api';
export function useScreenerHistory() {
const [runs, setRuns] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedRun, setSelectedRun] = useState(null);
useEffect(() => {
listScreenerRuns(30).then((r) => { setRuns(r); setLoading(false); });
}, []);
async function selectRun(id) {
if (!id) { setSelectedRun(null); return; }
const detail = await getScreenerRun(id);
setSelectedRun({
asof: detail.meta.asof,
mode: detail.meta.mode,
status: detail.meta.status,
run_id: detail.meta.id,
survivors_count: detail.meta.survivors_count,
weights: detail.meta.weights,
top_n: detail.meta.top_n,
results: detail.results,
telegram_payload: null,
warnings: [],
meta: detail.meta,
});
}
return { runs, runs_loading: loading, selectedRun, selectRun };
}

View File

@@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
import { getScreenerNodes } from '../../../../api';
export function useScreenerMeta() {
const [meta, setMeta] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getScreenerNodes().then((m) => { setMeta(m); setLoading(false); });
}, []);
return { meta, loading };
}

View File

@@ -0,0 +1,31 @@
import { useState } from 'react';
import { runScreener } from '../../../../api';
export function useScreenerRun() {
const [result, setResult] = useState(null);
const [running, setRunning] = useState(false);
async function call(mode, settings) {
setRunning(true);
try {
const body = {
mode,
weights: settings.weights,
node_params: settings.node_params,
gate_params: settings.gate_params,
top_n: settings.top_n,
};
const r = await runScreener(body);
setResult(r);
return r;
} finally {
setRunning(false);
}
}
return {
result, running,
runPreview: (s) => call('preview', s),
runSave: (s) => call('manual_save', s),
};
}

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react';
import { getScreenerSettings, saveScreenerSettings } from '../../../../api';
export function useScreenerSettings() {
const [remote, setRemote] = useState(null);
const [local, setLocal] = useState(null);
useEffect(() => {
getScreenerSettings().then((s) => { setRemote(s); setLocal(s); });
}, []);
const dirty = remote && local && JSON.stringify(remote) !== JSON.stringify(local);
async function save() {
if (!local) return;
const saved = await saveScreenerSettings({
weights: local.weights, node_params: local.node_params, gate_params: local.gate_params,
top_n: local.top_n, rr_ratio: local.rr_ratio,
atr_window: local.atr_window, atr_stop_mult: local.atr_stop_mult,
});
setRemote(saved);
setLocal(saved);
}
return { settings: local, dirty, setLocal, save };
}

View File

@@ -71,6 +71,13 @@ export const profitColorClass = (numericValue) => {
return '';
};
export const numFitClass = (text) => {
const len = String(text ?? '').length;
if (len >= 13) return 'is-fit-xs';
if (len >= 10) return 'is-fit-sm';
return '';
};
export const getVixLabel = (vix) => {
if (vix < 12) return '극히 낮음 (안일 주의)';
if (vix < 20) return '정상 (안정적)';

View File

@@ -1078,6 +1078,28 @@
font-weight: 500;
}
.sub-form-hint {
font-size: 10px;
color: var(--text-dim);
opacity: 0.7;
line-height: 1.4;
}
.sub-profile-hint {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
background: color-mix(in srgb, var(--accent-cyan) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--accent-cyan) 25%, transparent);
border-radius: var(--radius-sm);
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
margin: 0 16px 4px;
}
.sub-profile-hint__icon { flex-shrink: 0; font-size: 14px; }
.sub-form-input {
background: var(--bg-tertiary);
border: 1px solid var(--line);
@@ -1534,3 +1556,101 @@ input.sub-toggle:checked + .sub-toggle__label { color: var(--accent-subscription
grid-template-columns: 1fr;
}
}
/* === 신규: 알림 대상 카운트 뱃지 ===================================== */
.ns-pass-count {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 10px;
background: rgba(0, 212, 255, 0.12);
color: #00d4ff;
font-size: 11px;
font-weight: 600;
}
.ns-pass-count strong {
font-weight: 700;
}
/* === 캘린더 뷰 ========================================================= */
.sub-calendar {
background: var(--bg-secondary);
border: 1px solid var(--line);
border-radius: var(--radius-md);
overflow: hidden;
}
.sub-calendar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
font-weight: 600;
font-size: 14px;
color: var(--text-bright);
}
.sub-calendar__weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--line);
}
.sub-calendar__weekday {
text-align: center;
padding: 6px 0;
font-size: 10px;
color: var(--text-dim);
font-weight: 600;
}
.sub-calendar__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.sub-calendar__day {
min-height: 58px;
padding: 5px 4px 4px;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 3px;
cursor: default;
transition: background 0.1s;
}
.sub-calendar__day:nth-child(7n) { border-right: none; }
.sub-calendar__day.is-empty { background: var(--bg-tertiary); opacity: 0.35; }
.sub-calendar__day.has-items { cursor: pointer; }
.sub-calendar__day.has-items:hover { background: var(--surface-raised); }
.sub-calendar__day.is-today .sub-calendar__day-num {
background: var(--accent-cyan, #00d4ff);
color: var(--bg-primary);
border-radius: 50%;
}
.sub-calendar__day-num {
font-size: 11px;
color: var(--text-muted);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
font-weight: 500;
}
.sub-calendar__dots {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 0 2px;
}
.sub-calendar__dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.sub-calendar__more {
font-size: 9px;
color: var(--text-dim);
line-height: 1.5;
}

View File

@@ -618,16 +618,52 @@ function AnnouncementDetail({ item, onBookmark }) {
{item.match_score !== undefined && item.match_score !== null && (
<div className="sub-match-analysis">
<div>
<p className="sub-panel__eyebrow">매칭 분석</p>
<span className="sub-match-analysis__score">
{item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
</span>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div>
<p className="sub-panel__eyebrow">매칭 분석</p>
<span className="sub-match-analysis__score">
{item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
</span>
</div>
</div>
{item.score_breakdown && (
<div style={{ marginTop: 12 }}>
<p className="sub-panel__eyebrow" style={{ marginBottom: 8 }}>📊 점수 분석</p>
<div style={{ display: 'grid', gap: 8 }}>
{[
{ key: 'region', label: '지역', max: 35, color: '#00d4ff' },
{ key: 'type', label: '유형', max: 10, color: '#8b5cf6' },
{ key: 'area', label: '면적', max: 15, color: '#f59e0b' },
{ key: 'price', label: '가격', max: 15, color: '#f43f5e' },
{ key: 'eligibility', label: '자격', max: 25, color: '#34d399' },
].map(({ key, label, max, color }) => {
const v = item.score_breakdown[key] ?? 0;
return (
<div key={key} style={{ display: 'grid', gap: 3 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
<span style={{ color: 'var(--text-bright)', fontWeight: 500 }}>{label}</span>
<span>
<span style={{ fontWeight: 700, color }}>{v}</span>
<span style={{ color: 'var(--text-dim)' }}> / {max}</span>
</span>
</div>
<div style={{ height: 5, borderRadius: 3, background: 'var(--surface-raised)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 3, background: color,
width: `${(v / max) * 100}%`, transition: 'width 0.4s',
}} />
</div>
</div>
);
})}
</div>
</div>
)}
{item.match_reasons && item.match_reasons.length > 0 && (
<div>
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>💡 매칭 사유</p>
<p className="sub-panel__eyebrow" style={{ marginTop: 12 }}>💡 매칭 사유</p>
<ul className="sub-match-analysis__reasons">
{item.match_reasons.map((r, idx) => (
<li key={idx}>{r}</li>
@@ -652,6 +688,73 @@ function AnnouncementDetail({ item, onBookmark }) {
);
}
// ── CalendarView ─────────────────────────────────────────────────────────────
function CalendarView({ items, onDaySelect }) {
const [cur, setCur] = useState(() => {
const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1);
});
const year = cur.getFullYear(), month = cur.getMonth();
const dateMap = useMemo(() => {
const map = {};
for (const item of items) {
const raw = item.receipt_start || item.spsply_start || item.gnrl_rank1_start;
if (!raw || raw.length < 8) continue;
const key = `${raw.slice(0,4)}-${raw.slice(4,6)}-${raw.slice(6,8)}`;
(map[key] = map[key] || []).push(item);
}
return map;
}, [items]);
const firstDow = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
const todayD = new Date(), todayKey = `${todayD.getFullYear()}-${String(todayD.getMonth()+1).padStart(2,'0')}-${String(todayD.getDate()).padStart(2,'0')}`;
return (
<div className="sub-calendar">
<div className="sub-calendar__header">
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month-1, 1))}></button>
<span>{year} {month+1}</span>
<button className="sub-filter-btn" onClick={() => setCur(new Date(year, month+1, 1))}></button>
</div>
<div className="sub-calendar__weekdays">
{['일','월','화','수','목','금','토'].map(w => (
<div key={w} className="sub-calendar__weekday">{w}</div>
))}
</div>
<div className="sub-calendar__grid">
{cells.map((d, i) => {
const key = d ? `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}` : null;
const dayItems = key ? (dateMap[key] || []) : [];
const isToday = key === todayKey;
return (
<div
key={i}
className={`sub-calendar__day${!d ? ' is-empty' : ''}${isToday ? ' is-today' : ''}${dayItems.length > 0 ? ' has-items' : ''}`}
onClick={() => dayItems.length > 0 && onDaySelect(dayItems, `${year}${month+1}${d}`)}
>
{d && <span className="sub-calendar__day-num">{d}</span>}
{dayItems.length > 0 && (
<div className="sub-calendar__dots">
{dayItems.slice(0, 3).map((it, j) => (
<span key={j} className="sub-calendar__dot" style={{ background: STATUS_CONFIG[it.status]?.color || '#888' }} />
))}
{dayItems.length > 3 && <span className="sub-calendar__more">+{dayItems.length - 3}</span>}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ── AnnouncementsTab ─────────────────────────────────────────────────────────
function AnnouncementsTab() {
const [items, setItems] = useState([]);
@@ -663,8 +766,10 @@ function AnnouncementsTab() {
const [selected, setSelected] = useState(null);
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState('list'); // 'list' | 'calendar'
const [calendarDay, setCalendarDay] = useState(null); // { label, items }
const size = 20;
const size = viewMode === 'calendar' ? 200 : 20;
const load = async () => {
setLoading(true);
@@ -684,7 +789,7 @@ function AnnouncementsTab() {
}
};
useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter]);
useEffect(() => { load(); }, [page, statusFilter, regionFilter, bookmarkFilter, viewMode]);
const handleSelect = async (item) => {
setSelected(item.id);
@@ -754,6 +859,14 @@ function AnnouncementsTab() {
onChange={(e) => { setRegionFilter(e.target.value); setPage(1); }}
style={{ width: 160, padding: '6px 12px', fontSize: 12 }}
/>
<button
className={`sub-filter-btn${viewMode === 'calendar' ? ' is-active' : ''}`}
onClick={() => { setViewMode(v => v === 'calendar' ? 'list' : 'calendar'); setPage(1); setCalendarDay(null); }}
style={{ fontSize: 12 }}
title="캘린더 뷰 전환"
>
📅 캘린더
</button>
<button
className="sub-filter-btn"
onClick={handleDeleteClosed}
@@ -769,6 +882,42 @@ function AnnouncementsTab() {
<div className="sub-empty">불러오는 ...</div>
) : items.length === 0 ? (
<div className="sub-empty">조건에 맞는 공고가 없습니다.</div>
) : viewMode === 'calendar' ? (
<div style={{ display: 'grid', gap: 12 }}>
<CalendarView
items={items}
onDaySelect={(dayItems, label) => setCalendarDay({ items: dayItems, label })}
/>
{calendarDay && (
<div className="sub-panel">
<div className="sub-panel__head">
<div>
<p className="sub-panel__eyebrow">{calendarDay.label}</p>
<h3>공고 {calendarDay.items.length}</h3>
</div>
<button className="sub-filter-btn" onClick={() => setCalendarDay(null)} style={{ fontSize: 11 }}>닫기</button>
</div>
<div className="sub-panel__body" style={{ display: 'grid', gap: 8 }}>
{calendarDay.items.map(item => (
<div
key={item.id}
className="sub-card"
style={{ cursor: 'pointer', padding: '10px 14px' }}
onClick={() => { setViewMode('list'); handleSelect(item); }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-bright)' }}>{item.house_nm}</span>
<span className="sub-badge" style={{ background: STATUS_CONFIG[item.status]?.bg, color: STATUS_CONFIG[item.status]?.color, flexShrink: 0 }}>
{item.status}
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--text-dim)' }}>{item.region_name} · 접수 {item.receipt_start}</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="sub-list-layout">
{/* Card Grid */}
@@ -973,6 +1122,24 @@ function MatchesTab() {
).join(' · ')}
</div>
)}
{match.score_breakdown && (
<div style={{ display: 'flex', gap: 2, marginTop: 4 }}>
{[
{ key: 'region', max: 35, color: '#00d4ff' },
{ key: 'type', max: 10, color: '#8b5cf6' },
{ key: 'area', max: 15, color: '#f59e0b' },
{ key: 'price', max: 15, color: '#f43f5e' },
{ key: 'eligibility', max: 25, color: '#34d399' },
].map(({ key, max, color }) => {
const v = match.score_breakdown[key] ?? 0;
return (
<div key={key} style={{ flex: max, height: 4, borderRadius: 2, background: 'var(--surface-raised)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: color, width: `${(v / max) * 100}%` }} />
</div>
);
})}
</div>
)}
</div>
<div style={{ textAlign: 'center', flexShrink: 0, display: 'grid', gap: 6 }}>
<div>
@@ -1026,6 +1193,7 @@ function MatchesTab() {
// ── ProfileTab ────────────────────────────────────────────────────────────────
function ProfileTab() {
const [profile, setProfile] = useState({ ...DEFAULT_PROFILE });
const [passCount, setPassCount] = useState(null);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
@@ -1034,13 +1202,17 @@ function ProfileTab() {
(async () => {
setLoading(true);
try {
const data = await apiGet('/api/realestate/profile');
const [data, dash] = await Promise.all([
apiGet('/api/realestate/profile'),
apiGet('/api/realestate/dashboard').catch(() => null),
]);
if (data && Object.keys(data).length > 0) {
const display = { ...DEFAULT_PROFILE, ...data };
if (Array.isArray(display.preferred_regions)) display.preferred_regions = display.preferred_regions.join(', ');
if (Array.isArray(display.preferred_types)) display.preferred_types = display.preferred_types.join(', ');
setProfile(display);
}
if (dash?.pass_count != null) setPassCount(dash.pass_count);
} catch (e) {
console.error('Profile load error:', e);
} finally {
@@ -1184,6 +1356,26 @@ function ProfileTab() {
</div>
</div>
{/* 프로필 완성도 힌트 */}
{(() => {
const missing = [];
if (!profile.income_level) missing.push('소득 수준');
if (!profile.min_area || !profile.max_area) missing.push('희망 면적');
if (!profile.max_price) missing.push('최대 예산');
const hasDistricts = profile.preferred_districts &&
Object.values(profile.preferred_districts).some(arr => arr?.length > 0);
if (!hasDistricts) missing.push('자치구 티어');
if (missing.length === 0) return null;
return (
<div className="sub-profile-hint">
<span className="sub-profile-hint__icon">💡</span>
<span>
<strong>매칭 정확도 개선 가능</strong> {missing.join(', ')} 입력 정확한 점수를 산출합니다.
</span>
</div>
);
})()}
<div className="sub-modal__form">
{/* 기본 정보 */}
<div className="sub-form-section" style={{ borderBottom: '1px solid var(--line)' }}>
@@ -1301,10 +1493,13 @@ function ProfileTab() {
<input
className="sub-form-input"
type="number"
min="0"
max="300"
value={profile.income_level || ''}
onChange={e => handleChange('income_level', e.target.value)}
placeholder="도시근로자 평균 대비 %"
placeholder="도시근로자 평균 대비 %"
/>
<span className="sub-form-hint">청년 140 / 신혼·생애최초 160 / 신생아 200 · 미입력 검증 생략</span>
</label>
</div>
</div>
@@ -1379,6 +1574,7 @@ function ProfileTab() {
minScore={profile.min_match_score ?? 70}
notifyEnabled={profile.notify_enabled ?? true}
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
passCount={passCount}
/>
</div>
</div>

View File

@@ -1,4 +1,4 @@
export default function NotificationSettings({ minScore, notifyEnabled, onChange }) {
export default function NotificationSettings({ minScore, notifyEnabled, onChange, passCount }) {
const score = minScore ?? 70;
const enabled = notifyEnabled ?? true;
@@ -42,9 +42,16 @@ export default function NotificationSettings({ minScore, notifyEnabled, onChange
</label>
<p className="ns-hint">
{enabled
? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.`
: "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
{enabled ? (
<>
💡 {score} 이상 매치 텔레그램에 자동 알림합니다.
{passCount != null && (
<span className="ns-pass-count">
현재 <strong>{passCount}</strong> 대상
</span>
)}
</>
) : "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
</p>
</div>
</div>

View File

@@ -19,6 +19,7 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto'));
const Travel = lazy(() => import('./pages/travel/Travel'));
const Stock = lazy(() => import('./pages/stock/Stock'));
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
@@ -160,6 +161,10 @@ export const appRoutes = [
path: 'stock/trade',
element: <StockTrade />,
},
{
path: 'stock/screener',
element: <Screener />,
},
{
path: 'realestate',
element: <Subscription />,